doctrina-cli 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. package/templates/playbooks/delivery.md.template +12 -6
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.20.0 — current release. Zero runtime dependencies
6
+ **Status:** v0.22.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
@@ -23,14 +23,14 @@ read `AGENTS.md` and run `doctrina next` — it drives the rest. The
23
23
  surface, by the moment you reach for it:
24
24
 
25
25
  ```
26
- Bootstrap init · intake · adapter list|add|remove
26
+ Bootstrap init · ui · onboard · 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|card
28
+ Change triage · work · spec new|list|set · change new|apply|archive|check|tick|abandon|renumber|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
33
- Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
32
+ Orchestrate workspace init|add|attach|detach|list|tool|guard · delivery new|route|list|status|tick|check|close
33
+ Maintain upgrade · index rebuild|merge · hooks install · watch · metrics · completion · ci · templates list
34
34
  ```
35
35
 
36
36
  `doctrina --help` prints the same grouping with one line per command, and
@@ -44,7 +44,7 @@ code is in the
44
44
  |------|---------|
45
45
  | `--project-name <name>` | Override the project name (defaults to cwd basename) |
46
46
  | `--project-description <text>` | One-sentence description, with no intake. Without it on a terminal, `init` asks you to describe the project once and keeps the answer as the intake |
47
- | `--agent <name>` | Install the adapter for one of the twelve supported agents (`claude`, `codex`, `cursor`, `copilot`, `gemini`, `aider`, `windsurf`, `continue`, `amp`, `devin`, `factory`, `jules`) or `all` |
47
+ | `--agent <name>` | Install the adapter for one of the 13 supported agents (`claude`, `codex`, `cursor`, `copilot`, `gemini`, `aider`, `windsurf`, `continue`, `amp`, `devin`, `factory`, `jules`, `kiro`) or `all` |
48
48
  | `--date <YYYY-MM-DD>` | Override the system date in artifacts |
49
49
  | `--force` | Overwrite existing files |
50
50
  | `--non-interactive` | Fail instead of prompting for missing required values |
@@ -57,7 +57,7 @@ code is in the
57
57
  `alwaysApply: true` pointing at `AGENTS.md`. `copilot`, `gemini`,
58
58
  `aider`, `windsurf`, and `continue` follow the same thin-pointer
59
59
  pattern at each agent's native path.
60
- - `--agent codex` (and `amp`, `devin`, `factory`, `jules`) installs
60
+ - `--agent codex` (and `amp`, `devin`, `factory`, `jules`, `kiro`) installs
61
61
  nothing: these agents read `AGENTS.md` natively.
62
62
  - `--agent all` installs every adapter.
63
63
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.20.0",
3
+ "version": "0.22.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,6 +2,8 @@
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,
7
9
  isMergedConfig, mergeJson, unmergeJson, containsJson, holdsAnyOf,
@@ -137,6 +139,14 @@ function adapterAdd(projectRoot, name, cmdFlags) {
137
139
  written += 1;
138
140
  }
