doctrina-cli 0.17.0 → 0.20.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 +8 -4
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +74 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +131 -7
  6. package/src/commands/close.js +92 -28
  7. package/src/commands/context.js +49 -8
  8. package/src/commands/delivery.js +238 -0
  9. package/src/commands/index-rebuild.js +23 -0
  10. package/src/commands/init.js +15 -0
  11. package/src/commands/intake.js +47 -1
  12. package/src/commands/metrics.js +28 -3
  13. package/src/commands/next.js +1 -0
  14. package/src/commands/prime.js +76 -7
  15. package/src/commands/review.js +47 -8
  16. package/src/commands/skill.js +3 -127
  17. package/src/commands/upgrade.js +15 -0
  18. package/src/commands/verify.js +59 -6
  19. package/src/commands/work.js +139 -5
  20. package/src/commands/workspace.js +409 -0
  21. package/src/index.js +5 -7
  22. package/src/lib/actions.js +9 -5
  23. package/src/lib/adapters.js +80 -0
  24. package/src/lib/change-ops.js +92 -0
  25. package/src/lib/code-inventory.js +297 -0
  26. package/src/lib/commands.js +52 -5
  27. package/src/lib/context-slice.js +118 -0
  28. package/src/lib/delivery.js +208 -0
  29. package/src/lib/git.js +59 -2
  30. package/src/lib/metrics-model.js +23 -1
  31. package/src/lib/playbook.js +1 -1
  32. package/src/lib/runtime.js +6 -0
  33. package/src/lib/scan.js +13 -0
  34. package/src/lib/signoff.js +24 -0
  35. package/src/lib/skill-candidates.js +162 -0
  36. package/src/lib/skill-export.js +95 -0
  37. package/src/lib/snapshot.js +76 -2
  38. package/src/lib/spec-ops.js +117 -1
  39. package/src/lib/task-card.js +102 -0
  40. package/src/lib/validation-model.js +1 -1
  41. package/src/lib/verify-cache.js +76 -0
  42. package/src/lib/views.js +56 -2
  43. package/src/lib/work-model.js +90 -12
  44. package/src/lib/workspace-agents.js +259 -0
  45. package/src/lib/workspace-tools.js +113 -0
  46. package/src/lib/workspace.js +371 -0
  47. package/templates/AGENTS.md.template +17 -14
  48. package/templates/README.md +11 -0
  49. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  50. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  51. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  52. package/templates/change/spec-delta.md.template +2 -0
  53. package/templates/delivery.md.template +39 -0
  54. package/templates/playbooks/absorb.md.template +40 -0
  55. package/templates/playbooks/consolidate.md.template +28 -0
  56. package/templates/playbooks/delivery.md.template +38 -0
  57. package/templates/playbooks/work.md.template +4 -1
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > CLI for the Doctrina framework. The package is published as
4
4
  > `doctrina-cli`; the executable it installs is `doctrina`.
5
5
 
6
- **Status:** v0.17.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.20.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
@@ -25,10 +25,11 @@ surface, by the moment you reach for it:
25
25
  ```
26
26
  Bootstrap init · intake · adapter list|add|remove
27
27
  Orient prime · next · status · context · show · search · why · handoff
28
- Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon
28
+ Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon|delta|card
29
29
  contract new|list|check · decision new|accept|land|supersede|list|scope
30
30
  skill new|list|suggest · intent add|list
31
31
  Gate clarify · validate · coverage · trace · review · verify · close · doctor
32
+ Orchestrate workspace init|add|attach|detach|list|tool|guard · delivery new|route|list|status|check|close
32
33
  Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
33
34
  ```
34
35
 
@@ -72,14 +73,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
72
73
  - **REMOVED:** deletes the target spec.
73
74
  - **MODIFIED:** when the delta carries a fenced ` ```ops ` block
74
75
  (`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
75
- / `append-criterion` / `append-requirement` / `replace-requirement`),
76
+ / `append-criterion` / `append-requirement` / `replace-requirement` /
77
+ `remove-requirement` / `remove-criterion`),
76
78
  applies it mechanically to the target spec — all
77
79
  ops or none — and regenerates the index (ADR 0007). Without an ops
78
80
  block it prints a manual-merge pointer and does not write: auto-merging
79
81
  arbitrary prose is the kind of implicit decision Doctrina avoids.
80
82
 
81
83
  On any spec write, the index is rebuilt from the tree so it never drifts
