doctrina-cli 0.18.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 (68) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +84 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +164 -17
  6. package/src/commands/clarify.js +1 -204
  7. package/src/commands/close.js +18 -1
  8. package/src/commands/context.js +81 -9
  9. package/src/commands/contract.js +1 -0
  10. package/src/commands/delivery.js +356 -0
  11. package/src/commands/doctor.js +21 -1
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/index-rebuild.js +23 -0
  14. package/src/commands/init.js +28 -1
  15. package/src/commands/intake.js +60 -3
  16. package/src/commands/metrics.js +43 -3
  17. package/src/commands/next.js +41 -6
  18. package/src/commands/prime.js +125 -7
  19. package/src/commands/review.js +39 -3
  20. package/src/commands/skill.js +2 -1
  21. package/src/commands/status.js +28 -0
  22. package/src/commands/upgrade.js +33 -0
  23. package/src/commands/validate.js +18 -0
  24. package/src/commands/verify.js +32 -4
  25. package/src/commands/work.js +126 -7
  26. package/src/commands/workspace.js +419 -0
  27. package/src/index.js +100 -66
  28. package/src/lib/actions.js +44 -4
  29. package/src/lib/adapters.js +143 -1
  30. package/src/lib/args.js +33 -1
  31. package/src/lib/clarify-scan.js +221 -0
  32. package/src/lib/code-inventory.js +770 -0
  33. package/src/lib/commands.js +52 -5
  34. package/src/lib/context-slice.js +149 -0
  35. package/src/lib/delivery.js +286 -0
  36. package/src/lib/docs-impact.js +36 -1
  37. package/src/lib/fs-ops.js +23 -0
  38. package/src/lib/intake-model.js +1 -1
  39. package/src/lib/json-out.js +9 -1
  40. package/src/lib/metrics-model.js +23 -1
  41. package/src/lib/playbook.js +1 -1
  42. package/src/lib/shell.js +45 -0
  43. package/src/lib/skill-export.js +95 -0
  44. package/src/lib/snapshot.js +43 -2
  45. package/src/lib/task-card.js +195 -0
  46. package/src/lib/views.js +52 -3
  47. package/src/lib/work-model.js +66 -2
  48. package/src/lib/workspace-agents.js +306 -0
  49. package/src/lib/workspace-guard.js +65 -0
  50. package/src/lib/workspace-locate.js +141 -0
  51. package/src/lib/workspace-tools.js +160 -0
  52. package/src/lib/workspace-view.js +391 -0
  53. package/src/lib/workspace.js +323 -0
  54. package/templates/AGENTS.md.template +17 -14
  55. package/templates/README.md +11 -1
  56. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  57. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  58. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  59. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  60. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  61. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  62. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  63. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  64. package/templates/delivery.md.template +39 -0
  65. package/templates/playbooks/absorb.md.template +63 -0
  66. package/templates/playbooks/bootstrap.md.template +18 -6
  67. package/templates/playbooks/delivery.md.template +44 -0
  68. package/templates/playbooks/work.md.template +1 -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.18.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.21.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|delta
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|tick|check|close
32
33
  Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
