doctrina-cli 0.20.0 → 0.21.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.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +92 -14
  5. package/src/commands/clarify.js +1 -204
  6. package/src/commands/context.js +33 -2
  7. package/src/commands/contract.js +1 -0
  8. package/src/commands/delivery.js +135 -17
  9. package/src/commands/doctor.js +21 -1
  10. package/src/commands/handoff.js +11 -2
  11. package/src/commands/init.js +13 -1
  12. package/src/commands/intake.js +16 -5
  13. package/src/commands/metrics.js +15 -0
  14. package/src/commands/next.js +41 -6
  15. package/src/commands/prime.js +53 -4
  16. package/src/commands/review.js +39 -3
  17. package/src/commands/skill.js +2 -1
  18. package/src/commands/status.js +28 -0
  19. package/src/commands/upgrade.js +18 -0
  20. package/src/commands/validate.js +18 -0
  21. package/src/commands/verify.js +32 -4
  22. package/src/commands/work.js +43 -8
  23. package/src/commands/workspace.js +73 -63
  24. package/src/index.js +98 -62
  25. package/src/lib/actions.js +43 -3
  26. package/src/lib/adapters.js +63 -1
  27. package/src/lib/args.js +33 -1
  28. package/src/lib/clarify-scan.js +221 -0
  29. package/src/lib/code-inventory.js +576 -103
  30. package/src/lib/commands.js +8 -0
  31. package/src/lib/context-slice.js +34 -3
  32. package/src/lib/delivery.js +88 -10
  33. package/src/lib/docs-impact.js +36 -1
  34. package/src/lib/fs-ops.js +23 -0
  35. package/src/lib/intake-model.js +1 -1
  36. package/src/lib/json-out.js +9 -1
  37. package/src/lib/shell.js +45 -0
  38. package/src/lib/snapshot.js +5 -1
  39. package/src/lib/task-card.js +100 -7
  40. package/src/lib/views.js +3 -3
  41. package/src/lib/work-model.js +8 -2
  42. package/src/lib/workspace-agents.js +65 -18
  43. package/src/lib/workspace-guard.js +65 -0
  44. package/src/lib/workspace-locate.js +141 -0
  45. package/src/lib/workspace-tools.js +63 -16
  46. package/src/lib/workspace-view.js +391 -0
  47. package/src/lib/workspace.js +82 -130
  48. package/templates/AGENTS.md.template +1 -1
  49. package/templates/README.md +2 -1
  50. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  51. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  52. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  53. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  54. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  55. package/templates/playbooks/absorb.md.template +39 -16
  56. package/templates/playbooks/bootstrap.md.template +18 -6
  57. package/templates/playbooks/delivery.md.template +12 -6
