@sabaiway/agent-workflow-kit 14.21.0 → 14.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,12 +7,10 @@ import { EPICS_REL, readEpicStore } from './epic-store.mjs';
7
7
  import { checkEpic } from './epic-shape.mjs';
8
8
  import { QUEUE_PATH, checkClaims, judgeClose, sweepSiblings } from './epic-shape-ledger.mjs';
9
9
  import { PLANS_REL, isScratchPlanName, plansInFlight, readPlanEntries } from './plan-files.mjs';
10
- import { PIN_FILE, readStoryLine } from './plan-shape-ownership.mjs';
11
- import { PLAN_HEADINGS, checkPlan, isSweep, parseLedger } from './plan-shape.mjs';
12
- import { buildFacts, openRepo } from './plan-shape-facts.mjs';
13
- import { newestCheckpoint } from './checkpoint.mjs';
10
+ import { readStoryLine } from './plan-shape-ownership.mjs';
11
+ import { PLAN_HEADINGS, isSweep, parseLedger } from './plan-shape.mjs';
12
+ import { expandSweepPaths } from './plan-shape-facts.mjs';
14
13
  import { withRepository } from './checkpoint-core.mjs';
15
- import { parseBrief } from './task-brief.mjs';
16
14
  import { escapeForDisplay, isRenderableLine, shellQuoteArg } from './repo-lex.mjs';
17
15
 
18
16
  const WORK_TREE = 'work-tree';
@@ -25,21 +23,22 @@ const FRONT = '---';
25
23
  const CR_END = /\r$/;
26
24
  const SPAN = /\{\{([\s\S]*?)\}\}/g;
27
25
  const PLAN_STEM = /^[A-Za-z0-9_-]+$/;
28
- // The brief grammar's Plan field, matched one line at a time as task-brief matches it.
29
- const PLAN_FIELD = /^Plan: (docs\/plans\/[^/]+\.md)$/;
30
- const TASK_PREFIX = 'TASK-';
31
- const TASK_NUMBER = /-T([1-9][0-9]*)\.md$/;
26
+ const PLAN_TITLE = /^# Plan: (.+)$/;
27
+ const DOCS_AI = 'docs/ai/';
28
+ const PROMPT_PREFIX = `${PLANS_REL}/EXECUTE-PROMPT-`;
32
29
  const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
33
- const NO_CHECKPOINT = 'no-checkpoint';
34
30
  const HEADER_CODES = new Set(['sibling-header', 'sibling-read']);
35
31
  const HEAD_ARGS = ['rev-parse', '-q', '--verify', 'HEAD^{commit}'];
32
+ const INDEX_ARGS = ['diff-index', '--cached', '--quiet', 'HEAD', '--'];
33
+ const INDEX_EQUAL = 0;
34
+ const INDEX_DIFFERS = 1;
36
35
  const STAND_IN_EPIC = 'NEW-EPIC';
37
36
  const E1_CAUSE = `no epic file in ${EPICS_REL}`;
38
37
  const EPIC_TEMPLATE = '../references/authoring/EPIC_TEMPLATE.md';
39
38
  const TASK_TEMPLATE = '../references/authoring/TASK_TEMPLATE.md';
40
- const TEMPLATE_VERBS = new Set(['create', 'modify']);
41
39
  const READERS = Object.freeze({ store: 'readEpicStore', sweep: 'sweepSiblings', entries: 'readPlanEntries',
42
- plans: 'plansInFlight', plan: 'checkPlan', brief: 'parseBrief', sequence: 'newestCheckpoint', head: 'readGit' });
40
+ plans: 'plansInFlight', ledger: 'parseLedger', story: 'readStoryLine', prompt: 'readRegularFileNoFollow',
41
+ head: 'readGit', index: 'diff-index' });
43
42
 
44
43
  const q = shellQuoteArg;
45
44
  const action = (kind, text, command = null) => ({ kind, text: escapeForDisplay(text), command });