33
34
  ```
34
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.18.0",
3
+ "version": "0.21.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -2,8 +2,11 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
5
+ import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
6
+ import { relinkShadow, shadowRootOf } from "../lib/workspace.js";
5
7
  import {
6
8
  ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
9
+ isMergedConfig, mergeJson, unmergeJson, containsJson, holdsAnyOf,
7
10
  } from "../lib/adapters.js";
8
11
  import { flagBool } from "../lib/args.js";
9
12
  import { today } from "../lib/dates.js";
@@ -11,6 +14,7 @@ import { c } from "../lib/colors.js";
11
14
  import { suggest } from "../lib/suggest.js";
12
15
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
16
  import { projectName } from "../lib/project.js";
17
+ import { applySkillExport } from "../lib/skill-export.js";
14
18
 
15
19
  // `doctrina adapter` — add, remove, and inventory agent adapters (audit
16
20
  // item C1).
@@ -109,6 +113,21 @@ function adapterAdd(projectRoot, name, cmdFlags) {
109
113
  let skipped = 0;
110
114
  for (const file of spec.files) {
111
115
  const dest = path.join(projectRoot, file.relativePath);
116
+ // A shared settings file is merged, never overwritten nor skipped
117
+ // (change 0211): the user's own keys stay, ours are added once.
118
+ if (isMergedConfig(file) && exists(dest)) {
119
+ const merged = mergeInto(dest, renderAdapterFile(file, tokens));
120
+ if (merged === null) {
121
+ console.log(c.yellow("skip ") + ` ${file.relativePath} (not valid JSON — add the entries by hand)`);
122
+ skipped += 1;
123
+ } else if (merged) {
124
+ console.log(c.green("merged ") + ` ${file.relativePath}`);
125
+ written += 1;
126
+ } else {
127
+ console.log(c.gray("ok ") + ` ${file.relativePath} (already holds the doctrina entries)`);
128
+ }
129
+ continue;
130
+ }
112
131
  if (exists(dest) && !force) {
113
132
  console.log(c.yellow("skip ") + ` ${file.relativePath} (already present; --force overwrites)`);
114
133
  skipped += 1;
@@ -120,10 +139,24 @@ function adapterAdd(projectRoot, name, cmdFlags) {
120
139
  written += 1;
121
140
  }
122
141
 
142
+ // An adapter installed before its files moved (change 0238): the copies
143
+ // the CLI wrote under the old paths go, an edited one stays and is named.
144
+ const filesRoot = shadowRootOf(projectRoot) ?? projectRoot;
145
+ const moves = planAdapterMigration(projectRoot, tokens, filesRoot);
146
+ const gone = applyAdapterMigration(filesRoot, moves);
147
+ relinkShadow(projectRoot, gone);
148
+ for (const g of gone) console.log(c.green("removed") + ` ${g.rel} (replaced by its skill)`);
149
+ for (const rel of moves.kept) console.log(c.yellow("kept ") + ` ${rel} (edited since install — Cursor now reads the skill; remove it once your edits are moved)`);
123
150
  console.log("");
124
151
  console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
125
152
  (skipped > 0 ? `, ${skipped} left alone` : "") +
126
153
  (spec.source === "project" ? c.gray(" (from .doctrina/templates/adapters/)") : ""));
154
+ // The project's skills follow the agent in (change 0212): with the claude
155
+ // adapter, `.claude/skills/` becomes an export root.
156
+ const exported = applySkillExport(projectRoot);
157
+ if (exported.writes.length > 0) {
158
+ console.log(c.green("export ") + ` ${exported.writes.length} skill file${exported.writes.length === 1 ? "" : "s"} where the agent discovers skills`);
159
+ }
127
160
  console.log(c.gray("Additive only: AGENTS.md and .doctrina/product.md were not touched."));
128
161
  return 0;
129
162
  }
@@ -156,6 +189,29 @@ function adapterRemove(projectRoot, name, cmdFlags) {
156
189
  for (const file of spec.files) {
157
190
  const dest = path.join(projectRoot, file.relativePath);
158
191
  if (!isFile(dest)) continue;
192
+ // A shared settings file loses only the entries this adapter added;
193
+ // the file goes only when nothing of the user's is left in it.
194
+ if (isMergedConfig(file)) {
195
+ const left = unmergeFrom(dest, renderAdapterFile(file, tokens));
196
+ if (left === undefined) {
197
+ console.log(c.yellow("kept ") + ` ${file.relativePath} (not valid JSON — remove the doctrina entries by hand)`);
198
+ kept += 1;
199
+ continue;
200
+ }
201
+ if (left === UNTOUCHED) {
202
+ console.log(c.gray("kept ") + ` ${file.relativePath} (holds no doctrina entries — yours)`);
203
+ continue;
204
+ }
205
+ if (left !== null) {
206
+ console.log(c.green("updated") + ` ${file.relativePath} (doctrina entries removed; yours kept)`);
207
+ continue;
208
+ }
209
+ remove(dest);
210
+ console.log(c.green("removed") + ` ${file.relativePath}`);
211
+ removed += 1;
212
+ vacated.add(path.dirname(dest));
213
+ continue;
214
+ }
159
215
  const pristine = normalise(read(dest)) === normalise(renderAdapterFile(file, tokens));
160
216
  if (!pristine && !force) {
161
217
  console.log(c.yellow("kept ") + ` ${file.relativePath} (edited since install; --force removes it)`);
@@ -191,6 +247,34 @@ function adapterRemove(projectRoot, name, cmdFlags) {
191
247
  return 0;
192
248
  }
193
249
 
250
+ // Merge the rendered template into an existing JSON file. Returns true when
251
+ // the file changed, false when it already held every entry, null when the
252
+ // file is not JSON this command can safely rewrite.
253
+ function mergeInto(dest, rendered) {
254
+ let theirs;
255
+ try { theirs = JSON.parse(read(dest)); } catch { return null; }
256
+ const ours = JSON.parse(rendered);
257
+ if (containsJson(theirs, ours)) return false;
258
+ write(dest, JSON.stringify(mergeJson(theirs, ours), null, 2) + "\n", { force: true });
259
+ return true;
260
+ }
261
+
262
+ const UNTOUCHED = Symbol("untouched");
263
+
264
+ // Take the rendered template's entries back out of a JSON file. Returns what
265
+ // is left (written back), null when nothing is (the caller removes the
266
+ // file), undefined when the file is not JSON.
267
+ function unmergeFrom(dest, rendered) {
268
+ let theirs;
269
+ try { theirs = JSON.parse(read(dest)); } catch { return undefined; }
270
+ // A file holding none of our entries was never ours, or was rewritten by
271
+ // the user since: it is theirs, and stays as it is.
272
+ if (!holdsAnyOf(theirs, JSON.parse(rendered))) return UNTOUCHED;
273
+ const left = unmergeJson(theirs, JSON.parse(rendered));
274
+ if (left !== null) write(dest, JSON.stringify(left, null, 2) + "\n", { force: true });
275
+ return left;
276
+ }
277
+
194
278
  function normalise(text) {
195
279
  return text.replace(/\r\n/g, "\n").trim();
196
280
  }
@@ -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
  }
@@ -2,26 +2,28 @@
2
2
  import { parseChecklist, getSection, getHeader, parseChangeTitle } from "../lib/doc-model.js";
3
3
  import path from "node:path";
4
4
  import process from "node:process";
5
- import { exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, write } from "../lib/fs-ops.js";
5
+ import { eolOf, exists, isDir, isFile, lineCount, mkdirp, move, read, relPath, remove, walk, withEol, write } from "../lib/fs-ops.js";
6
6
  import { diffLines, formatUnified } from "../lib/diff.js";
7
7
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry } from "../lib/templates.js";
8
8
  import * as idx from "../lib/index-json.js";
9
- import { archivedChangeEntry, deriveIndex } from "../lib/scan.js";
9
+ import { archivedChangeEntry, deriveIndex, listHeader } from "../lib/scan.js";
10
10
  import { extractOps, applyOps } from "../lib/spec-ops.js";
11
11
  import { printAdrCheckpoint } from "../lib/adr-guard.js";
12
12
  import { GATES, TRANSITIONS, checkTransition, recordForcedGap } from "../lib/gates.js";
13
13
  import { today } from "../lib/dates.js";
14
- import { flagBool, flagString } from "../lib/args.js";
14
+ import { flagAll, flagBool, flagString } from "../lib/args.js";
15
15
  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
23
  import { changeNew, addDeltaOps } from "../lib/change-ops.js";
24
+ import { scanText } from "../lib/clarify-scan.js";
23
25
 
24
- const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta"];
26
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
25
27
 
26
28
  // Flags this command accepts. Declared HERE, with the command, so
27
29
  // adding a command never requires editing the entrypoint — the gap that
@@ -46,6 +48,8 @@ export async function run(positional, flags) {
46
48
  return await changeAbandon(positional.slice(1), flags);
47
49
  case "delta":
48
50
  return changeDelta(positional.slice(1), flags);
51
+ case "card":
52
+ return changeCard(positional.slice(1));
49
53
  default:
50
54
  console.error(c.red("error:") + ` unknown change subcommand "${sub ?? ""}"`);
51
55
  const guess = suggest(sub, SUBCOMMANDS);
@@ -56,6 +60,82 @@ export async function run(positional, flags) {
56
60
  }
57
61
  }
58
62
 
63
+ // `change card <id> [<n>]` (change 0222, ADR 0030): one task as an executor
64
+ // receives it — the task and the lines under it, the change's why, the spec
65
+ // cut to the task, and what to do when done. The executor does not see the
66
+ // planner's conversation; the card is its whole context.
67
+ function changeCard(args) {
68
+ const id = args[0];
69
+ if (!id) {
70
+ console.error(c.red("error:") + " change card requires <id> [task number]");
71
+ return EXIT.USAGE;
72
+ }
73
+ const refused = refuseChangeRef(id);
74
+ if (refused !== null) return refused;
75
+ const projectRoot = process.cwd();
76
+ ensureDoctrinaProject(projectRoot);
77
+ if (!isDir(path.join(projectRoot, ".doctrina", "changes", id))) {
78
+ console.error(c.red("error:") + ` change "${id}" not found at .doctrina/changes/${id}`);
79
+ return EXIT.USAGE;
80
+ }
81
+ let ordinal = null;
82
+ if (args[1] !== undefined) {
83
+ ordinal = Number(args[1]);
84
+ if (!Number.isInteger(ordinal) || ordinal < 1) {
85
+ console.error(c.red("error:") + ` task number must be a positive integer (got "${args[1]}")`);
86
+ return EXIT.USAGE;
87
+ }
88
+ }
89
+ const { card, reason } = taskCard(projectRoot, id, ordinal);
90
+ if (!card) {
91
+ console.log(c.gray(reason));
92
+ return reason.startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
93
+ }
94
+ const L = [];
95
+ L.push(`# Task card — ${card.change}, task ${card.ordinal} of ${card.total}` + (card.checked ? " (already ticked)" : ""));
96
+ L.push("");
97
+ L.push(`Change: ${card.title}` + (card.delivery ? ` · delivery ${card.delivery}` : ""));
98
+ L.push(`Repository: ${projectRoot}`);
99
+ L.push("");
100
+ L.push("## Task");
101
+ L.push("");
102
+ L.push(card.text);
103
+ for (const d of card.details) L.push(`- ${d}`);
104
+ if (card.why) {
105
+ L.push("", "## Why the change exists", "", card.why);
106
+ }
107
+ if (card.story) {
108
+ L.push("", `## The story (delivery ${card.delivery})`, "", card.story);
109
+ }
110
+ // What the change does to the spec — the requirement the card implements
111
+ // is here, not in the spec as it stands (change 0231).
112
+ if (card.changes.length > 0) {
113
+ L.push("", "## What this change does to the spec", "");
114
+ for (const ch of card.changes) L.push(`- ${ch.capability} ${ch.line}`);
115
+ }
116
+ for (const s of card.slices) {
117
+ L.push("", `## Spec — ${s.capability} as it stands (${s.whole ? "whole: nothing in it matched the task" : "cut to this task"})`, "", s.text.trim());
118
+ }
119
+ if (card.decisions.list.length > 0) {
120
+ L.push("", "## Decisions that bind it", "");
121
+ for (const d of card.decisions.list) {
122
+ L.push(`- ADR ${d.id} — ${d.title} (\`doctrina show ${d.id}\`)`);
123
+ if (d.decision) L.push(` ${d.decision}`);
124
+ }
125
+ if (card.decisions.more > 0) L.push(`- … and ${card.decisions.more} more accepted global decisions — \`doctrina prime --rules\``);
126
+ L.push("", "Where the story or the task contradicts one of these, stop and report it: the decision stands until the planner supersedes it.");
127
+ }
128
+ L.push("", "## When done", "");
129
+ L.push(card.fields.verify
130
+ ? `1. Run the card's check: \`${card.fields.verify}\` — it must pass.`
131
+ : "1. Run the project's check: `doctrina verify` — it must pass.");
132
+ L.push(`2. Tick the box: \`doctrina change tick ${card.change} ${card.ordinal}\`.`);
133
+ L.push("3. Report back: the files you changed, the commands you ran and what they printed.");
134
+ L.push("Stay inside this card: do not open, close or archive a change, and do not edit `.doctrina/` — the planner does.");
135
+ for (const line of L) console.log(line);
136
+ return EXIT.OK;
137
+ }
138
+
59
139
  // Enforce a lifecycle transition's preconditions from the shared gate map.