@@ -96,6 +96,7 @@ export const OPERATIONS = [
96
96
  ["delivery route", "Choose the projects a delivery touches, dependencies first"],
97
97
  ["delivery list", "Deliveries with status, ref and closed/total changes"],
98
98
  ["delivery status", "Per project: the changes carrying a delivery and their tasks"],
99
+ ["delivery tick", "List or tick a delivery's Release claims"],
99
100
  ["delivery check", "What still keeps a delivery from closing (exit 1 while anything does)"],
100
101
  ["delivery close", "Close a delivery once every project's change is closed"],
101
102
  ["templates list", "List the templates shipped by the installed CLI"],
@@ -542,6 +543,13 @@ export const AGENT_CHANGELOG = {
542
543
  // Each entry lists only ITS OWN delta. The series carries the rest
543
544
  // forward (see agentChangelogEntries), so a patch never has to restate
544
545
  // the minor that introduced the commands, and never silently erases it.
546
+ "0.21.0": [
547
+ "At a workspace root, `next`, `handoff`, `status`, `validate`, `context`, `doctor` and `metrics` answer for the workspace; never `doctrina init` there or in a shadowed repository's clone — the commands name `doctrina workspace attach` instead.",
548
+ "A task card carries the delta, the delivery's story and every binding decision, global ones included: as an executor, stop and report when the story or the task contradicts one; a card on placeholder tasks means the planner writes them first (`doctrina work --resume <id>`).",
549
+ "Run the command lines the CLI prints as printed: paths and titles are quoted for the shell, and tracker text stays data.",
550
+ "Cursor: the core loop is skills in `.cursor/skills/doctrina-*/`; run `doctrina upgrade --write` to move an older install. With a tool declared the guard fails closed — a refused call means fix the install or ask the human.",
551
+ "Absorb: after writing specs by hand run `doctrina validate --fix`; a stored code inventory resumes with a bare `doctrina intake`, never `--from-code` again.",
552
+ ],
545
553
  "0.20.0": [
546
554
  "Work across repositories is a delivery: from the workspace, `doctrina delivery new \"<title>\" --ref <id>`, then follow its playbook — route, one change per repository with `doctrina work \"<title>\" --delivery <id>`, close each, then `doctrina delivery close <id>`.",
547
555
  "Write tasks as self-contained cards (indented `Files:`, `Requirement:`, `Verify:` lines) and hand an executor `doctrina change card <id>`; one writer per repository, and only the planner closes (ADR 0030).",
@@ -1,6 +1,6 @@
1
1
  // @ts-check
2
2
  import { fold } from "./lexicon.js";
3
- import { getSection } from "./doc-model.js";
3
+ import { commentRanges, getSection } from "./doc-model.js";
4
4
 
5
5
  // The lean read pack (change 0216).
6
6
  //
@@ -17,6 +17,31 @@ const ADR_DIGEST_CHARS = 700;
17
17
  // How many requirements and criteria a sliced spec keeps at most, each.
18
18
  const SLICE_MAX_ITEMS = 25;
19
19
 
20
+ /**
21
+ * A change file as a reader needs it: without its HTML comments (change
22
+ * 0228). The proposal, tasks and delta templates carry their authoring
23
+ * instructions in comments — forty lines of "for ADDED… for MODIFIED…" in
24
+ * every delta — and the change in focus enters the pack whole, so a change
25
+ * with eight deltas spent ~2.6k tokens repeating the template to an agent
26
+ * that was reading, not writing. Null when the text has no comment.
27
+ *
28
+ * @param {string} text
29
+ * @returns {string | null}
30
+ */
31
+ export function withoutComments(text) {
32
+ const lf = String(text).replace(/\r\n/g, "\n");
33
+ const ranges = commentRanges(lf);
34
+ if (ranges.length === 0) return null;
35
+ let out = "";
36
+ let at = 0;
37
+ for (const [a, b] of ranges) {
38
+ out += lf.slice(at, a);
39
+ at = b;
40
+ }
41
+ out += lf.slice(at);
42
+ return out.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
43
+ }
44
+
20
45
  /**
21
46
  * An ADR as a reader deciding whether it binds the task needs it: the
22
47
  * title and the opening of its Decision section. `doctrina show NNNN`
@@ -50,7 +75,12 @@ export function adrDigest(text, title, id) {
50
75
  */
51
76
  export function sliceSpec(text, queryTerms, rel) {
52
77
  if (queryTerms.length === 0) return null;
53
- const lines = text.replace(/\r\n/g, "\n").split("\n");
78
+ // Read without the template's comments (second audit, change 0233). The
79
+ // EARS comment lists the five forms as bullets — "- Ubiquitous: The system
80
+ // shall <action>." — and they were counted as requirements: every pack of
81
+ // a scaffolded spec reported "… 5 not matching the task" for bullets no
82
+ // one wrote, and carried the forty-line header comment besides.
83
+ const lines = (withoutComments(text) ?? text.replace(/\r\n/g, "\n")).split("\n");
54
84
  // Items: a bullet or numbered entry with its continuation lines.
55
85
  /** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
56
86
  const items = [];
@@ -112,7 +142,8 @@ export function sliceSpec(text, queryTerms, rel) {
112
142
  }
113
143
  flushOmitted();
114
144
  const kept = keep.size;
115
- out.push("", `(sliced for "${queryTerms.join(" ")}" — ${kept} of ${items.length} requirements and criteria kept; ` +
145
+ const shownTerms = queryTerms.length > 12 ? `${queryTerms.slice(0, 12).join(" ")} …` : queryTerms.join(" ");
146
+ out.push("", `(sliced for "${shownTerms}" — ${kept} of ${items.length} requirements and criteria kept; ` +
116
147
  `full text: ${rel}, or \`context --full\`)`);
117
148
  return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
118
149
  }
@@ -1,12 +1,13 @@
1
1
  // @ts-check
2
2
  import { readdirSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { isDir, isFile, read, write } from "./fs-ops.js";
4
+ import { eolOf, isDir, isFile, read, withEol, write } from "./fs-ops.js";
5
5
  import { listHeader } from "./scan.js";
6
- import { checklistProgress, setHeader } from "./doc-model.js";
6
+ import { checklistProgress, maskComments, parseChecklist, setHeader } from "./doc-model.js";
7
+ import { parseLedger } from "./ledger.js";
7
8
  import { terms } from "./lexicon.js";
8
9
  import { slugify, padNumber } from "./dates.js";
9
- import { artifactRootOf } from "./workspace.js";
10
+ import { artifactRootOf, projectState } from "./workspace.js";
10
11
 
11
12
  // A DELIVERY is one item of work that crosses repositories (ADR 0030): a
12
13
  // story, a screen, a fix. It lives in the workspace's `deliveries/` and
@@ -72,8 +73,8 @@ export function setDeliveryHeader(wsDir, id, name, value) {
72
73
  let text = read(p);
73
74
  const next = setHeader(text, name, value);
74
75
  if (next !== null) text = next;
75
- else text = text.replace(/^(- \*\*Projects:\*\*.*)$/m, `$1\n- **${name}:** ${value}`);
76
- write(p, text, { force: true });
76
+ else text = text.replace(/^(- \*\*Projects:\*\*[^\r\n]*)/m, `$1\n- **${name}:** ${value}`);
77
+ write(p, withEol(text, eolOf(read(p))), { force: true });
77
78
  }
78
79
 
79
80
  /**
@@ -138,16 +139,26 @@ export function deliveryChanges(wsDir, ws, delivery) {
138
139
  const text = read(proposal);
139
140
  if ((listHeader(text, "Delivery") ?? "").trim() !== delivery.id) continue;
140
141
  const tasks = path.join(dir, entry, "tasks.md");
141
- const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0 };
142
+ const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0, placeholders: 0 };
142
143
  changes.push({
143
144
  id: archived ? entry.replace(/^\d{4}-\d{2}-\d{2}-/, "") : entry,
144
145
  archived,
145
146
  status: archived ? "archived" : (listHeader(text, "Status") ?? "proposed").toLowerCase(),
146
147
  tasksDone: progress.done, tasksTotal: progress.total,
148
+ // Scaffold boxes no one wrote (third audit, change 0234): counted
149
+ // as tasks, they read "3 tasks left — hand the next card" for a
150
+ // change whose card command answers that none is written.
151
+ placeholders: progress.placeholders ?? 0,
147
152
  });
148
153
  }
149
154
  }
150
- out.push({ project: name, path: project.path, changes });
155
+ // Abandoned for this delivery: the ledger keeps the delivery's id (change 0234).
156
+ const ledger = path.join(changesDir, "archive", "LEDGER.md");
157
+ const abandoned = isFile(ledger)
158
+ ? parseLedger(read(ledger)).entries.filter((e) => e.kind === "abandoned" && new RegExp(`\\(delivery ${delivery.id}\\)\\s*$`).test(e.title))
159
+ .map((e) => ({ id: e.id, reason: e.title.replace(/^abandoned\s*(—\s*)?/i, "").replace(/\s*\(delivery [^)]*\)\s*$/, "") }))
160
+ : [];
161
+ out.push({ project: name, path: project.path, absent: !isDir(project.path), changes, abandoned });
151
162
  }
152
163
  return out;
153
164
  }
@@ -181,9 +192,9 @@ export function daysBetween(from, to) {
181
192
  export function recordBlockers(delivery) {
182
193
  const out = [];
183
194
  if (sectionText(delivery.text, "Story") === "") out.push("## Story is empty — copy the tracker item's story and acceptance criteria, verbatim");
184
- const release = sectionText(delivery.text, "Release");
185
- const open = release.split("\n").filter((l) => /^[-*]\s*\[\s\]/.test(l));
186
- for (const box of open) out.push(`## Release: unticked — ${box.replace(/^[-*]\s*\[\s\]\s*/, "")}`);
195
+ // The same boxes `delivery tick` lists and ticks — one reader, so a box
196
+ // the check holds you to is always one the CLI can tick.
197
+ for (const box of releaseBoxes(delivery.text).filter((b) => !b.checked)) out.push(`## Release: unticked — ${box.text}`);
187
198
  if (sectionText(delivery.text, "Observability") === "") out.push("## Observability is empty — record what was watched after the release (monitor, dashboard or query, the window, what it showed)");
188
199
  return out;
189
200
  }
@@ -198,6 +209,8 @@ export function deliveryBlockers(wsDir, ws, delivery) {
198
209
  if (delivery.projects.length === 0) blockers.push("no project routed — `doctrina delivery route <id> <a,b>`");
199
210
  for (const row of deliveryChanges(wsDir, ws, delivery)) {
200
211
  if (row.missing) blockers.push(`${row.project}: ${row.missing}`);
212
+ else if (row.absent) blockers.push(`${row.project}: the repository is not at ${row.path} — \`doctrina workspace attach ${row.project} --path <dir>\``);
213
+ else if (row.changes.length === 0 && row.abandoned?.length) blockers.push(`${row.project}: its change ${row.abandoned.map((a) => a.id).join(", ")} was abandoned — re-route the delivery without ${row.project}, or open a new change there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
201
214
  else if (row.changes.length === 0) blockers.push(`${row.project}: no change carries this delivery — open one there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
202
215
  else for (const ch of row.changes.filter((x) => !x.archived)) {
203
216
  blockers.push(`${row.project}: change ${ch.id} is ${ch.status} (${ch.tasksDone}/${ch.tasksTotal} tasks) — close it there with \`doctrina close ${ch.id}\``);
@@ -206,3 +219,68 @@ export function deliveryBlockers(wsDir, ws, delivery) {
206
219
  blockers.push(...recordBlockers(delivery));
207
220
  return blockers;
208
221
  }
222
+
223
+ /**
224
+ * The `## Release` boxes of a delivery, in order, with their line index in
225
+ * the LF-normalised text (change 0226). The other sections are prose the
226
+ * planner writes; these are claims, and a claim is ticked through the CLI.
227
+ */
228
+ export function releaseBoxes(text) {
229
+ // One owner for the box grammar (ADR 0021): the document model reads the
230
+ // boxes; a box inside a comment is the template's example, not a claim.
231
+ const lf = String(text).replace(/\r\n/g, "\n");
232
+ return parseChecklist(maskComments(lf), { section: "Release" })
233
+ .filter((b) => b.section === "Release")
234
+ .map((b, i) => ({ ordinal: i + 1, line: b.line, checked: b.checked, text: b.text }));
235
+ }
236
+
237
+ /** The text with the given Release boxes ticked (all when `ordinals` is "all"). */
238
+ export function tickRelease(text, ordinals) {
239
+ const lines = String(text).replace(/\r\n/g, "\n").split("\n");
240
+ for (const box of releaseBoxes(text)) {
241
+ if (ordinals === "all" || ordinals.includes(box.ordinal)) {
242
+ lines[box.line] = lines[box.line].replace(/\[\s\]/, "[x]");
243
+ }
244
+ }
245
+ return lines.join("\n");
246
+ }
247
+
248
+ /**
249
+ * Projects a delivery cannot be routed to yet (change 0226): one whose
250
+ * repository is missing, or one still `pending` — no spec to write a delta
251
+ * against, so the change opened there would have nothing to change.
252
+ * @returns {{ name: string, state: string, reason: string, path: string }[]}
253
+ */
254
+ export function unroutable(wsDir, ws, names) {
255
+ const out = [];
256
+ for (const name of names) {
257
+ const project = ws.projects.find((p) => p.name === name);
258
+ if (!project) continue;
259
+ const st = projectState(wsDir, project);
260
+ if (st.state !== "ready") out.push({ name, state: st.state, reason: st.reason, path: project.path });
261
+ }
262
+ return out;
263
+ }
264
+
265
+ /**
266
+ * Open changes in a routed repository that do NOT belong to this delivery
267
+ * (change 0226). Each is a second writer the planner must sequence: one
268
+ * writer per repository at a time holds across deliveries too.
269
+ * @returns {{ project: string, id: string, delivery: string|null }[]}
270
+ */
271
+ export function otherWriters(wsDir, ws, delivery) {
272
+ const out = [];
273
+ for (const name of delivery.projects) {
274
+ const project = ws.projects.find((p) => p.name === name);
275
+ if (!project) continue;
276
+ const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
277
+ if (!isDir(changesDir)) continue;
278
+ for (const entry of readdirSync(changesDir).sort()) {
279
+ const proposal = path.join(changesDir, entry, "proposal.md");
280
+ if (entry === "archive" || !isFile(proposal)) continue;
281
+ const of = (listHeader(read(proposal), "Delivery") ?? "").trim() || null;
282
+ if (of !== delivery.id) out.push({ project: name, id: entry, delivery: of });
283
+ }
284
+ }
285
+ return out;
286
+ }
@@ -257,10 +257,16 @@ export function documentedSurfaceSignals(changeDir, projectRoot = null) {
257
257
  signals.push(`endpoints: ${[...routes].sort().slice(0, 6).join(", ")}`);
258
258
  }
259
259
 
260
+ // An UPPER_SNAKE name is an environment variable when something says so
261
+ // (change 0239): the shape alone also matches an error code
262
+ // (`CONTA_BLOQUEADA`) or a constant (`MAX_FALHAS`), and every change that
263
+ // merely cited one was asked to document an "environment" it never touched.
264
+ const exampleNames = projectRoot === null ? new Set() : envExampleNames(projectRoot);
260
265
  const envVars = new Set();
261
266
  for (const m of code.matchAll(/\b([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)\b/g)) {
262
267
  // Not twice: a declared variable is already reported by name above.
263
- if (!touchedDeclared.includes(m[1])) envVars.add(m[1]);
268
+ if (touchedDeclared.includes(m[1])) continue;
269
+ if (looksLikeEnv(m[1], text) || exampleNames.has(m[1])) envVars.add(m[1]);
264
270
  }
265
271
  if (envVars.size > 0) {
266
272
  signals.push(`environment: ${[...envVars].sort().slice(0, 8).join(", ")}`);
@@ -408,3 +414,32 @@ export function checkDocsImpact(projectRoot, changeDir) {
408
414
  reason: "alters a documented surface but no docs/ or README change accompanies it",
409
415
  };
410
416
  }
417
+
418
+ // The suffixes configuration read from the environment carries.
419
+ const ENV_SUFFIX = /_(URL|URI|DSN|HOST|PORT|ENDPOINT|KEY|TOKEN|SECRET|PASSWORD|PASS|USER|USERNAME|PATH|DIR|FILE|HOME|ENV|MODE|LEVEL|TIMEOUT|REGION|BUCKET|ENABLED|DISABLED|DEBUG|FLAG|PROFILE)$/;
420
+
421
+ /**
422
+ * Whether the change text marks a name as an environment variable: read the
423
+ * way code reads one (`process.env.X`, `$X`, `${X}`, `getenv("X")`,
424
+ * `os.environ["X"]`, `X=`), named on a line that speaks of the environment,
425
+ * or carrying a configuration suffix (`_URL`, `_TOKEN`, `_PORT`…).
426
+ * @param {string} name
427
+ * @param {string} text the change's authored text
428
+ */
429
+ export function looksLikeEnv(name, text) {
430
+ if (ENV_SUFFIX.test(name)) return true;
431
+ const n = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
432
+ if (new RegExp(`(process\\.env\\.|process\\.env\\[["'\`]|\\benv\\.|\\$\\{?|getenv\\(["']|environ(?:\\.get\\(|\\[)["']|ENV\\[["'])${n}\\b|\\b${n}=`).test(text)) return true;
433
+ return text.split("\n").some((line) => line.includes(name) && /\b(env|environment|environment variable|variável de ambiente|ambiente|secret|segredo|\.env)\b/i.test(line));
434
+ }
435
+
436
+ /** The variable names a project's env example files declare. */
437
+ function envExampleNames(projectRoot) {
438
+ const out = new Set();
439
+ for (const f of [".env.example", ".env.sample", ".env.template", ".env.dist", "example.env"]) {
440
+ const file = path.join(projectRoot, f);
441
+ if (!isFile(file)) continue;
442
+ for (const m of read(file).matchAll(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/gm)) out.add(m[1]);
443
+ }
444
+ return out;
445
+ }
package/src/lib/fs-ops.js CHANGED
@@ -19,6 +19,29 @@ import path from "node:path";
19
19
  // A file that had one loses it when a command rewrites it. That is a
20
20
  // normalisation, and the right one — nothing in this tree is served by
21
21
  // keeping it.
22
+ /**
23
+ * The line ending a file already uses: CRLF when it carries one, LF otherwise.
24
+ * A command that rewrites a file a person owns keeps the file's ending — a
25
+ * Windows checkout with `core.autocrlf` stores CRLF, and turning it into LF
26
+ * on a tick makes every line of the file show as changed in the diff.
27
+ * @param {string} text
28
+ * @returns {"\r\n"|"\n"}
29
+ */
30
+ export function eolOf(text) {
31
+ return /\r\n/.test(String(text)) ? "\r\n" : "\n";
32
+ }
33
+
34
+ /**
35
+ * `text` with every line ending made `eol` — what a rewrite hands to `write`
36
+ * after editing in LF, so an inserted line never mixes endings.
37
+ * @param {string} text
38
+ * @param {"\r\n"|"\n"} eol
39
+ */
40
+ export function withEol(text, eol) {
41
+ const lf = String(text).replace(/\r\n/g, "\n");
42
+ return eol === "\n" ? lf : lf.replace(/\n/g, "\r\n");
43
+ }
44
+
22
45
  export function read(p) {
23
46
  const text = readFileSync(p, "utf8");
24
47
  return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text;
@@ -15,7 +15,7 @@ import { stateWord } from "./spec-ops.js";
15
15
  // Clarification gate (review Topic A). A description too thin to spec from is
16
16
  // the moment to ask the user, not to let the agent invent requirements. Prints
17
17
  // the specific gaps before the bootstrap playbook; advisory, never blocking —
18
- // the intake is still captured verbatim (the playbook step 6 resolves it).
18
+ // the intake is still captured verbatim (the playbook step 8 resolves it).
19
19
  /**
20
20
  * Does this argument read as a FILE PATH, or as the description itself?
21
21
  *
@@ -71,12 +71,14 @@ export function deferJson() {
71
71
  * Write the held payload with the exit code the command actually returned,
72
72
  * and leave deferred mode. A no-op when the command emitted nothing.
73
73
  */
74
+ /** Write the held payload; false when the command held none. */
74
75
  export function flushJson(code) {
75
76
  deferring = false;
76
- if (!held) return;
77
+ if (!held) return false;
77
78
  const { command, data } = held;
78
79
  held = null;
79
80
  writeEnvelope(command, data, { ok: code === EXIT.OK, exitCode: code });
81
+ return true;
80
82
  }
81
83
 
82
84
  // Print a structured payload. `command` is the invocation, `data` whatever
@@ -165,6 +167,12 @@ export async function captureOutput(fn) {
165
167
  try {
166
168
  const code = await fn();
167
169
  return { code: code ?? EXIT.OK, stdout, stderr };
170
+ } catch (err) {
171
+ // A thrown precondition still answers inside the envelope (change 0234):
172
+ // it escaped as English on stderr, with no JSON on stdout at all.
173
+ stderr.push(`error: ${err?.message ?? err}`);
174
+ if (err?.remedy) stderr.push(`hint: run ${err.remedy} first, then retry`);
175
+ return { code: err?.exitCode ?? EXIT.GATE, stdout, stderr, error: err };
168
176
  } finally {
169
177
  console.log = origLog;
170
178
  console.error = origError;
@@ -0,0 +1,45 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+
4
+ // THE COMMANDS THE CLI PRINTS ARE RUN AS PRINTED (fourth audit, change 0235).
5
+ //
6
+ // `next`, `delivery status`, the playbooks and the hub hand an agent a line
7
+ // to paste into its shell: `cd <repo> && doctrina work "<title>" --delivery
8
+ // <id>`. The title comes from the tracker — text the delivery playbook calls
9
+ // data, never an instruction — and it was interpolated between double quotes
10
+ // as it came: a story titled `Fix $(curl … | sh)` became a command the agent
11
+ // ran, and a repository under `~/my api/` became a `cd` into `~/my`.
12
+
13
+ // Inside double quotes a POSIX shell still expands these.
14
+ const DOUBLE_QUOTE_UNSAFE = /["`$\\!]/;
15
+ const BARE_PATH = /^[A-Za-z0-9_./:\\~+@%=,-]+$/;
16
+
17
+ /**
18
+ * One shell argument, quoted so the shell reads it back verbatim. Plain text
19
+ * keeps the double quotes every surface has always printed; text a double
20
+ * quote would still expand is single-quoted — POSIX `'\''` for an embedded
21
+ * quote, PowerShell's `''` on Windows.
22
+ * @param {string} value
23
+ * @param {NodeJS.Platform} [platform]
24
+ */
25
+ export function qarg(value, platform = process.platform) {
26
+ const s = String(value ?? "");
27
+ if (!DOUBLE_QUOTE_UNSAFE.test(s)) return `"${s}"`;
28
+ const inner = platform === "win32" ? s.replace(/'/g, "''") : s.replace(/'/g, "'\\''");
29
+ return `'${inner}'`;
30
+ }
31
+
32
+ /**
33
+ * A path as a shell argument: bare when nothing in it needs quoting.
34
+ * @param {string} dir
35
+ * @param {NodeJS.Platform} [platform]
36
+ */
37
+ export function qpath(dir, platform = process.platform) {
38
+ const s = String(dir ?? "");
39
+ return BARE_PATH.test(s) ? s : qarg(s, platform);
40
+ }
41
+
42
+ /** `cd <dir> && <command>`, with the directory quoted when it must be. */
43
+ export function cdThen(dir, command) {
44
+ return `cd ${qpath(dir)} && ${command}`;
45
+ }
@@ -155,7 +155,11 @@ export function openChanges(projectRoot) {
155
155
  // The capabilities its deltas target — what `prime` turns into the
156
156
  // exact read command for the change (change 0217).
157
157
  const capabilities = deltaCapabilities(path.join(changesDir, id));
158
- out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities });
158
+ // The delivery a change belongs to (change 0226): a session opened in the
159
+ // repository — an executor's, or a human's — sees it is one part of a
160
+ // larger item of work, not a change on its own.
161
+ const delivery = proposal ? (listHeader(proposal, "Delivery") ?? null) : null;
162
+ out.push({ id, title, status, tasksDone, tasksTotal, unchecked, date, ageDays: ageInDays(date), capabilities, delivery });
159
163
  }
160
164
  return out;
161
165
  }
@@ -1,9 +1,12 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
- import { isFile, read } from "./fs-ops.js";
4
- import { parseChangeTitle, parseChecklist } from "./doc-model.js";
5
- import { listHeader, deltaCapabilities } from "./scan.js";
6
- import { sliceSpec } from "./context-slice.js";
3
+ import { isDir, isFile, read, walk } from "./fs-ops.js";
4
+ import { getSection, parseChangeTitle, parseChecklist } from "./doc-model.js";
5
+ import { listHeader, deltaCapabilities, parseAdrScope } from "./scan.js";
6
+ import { extractOps } from "./spec-ops.js";
7
+ import { findWorkspace } from "./workspace.js";
8
+ import { deliveryPath, resolveDeliveryId, sectionText } from "./delivery.js";
9
+ import { sliceSpec, withoutComments } from "./context-slice.js";
7
10
  import { terms } from "./lexicon.js";
8
11
 
9
12
  // A TASK CARD (change 0222, ADR 0030): one task of a change, carrying what
@@ -64,6 +67,10 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
64
67
  let box;
65
68
  if (ordinal === null) {
66
69
  box = boxes.find((b) => !b.checked && !b.placeholder);
70
+ // Only scaffold boxes left is not "every task is ticked" (third audit,
71
+ // change 0234): the planner was told to review and close a change
72
+ // whose tasks no one had written.
73
+ if (!box && boxes.some((b) => !b.checked && b.placeholder)) return { card: null, reason: "tasks.md holds only the scaffold's placeholder boxes — write the tasks as cards first" };
67
74
  if (!box) return { card: null, reason: "every task is ticked — the change is ready for review and close" };
68
75
  } else {
69
76
  box = boxes.find((b) => b.ordinal === ordinal);
@@ -73,14 +80,40 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
73
80
  // CRLF on a Windows checkout: the Why is read paragraph by paragraph.
74
81
  const proposal = isFile(path.join(changeDir, "proposal.md")) ? read(path.join(changeDir, "proposal.md")).replace(/\r\n/g, "\n") : "";
75
82
  const fields = cardFields(box.details);
76
- const query = terms([box.text, ...box.details].join(" "));
83
+ // What this change DOES to each spec (change 0231). The slice below is
84
+ // the spec as it stands — the current truth, before apply — so the
85
+ // requirement the executor is there to implement lived only in the delta,
86
+ // and reached the card only if the planner copied it into a line by hand.
87
+ const changes = [];
88
+ for (const cap of deltaCapabilities(changeDir)) {
89
+ const delta = path.join(changeDir, "specs", cap, "delta.md");
90
+ if (!isFile(delta)) continue;
91
+ for (const op of extractOps(read(delta))) {
92
+ if (op.verb === "append-requirement") changes.push({ capability: cap, line: `adds ${/^[aeiou]/.test(op.section) ? "an" : "a"} ${op.section} requirement: ${op.value}` });
93
+ else if (op.verb === "replace-requirement") changes.push({ capability: cap, line: `replaces ${op.section} requirement ${op.n} with: ${op.value}` });
94
+ else if (op.verb === "remove-requirement") changes.push({ capability: cap, line: `removes ${op.section} requirement ${op.n}` });
95
+ else if (op.verb === "append-criterion") changes.push({ capability: cap, line: `adds the criterion: ${op.value}` });
96
+ else if (op.verb === "replace-criterion") changes.push({ capability: cap, line: `replaces criterion ${op.n} with: ${op.value}` });
97
+ else if (op.verb === "remove-criterion") changes.push({ capability: cap, line: `removes criterion ${op.n}` });
98
+ }
99
+ }
100
+ // The task's words AND what the change writes into the spec (second
101
+ // audit, change 0233): a card for "lock the account on the third wrong
102
+ // password" cut the spec's only /login requirement, because the task never
103
+ // said "login" — the delta it implements did. Requirements only: a
104
+ // criterion's "[unverified] … — verified by `…`" matched any spec's
105
+ // placeholder on the boilerplate alone.
106
+ const query = terms([box.text, ...box.details, parseChangeTitle(proposal) ?? "", ...changes.filter((ch) => / requirement[^:]*: /.test(ch.line)).map((ch) => ch.line.slice(ch.line.indexOf(": ") + 2))].join(" "));
77
107
  const slices = [];
78
108
  for (const cap of deltaCapabilities(changeDir)) {
79
109
  const rel = `.doctrina/specs/${cap}/spec.md`;
80
110
  const specPath = path.join(projectRoot, rel);
81
111
  if (!isFile(specPath)) continue;
82
- const slice = sliceSpec(read(specPath), query, rel);
83
- if (slice) slices.push({ capability: cap, ...slice });
112
+ // Nothing in the spec matched the task: the whole spec, as `context`
113
+ // gives it (change 0231). Dropping it left a card with no spec at all.
114
+ const text = read(specPath);
115
+ const slice = sliceSpec(text, query, rel);
116
+ slices.push(slice ? { capability: cap, ...slice } : { capability: cap, text: withoutComments(text) ?? text, kept: null, total: null, whole: true });
84
117
  }
85
118
  return {
86
119
  card: {
@@ -96,7 +129,67 @@ export function taskCard(projectRoot, changeId, ordinal = null) {
96
129
  fields,
97
130
  why: firstParagraph(proposal, "Why"),
98
131
  slices,
132
+ changes,
133
+ story: deliveryStory(projectRoot, listHeader(proposal, "Delivery")),
134
+ decisions: bindingDecisions(projectRoot, deltaCapabilities(changeDir)),
99
135
  },
100
136
  reason: null,
101
137
  };
102
138
  }
139
+
140
+ // The story the change serves, when it belongs to a delivery (change 0231):
141
+ // its acceptance criteria are what the work is checked against, and they
142
+ // live in the workspace, where an executor opened in the repository never
143
+ // looks.
144
+ const STORY_MAX_CHARS = 1500;
145
+ function deliveryStory(projectRoot, deliveryId) {
146
+ if (!deliveryId) return null;
147
+ const found = findWorkspace(projectRoot);
148
+ if (!found) return null;
149
+ const id = resolveDeliveryId(found.dir, deliveryId.trim());
150
+ if (!id || !isFile(deliveryPath(found.dir, id))) return null;
151
+ const story = sectionText(read(deliveryPath(found.dir, id)), "Story");
152
+ if (!story) return null;
153
+ return story.length > STORY_MAX_CHARS ? `${story.slice(0, STORY_MAX_CHARS).replace(/\s+\S*$/, "")} …` : story;
154
+ }
155
+
156
+ // The accepted decisions that bind the capabilities the change touches:
157
+ // those whose Scope names one, and the unscoped ones that cite one. A card
158
+ // the executor follows without them is how an implementation re-decides
159
+ // what an ADR settled.
160
+ function bindingDecisions(projectRoot, capabilities) {
161
+ const dir = path.join(projectRoot, ".doctrina", "decisions");
162
+ if (!isDir(dir) || capabilities.length === 0) return { list: [], more: 0 };
163
+ /** @type {Array<{ id: string, title: string, decision: string, rank: number }>} */
164
+ const found = [];
165
+ for (const f of walk(dir).sort()) {
166
+ const m = path.basename(f).match(/^(\d{4})-.*\.md$/);
167
+ if (!m) continue;
168
+ const text = read(f).replace(/\r\n/g, "\n");
169
+ if ((listHeader(text, "Status") ?? "").trim().toLowerCase() !== "accepted") continue;
170
+ const scope = parseAdrScope(text);
171
+ const cites = (/** @type {string} */ cap) => new RegExp(`(^|[^a-z0-9-])${cap.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9-]|$)`, "im").test(text);
172
+ // An unscoped ADR is GLOBAL — it binds every capability, as `decision
173
+ // scope` documents and the context pack loads it (sixth audit, change
174
+ // 0237). The card bound one only when its text named the capability, so
175
+ // a repository-wide rule ("error codes are MAIUSCULAS_SNAKE") never
176
+ // reached the executor, who reads nothing else. Scoped first, then the
177
+ // global ones that name the capability, then the rest.
178
+ const rank = scope.length > 0 ? (capabilities.some((cap) => scope.includes(cap)) ? 0 : -1) : (capabilities.some(cites) ? 1 : 2);
179
+ if (rank < 0) continue;
180
+ const decision = (getSection(text, "Decision") ?? "").replace(/<!--[\s\S]*?-->/g, "").replace(/\s+/g, " ").trim();
181
+ found.push({
182
+ id: m[1],
183
+ title: text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m)?.[1]?.trim() ?? path.basename(f),
184
+ decision: decision.length > DECISION_EXCERPT ? `${decision.slice(0, DECISION_EXCERPT).replace(/\s+\S*$/, "")} …` : decision,
185
+ rank,
186
+ });
187
+ }
188
+ const sorted = found.sort((a, b) => a.rank - b.rank || a.id.localeCompare(b.id));
189
+ return { list: sorted.slice(0, DECISIONS_ON_CARD), more: Math.max(0, sorted.length - DECISIONS_ON_CARD) };
190
+ }
191
+
192
+ // How many binding decisions a card names, and how much of each it quotes.
193
+ const DECISIONS_ON_CARD = 8;
194
+ const DECISION_EXCERPT = 240;
195
+
package/src/lib/views.js CHANGED
@@ -187,7 +187,7 @@ export function prime(s) {
187
187
  // one forgotten for three weeks used to read identically.
188
188
  const age = ch.ageDays !== null && ch.ageDays !== undefined && ch.ageDays >= STALE_CHANGE_DAYS
189
189
  ? ` · open ${ch.ageDays}d` : "";
190
- out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age})`)}`);
190
+ out.push(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks}${age}${ch.delivery ? ` · delivery ${ch.delivery}` : ""})`)}`);
191
191
  }
192
192
  }
193
193
 
@@ -236,7 +236,7 @@ export function prime(s) {
236
236
  * Worded as facts, not orders: an agent's prompt-injection defences treat
237
237
  * imperatives from a hook with suspicion, and they should.
238
238
  *
239
- * @param {Array<{id: string, title: string|null, status: string}>} open
239
+ * @param {Array<{id: string, title: string|null, status: string, delivery?: string|null}>} open
240
240
  * @param {Array<{id: string, date: string, path: string}>} [recent]
241
241
  */
242
242
  export function pulse(open, recent = []) {
@@ -247,7 +247,7 @@ export function pulse(open, recent = []) {
247
247
  return ["Doctrina: no change is open in this project. A request that changes behaviour is opened as a change " +
248
248
  "before code is edited — `doctrina work \"<request>\"` (it classifies the lane itself); a question or a read needs none." + last];
249
249
  }
250
- const named = open.slice(0, 3).map((ch) => `${ch.id}${ch.title ? ` — "${ch.title}"` : ""} (${ch.status})`).join("; ");
250
+ const named = open.slice(0, 3).map((ch) => `${ch.id}${ch.title ? ` — "${ch.title}"` : ""} (${ch.status}${ch.delivery ? `, part of delivery ${ch.delivery}` : ""})`).join("; ");
251
251
  const more = open.length > 3 ? ` and ${open.length - 3} more` : "";
252
252
  return [`Doctrina: open change${open.length === 1 ? "" : "s"}: ${named}${more}. Work that belongs to ${open.length === 1 ? "it" : "one of them"} ` +
253
253
  "is named by its id in the first sentence to the human and finishes with `doctrina close <id>`; " +
@@ -16,7 +16,13 @@ import { globToRegExp } from "./runtime.js";
16
16
  // file scores its capability when the file sits under a path segment named for
17
17
  // it, or when the spec cites the file as evidence. A deterministic overlap
18
18
  // hint for the agent — never a decision (ADR 0005).
19
- export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
19
+ //
20
+ // `pending` (change 0228) is, per capability, the spec as the change under
21
+ // review will leave it: the criterion a change adds cites its new test, a
22
+ // `set-header Source:` claims new paths, and `review` runs before `apply`
23
+ // merges either — so without it the files the change itself claims were
24
+ // reported as belonging to no capability.
25
+ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3, pending = new Map() } = {}) {
20
26
  const specsDir = path.join(projectRoot, ".doctrina", "specs");
21
27
  if (!files || files.length === 0 || !isDir(specsDir)) return [];
22
28
  const norm = files.map((f) => f.replace(/\\/g, "/"));
@@ -25,7 +31,7 @@ export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
25
31
  for (const cap of readdirSync(specsDir).sort()) {
26
32
  const specPath = path.join(specsDir, cap, "spec.md");
27
33
  if (!isFile(specPath)) continue;
28
- const text = read(specPath);
34
+ const text = pending.get(cap) ?? read(specPath);
29
35
  specs.push({ cap, text, declared: parseSourceGlobs(text).map((g) => globToRegExp(g)) });
30
36
  }
31
37