@@ -53,13 +52,14 @@ const makeTools = (toolsDir) => {
53
52
  epicCheck: (path, kind = 'run') => node(kind, `check the epic ${path}`, 'epic-shape-cli.mjs', '--check', q(path)),
54
53
  review: (path) => node('run', `read the review brief of the epic ${path}; the solo review is the reading of it`, 'epic-shape-cli.mjs', '--review-brief', q(path)),
55
54
  close: (path) => node('run', `close the epic ${path}`, 'epic-shape-cli.mjs', '--close', q(path)),
56
- planCheck: (plan) => node('run', `check the plan ${plan}`, 'plan-shape-cli.mjs', '--check', q(plan)),
55
+ planCheck: (plan, text = `check the plan ${plan}`) => node('run', text, 'plan-shape-cli.mjs', '--check', q(plan)),
57
56
  planVerify: (plan) => node('run', `verify the plan ${plan} against the tree`, 'plan-shape-cli.mjs', '--verify', q(plan)),
58
- mint: (plan) => node('run', `mint a checkpoint of the plan ${plan}`, 'checkpoint.mjs', 'mint', '--plan', q(plan)),
59
- prune: (plan) => node('run', `prune the checkpoints of the plan ${plan}`, 'checkpoint.mjs', 'prune', '--plan', q(plan)),
60
- stamp: (brief) => node('run', `stamp the brief ${brief}`, 'task-brief.mjs', 'stamp', q(brief)),
61
- check: (brief) => node('run', `check the brief ${brief}`, 'task-brief.mjs', 'check', q(brief)),
62
- reviewState: () => node('run', 'check the review state of the tree', 'review-state.mjs', '--check'),
57
+ reviewState: () => node('run', 'check the review state of the tree: the commit gate', 'review-state.mjs', '--check'),
58
+ diff: () => lineOf('run', 'read the staged diff: the diff review reads it, in the solo walk you read it yourself', ['git', 'diff', '--cached']),
59
+ commit: (plan) => lineOf('run', `commit every staged change once, titled as the plan ${plan.path}`, ['git', 'commit', '-m', q(plan.title)]),
60
+ stage: (row, expansion) => (isSweep(row.path)
61
+ ? action('write', `stage every path the sweep row ${row.id} (${row.path}) expands to: ${expansion()}`)
62
+ : lineOf('run', `stage the path of the row ${row.id}`, ['git', '--literal-pathspecs', 'add', '--', q(row.path)])),
63
63
  copy: (template, target, text) => lineOf('write', text, ['cp', q(resolve(toolsDir, template)), q(target)]),
64
64
  };
65
65
  };