60
140
  // One implementation, so every driver of a transition refuses — and
61
141
  // explains, and forces — identically (C6).
@@ -153,9 +233,6 @@ function changeApply(args, flags) {
153
233
  // and exit 0 — through the very analyze → apply path the docs prescribe.
154
234
  // The preconditions now come from the shared map, so what guards a
155
235
  // transition does not depend on which command drove it.
156
- const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
157
- if (!applyGate.ok) return 1;
158
-
159
236
  // Applying twice is silent corruption. The ops verbs are ADDITIVE —
160
237
  // `append-requirement` appends, `bump-version` bumps — so a second pass
161
238
  // duplicates every requirement and criterion the delta carries and bumps
@@ -173,6 +250,12 @@ function changeApply(args, flags) {
173
250
  return 0;
174
251
  }
175
252
 
253
+ // The skip above comes FIRST (change 0221): the apply gate is a pre-apply
254
+ // question, and on an applied change its ADDED check reads the content the
255
+ // apply wrote as a conflict — which refused the resume of a killed close.
256
+ const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
257
+ if (!applyGate.ok) return 1;
258
+
176
259
  const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
177
260
  const date = today();
178
261
  let errors = 0;
@@ -353,6 +436,13 @@ async function changeCheck(id, { verbose = false } = {}) {
353
436
  opsFindings += 1;
354
437
  } else {
355
438
  console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
439
+ // What clarify will say about the spec once this lands (change 0232):
440
+ // the smells the merged spec has and the current one does not.
441
+ // Advisory — clarify is not a close gate — but a project that runs
442
+ // `clarify --all` in its build learned about the word mid-close.
443
+ for (const smell of newSmells(read(targetPath), result.text, projectRoot)) {
444
+ console.log(c.yellow("! ") + `${rel}: writes ${smell.rule} "${smell.token}" into ${capability}'s spec — ${smell.hint} (\`clarify\` flags it after apply)`);
445
+ }
356
446
  }
357
447
  }