139
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)`);
140
150
  console.log("");
141
151
  console.log(`${c.bold("Adapter " + name)} — ${written} file${written === 1 ? "" : "s"} written` +
142
152
  (skipped > 0 ? `, ${skipped} left alone` : "") +
@@ -2,16 +2,16 @@
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";
@@ -19,15 +19,19 @@ import { confirm, isInteractive } from "../lib/prompt.js";
19
19
  import { taskCard } from "../lib/task-card.js";
20
20
  import { EXIT } from "../lib/exit-codes.js";
21
21
  import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
22
+ import { renumberChange } from "../lib/change-numbers.js";
23
+ import { appliedDeltas, deltaFingerprint, deltaKey, pendingOps, recordAppliedDeltas } from "../lib/applied-deltas.js";
22
24
  import { parseOperation, parseCapabilityFromDelta, isUntouchedScaffold } from "../lib/doc-model.js";
23
25
  import { changeNew, addDeltaOps } from "../lib/change-ops.js";
26
+ import { propertyShape } from "../lib/properties.js";
27
+ import { scanText } from "../lib/clarify-scan.js";
24
28
 
25
- const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
29
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "renumber", "delta", "card"];
26
30
 
27
31
  // Flags this command accepts. Declared HERE, with the command, so
28
32
  // adding a command never requires editing the entrypoint — the gap that
29
33
  // let six flags ship undeclared and silently swallow a positional (C3).
30
- export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "bump", "op"] };
34
+ export const flags = { boolean: ["json", "all", "chore", "design", "force", "no-spec", "verbose"], string: ["reason", "requirement", "criterion", "property", "bump", "op"] };
31
35
 
32
36
  export async function run(positional, flags) {
33
37
  const sub = positional[0];
@@ -45,6 +49,8 @@ export async function run(positional, flags) {
45
49
  return changeTick(positional.slice(1), flags);
46
50
  case "abandon":
47
51
  return await changeAbandon(positional.slice(1), flags);
52
+ case "renumber":
53
+ return changeRenumber(positional.slice(1));
48
54
  case "delta":
49
55
  return changeDelta(positional.slice(1), flags);
50
56
  case "card":
@@ -88,7 +94,7 @@ function changeCard(args) {
88
94
  const { card, reason } = taskCard(projectRoot, id, ordinal);
89
95
  if (!card) {
90
96
  console.log(c.gray(reason));
91
- return reason.startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
97
+ return (reason ?? "").startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
92
98
  }
93
99
  const L = [];
94
100
  L.push(`# Task card — ${card.change}, task ${card.ordinal} of ${card.total}` + (card.checked ? " (already ticked)" : ""));