82
- from the applied spec. `doctrina change abandon <id>` is the inverse of
84
+ from the applied spec. `doctrina change delta <id> <capability>` writes the
85
+ ops lines for you (`--requirement`, `--criterion`, `--bump`), dry-run
86
+ against the spec before anything is written. `doctrina change abandon <id>` is the inverse of
83
87
  `change new`: it deletes an open change and its index entry and ledgers
84
88
  the abandonment. `doctrina close <id>` runs apply together with verify,
85
89
  coverage, archive and validate in one attested pass — the definition of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.17.0",
3
+ "version": "0.20.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -4,6 +4,7 @@ import process from "node:process";
4
4
  import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
5
5
  import {
6
6
  ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
7
+ isMergedConfig, mergeJson, unmergeJson, containsJson, holdsAnyOf,
7
8
  } from "../lib/adapters.js";
8
9
  import { flagBool } from "../lib/args.js";
9
10
  import { today } from "../lib/dates.js";
@@ -11,6 +12,7 @@ import { c } from "../lib/colors.js";
11
12
  import { suggest } from "../lib/suggest.js";
12
13
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
14
  import { projectName } from "../lib/project.js";
15
+ import { applySkillExport } from "../lib/skill-export.js";
14
16
 
15
17
  // `doctrina adapter` — add, remove, and inventory agent adapters (audit
16
18
  // item C1).