358
448
  if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
@@ -420,14 +510,17 @@ function changeDelta(args, flags) {
420
510
  }
421
511
  // In the order a reader expects them: requirement, criterion, any raw op,
422
512
  // and the bump last.
513
+ // `--requirement`, `--criterion` and `--op` repeat (change 0228): each
514
+ // occurrence is one op, in the order given. The parser keeps the last
515
+ // value of a repeated flag, so reading them with flagString wrote one op
516
+ // and reported success for a command line that asked for three.
423
517
  const lines = [];
424
- const requirement = flagString(flags, "requirement");
425
- const criterion = flagString(flags, "criterion");
426
- const raw = flagString(flags, "op") ?? (rest.length > 0 ? rest.join(" ") : undefined);
518
+ const raw = flagAll(flags, "op");
519
+ if (raw.length === 0 && rest.length > 0) raw.push(rest.join(" "));
427
520
  const bump = flagString(flags, "bump");
428
- if (requirement) lines.push(`append-requirement ${requirement}`);
429
- if (criterion) lines.push(`append-criterion ${criterion}`);
430
- if (raw) lines.push(raw);
521
+ for (const r of flagAll(flags, "requirement")) lines.push(`append-requirement ${r}`);
522
+ for (const r of flagAll(flags, "criterion")) lines.push(`append-criterion ${r}`);
523
+ lines.push(...raw);
431
524
  if (bump) lines.push(`bump-version ${bump}`);
