Skip to content

Commit 5bcda7c

Browse files
renezander030claude
andcommitted
ats: enforce Goal+Log task body format + fold triage into single-task get
normalizeTaskBody (core) lifts a buried goal to a ::wrapped:: # Goal at the top, gathers next-action + dated bullets under # Log, preserves other prose under ## Notes. Idempotent, non-destructive, no LLM. Wired into create/update (when content is written) and single-task 'tasks get', which also runs Haiku triage tagging in the same read and persists both. Adds 'ats tasks normalize' (structure-only backfill) and 'ats fmt' (stdin filter). Cost-capped like the triage cron: per-call --max-budget-usd 0.05 (inherited via beads-triage classify) plus a rolling daily cap ATS_GET_TRIAGE_MAX_PER_DAY (default 25) that degrades to free structure-only normalize past the cap. find/list/search/hybrid/similar never auto-touch pulled-in tasks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a641f89 commit 5bcda7c

4 files changed

Lines changed: 318 additions & 3 deletions

File tree

packages/cli/bin/ats.js

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ import {
6868
acknowledgeTaskEvents,
6969
snapshotTaskEvents,
7070
collectAndSpoolTaskEvents,
71+
normalizeTaskBody,
72+
TRIAGE_TAG,
7173
} from '@reneza/ats-core';
7274
import { scaffoldAdapter } from '../scaffold.js';
7375
import { runDoctor, formatDoctor } from '../doctor.js';
@@ -205,6 +207,9 @@ async function main() {
205207
case 'bench':
206208
handleBench();
207209
return;
210+
case 'fmt':
211+
handleFmt();
212+
return;
208213
case 'sync':
209214
result = await handleSync();
210215
break;
@@ -365,7 +370,7 @@ function helpFor(command) {
365370
const COMPLETION_COMMANDS = [
366371
'setup', 'find', 'open', 'get', 'url', 'links', 'create', 'update', 'hybrid', 'similar',
367372
'intent', 'promote', 'hierarchy', 'lifecycle', 'link', 'reference', 'relate', 'graph', 'context', 'ledger', 'security', 'events',
368-
'doctor', 'status', 'cache', 'bench', 'sync', 'adapter', 'init', 'config', 'auth',
373+
'doctor', 'status', 'cache', 'bench', 'fmt', 'sync', 'adapter', 'init', 'config', 'auth',
369374
'projects', 'tasks', 'notes', 'help', 'completion',
370375
];
371376

@@ -647,6 +652,96 @@ function auditCliWrite(action, result, fallback, metadata, advanced = false) {
647652
}
648653
}
649654

655+
// Cheap-model triage classifier (route/type/model/effort/do tags). Shells the
656+
// shared beads-triage.py in --emit-json mode so the LLM prompt has ONE source of
657+
// truth. Returns {tags:[], next:''} or null on any failure (caller degrades to
658+
// structure-only). No write happens here — the caller persists once.
659+
// Default location is Rene's checkout; override with ATS_TRIAGE_BIN. runTriageEmit's
660+
// existsSync guard means triage is simply skipped where the script isn't present.
661+
const TRIAGE_BIN = process.env.ATS_TRIAGE_BIN || path.join(os.homedir(), 'claude', 'beads-triage.py');
662+
663+
// Cost caps mirror beads-triage.py: (1) PER-CALL — the shared classify() already
664+
// passes `--max-budget-usd 0.05`, inherited here automatically. (2) VOLUME — the cron
665+
// caps tasks/run (TRIAGE_MAX_PER_RUN) to protect the shared 5h budget from bursts;
666+
// get processes one task per call, so the analog is a rolling DAILY cap on get-triage
667+
// Haiku calls. Past the cap, get degrades to structure-only normalize (no loss — the
668+
// task still conforms and gets tagged on a later get or by the batch cron). 0 disables.
669+
const GET_TRIAGE_MAX_PER_DAY = parseInt(process.env.ATS_GET_TRIAGE_MAX_PER_DAY || '25', 10);
670+
function getTriageBudgetFile() {
671+
return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'ats', 'get-triage-budget.json');
672+
}
673+
function claimGetTriageBudget() {
674+
if (!(GET_TRIAGE_MAX_PER_DAY > 0)) return false;
675+
const day = new Date().toISOString().slice(0, 10);
676+
const file = getTriageBudgetFile();
677+
let st;
678+
try { st = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { st = {}; }
679+
if (st.day !== day) st = { day, count: 0 };
680+
if (st.count >= GET_TRIAGE_MAX_PER_DAY) return false;
681+
st.count += 1;
682+
try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(st)); } catch { /* best-effort */ }
683+
return true;
684+
}
685+
686+
function runTriageEmit(task) {
687+
try {
688+
if (!fs.existsSync(TRIAGE_BIN)) return null;
689+
const env = { ...process.env };
690+
delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT;
691+
const r = spawnSync('python3', [TRIAGE_BIN, '--emit-json'], {
692+
input: JSON.stringify([task]), encoding: 'utf8', timeout: 60000, env,
693+
});
694+
if (r.status !== 0 || !r.stdout) return null;
695+
const line = r.stdout.trim().split('\n').filter(Boolean).pop();
696+
const d = JSON.parse(line);
697+
return { tags: d.tags || [], next: (d.next || '').trim() };
698+
} catch { return null; }
699+
}
700+
701+
// Single explicit `ats tasks get PROJECT_ID TASK_ID` = "the task at hand" → read
702+
// the context once, normalize the Goal+Log body, classify triage tags, and persist
703+
// BOTH in one write. Skips the LLM call when the task is already triaged AND its
704+
// body is already conforming (freshness guard). Never fires for find/list/search.
705+
async function formatTriageOnGet(t, adapter, proj, id, task) {
706+
const fullProj = task.fullProjectId || task.projectId || proj;
707+
const fullId = task.fullId || task.id || id;
708+
const curTags = task.tags || [];
709+
const norm = normalizeTaskBody(task.content || '');
710+
const hasTriage = curTags.some((x) => TRIAGE_TAG.test(x));
711+
const skip = args.options['no-triage'] === true || process.env.ATS_GET_NOTRIAGE;
712+
713+
let newTags = null;
714+
let next = '';
715+
if (!skip && (!hasTriage || norm.changed)) {
716+
if (claimGetTriageBudget()) {
717+
const r = runTriageEmit({ id: fullId, projectId: fullProj, title: task.title, content: norm.content });
718+
if (r) { newTags = r.tags; next = r.next; }
719+
} else {
720+
process.stderr.write(`[ats] get-triage daily cap (${GET_TRIAGE_MAX_PER_DAY}) reached — structure-only this read; triage deferred to the batch cron.\n`);
721+
}
722+
}
723+
const finalBody = next ? normalizeTaskBody(norm.content, { next }).content : norm.content;
724+
const bodyChanged = finalBody.trim() !== (task.content || '').trim();
725+
if (!bodyChanged && !newTags) return task; // already conforming + tagged → no write
726+
727+
const patch = { content: finalBody };
728+
if (newTags) patch.tags = [...curTags.filter((x) => !TRIAGE_TAG.test(x)), ...newTags];
729+
const res = t?.update
730+
? await t.update(proj, id, patch)
731+
: await adapter.updateTask(proj, id, { ...patch, tags: tagsToArray(patch.tags) });
732+
auditCliWrite('task.updated', res, { projectId: proj, taskId: id }, { fields: Object.keys(patch), via: 'get-format' });
733+
return res.task || res;
734+
}
735+
736+
// `ats fmt [--next "..."]` — pure stdin→stdout Goal+Log normalizer (no adapter,
737+
// no network). Lets other tools (e.g. beads-triage.py) reuse the one normalizer.
738+
function handleFmt() {
739+
let body;
740+
try { body = fs.readFileSync(0, 'utf8'); } catch { body = ''; }
741+
const { content } = normalizeTaskBody(body, { next: args.options.next || '' });
742+
process.stdout.write(content);
743+
}
744+
650745
async function handleTasks() {
651746
const adapter = await loadAdapter();
652747
const t = adapter.__ext?.tasks; // optional: rich adapters (TickTick) provide it
@@ -655,9 +750,26 @@ async function handleTasks() {
655750
case 'list':
656751
if (!args.positional[0]) { console.error('Usage: ats tasks list PROJECT_ID'); process.exit(1); }
657752
return t?.list ? await t.list(args.positional[0]) : await adapter.listTasksInProject(args.positional[0]);
658-
case 'get':
753+
case 'get': {
659754
if (!args.positional[0] || !args.positional[1]) { console.error('Usage: ats tasks get PROJECT_ID TASK_ID'); process.exit(1); }
660-
return t?.get ? await t.get(args.positional[0], args.positional[1]) : await adapter.getTask(args.positional[0], args.positional[1]);
755+
const [gp, gid] = args.positional;
756+
const got = t?.get ? await t.get(gp, gid) : await adapter.getTask(gp, gid);
757+
if (args.options['no-format'] === true || process.env.ATS_GET_NOFORMAT) return got;
758+
return await formatTriageOnGet(t, adapter, gp, gid, got);
759+
}
760+
case 'normalize': {
761+
// Structure-only backfill (no triage/LLM): lift Goal, ensure Log, preserve Notes.
762+
if (!args.positional[0] || !args.positional[1]) { console.error('Usage: ats tasks normalize PROJECT_ID TASK_ID'); process.exit(1); }
763+
const [np, nid] = args.positional;
764+
const task = t?.get ? await t.get(np, nid) : await adapter.getTask(np, nid);
765+
const norm = normalizeTaskBody(task.content || '');
766+
if (!norm.changed) return { task: { projectId: np, taskId: nid }, changed: false };
767+
const res = t?.update
768+
? await t.update(np, nid, { content: norm.content })
769+
: await adapter.updateTask(np, nid, { content: norm.content });
770+
auditCliWrite('task.updated', res, { projectId: np, taskId: nid }, { fields: ['content'], via: 'normalize' });
771+
return res.task || res;
772+
}
661773
case 'create': {
662774
let projectId = args.options.project || '';
663775
let title = args.positional[0];
@@ -686,6 +798,9 @@ async function handleTasks() {
686798
console.error('Run without arguments for interactive mode.');
687799
process.exit(1);
688800
}
801+
// Conform any body that's written (deterministic, no LLM). Bare quick-captures
802+
// (no --content) stay clean; they get the Goal+Log skeleton on first `get`.
803+
if (opts.content) opts.content = normalizeTaskBody(opts.content).content;
689804
const result = t?.create
690805
? await t.create(projectId, title, opts)
691806
: await adapter.createTask({
@@ -726,6 +841,8 @@ async function handleTasks() {
726841
tags: args.options.tags,
727842
reminder: args.options.reminder,
728843
};
844+
// Normalize the body whenever content is being written (no extra fetch when it isn't).
845+
if (patch.content !== undefined) patch.content = normalizeTaskBody(patch.content).content;
729846
const result = t?.update
730847
? await t.update(args.positional[0], args.positional[1], patch)
731848
: await adapter.updateTask(args.positional[0], args.positional[1], { ...patch, tags: tagsToArray(patch.tags) });

packages/core/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,4 @@ export {
6060
scoreProgressEpisodes,
6161
formatProgressBenchmark,
6262
} from './progress-benchmark.js';
63+
export { normalizeTaskBody, TRIAGE_TAG } from './task-format.js';

packages/core/task-format.js

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* task-format.js — deterministic Goal+Log body normalizer.
3+
*
4+
* Guarantees a task body STARTS with a `# Goal` section (goal wrapped in
5+
* `::double-colons::`), immediately followed by a `# Log` section (next-action
6+
* bullets first, then dated `- YYYY-MM-DD:` history). Any other prose is
7+
* preserved under `## Notes`. No LLM, no reasoning — purely structural, so the
8+
* format exists for high readability. Idempotent: re-running is a no-op.
9+
*
10+
* A buried goal (an existing `# Goal`/`## Goal` section anywhere, a `::…::`
11+
* highlight, or a `goal:`/`ziel:` line) is lifted back to the top.
12+
*/
13+
14+
const HEADING = /^(#{1,6})\s+(.+?)\s*$/;
15+
const HIGHLIGHT = /^\s*::(.+?)::\s*$/;
16+
const GOAL_LINE = /^\s*(goal|ziel)\s*:\s*(.+?)\s*$/i;
17+
const DATED = /^\s*[-*]\s*(\*\*)?\d{4}-\d{2}-\d{2}/;
18+
const ACTION = /^\s*[-*]\s*(\*\*)?\s*(next|todo|next best action|tbd)\b/i;
19+
const BULLET = /^\s*[-*]\s+/;
20+
21+
// Triage tag namespaces — used by callers to decide whether a task is already classified.
22+
export const TRIAGE_TAG = /^(route|type|model|effort|do|tool|review)[:-]/;
23+
24+
function promoteHeadings(content) {
25+
// migrate any-level "## Goal"/"## Log" (old triage H2 form) to canonical H1
26+
return content.replace(/^#{1,6}\s+(Goal|Ziel|Log)\b.*$/gim, (_m, w) =>
27+
'# ' + (/log/i.test(w) ? 'Log' : 'Goal'));
28+
}
29+
30+
function splitSections(content) {
31+
// Break on ANY markdown heading so an H2 (e.g. "## Notes") starts its own
32+
// section instead of being swallowed by the preceding H1. `raw` keeps the
33+
// original heading line (with its level) so non-Goal/Log sections round-trip.
34+
const out = [];
35+
let cur = { heading: null, raw: null, lines: [] };
36+
for (const ln of content.split('\n')) {
37+
const m = ln.match(HEADING);
38+
if (m) { out.push(cur); cur = { heading: m[2], raw: ln.trimEnd(), lines: [] }; }
39+
else cur.lines.push(ln);
40+
}
41+
out.push(cur);
42+
return out.filter((s, i) => i === 0 || s.heading !== null || s.lines.join('').trim());
43+
}
44+
45+
function firstMeaningful(lines) {
46+
for (const l of lines) { const t = l.trim(); if (t) return t; }
47+
return '';
48+
}
49+
50+
/**
51+
* @param {string} content - existing task body (markdown)
52+
* @param {{next?: string}} [opts] - next-step to fold in as the first Log action bullet
53+
* @returns {{content: string, changed: boolean, goal: string|null}}
54+
*/
55+
export function normalizeTaskBody(content = '', opts = {}) {
56+
const original = content || '';
57+
const secs = splitSections(promoteHeadings(original));
58+
59+
let goal = null;
60+
const logLines = [];
61+
const preamble = []; // heading-less leftover prose
62+
const blocks = []; // other headed sections, preserved with their level
63+
64+
for (const s of secs) {
65+
const h = (s.heading || '').toLowerCase();
66+
if (h === 'goal') {
67+
if (goal == null) goal = firstMeaningful(s.lines).replace(HIGHLIGHT, '$1').replace(/^::|::$/g, '').trim();
68+
continue;
69+
}
70+
if (h === 'log') {
71+
for (const l of s.lines) if (l.trim()) logLines.push(l.replace(/\s+$/, ''));
72+
continue;
73+
}
74+
const kept = [];
75+
for (const l of s.lines) {
76+
if (goal == null && HIGHLIGHT.test(l)) { goal = l.match(HIGHLIGHT)[1].trim(); continue; }
77+
if (goal == null && GOAL_LINE.test(l)) { goal = l.match(GOAL_LINE)[2].trim(); continue; }
78+
if (DATED.test(l) || ACTION.test(l)) { logLines.push(l.replace(/\s+$/, '')); continue; }
79+
kept.push(l.replace(/\s+$/, ''));
80+
}
81+
if (s.raw) blocks.push({ heading: h, raw: s.raw, lines: kept });
82+
else for (const l of kept) preamble.push(l);
83+
}
84+
85+
// fold an explicit next-step in as the first action bullet (skip if already present)
86+
const next = (opts.next || '').trim();
87+
if (next) {
88+
const already = logLines.some((l) => l.replace(/[*_`]/g, '').toLowerCase().includes(next.toLowerCase()));
89+
if (!already) logLines.unshift(`- next: ${next}`);
90+
}
91+
92+
const actions = logLines.filter((l) => ACTION.test(l));
93+
const dated = logLines.filter((l) => DATED.test(l));
94+
const otherLog = logLines.filter((l) => !ACTION.test(l) && !DATED.test(l) && BULLET.test(l));
95+
let log = [...actions, ...dated, ...otherLog];
96+
if (!log.length) log = ['- ', '- ', '- '];
97+
98+
// Leftover preamble prose folds under "## Notes" — into an existing Notes block
99+
// if there is one (so re-runs don't stack headers), else a fresh one at the front.
100+
const preText = preamble.join('\n').trim();
101+
if (preText) {
102+
const notes = blocks.find((b) => b.heading === 'notes');
103+
if (notes) notes.lines = [...preText.split('\n'), '', ...notes.lines];
104+
else blocks.unshift({ heading: 'notes', raw: '## Notes', lines: preText.split('\n') });
105+
}
106+
107+
const parts = ['# Goal', goal ? `::${goal}::` : '::TODO — set goal::', '', '# Log', log.join('\n')];
108+
for (const b of blocks) {
109+
const body = b.lines.join('\n').trim();
110+
parts.push('', b.raw);
111+
if (body) parts.push(body);
112+
}
113+
const result = parts.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
114+
return { content: result, changed: result.trim() !== original.trim(), goal: goal || null };
115+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { normalizeTaskBody } from '../task-format.js';
4+
5+
test('lifts a buried goal: line to a wrapped # Goal at the top', () => {
6+
const input = 'preamble\n\ngoal: ship the normalizer\n\n- 2026-06-20: drafted spec\n- next: write core';
7+
const { content, goal, changed } = normalizeTaskBody(input);
8+
assert.equal(goal, 'ship the normalizer');
9+
assert.ok(changed);
10+
assert.ok(content.startsWith('# Goal\n::ship the normalizer::'));
11+
assert.ok(content.includes('# Log'));
12+
// action bullet before dated bullet
13+
assert.ok(content.indexOf('- next: write core') < content.indexOf('- 2026-06-20'));
14+
// prose preserved
15+
assert.ok(content.includes('## Notes'));
16+
assert.ok(content.includes('preamble'));
17+
});
18+
19+
test('lifts a ::highlight:: as the goal', () => {
20+
const { goal } = normalizeTaskBody('notes\n::do the thing::\nmore');
21+
assert.equal(goal, 'do the thing');
22+
});
23+
24+
test('migrates old H2 ## Log / **Next** form to canonical H1', () => {
25+
const input = '## Log\n- **Next:** write core\n\n---\n\nbody text';
26+
const { content } = normalizeTaskBody(input);
27+
assert.ok(content.startsWith('# Goal'));
28+
assert.ok(/^# Log$/m.test(content));
29+
assert.ok(content.includes('write core'));
30+
});
31+
32+
test('no goal anywhere → TODO placeholder + empty action scaffold', () => {
33+
const { content, goal } = normalizeTaskBody('just a thought');
34+
assert.equal(goal, null);
35+
assert.ok(content.includes('::TODO — set goal::'));
36+
assert.ok(content.includes('# Log'));
37+
});
38+
39+
test('idempotent: re-running a conforming body is a no-op', () => {
40+
const once = normalizeTaskBody('goal: x\n- next: y\n- 2026-06-20: z').content;
41+
const twice = normalizeTaskBody(once);
42+
assert.equal(twice.content, once);
43+
assert.equal(twice.changed, false);
44+
});
45+
46+
test('folds an explicit next-step in as the first action bullet', () => {
47+
const { content } = normalizeTaskBody('goal: x\n- 2026-06-20: z', { next: 'do the next thing' });
48+
assert.ok(content.includes('- next: do the next thing'));
49+
assert.ok(content.indexOf('- next: do the next thing') < content.indexOf('- 2026-06-20'));
50+
});
51+
52+
test('does not duplicate a next-step already present', () => {
53+
const body = 'goal: x\n- next: already here';
54+
const { content } = normalizeTaskBody(body, { next: 'already here' });
55+
assert.equal((content.match(/already here/g) || []).length, 1);
56+
});
57+
58+
test('idempotent on its OWN output when a ## Notes section exists', () => {
59+
const input = 'goal: ship\n\nprose to keep\n\n- 2026-06-20: did x\n\nmore prose';
60+
const once = normalizeTaskBody(input);
61+
assert.ok(once.content.includes('## Notes'));
62+
assert.ok(once.content.includes('prose to keep'));
63+
assert.ok(once.content.includes('more prose'));
64+
const twice = normalizeTaskBody(once.content);
65+
assert.equal(twice.content, once.content); // no drift
66+
assert.equal(twice.changed, false);
67+
// exactly one Notes header — no stacking on re-run
68+
assert.equal((twice.content.match(/^## Notes$/gm) || []).length, 1);
69+
});
70+
71+
test('preserves a user H2 section that is not Notes', () => {
72+
const { content } = normalizeTaskBody('goal: x\n\n## Refs\n- http://e.com');
73+
assert.ok(content.includes('## Refs'));
74+
assert.ok(content.includes('http://e.com'));
75+
assert.equal(normalizeTaskBody(content).content, content); // idempotent
76+
});
77+
78+
test('empty input yields the full scaffold', () => {
79+
const { content } = normalizeTaskBody('');
80+
assert.ok(content.startsWith('# Goal'));
81+
assert.ok(content.includes('# Log'));
82+
});

0 commit comments

Comments
 (0)