@@ -103,8 +109,26 @@ function changeCard(args) {
103
109
  if (card.why) {
104
110
  L.push("", "## Why the change exists", "", card.why);
105
111
  }
112
+ if (card.story) {
113
+ L.push("", `## The story (delivery ${card.delivery})`, "", card.story);
114
+ }
115
+ // What the change does to the spec — the requirement the card implements
116
+ // is here, not in the spec as it stands (change 0231).
117
+ if (card.changes.length > 0) {
118
+ L.push("", "## What this change does to the spec", "");
119
+ for (const ch of card.changes) L.push(`- ${ch.capability} ${ch.line}`);
120
+ }
106
121
  for (const s of card.slices) {
107
- L.push("", `## Spec — ${s.capability} (cut to this task)`, "", s.text.trim());
122
+ L.push("", `## Spec — ${s.capability} as it stands (${s.whole ? "whole: nothing in it matched the task" : "cut to this task"})`, "", s.text.trim());
123
+ }
124
+ if (card.decisions.list.length > 0) {
125
+ L.push("", "## Decisions that bind it", "");
126
+ for (const d of card.decisions.list) {
127
+ L.push(`- ADR ${d.id} — ${d.title} (\`doctrina show ${d.id}\`)`);
128
+ if (d.decision) L.push(` ${d.decision}`);
129
+ }
130
+ if (card.decisions.more > 0) L.push(`- … and ${card.decisions.more} more accepted global decisions — \`doctrina prime --rules\``);
131
+ L.push("", "Where the story or the task contradicts one of these, stop and report it: the decision stands until the planner supersedes it.");
108
132
  }
109
133
  L.push("", "## When done", "");
110
134
  L.push(card.fields.verify
@@ -223,21 +247,53 @@ function changeApply(args, flags) {
223
247
  const proposalPath = path.join(changeDir, "proposal.md");
224
248
  const alreadyApplied = isFile(proposalPath)
225
249
  && (getHeader(read(proposalPath), "Status") ?? "").trim().toLowerCase() === "applied";
250
+ const allDeltas = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
251
+ // WHICH deltas the stamp covers (change 0247). The stamp is the change's,
252
+ // and a change is not finished when it is stamped: a close that applied
253
+ // and then stopped at a later gate leaves it `applied`, and the fix for
254
+ // that gate is often one more delta. The stamp said "nothing to do", the
255
+ // close went on, and the change was archived naming a spec it had never
256
+ // touched. The record names each delta that was applied, with what it held.
257
+ /** @type {Map<string, Set<number> | null> | null} delta → the ops still to apply (null: the whole delta) */
258
+ let lateOnly = null;
226
259
  if (alreadyApplied && !flagBool(flags, "force", false)) {
227
- console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
228
- console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
229
- console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
230
- console.log(c.gray("only after reverting the specs to their pre-apply state."));
231
- return 0;
260
+ const record = appliedDeltas(read(proposalPath));
261
+ const late = new Map();
262
+ const edited = [];
263
+ for (const p of record ? allDeltas : []) {
264
+ const recorded = record?.get(deltaKey(changeDir, p));
265
+ if (!recorded) {
266
+ late.set(p, null);
267
+ continue;
268
+ }
269
+ const { fresh, gone } = pendingOps(recorded, deltaFingerprint(read(p)));
270
+ if (gone) edited.push(p);
271
+ else if (fresh.length > 0) late.set(p, new Set(fresh));
272
+ }
273
+ if (edited.length > 0) {
274
+ console.error(c.red("error:") + ` ${edited.map((p) => relPath(changeDir, p)).join(", ")}: an op that was already applied changed or was removed — the spec holds what it wrote, and applying the delta again would add to that, not replace it`);
275
+ console.error(c.gray("hint: ") + "restore the op as it was applied and add the correction as a new op (`replace-requirement`, `replace-criterion`, `doctrina change delta`), or revert the spec to its pre-apply state and run `doctrina change apply " + id + " --force`");
276
+ return 1;
277
+ }
278
+ if (late.size === 0) {
279
+ console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
280
+ console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
281
+ console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
282
+ console.log(c.gray("only after reverting the specs to their pre-apply state."));
283
+ return 0;
284
+ }
285
+ const what = [...late].map(([p, ops]) => `${relPath(changeDir, p)}${ops ? ` (${ops.size} new op${ops.size === 1 ? "" : "s"})` : ""}`).join(", ");
286
+ console.log(c.gray("note: ") + `change "${id}" was applied before this was added — applying only ${what}`);
287
+ lateOnly = late;
232
288
  }
233
289
 
234
290
  // The skip above comes FIRST (change 0221): the apply gate is a pre-apply
235
291
  // question, and on an applied change its ADDED check reads the content the
236
292
  // apply wrote as a conflict — which refused the resume of a killed close.
237
- const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
293
+ const applyGate = lateOnly ? { ok: true, forced: null } : enforceTransition(projectRoot, id, changeDir, "apply", flags);
238
294
  if (!applyGate.ok) return 1;
239
295
 
240
- const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
296
+ const deltaFiles = lateOnly ? [...lateOnly.keys()] : allDeltas;
241
297
  const date = today();
242
298
  let errors = 0;
243
299
  let writes = 0;
@@ -291,7 +347,8 @@ function changeApply(args, flags) {
291
347
  // `ops` block (set-header / bump-version / set-criterion / ...; F3);
292
348
  // otherwise it falls back to the historical manual-merge pointer, so
293
349
  // prose deltas keep working unchanged.
294
- const ops = extractOps(text);
350
+ const only = lateOnly?.get(deltaPath) ?? null;
351
+ const ops = extractOps(text).filter((_op, i) => only === null || only.has(i));
295
352
  if (ops.length === 0) {
296
353
  console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand (no ops block)`);
297
354
  manual += 1;
@@ -334,6 +391,10 @@ function changeApply(args, flags) {
334
391
  console.log(c.green("status") + " proposal.md → applied");
335
392
  flippedToApplied = true;
336
393
  }
394
+ // Every delta the proposal's stamp now covers, with what it held.
395
+ const stamped = read(proposalPath);
396
+ const withRecord = recordAppliedDeltas(stamped, allDeltas.map((p) => [deltaKey(changeDir, p), deltaFingerprint(read(p))]));
397
+ if (withRecord !== stamped) write(proposalPath, withRecord, { force: true });
337
398
  }
338
399
  }
339
400
 
@@ -417,6 +478,13 @@ async function changeCheck(id, { verbose = false } = {}) {
417
478
  opsFindings += 1;
418
479
  } else {
419
480
  console.log(c.green("✓ ") + `${rel}: ${result.applied.length} op${result.applied.length === 1 ? "" : "s"} would apply cleanly`);
481
+ // What clarify will say about the spec once this lands (change 0232):
482
+ // the smells the merged spec has and the current one does not.
483
+ // Advisory — clarify is not a close gate — but a project that runs
484
+ // `clarify --all` in its build learned about the word mid-close.
485
+ for (const smell of newSmells(read(targetPath), result.text, projectRoot)) {
486
+ console.log(c.yellow("! ") + `${rel}: writes ${smell.rule} "${smell.token}" into ${capability}'s spec — ${smell.hint} (\`clarify\` flags it after apply)`);
487
+ }
420
488
  }
421
489
  }
422
490
  if (deltaFiles.length === 0) console.log(c.gray("- no spec deltas"));
@@ -484,14 +552,20 @@ function changeDelta(args, flags) {
484
552
  }
485
553
  // In the order a reader expects them: requirement, criterion, any raw op,
486
554
  // and the bump last.
555
+ // `--requirement`, `--criterion` and `--op` repeat (change 0228): each
556
+ // occurrence is one op, in the order given. The parser keeps the last
557
+ // value of a repeated flag, so reading them with flagString wrote one op
558
+ // and reported success for a command line that asked for three.
487
559
  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);
560
+ const raw = flagAll(flags, "op");
561
+ if (raw.length === 0 && rest.length > 0) raw.push(rest.join(" "));
491
562
  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);