@@ -67,7 +67,8 @@ const state = (reader, file, cause, next = null) => ({ reader, file, cause: esca
67
67
  const findSpans = (file, text) => [...text.matchAll(SPAN)].map(([, span]) => ({ file, span: escapeForDisplay(span) }));
68
68
  const readText = (cwd, path) => {
69
69
  const result = readRegularFileNoFollow(resolve(cwd, path));
70
- return result.outcome === 'ok' ? { text: result.content } : { cause: `${result.outcome}: ${result.code ?? result.className ?? 'unreadable'}` };
70
+ return result.outcome === 'ok' ? { text: result.content }
71
+ : { absent: result.outcome === 'absent', cause: `${result.outcome}: ${result.code ?? result.className ?? 'unreadable'}` };
71
72
  };
72
73
  const isPresent = (cwd, path) => lstatSync(resolve(cwd, path), { throwIfNoEntry: false }) !== undefined;
73
74
  // The lines of a file as the kit's readers see them: a CRLF file reads as its LF twin.
@@ -80,13 +81,17 @@ const declaresEpic = (text) => {
80
81
  };
81
82
  const stripPath = (finding) => (finding.message.startsWith(`${finding.path}: `) ? finding.message.slice(finding.path.length + 2) : finding.message);
82
83
 
83
- const readHead = (cwd, env) => withRepository(cwd, { env }, (context) => {
84
- const result = context.run(HEAD_ARGS, context.top, undefined, context.env);
85
- const stdout = String(result.stdout ?? '').trim();
86
- if (result.status === 0 && OID.test(stdout)) return { ok: true, oid: stdout };
87
- if (result.status === 1 && stdout === '' && !result.error) return { ok: true, oid: null };
88
- return { ok: false, reason: String(result.stderr ?? result.error?.message ?? 'HEAD could not be read').trim() };
84
+ const askGit = (cwd, env, args) => withRepository(cwd, { env }, (context) => {
85
+ const result = context.run(args, context.top, undefined, context.env);
86
+ return { status: result.error ? null : result.status, stdout: String(result.stdout ?? '').trim(),
87
+ reason: String(result.stderr ?? '').trim() || (result.error?.message ?? `git ${args[0]} exited ${result.status}`) };
89
88
  });
89
+ const readHead = (cwd, env) => {
90
+ const answer = askGit(cwd, env, HEAD_ARGS);
91
+ if (answer.status === 0 && OID.test(answer.stdout)) return { ok: true, oid: answer.stdout };
92
+ if (answer.status === 1 && answer.stdout === '') return { ok: true, oid: null };
93
+ return { ok: false, reason: answer.reason };
94
+ };
90
95
 
91
96
  const readStore = (cwd) => {
92
97
  const store = readEpicStore(cwd);
@@ -104,73 +109,55 @@ const readStore = (cwd) => {
104
109
  states: [...states, ...siblingStates.map((finding) => ({ finding }))] };
105
110
  };
106
111
 
107
- const judgeEpic = (entries, entry) => {
108
- const path = `${EPICS_REL}/${entry.name}`;
109
- const others = entries.filter((other) => other.name !== entry.name);
110
- const shape = checkEpic(entry.text, path);
111
- const sweep = sweepSiblings(others, EPICS_REL);
112
- const claims = shape.findings.length === 0 && sweep.ok ? checkClaims([shape.epic, ...sweep.epics]) : { findings: [] };
113
- const findings = [...shape.findings, ...sweep.findings, ...claims.findings];
114
- return { path, others, epic: shape.epic, accepted: !findings.some((finding) => finding.severity !== INFO) };
115
- };
116
-
117
- const planOf = (text) => linesOf(text).map((line) => PLAN_FIELD.exec(line)?.[1]).find(Boolean) ?? null;
118
112
  const planShape = (row, epicId) => `The shape: the title # Plan: and a name; the five headings; under Goal and boundary the line Story: ${row.id} of ${epicId} and a governing spec path under docs/ai/specs or the words not adopted; one ledger row per file in six fields (id, verb, path, responsibility, budget, anchor) — a create or modify row an anchor that resolves to one place and, with no declared source-size cap, the budget n/a, a delete row a dash (—) in budget and anchor; the total line last, its before figure the current lines of every deleted file and of every modify row with a numeric budget, so total: 0 → 0 lines when every row is a create or modify with the budget n/a; at least one - bullet under Verification.`;
119
113
 
120
114
  // The Story line is read under Goal and boundary alone, as plan-shape reads it.
121
- const storyOf = (text) => {
122
- const { document } = parseLedger(text);
115
+ const storyOf = ({ document }) => {
123
116
  const goal = document.headings.find((heading) => heading.text === PLAN_HEADINGS[0]);
124
117
  const end = goal && document.headings.find((heading) => heading.index > goal.index && heading.level <= 2);
125
118
  const lines = goal ? document.lines.slice(goal.index + 1, end?.index).map((line, offset) => ({ text: line, line: goal.index + 2 + offset })) : [];
126
119
  return readStoryLine(lines);
127
120
  };
121
+ // A plan's LEDGER ROWS are its valid rows outside docs/ai/, each with its prompt's canonical name.
128
122
  const readPlan = (cwd, tools, path) => {
129
123
  const read = readText(cwd, path);
130
- if (read.text === undefined) return { path, text: null, story: null, failure: state(READERS.plans, path, read.cause) };
124
+ if (read.text === undefined) return { path, story: null, failure: state(READERS.plans, path, read.cause) };
125
+ const refused = (reader, cause) => ({ path, story: null, failure: state(reader, path, cause, tools.planCheck(path)) });
131
126
  try {
132
- const { story, findings } = storyOf(read.text);
133
- if (findings.length) return { path, text: read.text, story: null, failure: state(READERS.plan, path, findings[0].message, tools.planCheck(path)) };
134
- return { path, text: read.text, story, failure: null };
127
+ const parsed = parseLedger(read.text);
128
+ const { story, findings } = storyOf(parsed);
129
+ if (findings.length) return refused(READERS.story, findings[0].message);
130
+ const stem = posix.basename(path, MARKDOWN);
131
+ const rows = parsed.rows.filter((row) => row.valid);
132
+ const ledger = rows.filter((row) => !row.path.startsWith(DOCS_AI)).map((row) => ({ ...row, prompt: `${PROMPT_PREFIX}${stem}-${row.id}${MARKDOWN}` }));
133
+ const title = linesOf(read.text).map((line) => PLAN_TITLE.exec(line)?.[1]).find(Boolean) ?? null;
134
+ return { path, story, hasRow: rows.length > 0, ledger, title, failure: null };
135
135
  } catch (error) {
136
- return { path, text: read.text, story: null, failure: state(READERS.plan, path, error.message, tools.planCheck(path)) };
136
+ return refused(READERS.ledger, error.message);
137
137
  }
138
138
  };
139
139
 
140
140
  const readPlans = (cwd, tools) => {
141
141
  try {
142
142
  const listing = readPlanEntries(cwd);
143
- const names = plansInFlight(cwd, () => listing);
144
- const plans = names.map((name) => readPlan(cwd, tools, `${PLANS_REL}/${name}`));
145
- const briefs = listing.filter((entry) => entry.isFile() && entry.name.startsWith(TASK_PREFIX) && entry.name.endsWith(MARKDOWN))
146
- .map((entry) => {
147
- const path = `${PLANS_REL}/${entry.name}`;
148
- const read = readText(cwd, path);
149
- return { path, name: entry.name, text: read.text ?? '', cause: read.cause ?? null, plan: planOf(read.text ?? ''),
150
- number: Number(TASK_NUMBER.exec(entry.name)?.[1] ?? 1) };
151
- });
152
- return { ok: true, plans, briefs, states: plans.filter((plan) => plan.failure).map((plan) => plan.failure) };
143
+ const plans = plansInFlight(cwd, () => listing).map((name) => readPlan(cwd, tools, `${PLANS_REL}/${name}`));
144
+ const owners = plans.flatMap((plan) => (plan.ledger ?? []).map((row) => ({ prompt: row.prompt, path: plan.path })));
145
+ const shared = [...new Set(owners.map(({ prompt }) => prompt))]
146
+ .map((prompt) => ({ prompt, paths: [...new Set(owners.filter((owner) => owner.prompt === prompt).map(({ path }) => path))] }))
147
+ .filter(({ paths }) => paths.length > 1);
148
+ const sharing = new Set(shared.flatMap(({ paths }) => paths));
149
+ return { ok: true, plans: plans.map((plan) => ({ ...plan, shared: sharing.has(plan.path) })),
150
+ states: plans.filter((plan) => plan.failure).map((plan) => plan.failure),
151
+ shared: shared.map(({ prompt, paths }) => state(READERS.ledger, prompt, `the prompt name is derived by ${paths.join(', ')}: rename a row or a plan so every prompt has one name`)) };
153
152
  } catch (error) {
154
153
  return { ok: false, state: state(READERS.entries, PLANS_REL, error.message) };
155
154
  }
156
155
  };
157
156
 
158
- const readBinding = (brief) => {
159
- if (brief.cause) return { refused: brief.cause };
160
- const parsed = parseBrief(brief.text);
161
- if (!parsed.ok) return { refused: parsed.reason };
162
- if (parsed.unclosed || parsed.blocks.length > 1) return { refused: 'binding: the block count is not one closed block', binding: true };
163
- if (parsed.blocks.length === 0) return { parsed, bound: false };
164
- const value = (() => { try { return JSON.parse(parsed.blocks[0].source); } catch { return null; } })();
165
- const isOid = (field) => typeof field === 'string' && OID.test(field);
166
- if (!isOid(value?.checkpoint) || !isOid(value?.head)) return { refused: 'binding: the block carries no checkpoint and head', binding: true };
167
- return { parsed, bound: true, checkpoint: value.checkpoint, head: value.head };
168
- };
169
-
170
157
  const judgeStory = (context, epicPath, epicId, row) => {
171
- const { cwd, tools, plans } = context;
158
+ const { cwd, tools, plans, today } = context;
172
159
  const own = plans.plans.filter((plan) => plan.story?.id === row.id && plan.story?.epic === epicId);
173
- const stateOf = (stage, actions, extra = {}) => ({ story: { id: row.id, plan: extra.plan ?? null, stage, residual: extra.residual ?? [], actions } });
160
+ const stateOf = (stage, actions, plan = null, residual = []) => ({ story: { id: row.id, plan, stage, residual, actions } });
174
161
  if (own.length > 1) return { state: state(READERS.plans, own[0].path, `story ${row.id} of ${epicId} has ${own.length} plans: ${own.map((plan) => plan.path).join(', ')}`) };
175
162
  if (own.length === 0) {
176
163
  if (plans.states.length) return {};
@@ -182,106 +169,100 @@ const judgeStory = (context, epicPath, epicId, row) => {
182
169
  if (isPresent(cwd, standIn)) return { state: state(READERS.plans, standIn, `the plan stand-in path is taken by a file carrying no Story line ${row.id} of ${epicId} under Goal and boundary: move the line there, or rename or remove the file`) };
183
170
  return stateOf('S1', [tools.review(epicPath), action('write', `author the plan by hand at ${standIn}. ${planShape(row, epicId)}`)]);
184
171
  }
185
- const plan = own[0];
186
- try {
187
- return judgePlan(context, epicPath, row, plan, stateOf);
188
- } catch (error) {
189
- return { state: state(READERS.plan, plan.path, error.message, tools.planCheck(plan.path)) };
172
+ const [plan] = own;
173
+ if (plan.shared) return {};
174
+ if (!plan.hasRow) return { state: state(READERS.ledger, plan.path, 'the plan has no valid row in its Module ledger', tools.planCheck(plan.path, `add a ledger row to ${plan.path}, then check the plan`)) };
175
+ const prompts = plan.ledger.map((ledgerRow) => ({ row: ledgerRow, ...readText(cwd, ledgerRow.prompt) }));
176
+ const refused = prompts.find((prompt) => prompt.text === undefined && !prompt.absent);
177
+ if (refused) return { state: state(READERS.prompt, refused.row.prompt, refused.cause) };
178
+ const handed = ((gap) => (gap < 0 ? prompts.length : gap))(prompts.findIndex((prompt) => prompt.absent));
179
+ const stray = prompts.slice(handed).filter((prompt) => !prompt.absent).map((prompt) => prompt.row);
180
+ const next = plan.ledger[handed];
181
+ if (stray.length) {
182
+ return { state: state(READERS.prompt, stray[0].prompt, `row ${stray[0].id} has a prompt after row ${next.id}, which has none`,
183
+ action('write', `remove the prompts after the first gap (${stray.map((ledgerRow) => ledgerRow.prompt).join(', ')}), then copy and fill the prompt of row ${next.id}`)) };
190
184
  }
185
+ const residual = prompts.slice(0, handed).flatMap((prompt) => findSpans(prompt.row.prompt, prompt.text));
186
+ const copyFill = (ledgerRow) => [tools.copy(TASK_TEMPLATE, ledgerRow.prompt, `copy the task template to ${ledgerRow.prompt}, the prompt of row ${ledgerRow.id}`),
187
+ action('write', `fill every key of the task template's closed list in ${ledgerRow.prompt}; ROW_LINE is the row ${ledgerRow.id}, copied verbatim`)];
188
+ const carry = (ledgerRow) => [
189
+ action('write', `carry the row ${ledgerRow.id}: write its Files yourself in the solo walk, else hand ${ledgerRow.prompt} to the carrier task.execute resolves to, a bridge only for new tests or code and with the contract block the engine's The task names; a file you already wrote you lay down yourself`),
190
+ action('run', `run every command under ## Run in ${ledgerRow.prompt}; the guide prints no line of a prompt`), tools.stage(ledgerRow, () => {
191
+ try { return expandSweepPaths(cwd, [ledgerRow.path])[ledgerRow.path].join(', ') || 'no path on disk'; } catch (error) { return `not expanded (${error.message})`; }
192
+ })];
193
+ if (handed === 0 && next) return stateOf('S2', [tools.planCheck(plan.path), ...copyFill(next)], plan.path, residual);
194
+ const last = handed > 0 ? carry(plan.ledger[handed - 1]) : [];
195
+ if (next) return stateOf('S3', [...last, ...copyFill(next)], plan.path, residual);
196
+ return stateOf('S4', [...last, tools.planVerify(plan.path), action('write', `edit the story row ${row.id} of ${epicPath} to read state: landed ${today} before the diff review, and stage it with the story's changes where git tracks ${EPICS_REL}`)], plan.path, residual);
191
197
  };
192
198
 
193
- const judgePlan = (context, epicPath, row, plan, stateOf) => {
194
- const { cwd, env, tools, plans, head, today } = context;
195
- const stem = posix.basename(plan.path, MARKDOWN);
196
- const standIn = (number) => `${PLANS_REL}/${TASK_PREFIX}${stem}-T${number}${MARKDOWN}`;
197
- const taken = plans.briefs.find((brief) => brief.plan !== null && brief.plan !== plan.path && brief.path === standIn(brief.number));
198
- if (taken) return { state: state(READERS.brief, taken.path, `the brief stand-in path is taken by a brief of ${taken.plan}: rename or remove it`) };
199
- const briefs = plans.briefs.filter((brief) => brief.plan === plan.path || (brief.plan === null && brief.path === standIn(brief.number)));
200
- const judged = briefs.map((brief) => ({ ...brief, ...readBinding(brief) }));
201
- const refusedBrief = judged.find((brief) => brief.refused);
202
- if (refusedBrief) return { state: state(READERS.brief, refusedBrief.path, refusedBrief.refused,
203
- refusedBrief.binding ? tools.check(refusedBrief.path) : tools.stamp(refusedBrief.path)) };
204
- const top = Math.max(0, ...judged.map((brief) => brief.number));
205
- const inHandAll = judged.filter((brief) => brief.number === top);
206
- if (inHandAll.length > 1) return { state: state(READERS.brief, inHandAll[0].path, `briefs ${inHandAll.map((brief) => brief.path).join(', ')} carry one task number`) };
207
- const inHand = inHandAll[0];
208
- const rows = parseLedger(plan.text).rows.filter((ledgerRow) => ledgerRow.valid);
209
- const briefed = new Set(judged.map((brief) => brief.parsed.rowId));
210
- const unbriefed = rows.find((ledgerRow) => !briefed.has(ledgerRow.id));
211
- const residual = judged.filter((brief) => brief.bound).flatMap((brief) => findSpans(brief.path, brief.text));
212
- const extra = { plan: plan.path, residual };
213
- const newest = newestCheckpoint(cwd, plan.path, { env });
214
- const noHead = () => ({ state: state(READERS.head, null, 'HEAD is unborn: no commit to compare the brief with') });
215
- const copyAction = (ledgerRow, number) => (ledgerRow && TEMPLATE_VERBS.has(ledgerRow.verb) && !isSweep(ledgerRow.path) && posix.basename(ledgerRow.path) !== PIN_FILE
216
- ? [tools.copy(TASK_TEMPLATE, standIn(number), `copy the task template to ${standIn(number)}`),
217
- action('write', `fill every key of the brief template's closed list in ${standIn(number)}, Row: ${ledgerRow.id}`), tools.stamp(standIn(number))]
218
- : [action('write', `write the brief of row ${ledgerRow?.id ?? 'none'} by hand at ${standIn(number)} per the brief grammar (checkpoint, task-brief); the task template does not fit it`), tools.stamp(standIn(number))]);
219
- if (!newest.ok && !String(newest.reason).startsWith(NO_CHECKPOINT)) return { state: state(READERS.sequence, plan.path, newest.reason) };
220
- if (!newest.ok) {
221
- if (!judged.some((brief) => brief.bound)) {
222
- const paths = rows.flatMap((ledgerRow) => [ledgerRow.path, ledgerRow.anchorPath].filter(Boolean));
223
- const result = checkPlan(plan.text, buildFacts(cwd, { paths, repo: openRepo(cwd) }));
224
- if (result.findings.length) return { state: state(READERS.plan, plan.path, result.findings[0].message, tools.planCheck(plan.path)) };
225
- return stateOf('S2', [tools.planCheck(plan.path), tools.mint(plan.path)], extra);
226
- }
227
- if (head.oid === null) return noHead();
228
- if (!unbriefed && inHand.bound && inHand.head !== head.oid) {
229
- return stateOf('S8', [action('write', `edit the story row ${row.id} of ${epicPath} to read state: landed ${today}`)], extra);
230
- }
231
- return { state: state(READERS.sequence, plan.path, 'the sequence was pruned before the story was done', tools.mint(plan.path)) };
232
- }
233
- if (!inHand) return stateOf('S3', copyAction(rows[0], 1), extra);
234
- if (!inHand.bound) return stateOf('S4', [tools.stamp(inHand.path)], extra);
235
- if (head.oid === null) return noHead();
236
- if (inHand.head !== head.oid) {
237
- return stateOf('S7', unbriefed ? [tools.mint(plan.path), ...copyAction(unbriefed, top + 1)]
238
- : [tools.planVerify(plan.path), tools.prune(plan.path)], extra);
199
+ const judgeEpic = (context, entries, entry) => {
200
+ const path = `${EPICS_REL}/${entry.name}`;
201
+ const id = posix.basename(path, MARKDOWN);
202
+ const others = entries.filter((other) => other.name !== entry.name);
203
+ const shape = checkEpic(entry.text, path);
204
+ const sweep = sweepSiblings(others, EPICS_REL);
205
+ const claims = shape.findings.length === 0 && sweep.ok ? checkClaims([shape.epic, ...sweep.epics]) : { findings: [] };
206
+ const accepted = ![...shape.findings, ...sweep.findings, ...claims.findings].some((finding) => finding.severity !== INFO);
207
+ // The leftover: the plans in flight naming a landed row, in ledger order.
208
+ const leftover = accepted ? shape.epic.rows.filter((row) => row.state === LANDED)
209
+ .flatMap((row) => context.plans.plans.filter((plan) => plan.story?.epic === id && plan.story.id === row.id)) : [];
210
+ return { entry, path, id, others, epic: shape.epic, accepted, leftover };
211
+ };
212
+
213
+ const readIndex = (context, held) => {
214
+ const paths = held.flatMap((judged) => judged.leftover.map((plan) => plan.path));
215
+ if (context.head.oid === null) return { ok: false, state: state(READERS.head, paths[0], `HEAD is unborn: the index of the leftover of ${paths.join(', ')} has no commit to be read against`) };
216
+ const answer = askGit(context.cwd, context.env, INDEX_ARGS);
217
+ if (answer.status !== INDEX_EQUAL && answer.status !== INDEX_DIFFERS) return { ok: false, state: state(READERS.index, paths[0], answer.reason) };
218
+ if (answer.status === INDEX_DIFFERS && held.length > 1) {
219
+ return { ok: false, state: state(READERS.index, paths[0], `the index differs from HEAD while ${held.length} epics have a landed plan in flight (${paths.join(', ')}): one commit cannot keep their stories apart`) };
239
220
  }
240
- if (inHand.checkpoint !== newest.oid) return stateOf('S5', [tools.stamp(inHand.path)], extra);
241
- const files = inHand.parsed.files.map((file) => file.path);
242
- return stateOf('S6', [tools.check(inHand.path),
243
- action('write', `bring the Files of ${inHand.path} to the row's post-state, the test first, until every Acceptance line is green`),
244
- ...inHand.parsed.acceptance.map(([command]) => lineOf('run', 'run the Acceptance command of the brief', [command])),
245
- tools.reviewState(),
246
- lineOf('run', 'stage the Files of the brief', ['git', '--literal-pathspecs', 'add', '--', ...files.map(q)]),
247
- lineOf('run', 'commit the Files of the brief', ['git', 'commit', '-m', q(inHand.parsed.title)])], extra);
221
+ return { ok: true, differs: answer.status === INDEX_DIFFERS };
248
222
  };
249
223
 
250
- const cleanupOf = (context, epicId, rows) => context.plans.plans
251
- .filter((plan) => plan.story?.epic === epicId && rows.some((row) => row.id === plan.story.id && row.state === LANDED))
252
- .flatMap((plan) => {
253
- const newest = newestCheckpoint(context.cwd, plan.path, { env: context.env });
254
- const prune = !newest.ok && String(newest.reason).startsWith(NO_CHECKPOINT) ? [] : [context.tools.prune(plan.path)];
255
- return [...prune, action('write', `remove the plan ${plan.path} and its briefs: the Cleanup of a landed story`)];
224
+ const leftoverActions = (context, plans) => {
225
+ if (!context.index.ok || plans.some((plan) => plan.shared)) return [];
226
+ const removals = plans.map((plan) => {
227
+ const prompts = plan.ledger.map((row) => row.prompt).filter((prompt) => isPresent(context.cwd, prompt));
228
+ return action('write', `remove the plan ${plan.path} and its prompts (${prompts.length ? prompts.join(', ') : 'none on disk'}): the Cleanup of a landed story`);
256
229
  });
230
+ if (!context.index.differs) return removals;
231
+ const { tools } = context;
232
+ const commit = plans[0].title === null ? action('write', `commit every staged change once: the plan ${plans[0].path} carries no # Plan: title to name it`) : tools.commit(plans[0]);
233
+ return [tools.diff(), tools.reviewState(), commit, ...removals];
234
+ };
257
235
 
258
- const judgeEntry = (context, store, entry) => {
259
- const judged = judgeEpic(store.entries, entry);
236
+ const closeActions = (context, judged) => {
237
+ const queueRead = readRegularFileNoFollow(resolve(context.cwd, QUEUE_PATH));
238
+ const queue = queueRead.outcome === 'ok' ? queueRead.content : { outcome: queueRead.outcome, reason: queueRead.code ?? queueRead.className };
239
+ const closed = judgeClose({ epic: judged.entry.text, path: judged.path, queue, entries: judged.others });
240
+ const edits = closed.findings.filter((finding) => finding.severity !== INFO).map((finding) => (finding.code === 'close-result'
241
+ ? action('write', `add the line Result line: ${context.today} under ## Acceptance of ${judged.path}`)
242
+ : action('write', finding.code === 'close-queue' ? `delete the queue row: ${stripPath(finding)}` : stripPath(finding))));
243
+ return [...edits, context.tools.close(judged.path),
244
+ action('write', `commit the close's edits (the header, the Result line, the queue row) where git tracks them: the epic's own change after its last story's commit`)];
245
+ };
246
+
247
+ const judgeEntry = (context, judged) => {
260
248
  const { tools } = context;
261
- const id = posix.basename(judged.path, MARKDOWN);
262
- const base = { stage: 'E2', id, path: judged.path, cause: null, fact: null, residual: [], actions: [tools.epicCheck(judged.path)], stories: [] };
249
+ const { path, id, epic } = judged;
250
+ const base = { stage: 'E2', id, path, cause: null, fact: null, residual: [], actions: [tools.epicCheck(path)], stories: [] };
263
251
  if (!judged.accepted) return { entry: base, states: [] };
264
- const { epic } = judged;
265
- const residual = findSpans(judged.path, entry.text);
266
- const accepted = { ...base, residual };
267
- if (epic.fields.state === LANDED) return { entry: { ...accepted, stage: 'E5', actions: cleanupOf(context, id, epic.rows) }, states: [] };
252
+ const accepted = { ...base, residual: findSpans(path, judged.entry.text) };
253
+ const leftover = judged.leftover.length ? leftoverActions(context, judged.leftover) : null;
254
+ if (epic.fields.state === LANDED) return { entry: { ...accepted, stage: 'E5', actions: leftover ?? [] }, states: [] };
268
255
  const landed = new Set(epic.rows.filter((row) => row.state === LANDED).map((row) => row.id));
269
256
  const inHand = epic.rows.filter((row) => row.state !== LANDED && row.dependsOn.every((dependency) => landed.has(dependency)));
270
- if (inHand.length === 0) {
271
- const queueRead = readRegularFileNoFollow(resolve(context.cwd, QUEUE_PATH));
272
- const queue = queueRead.outcome === 'ok' ? queueRead.content : { outcome: queueRead.outcome, reason: queueRead.code ?? queueRead.className };
273
- const closed = judgeClose({ epic: entry.text, path: judged.path, queue, entries: judged.others });
274
- const edits = closed.findings.filter((finding) => finding.severity !== INFO).map((finding) => (finding.code === 'close-result'
275
- ? action('write', `add the line Result line: ${context.today} under ## Acceptance of ${judged.path}`)
276
- : action('write', finding.code === 'close-queue' ? `delete the queue row: ${stripPath(finding)}` : stripPath(finding))));
277
- return { entry: { ...accepted, stage: 'E4', actions: [...edits, tools.close(judged.path), ...cleanupOf(context, id, epic.rows)] }, states: [] };
278
- }
279
- const stories = inHand.map((row) => judgeStory(context, judged.path, id, row));
280
- return { entry: { ...accepted, stage: 'E3', fact: tools.epicCheck(judged.path, 'fact'), actions: cleanupOf(context, id, epic.rows),
281
- stories: stories.filter((story) => story.story).map((story) => story.story) }, states: stories.filter((story) => story.state).map((story) => story.state) };
257
+ if (inHand.length === 0) return { entry: { ...accepted, stage: 'E4', actions: leftover ?? (context.index === null ? closeActions(context, judged) : []) }, states: [] };
258
+ const held = { ...accepted, stage: 'E3', fact: tools.epicCheck(path, 'fact'), actions: leftover ?? [] };
259
+ if (context.index !== null) return { entry: held, states: [] };
260
+ const stories = inHand.map((row) => judgeStory(context, path, id, row));
261
+ return { entry: { ...held, stories: stories.filter((story) => story.story).map((story) => story.story) },
262
+ states: stories.filter((story) => story.state).map((story) => story.state) };
282
263
  };
283
264
 
284
- const judgeFirstEpic = (context, store) => {
265
+ const judgeFirstEpic = (context) => {
285
266
  const standIn = `${EPICS_REL}/${STAND_IN_EPIC}${MARKDOWN}`;
286
267
  if (isPresent(context.cwd, standIn)) return { states: [state(READERS.store, standIn, 'the epic stand-in path is taken by a file that is not an epic: rename or remove it')], entries: [] };
287
268
  return { states: [], entries: [{ stage: 'E1', id: null, path: null, cause: E1_CAUSE, fact: null, residual: [], actions: [
@@ -304,12 +285,17 @@ export const readTierFacts = ({ cwd, env, toolsDir, today }) => {
304
285
  const head = readHead(cwd, env);
305
286
  const headStates = head.ok ? [] : [state(READERS.head, null, head.reason)];
306
287
  if (!plans.ok) return { location, states: [...storeStates, plans.state, ...headStates], entries: [], skipped: store.skipped };
307
- const context = { cwd, env, tools, plans, head: head.ok ? head : { oid: null }, today };
288
+ const planStates = [...storeStates, ...plans.states, ...plans.shared, ...headStates];
289
+ if (!head.ok) return { location, states: planStates, entries: [], skipped: store.skipped };
290
+ const context = { cwd, env, tools, plans, head, today, index: null };
308
291
  if (store.epics.length === 0) {
309
- const first = store.headerFindings.length ? { states: [], entries: [] } : judgeFirstEpic(context, store);
310
- return { location, states: [...storeStates, ...plans.states, ...headStates, ...first.states], entries: first.entries, skipped: store.skipped };
292
+ const first = store.headerFindings.length ? { states: [], entries: [] } : judgeFirstEpic(context);
293
+ return { location, states: [...planStates, ...first.states], entries: first.entries, skipped: store.skipped };
311
294
  }
312
- const judged = store.epics.map((entry) => judgeEntry(context, store, entry));
313
- return { location, states: [...storeStates, ...plans.states, ...headStates, ...judged.flatMap((item) => item.states)],
314
- entries: judged.map((item) => item.entry), skipped: store.skipped };
295
+ const judged = store.epics.map((entry) => judgeEpic(context, store.entries, entry));
296
+ const held = judged.filter((item) => item.leftover.length);
297
+ const index = held.length ? readIndex(context, held) : null;
298
+ const rendered = judged.map((item) => judgeEntry({ ...context, index }, item));
299
+ return { location, states: [...planStates, ...(index?.state ? [index.state] : []), ...rendered.flatMap((item) => item.states)],
300
+ entries: rendered.map((item) => item.entry), skipped: store.skipped };
315
301
  };
@@ -38,25 +38,19 @@ const EPIC_KEYS = [
38
38
  ['QUEUE_BUCKET', 'the queue section the epic\'s row stands in.'],
39
39
  ];
40
40
  const TASK_KEYS = [
41
- ['TASK_NAME', 'the task in a few plain words; the commit title.'],
42
- ['STORY_ID', 'the story the task serves: S1, S2 and so on.'],
43
- ['EPIC_ID', 'the epic the story belongs to, its file stem.'],
41
+ ['TASK_NAME', 'the task in a few plain words.'],
44
42
  ['PLAN_PATH', 'the plan under docs/plans that carries the row.'],
45
- ['ROW_ID', 'the ledger row the task carries.'],
46
- ['TEST_PATH', 'the test file of the row, written first.'],
47
- ['MODULE_PATH', 'the module file of the row.'],
48
- ['ACCEPTANCE_COMMAND', 'a command line that proves the task done.'],
49
- ['EXPECTED_OUTCOME', 'what that command answers when the task is done.'],
50
- ['NEGATIVE_CASE', 'a case the task must not break.'],
51
- ['NEGATIVE_OUTCOME', 'what stays true in that case.'],
52
- ['TEST_MAX_LINES', 'the line budget of the test file.'],
53
- ['MODULE_MAX_LINES', 'the line budget of the module file.'],
43
+ ['ROW_LINE', 'the ledger row, copied verbatim.'],
44
+ ['FILE_PATH', 'a path the carrier may write, one line per path, none ignored by git.'],
45
+ ['SCENARIO_LINE', 'a scenario line of the governing spec, verbatim, or the acceptance bullet of a plan that governs none.'],
46
+ ['RUN_COMMAND', 'a command line the carrier runs, the row\'s tests.'],
47
+ ['FORBIDDEN_COMMAND', 'a command the carrier must not run, such as git commit before the diff review.'],
54
48
  ];
55
49
  const renderTier = (toolsDir, cwd) => [
56
50
  'An epic is one outcome too large for one plan, split into stories in its ledger under docs/ai/epics.',
57
51
  'A story is one row of an epic\'s ledger, carried by one plan under docs/plans.',
58
- 'A task is one row of a story\'s plan, carried by one brief bound to a checkpoint.',
59
- 'A story runs as five sessions: spec, plan, tests, code, diff review + release + record.',
52
+ 'A task is one row of a story\'s plan, handed out in one prompt from the task template; the story commits once, after its diff review.',
53
+ 'A story runs as four sessions: spec and plan, tests, code, diff review + release + record.',
60
54
  `The epic template: ${resolve(toolsDir, '../references/authoring/EPIC_TEMPLATE.md')}`,
61
55
  `The task template: ${resolve(toolsDir, '../references/authoring/TASK_TEMPLATE.md')}`,
62
56
  'Epic template keys:',