432
525
 
433
526
  const r = addDeltaOps(projectRoot, id, capability, lines);
@@ -550,11 +643,12 @@ function changeTick(args, flags) {
550
643
  byPath.get(box.file.path).push(box);
551
644
  }
552
645
  for (const [filePath, hits] of byPath) {
553
- const lines = read(filePath).split(/\r?\n/);
646
+ const before = read(filePath);
647
+ const lines = before.split(/\r?\n/);
554
648
  for (const box of hits) {
555
649
  lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
556
650
  }
557
- write(filePath, lines.join("\n"), { force: true });
651
+ write(filePath, withEol(lines.join("\n"), eolOf(before)), { force: true });
558
652
  }
559
653
  console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
560
654
  return 0;
@@ -707,13 +801,22 @@ async function changeAbandon(args, flags) {
707
801
 
708
802
  const reason = flagString(flags, "reason") ?? "";
709
803
  const date = today();
804
+ // The delivery it belonged to stays on record (third audit, change 0234):
805
+ // the proposal that carried it is deleted, and the delivery's status went
806
+ // back to "no change yet", as if the planner had never decided anything.
807
+ const proposalFile = path.join(changeDir, "proposal.md");
808
+ const delivery = isFile(proposalFile) ? (listHeader(read(proposalFile), "Delivery") ?? "").trim() : "";
809
+ const recorded = delivery ? `${reason ? `${reason} ` : ""}(delivery ${delivery})` : reason;
710
810
 
711
811
  remove(changeDir);
712
812
  console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
713
813
 
714
814
  // Ledger line so the abandonment is part of the visible history.
715
- const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, reason, date));
815
+ const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, recorded, date));
716
816
  console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