563
+ for (const r of flagAll(flags, "requirement")) lines.push(`append-requirement ${r}`);
564
+ for (const r of flagAll(flags, "criterion")) lines.push(`append-criterion ${r}`);
565
+ // A criterion that claims something of every input (change 0254): the
566
+ // mark is what makes coverage ask the cited test to be a property.
567
+ for (const r of flagAll(flags, "property")) lines.push(`append-criterion [property] ${r.replace(/^\[property\]\s*/i, "")}`);
568
+ lines.push(...raw);
495
569
  if (bump) lines.push(`bump-version ${bump}`);
496
570
 
497
571
  const r = addDeltaOps(projectRoot, id, capability, lines);
@@ -502,7 +576,23 @@ function changeDelta(args, flags) {
502
576
  return r.code;
503
577
  }
504
578
  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`));
579
+ c.gray(` — ${lines.length} op${lines.length === 1 ? "" : "s"} added; the block now holds ${(r.block ?? []).length} (${(r.block ?? []).join(", ")}), and applies cleanly`));
580
+ // The moment the requirement is written is the moment its proof is
581
+ // chosen (change 0254): one that claims something of every input is told
582
+ // so here, while the test for it does not exist yet.
583
+ if (flagAll(flags, "property").length === 0) {
584
+ let noted = 0;
585
+ for (const req of flagAll(flags, "requirement")) {
586
+ const shape = propertyShape(req.replace(/^[a-z-]+:\s*/i, ""));
587
+ if (!shape) continue;
588
+ noted++;
589
+ console.log(c.yellow("note: ") + `"${req.length > 70 ? req.slice(0, 69) + "…" : req}" reads as ${shape.id === "invariant" ? "an invariant" : shape.id} — ${shape.property}.`);
590
+ }
591
+ if (noted > 0) {
592
+ console.log(c.gray(" An example test proves the cases somebody thought of. If a failure here would be a bug, write a property test and claim it: ") +
593
+ c.cyan(`doctrina change delta ${id} ${capability} --property "<what holds for every input> — verified by \`<the property test>\`"`));
594
+ }
595
+ }
506
596
  console.log(c.gray("Preview the merge: ") + c.cyan(`doctrina change check ${id} --verbose`));
507
597
  return EXIT.OK;
508
598
  }
@@ -614,11 +704,12 @@ function changeTick(args, flags) {
614
704
  byPath.get(box.file.path).push(box);
615
705
  }
616
706
  for (const [filePath, hits] of byPath) {
617
- const lines = read(filePath).split(/\r?\n/);
707
+ const before = read(filePath);
708
+ const lines = before.split(/\r?\n/);
618
709
  for (const box of hits) {
619
710
  lines[box.lineIndex] = lines[box.lineIndex].replace(/\[ \]/, "[x]");
620
711
  }
621
- write(filePath, lines.join("\n"), { force: true });
712
+ write(filePath, withEol(lines.join("\n"), eolOf(before)), { force: true });
622
713
  }
623
714
  console.log(c.green("ticked ") + `${picked.length} box${picked.length === 1 ? "" : "es"} in ${byPath.size} file${byPath.size === 1 ? "" : "s"}`);
624
715
  return 0;
@@ -771,13 +862,22 @@ async function changeAbandon(args, flags) {
771
862
 
772
863
  const reason = flagString(flags, "reason") ?? "";
773
864
  const date = today();
865
+ // The delivery it belonged to stays on record (third audit, change 0234):
866
+ // the proposal that carried it is deleted, and the delivery's status went
867
+ // back to "no change yet", as if the planner had never decided anything.
868
+ const proposalFile = path.join(changeDir, "proposal.md");
869
+ const delivery = isFile(proposalFile) ? (listHeader(read(proposalFile), "Delivery") ?? "").trim() : "";
870
+ const recorded = delivery ? `${reason ? `${reason} ` : ""}(delivery ${delivery})` : reason;
774
871
 
775
872
  remove(changeDir);
776
873
  console.log(c.green("removed") + ` ${relPath(projectRoot, changeDir)}`);
777
874
 
778
875
  // Ledger line so the abandonment is part of the visible history.
779
- const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, reason, date));
876
+ const ledgerFile = appendLedgerLine(projectRoot, abandonedLine(id, recorded, date));
780
877
  console.log(c.green("ledger") + ` +1 line in ${relPath(projectRoot, ledgerFile)}`);
878
+ if (delivery) {
879
+ 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}\``);
880
+ }
781
881
 