@@ -109,6 +111,21 @@ function adapterAdd(projectRoot, name, cmdFlags) {
109
111
  let skipped = 0;
110
112
  for (const file of spec.files) {
111
113
  const dest = path.join(projectRoot, file.relativePath);
114
+ // A shared settings file is merged, never overwritten nor skipped
115
+ // (change 0211): the user's own keys stay, ours are added once.
116
+ if (isMergedConfig(file) && exists(dest)) {
117
+ const merged = mergeInto(dest, renderAdapterFile(file, tokens));
118
+ if (merged === null) {
119
+ console.log(c.yellow("skip ") + ` ${file.relativePath} (not valid JSON — add the entries by hand)`);
120
+ skipped += 1;
121
+ } else if (merged) {
122
+ console.log(c.green("merged ") + ` ${file.relativePath}`);
123
+ written += 1;
124
+ } else {
125
+ console.log(c.gray("ok ") + ` ${file.relativePath} (already holds the doctrina entries)`);
126
+ }
127
+ continue;
128
+ }
112
129
  if (exists(dest) && !force) {
113
130
  console.log(c.yellow("skip ") + ` ${file.relativePath} (already present; --force overwrites)`);
114
131
  skipped += 1;
@@ -124,6 +141,12 @@ function adapterAdd(projectRoot, name, cmdFlags) {
124
141
  console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
125
142
  (skipped > 0 ? `, ${skipped} left alone` : "") +
126
143
  (spec.source === "project" ? c.gray(" (from .doctrina/templates/adapters/)") : ""));
144
+ // The project's skills follow the agent in (change 0212): with the claude
145
+ // adapter, `.claude/skills/` becomes an export root.
146
+ const exported = applySkillExport(projectRoot);
147
+ if (exported.writes.length > 0) {
148
+ console.log(c.green("export ") + ` ${exported.writes.length} skill file${exported.writes.length === 1 ? "" : "s"} where the agent discovers skills`);
149
+ }
127
150
  console.log(c.gray("Additive only: AGENTS.md and .doctrina/product.md were not touched."));
128
151
  return 0;
129
152
  }
@@ -156,6 +179,29 @@ function adapterRemove(projectRoot, name, cmdFlags) {
156
179
  for (const file of spec.files) {
157
180
  const dest = path.join(projectRoot, file.relativePath);
158
181
  if (!isFile(dest)) continue;
182
+ // A shared settings file loses only the entries this adapter added;
183
+ // the file goes only when nothing of the user's is left in it.
184
+ if (isMergedConfig(file)) {
185
+ const left = unmergeFrom(dest, renderAdapterFile(file, tokens));
186
+ if (left === undefined) {
187
+ console.log(c.yellow("kept ") + ` ${file.relativePath} (not valid JSON — remove the doctrina entries by hand)`);
188
+ kept += 1;
189
+ continue;
190
+ }
191
+ if (left === UNTOUCHED) {
192
+ console.log(c.gray("kept ") + ` ${file.relativePath} (holds no doctrina entries — yours)`);
193
+ continue;
194
+ }
195
+ if (left !== null) {
196
+ console.log(c.green("updated") + ` ${file.relativePath} (doctrina entries removed; yours kept)`);
197
+ continue;
198
+ }
199
+ remove(dest);
200
+ console.log(c.green("removed") + ` ${file.relativePath}`);
201
+ removed += 1;
202
+ vacated.add(path.dirname(dest));
203
+ continue;
204
+ }
159
205
  const pristine = normalise(read(dest)) === normalise(renderAdapterFile(file, tokens));
160
206
  if (!pristine && !force) {
161
207
  console.log(c.yellow("kept ") + ` ${file.relativePath} (edited since install; --force removes it)`);
@@ -191,6 +237,34 @@ function adapterRemove(projectRoot, name, cmdFlags) {
191
237
  return 0;
192
238
  }
193
239
 
240
+ // Merge the rendered template into an existing JSON file. Returns true when
241
+ // the file changed, false when it already held every entry, null when the
242
+ // file is not JSON this command can safely rewrite.
243
+ function mergeInto(dest, rendered) {
244
+ let theirs;
245
+ try { theirs = JSON.parse(read(dest)); } catch { return null; }
246
+ const ours = JSON.parse(rendered);
247
+ if (containsJson(theirs, ours)) return false;
248
+ write(dest, JSON.stringify(mergeJson(theirs, ours), null, 2) + "\n", { force: true });
249
+ return true;
250
+ }
251
+
252
+ const UNTOUCHED = Symbol("untouched");
253
+
254
+ // Take the rendered template's entries back out of a JSON file. Returns what
255
+ // is left (written back), null when nothing is (the caller removes the
256
+ // file), undefined when the file is not JSON.
257
+ function unmergeFrom(dest, rendered) {
258
+ let theirs;
259
+ try { theirs = JSON.parse(read(dest)); } catch { return undefined; }
260
+ // A file holding none of our entries was never ours, or was rewritten by
261
+ // the user since: it is theirs, and stays as it is.
262
+ if (!holdsAnyOf(theirs, JSON.parse(rendered))) return UNTOUCHED;
263
+ const left = unmergeJson(theirs, JSON.parse(rendered));
264
+ if (left !== null) write(dest, JSON.stringify(left, null, 2) + "\n", { force: true });
265
+ return left;
266
+ }
267
+
194
268
  function normalise(text) {
195
269
  return text.replace(/\r\n/g, "\n").trim();
196
270
  }
@@ -1,7 +1,9 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { isDir, relPath } from "../lib/fs-ops.js";
4
+ import { isDir, isFile, read, relPath } from "../lib/fs-ops.js";
5
+ import { listHeader } from "../lib/scan.js";
6
+ import { stripAnsi } from "../lib/json-out.js";
5
7
  import { c } from "../lib/colors.js";
6
8
  import { collectAnalysis } from "../lib/analysis.js";
7
9
  import { EXIT } from "../lib/exit-codes.js";
@@ -40,13 +42,22 @@ export async function run(positional, _flags) {
40
42
  console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
41
43
  console.log("");
42
44
 
43
- const results = collectAnalysis(projectRoot, changeDir);
45
+ // An APPLIED change is asked only what still means something (change
46
+ // 0221): its pre-apply checks read the spec the apply wrote as a conflict,
47
+ // and a close stopped after its apply could never be rerun. The integrity
48
+ // checks — the set `archive` asks — still count.
49
+ const proposal = path.join(changeDir, "proposal.md");
50
+ const applied = isFile(proposal) && (listHeader(read(proposal), "Status") ?? "").toLowerCase() === "applied";
51
+ const results = collectAnalysis(projectRoot, changeDir)
52
+ .map((r) => applied && r.kind === "fail" && r.scope === "pre-apply"
53
+ ? { ...r, kind: "info", line: c.gray("- " + stripAnsi(r.line).replace(/^✗\s*/, "") + " (already applied — a pre-apply check, not asked again)") }
54
+ : r);
44
55
  for (const r of results) console.log(r.line);
45
56
  console.log("");
46
57
 
47
58
  const failed = results.filter((r) => r.kind === "fail").length;
48
59
  if (failed === 0) {
49
- console.log(c.green("ok") + ` ready to apply`);
60
+ console.log(c.green("ok") + (applied ? " already applied — resuming: the integrity checks pass" : ` ready to apply`));
50
61
  } else {
51
62
  console.log(c.red("fail") + ` ${failed} issue${failed === 1 ? "" : "s"}`);
52
63
  }
@@ -16,17 +16,18 @@ import { c } from "../lib/colors.js";
16
16
  import { appendLedgerLine, archivedLine, abandonedLine } from "../lib/ledger.js";
17
17
  import { suggest } from "../lib/suggest.js";
18
18
  import { confirm, isInteractive } from "../lib/prompt.js";
19
+ import { taskCard } from "../lib/task-card.js";
19
20
  import { EXIT } from "../lib/exit-codes.js";
20
21
  import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
21
22
  import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
22
- import { changeNew } from "../lib/change-ops.js";
23
+ import { changeNew, addDeltaOps } from "../lib/change-ops.js";
23
24
 
24
- const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon"];
25
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
25
26
 
26
27
  // Flags this command accepts. Declared HERE, with the command, so
27
28
  // adding a command never requires editing the entrypoint — the gap that
28
29
  // let six flags ship undeclared and silently swallow a positional (C3).
29
- export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason"] };
30
+ export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "bump", "op"] };
30
31
 
31
32
  export async function run(positional, flags) {
32
33
  const sub = positional[0];
@@ -44,6 +45,10 @@ export async function run(positional, flags) {
44
45
  return changeTick(positional.slice(1), flags);
45
46
  case "abandon":
46
47
  return await changeAbandon(positional.slice(1), flags);
48
+ case "delta":
49
+ return changeDelta(positional.slice(1), flags);
50
+ case "card":
51
+ return changeCard(positional.slice(1));
47
52
  default:
48
53
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
49
54
  const guess = suggest(sub, SUBCOMMANDS);
@@ -54,6 +59,64 @@ export async function run(positional, flags) {
54
59
  }
55
60
  }
56
61
 
62
+ // `change card <id> [<n>]` (change 0222, ADR 0030): one task as an executor
63
+ // receives it — the task and the lines under it, the change's why, the spec
64
+ // cut to the task, and what to do when done. The executor does not see the
65
+ // planner's conversation; the card is its whole context.
66
+ function changeCard(args) {
67
+ const id = args[0];
68
+ if (!id) {
69
+ console.error(c.red("error:") + " change card requires <id> [task number]");
70
+ return EXIT.USAGE;
71
+ }
72
+ const refused = refuseChangeRef(id);
73
+ if (refused !== null) return refused;
74
+ const projectRoot = process.cwd();
75
+ ensureDoctrinaProject(projectRoot);
76
+ if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
77
+ console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
78
+ return EXIT.USAGE;
79
+ }
80
+ let ordinal = null;
81
+ if (args[1] !== undefined) {
82
+ ordinal = Number(args[1]);
83
+ if (!Number.isInteger(ordinal) || ordinal < 1) {
84
+ console.error(c.red("error:") + ` task number must be a positive integer (got "${args[1]}")`);
85
+ return EXIT.USAGE;
86
+ }
87
+ }
88
+ const { card, reason } = taskCard(projectRoot, id, ordinal);
89
+ if (!card) {
90
+ console.log(c.gray(reason));
91
+ return reason.startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
92
+ }
93
+ const L = [];
94
+ L.push(`# Task card — ${card.change}, task ${card.ordinal} of ${card.total}` + (card.checked ? " (already ticked)" : ""));
95
+ L.push("");
96
+ L.push(`Change: ${card.title}` + (card.delivery ? ` · delivery ${card.delivery}` : ""));
97
+ L.push(`Repository: ${projectRoot}`);
98
+ L.push("");
99
+ L.push("## Task");
100
+ L.push("");
101
+ L.push(card.text);
102
+ for (const d of card.details) L.push(`- ${d}`);
103
+ if (card.why) {
104
+ L.push("", "## Why the change exists", "", card.why);
105
+ }
106
+ for (const s of card.slices) {
107
+ L.push("", `## Spec — ${s.capability} (cut to this task)`, "", s.text.trim());
108
+ }
109
+ L.push("", "## When done", "");
110
+ L.push(card.fields.verify
111
+ ? `1. Run the card's check: \`${card.fields.verify}\` — it must pass.`
112
+ : "1. Run the project's check: `doctrina verify` — it must pass.");
113
+ L.push(`2. Tick the box: \`doctrina change tick ${card.change} ${card.ordinal}\`.`);
114
+ L.push("3. Report back: the files you changed, the commands you ran and what they printed.");
115
+ L.push("Stay inside this card: do not open, close or archive a change, and do not edit `.doctrina/` — the planner does.");
116
+ for (const line of L) console.log(line);
117
+ return EXIT.OK;
118
+ }
119
+
57
120
  // Enforce a lifecycle transition's preconditions from the shared gate map.
58
121
  // One implementation, so every driver of a transition refuses — and
59
122
  // explains, and forces — identically (C6).
@@ -151,9 +214,6 @@ function changeApply(args, flags) {
151
214
  // and exit 0 — through the very analyze → apply path the docs prescribe.
152
215
  // The preconditions now come from the shared map, so what guards a
153
216
  // transition does not depend on which command drove it.
154
- const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
155
- if (!applyGate.ok) return 1;
156
-
157
217
  // Applying twice is silent corruption. The ops verbs are ADDITIVE —
158
218
  // `append-requirement` appends, `bump-version` bumps — so a second pass
159
219
  // duplicates every requirement and criterion the delta carries and bumps
@@ -171,6 +231,12 @@ function changeApply(args, flags) {
171
231
  return 0;
172
232
  }
173
233
 
234
+ // The skip above comes FIRST (change 0221): the apply gate is a pre-apply
235
+ // question, and on an applied change its ADDED check reads the content the
236
+ // apply wrote as a conflict — which refused the resume of a killed close.
237
+ const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
238
+ if (!applyGate.ok) return 1;
239
+
174
240
  const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
175
241
  const date = today();
176
242
  let errors = 0;
@@ -398,6 +464,49 @@ async function changeCheck(id, { verbose = false } = {}) {
398
464
  // `tick <id> --all` checks everything (tasks.md + the proposal's
399
465
  // ## Verification). Ticking is a claim of completion — the honest gate is
400
466
  // still archive/verify; this only removes the mechanical friction.
467
+ // `change delta <id> <capability> [ops]` — write ops lines into the change's
468
+ // MODIFIED delta through the grammar `apply` reads (change 0205). Rendering
469
+ // only; lib/change-ops.js decides.
470
+ function changeDelta(args, flags) {
471
+ const [id, capability, ...rest] = args;
472
+ if (!id || !capability) {
473
+ console.error(c.red("error:") + " change delta requires <id> <capability> and at least one operation");
474
+ console.error(c.gray("hint: ") + "doctrina change delta <id> <capability> --requirement \"event: When <trigger>, the system shall <action>.\" --criterion \"[unverified] <signal> — verified by `<test>`\" --bump minor");
475
+ return EXIT.USAGE;
476
+ }
477
+ const refused = refuseChangeRef(id);
478
+ if (refused !== null) return refused;
479
+ const projectRoot = process.cwd();
480
+ ensureDoctrinaProject(projectRoot);
481
+ if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
482
+ console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
483
+ return EXIT.USAGE;
484
+ }
485
+ // In the order a reader expects them: requirement, criterion, any raw op,
486
+ // and the bump last.
487
+ const lines = [];
488
+ const requirement = flagString(flags, "requirement");
489
+ const criterion = flagString(flags, "criterion");
490
+ const raw = flagString(flags, "op") ?? (rest.length > 0 ? rest.join(" ") : undefined);
491
+ const bump = flagString(flags, "bump");
492
+ if (requirement) lines.push(`append-requirement ${requirement}`);
493
+ if (criterion) lines.push(`append-criterion ${criterion}`);
494
+ if (raw) lines.push(raw);
495
+ if (bump) lines.push(`bump-version ${bump}`);
496
+
497
+ const r = addDeltaOps(projectRoot, id, capability, lines);
498
+ if (r.code !== EXIT.OK) {
499
+ console.error(c.red("error:") + ` ${r.error}`);
500
+ for (const d of r.details ?? []) console.error(` - ${d}`);
501
+ if (r.hint) console.error(c.gray("hint: ") + r.hint);
502
+ return r.code;
503
+ }
504
+ console.log(c.green(r.created ? "created" : "updated") + ` ${r.deltaRel}` +
505
+ c.gray(` — ${lines.length} op${lines.length === 1 ? "" : "s"} added; the block now holds ${r.block.length} (${r.block.join(", ")}), and applies cleanly`));
506
+ console.log(c.gray("Preview the merge: ") + c.cyan(`doctrina change check ${id} --verbose`));
507
+ return EXIT.OK;
508
+ }
509
+
401
510
  function changeTick(args, flags) {
402
511
  const id = args[0];
403
512
  if (!id) {
@@ -768,6 +877,18 @@ Subcommands:
768
877
  or every one with --all. No args = list only.
769
878
  abandon <id> Delete an open change folder and its index entry, and
770
879
  record the abandonment in the ledger ([--reason "..."]).
880
+ delta <id> <capability> [--requirement "<section>: <text>"] [--criterion "<text>"]
881
+ [--bump major|minor|patch] [--op "<op line>" | <op line>]
882
+ Write ops into the change's MODIFIED delta for the
883
+ capability — created from the template when absent —
884
+ parsed by the grammar apply reads and dry-run against
885
+ the spec first: nothing is written unless the whole
886
+ block applies. A second --bump replaces the first.
887
+ card <id> [<n>] Print task <n> (default: the next open one) as a
888
+ self-contained card for an executor: the task and the
889
+ lines indented under it (Files:, Requirement:, Verify:),
890
+ the change's Why, the spec cut to the task, and the
891
+ tick command. Exit 0 with a note when every task is done.
771
892
 
772
893
  apply / archive / check accept multiple ids (batch close of a backlog); the
773
894
  exit code is the worst per-id result.
@@ -780,9 +901,12 @@ A MODIFIED delta may carry a fenced operations block that \`apply\` executes:
780
901
  append-criterion [unverified] new signal — verified by \`test/x.test.ts\`
781
902
  append-requirement event: When <trigger>, the system shall <action>.
782
903
  replace-requirement ubiquitous 2: The system shall <action>.
904
+ remove-requirement state 1: superseded by event 3
905
+ remove-criterion 4: proven by criterion 2 since 0.9
783
906
  \`\`\`
784
907
  append-* ops resolve numbering/position at apply time, so concurrent open
785
- changes appending to the same spec cannot collide.
908
+ changes appending to the same spec cannot collide. \`change delta\` writes
909
+ these lines for you, one call per addition.
786
910
 
787
911
  Options:
788
912
  --force Overwrite existing files (where applicable)
@@ -1,19 +1,19 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { appendFileSync } from "node:fs";
5
- import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
4
+ import { appendFileSync, readdirSync } from "node:fs";
5
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { flagBool } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
- import { parseCapabilityFromDelta } from "../lib/doc-model.js";
9
8
  import { printAdrCheckpoint, acceptedDecisionCount } from "../lib/adr-guard.js";
10
9
  import { docsRemedy, checkDocsImpact, checkChangelogImpact } from "../lib/docs-impact.js";
11
10
  import { collectRuntimeFindings } from "../lib/runtime.js";
12
11
  import { derivedImplementations, implementationMismatch, summarize } from "../lib/coverage-model.js";
13
- import { specHeader, dependentsOf } from "../lib/scan.js";
12
+ import { specHeader, dependentsOf, deltaCapabilities } from "../lib/scan.js";
14
13
  import { sequence, sequenceLabels, stepRerun } from "../lib/gates.js";
15
14
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
16
15
  import { refuseChangeRef } from "../lib/project.js";
16
+ import { unfinishedChanges } from "../lib/snapshot.js";
17
17
  import * as analyze from "./analyze.js";
18
18
  import * as change from "./change.js";
19
19
  import * as verify from "./verify.js";
@@ -23,7 +23,7 @@ import * as validate from "./validate.js";
23
23
  import * as indexRebuild from "./index-rebuild.js";
24
24
  import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
25
25
  import * as review from "./review.js";
26
- import * as skill from "./skill.js";
26
+ import { skillCandidates } from "../lib/skill-candidates.js";
27
27
 
28
28
  // One-command close (review 2026-06-27 passive-user feature #2). The work
29
29
  // playbook lists the closing sequence — analyze → apply → runtime → verify →
@@ -65,9 +65,62 @@ export async function run(positional, flags) {
65
65
  ? c.green("ok") + ` all ${positional.length} changes closed`
66
66
  : c.red("fail") + " at least one close stopped — see above");
67
67
  }
68
+ reportSkills(projectRoot, positional);
69
+ reportUnfinished(projectRoot, positional);
68
70
  return worst;
69
71
  }
70
72
 
73
+ // Capture the lesson while it is fresh (operator review §4.7) — the lesson
74
+ // THIS close produced (change 0202). The close used to print the whole
75
+ // `skill suggest` listing, per id: the same thirteen candidates after every
76
+ // close, which a field review of 0.17 named as noise nobody read. Now a
77
+ // candidate born from a change just closed gets its line; every other one is
78
+ // a count pointing at the full listing. Once per run, batch or not.
79
+ // Advisory — a listing, never a gate, and a failure never un-closes anything.
80
+ function reportSkills(projectRoot, closing) {
81
+ let candidates;
82
+ try {
83
+ candidates = skillCandidates(projectRoot);
84
+ } catch {
85
+ return;
86
+ }
87
+ if (candidates.length === 0) return;
88
+ const ids = new Set(closing);
89
+ const fresh = candidates.filter((cand) => cand.source === "change" && ids.has(cand.id));
90
+ const waiting = candidates.length - fresh.length;
91
+ console.log("");
92
+ console.log(c.gray("──── skills (advisory)"));
93
+ for (const cand of fresh) {
94
+ console.log(c.yellow(" ! ") + `${cand.id} is fix-shaped — capture the lesson while it is fresh: ` +
95
+ c.cyan(`doctrina skill new ${cand.slug}`));
96
+ }
97
+ if (waiting > 0) {
98
+ console.log(c.gray(` ${waiting} ${fresh.length > 0 ? "older " : ""}candidate${waiting === 1 ? "" : "s"} waiting — \`doctrina skill suggest\``));
99
+ }
100
+ }
101
+
102
+ // The other open changes, said at the one moment someone is looking (change
103
+ // 0201). A field review of 0.17: change 0078 shipped, stayed open, and no
104
+ // close after it said a word. A change that looks finished — applied, or
105
+ // every task ticked — and one open a week or more get a line each; the rest
106
+ // are a count. Advisory: it never moves the exit code.
107
+ function reportUnfinished(projectRoot, closing) {
108
+ const others = unfinishedChanges(projectRoot, { exclude: closing });
109
+ if (others.length === 0) return;
110
+ const flagged = others.filter((ch) => ch.reason !== null);
111
+ console.log("");
112
+ console.log(c.gray(`──── other open changes (advisory) — ${others.length}`));
113
+ for (const ch of flagged) {
114
+ const age = ch.ageDays !== null ? `, opened ${ch.ageDays}d ago` : "";
115
+ const how = ch.finished ? ` — ${c.cyan(`doctrina close ${ch.id}`)}` : "";
116
+ console.log(c.yellow(" ! ") + `${ch.id}: ${ch.reason}${age}${how}`);
117
+ }
118
+ const quiet = others.length - flagged.length;
119
+ if (quiet > 0) {
120
+ console.log(c.gray(` ${quiet} more open and recent — \`doctrina next\``));
121
+ }
122
+ }
123
+
71
124
  async function closeOne(projectRoot, id, flags) {
72
125
  const refused = refuseChangeRef(id);
73
126
  if (refused !== null) return refused;
@@ -113,6 +166,8 @@ async function closeOne(projectRoot, id, flags) {
113
166
  // archive), which is rendering, not sequencing.
114
167
  const verifyConfigured = isFile(path.join(projectRoot, ".doctrina", "verify.json"));
115
168
  const runners = {
169
+ // An applied change resumes here: analyze asks it only the integrity
170
+ // checks (change 0221).
116
171
  analyze: { run: () => analyze.run([id], new Map()) },
117
172
 
118
173
  // ADR checkpoint (operator review §4.6): the playbook's "record an ADR"
@@ -138,13 +193,17 @@ async function closeOne(projectRoot, id, flags) {
138
193
  },
139
194
 
140
195
  // The review runs against the working tree, which for a close IS the
141
- // change's diff: the work is done and not yet archived. Its exit code is
196
+ // change's diff: the work is done and not yet archived. It is told which
197
+ // change it is reviewing (change 0200): the deltas it carries are the
198
+ // spec updates, merged one step later by `apply`, and code under a
199
+ // capability the change carries no delta for is one line, not one
200
+ // break per capability. Its exit code is
142
201
  // discarded on purpose — the step is declared advisory, and an advisory
143
202
  // step that could still move the close's result would be advisory in name
144
203
  // only. A crash is caught by the loop and reported the same way.
145
204
  review: {
146
205
  run: async () => {
147
- await review.run([], new Map());
206
+ await review.run([], new Map([["change", id]]));
148
207
  return 0;
149
208
  },
150
209
  },
@@ -237,7 +296,10 @@ async function closeOne(projectRoot, id, flags) {
237
296
  // verify is conditional: skipped with a loud note when no verify.json is
238
297
  // declared, because a project may not have wired the real gate yet.
239
298
  verify: verifyConfigured
240
- ? { run: () => verify.run([], new Map()) }
299
+ // --reuse (change 0203): the suite the agent just ran by hand, or the
300
+ // one the previous id of this batch ran, on byte-identical code, is
301
+ // not run a second time. Anything that differs runs everything.
302
+ ? { run: () => verify.run([], new Map([["reuse", true]])) }
241
303
  : { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
242
304
 
243
305
  coverage: {
@@ -270,7 +332,9 @@ async function closeOne(projectRoot, id, flags) {
270
332
  // one session with an empty `## [Unreleased]` behind them.
271
333
  const log = checkChangelogImpact(projectRoot, changeDir);
272
334
  if (log.ok) {
273
- console.log(c.green("ok") + ` ${log.reason}`);
335
+ // One line when both answers are the same sentence (change 0202):
336
+ // "touches no documented surface" twice read as two checks.
337
+ if (log.reason !== r.reason) console.log(c.green("ok") + ` ${log.reason}`);
274
338
  return 0;
275
339
  }
276
340
  console.error(c.red("error:") + ` this change ${log.reason}:`);
@@ -404,18 +468,15 @@ async function closeOne(projectRoot, id, flags) {
404
468
  }
405
469
  }
406
470
 
407
- // Capture the lesson while it is fresh (operator review §4.7): surface the
408
- // fix-shaped lessons not yet written as skills, right where the operator
409
- // just proved they finish what they start. Advisory — a listing, never a
410
- // gate, and a suggest failure never un-closes the change.
411
- console.log("");
412
- console.log(c.gray("──── skills (advisory)"));
413
- try {
414
- await skill.run(["suggest"], new Map());
415
- } catch { /* advisory only */ }
416
-
417
471
  console.log("");
418
472
  console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
473
+ // Where it went, and the sentence for the human (change 0214): the close
474
+ // moves the change out of every default read, and a field review found
475
+ // the person asking for "the doctrina work" of work already archived.
476
+ const archived = archivedPathOf(projectRoot, id);
477
+ if (archived) {
478
+ console.log(c.bold("Tell the human: ") + `change ${id} is closed — archived at ${archived}`);
479
+ }
419
480
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
420
481
  return 0;
421
482
  }
@@ -448,6 +509,14 @@ function closingClaim(ran, skippedSteps) {
448
509
  return `${claimed || " —"} ${skipped.length === 1 ? "Skipped" : "Skipped"}: ${skipped.join(", ")}.`;
449
510
  }
450
511
 
512
+ // The archive folder a just-closed change landed in, newest first.
513
+ function archivedPathOf(projectRoot, id) {
514
+ const dir = path.join(projectRoot, ".doctrina", "changes", "archive");
515
+ if (!isDir(dir)) return null;
516
+ const name = readdirSync(dir).sort().reverse().find((n) => n.endsWith(`-${id}`) && /^\d{4}-\d{2}-\d{2}-/.test(n));
517
+ return name ? `.doctrina/changes/archive/${name}/` : null;
518
+ }
519
+
451
520
  function missingRunner(step) {
452
521
  console.error(c.red("error:") +
453
522
  ` "${step.label}" is declared in the close sequence but has no runner here`);
@@ -484,14 +553,7 @@ function reportDependentCoverage(projectRoot, touched) {
484
553
  }
485
554
 
486
555
  function touchedCapabilities(projectRoot, id) {
487
- const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
488
- const caps = new Set();
489
- for (const deltaPath of walk(specsDir)) {
490
- if (!deltaPath.endsWith("delta.md")) continue;
491
- const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
492
- if (cap) caps.add(cap);
493
- }
494
- return [...caps].sort();
556
+ return deltaCapabilities(path.join(projectRoot, ".doctrina", "changes", id));
495
557
  }
496
558
 
497
559
  // The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
@@ -522,7 +584,9 @@ first failure with the exact command to rerun:
522
584
 
523
585
  ${SEQUENCE}
524
586
 
525
- then a closing skill-suggest listing (advisory).
587
+ then two advisory notes, once per run: the skill candidate a just-closed
588
+ fix-shaped change produced (the others are a count), and the other open
589
+ changes that look finished or are a week old.
526
590
 
527
591
  The coverage gate is scoped to the capabilities the change's deltas touch,
528
592
  so a deliberately deferred spec elsewhere cannot block an unrelated close;