817
+ if (delivery) {
818
+ console.log(c.gray("hint: ") + `it was this repository's part of delivery ${delivery} — from the workspace, re-route the delivery without this repository (\`doctrina delivery route ${delivery} <the others>\`) or open a new change here with \`doctrina work "<title>" --delivery ${delivery}\``);
819
+ }
717
820
 
718
821
  // Drop the change entry; rebuild from the tree so nothing drifts.
719
822
  const current = idx.load(projectRoot);
@@ -766,6 +869,26 @@ export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
766
869
  continue;
767
870
  }
768
871
  console.log(c.yellow("MODIFIED") + ` ${targetRel}`);
872
+ // A delta with an ops block is previewed as the spec it produces (change
873
+ // 0228): the ops run in memory, as apply runs them, and the diff is the
874
+ // spec before against the spec after. Diffing the spec against the
875
+ // delta FILE printed every line of the spec as a '-' — three hundred
876
+ // lines of noise around the four that change.
877
+ const ops = extractOps(text);
878
+ if (ops.length > 0) {
879
+ const current = read(targetPath);
880
+ const after = applyOps(current, ops);
881
+ if (after.errors.length > 0) {
882
+ for (const e of after.errors) console.error(c.red("error:") + ` ${rel}: ${e}`);
883
+ errors += 1;
884
+ continue;
885
+ }
886
+ console.log(formatUnified(diffLines(current, after.text), {
887
+ aLabel: targetRel,
888
+ bLabel: `${targetRel} (after apply)`,
889
+ }));
890
+ continue;
891
+ }
769
892
  console.log(c.gray("note: the delta body is a fragment to merge — '-' lines are current"));
770
893
  console.log(c.gray(" spec content absent from the delta, not necessarily removals."));
771
894
  const out = formatUnified(diffLines(read(targetPath), body), {
@@ -786,6 +909,22 @@ function extractDeltaBody(text) {
786
909
  }
787
910
 
788
911
 
912
+ // The smells `after` has that `before` does not, matched by rule, token and
913
+ // the line's own text — line numbers shift when a delta inserts a line.
914
+ function newSmells(before, after, projectRoot) {
915
+ const key = (/** @type {string} */ text, /** @type {{ line: number, rule: string, token: string }} */ sm) =>
916
+ `${sm.rule}|${sm.token.toLowerCase()}|${text.split("\n")[sm.line - 1] ?? ""}`;
917
+ const seen = new Map();
918
+ for (const sm of scanText(before, projectRoot)) seen.set(key(before, sm), (seen.get(key(before, sm)) ?? 0) + 1);
919
+ const out = [];
920
+ for (const sm of scanText(after, projectRoot)) {
921
+ const k = key(after, sm);
922
+ if ((seen.get(k) ?? 0) > 0) seen.set(k, seen.get(k) - 1);
923
+ else out.push(sm);
924
+ }
925
+ return out;
926
+ }
927
+
789
928
  export const help = `
790
929
  Usage: doctrina change <subcommand> [args]
791
930
 
@@ -808,6 +947,7 @@ Subcommands:
808
947
  its target + the archive gate preview + an advisory
809
948
  list of accepted ADRs citing the touched capabilities.
810
949
  Everything close would refuse, listed BEFORE it runs.
950
+ --verbose also prints each delta as it would land.
811
951
  tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
812
952
  ## Verification) with ordinals; tick the given ones,
813
953
  or every one with --all. No args = list only.
@@ -819,7 +959,14 @@ Subcommands:
819
959
  capability — created from the template when absent —
820
960
  parsed by the grammar apply reads and dry-run against
821
961
  the spec first: nothing is written unless the whole
822
- block applies. A second --bump replaces the first.
962
+ block applies. --requirement, --criterion and --op
963
+ repeat, one op each, in order; a second --bump
964
+ replaces the first.
965
+ card <id> [<n>] Print task <n> (default: the next open one) as a
966
+ self-contained card for an executor: the task and the
967
+ lines indented under it (Files:, Requirement:, Verify:),
968
+ the change's Why, the spec cut to the task, and the
969
+ tick command. Exit 0 with a note when every task is done.
823
970
 
824
971
  apply / archive / check accept multiple ids (batch close of a backlog); the
825
972
  exit code is the worst per-id result.