782
882
  // Drop the change entry; rebuild from the tree so nothing drifts.
783
883
  const current = idx.load(projectRoot);
@@ -788,6 +888,34 @@ async function changeAbandon(args, flags) {
788
888
  return 0;
789
889
  }
790
890
 
891
+ // Give an open change the next free number (change 0242): two branches that
892
+ // each opened a change took the same one, and the merge holds both.
893
+ function changeRenumber(args) {
894
+ const id = args[0];
895
+ if (!id) {
896
+ console.error(c.red("error:") + " change renumber requires <id>");
897
+ return 2;
898
+ }
899
+ const refused = refuseChangeRef(id);
900
+ if (refused !== null) return refused;
901
+ const projectRoot = process.cwd();
902
+ ensureDoctrinaProject(projectRoot);
903
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
904
+ if (!isDir(changeDir)) {
905
+ console.error(c.red("error:") + ` change "${id}" is not an open change (${relPath(projectRoot, changeDir)} not found) — an archived change keeps its number`);
906
+ return EXIT.USAGE;
907
+ }
908
+ const { to, rewritten } = renumberChange(projectRoot, id);
909
+ console.log(c.green("renumbered") + ` ${id} → ${to}`);
910
+ if (rewritten.length > 0) console.log(c.gray(` the id was rewritten in ${rewritten.join(", ")}`));
911
+ const derived = deriveIndex(projectRoot, idx.load(projectRoot));
912
+ derived.last_updated = today();
913
+ idx.save(projectRoot, derived);
914
+ console.log(c.green("indexed") + ` change "${to}" in index.json`);
915
+ console.log(c.gray("next: ") + `name ${to} wherever the old number was written outside the change — a commit message, a branch, a task card already handed out`);
916
+ return 0;
917
+ }
918
+
791
919
  // The per-delta preview: what applying this delta would do to its target.
792
920
  // ADDED reports the body it would write, REMOVED the spec it would delete,
793
921
  // MODIFIED a line diff against the current spec.
@@ -830,6 +958,26 @@ export function printDeltaPreview(projectRoot, changeDir, deltaFiles) {
830
958
  continue;
831
959
  }
832
960
  console.log(c.yellow("MODIFIED") + ` ${targetRel}`);
961
+ // A delta with an ops block is previewed as the spec it produces (change
962
+ // 0228): the ops run in memory, as apply runs them, and the diff is the
963
+ // spec before against the spec after. Diffing the spec against the
964
+ // delta FILE printed every line of the spec as a '-' — three hundred
965
+ // lines of noise around the four that change.
966
+ const ops = extractOps(text);
967
+ if (ops.length > 0) {
968
+ const current = read(targetPath);
969
+ const after = applyOps(current, ops);
970
+ if (after.errors.length > 0) {
971
+ for (const e of after.errors) console.error(c.red("error:") + ` ${rel}: ${e}`);
972
+ errors += 1;
973
+ continue;
974
+ }
975
+ console.log(formatUnified(diffLines(current, after.text), {
976
+ aLabel: targetRel,
977
+ bLabel: `${targetRel} (after apply)`,
978
+ }));
979
+ continue;
980
+ }
833
981
  console.log(c.gray("note: the delta body is a fragment to merge — '-' lines are current"));
834
982
  console.log(c.gray(" spec content absent from the delta, not necessarily removals."));
835
983
  const out = formatUnified(diffLines(read(targetPath), body), {
@@ -850,6 +998,22 @@ function extractDeltaBody(text) {
850
998
  }
851
999
 
852
1000
 
1001
+ // The smells `after` has that `before` does not, matched by rule, token and
1002
+ // the line's own text — line numbers shift when a delta inserts a line.
1003
+ function newSmells(before, after, projectRoot) {
1004
+ const key = (/** @type {string} */ text, /** @type {{ line: number, rule: string, token: string }} */ sm) =>
1005
+ `${sm.rule}|${sm.token.toLowerCase()}|${text.split("\n")[sm.line - 1] ?? ""}`;
1006
+ const seen = new Map();
1007
+ for (const sm of scanText(before, projectRoot)) seen.set(key(before, sm), (seen.get(key(before, sm)) ?? 0) + 1);
1008
+ const out = [];
1009
+ for (const sm of scanText(after, projectRoot)) {
1010
+ const k = key(after, sm);
1011
+ if ((seen.get(k) ?? 0) > 0) seen.set(k, seen.get(k) - 1);
1012
+ else out.push(sm);
1013
+ }
1014
+ return out;
1015
+ }
1016
+
853
1017
  export const help = `
854
1018
  Usage: doctrina change <subcommand> [args]
855
1019
 
@@ -872,18 +1036,29 @@ Subcommands:
872
1036
  its target + the archive gate preview + an advisory
873
1037
  list of accepted ADRs citing the touched capabilities.
874
1038
  Everything close would refuse, listed BEFORE it runs.
1039
+ --verbose also prints each delta as it would land.
875
1040
  tick <id> [n... |--all] List the unchecked boxes (tasks.md + proposal
876
1041
  ## Verification) with ordinals; tick the given ones,
877
1042
  or every one with --all. No args = list only.
878
1043
  abandon <id> Delete an open change folder and its index entry, and
879
1044
  record the abandonment in the ledger ([--reason "..."]).
1045
+ renumber <id> Give an open change the next free number: the folder
1046
+ moves and the id is rewritten in its own files. For
1047
+ the merge that brought together two changes opened
1048
+ on two branches under one number — \`validate\` names it.
880
1049
  delta <id> <capability> [--requirement "<section>: <text>"] [--criterion "<text>"]
881
- [--bump major|minor|patch] [--op "<op line>" | <op line>]
1050
+ [--property "<text>"] [--bump major|minor|patch] [--op "<op line>" | <op line>]
882
1051
  Write ops into the change's MODIFIED delta for the
883
1052
  capability — created from the template when absent —
884
1053
  parsed by the grammar apply reads and dry-run against
885
1054
  the spec first: nothing is written unless the whole
886
- block applies. A second --bump replaces the first.
1055
+ block applies. --requirement, --criterion and --op
1056
+ repeat, one op each, in order; a second --bump
1057
+ replaces the first. --property writes a criterion
1058
+ marked [property]: a claim about every input, which
1059
+ coverage counts only when the test it cites is
1060
+ property-based (\`doctrina coverage --properties\`
1061
+ lists the requirements that read as one).
887
1062
  card <id> [<n>] Print task <n> (default: the next open one) as a
888
1063
  self-contained card for an executor: the task and the
889
1064
  lines indented under it (Files:, Requirement:, Verify:),