doctrina-cli 0.21.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 (75) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/change.js +111 -14
  4. package/src/commands/clarify.js +2 -2
  5. package/src/commands/close.js +1 -1
  6. package/src/commands/coverage.js +69 -6
  7. package/src/commands/doctor.js +62 -4
  8. package/src/commands/hooks.js +56 -11
  9. package/src/commands/index-rebuild.js +47 -1
  10. package/src/commands/init.js +51 -7
  11. package/src/commands/metrics.js +1 -1
  12. package/src/commands/onboard.js +113 -0
  13. package/src/commands/review.js +1 -1
  14. package/src/commands/search.js +2 -2
  15. package/src/commands/templates.js +2 -1
  16. package/src/commands/triage.js +16 -1
  17. package/src/commands/ui.js +512 -0
  18. package/src/commands/upgrade.js +46 -6
  19. package/src/commands/verify.js +3 -3
  20. package/src/commands/why.js +1 -1
  21. package/src/commands/work.js +53 -40
  22. package/src/commands/workspace.js +16 -3
  23. package/src/index.js +9 -0
  24. package/src/lib/actions.js +19 -0
  25. package/src/lib/adapters.js +23 -3
  26. package/src/lib/agent-bridge.js +400 -0
  27. package/src/lib/applied-deltas.js +82 -0
  28. package/src/lib/args.js +1 -1
  29. package/src/lib/change-numbers.js +115 -0
  30. package/src/lib/change-ops.js +2 -2
  31. package/src/lib/clarify-scan.js +3 -3
  32. package/src/lib/code-inventory.js +22 -5
  33. package/src/lib/commands.js +50 -4
  34. package/src/lib/config.js +27 -8
  35. package/src/lib/conflict-markers.js +61 -0
  36. package/src/lib/console-model.js +668 -0
  37. package/src/lib/console-prefs.js +67 -0
  38. package/src/lib/console-room.js +201 -0
  39. package/src/lib/console-state.js +150 -0
  40. package/src/lib/console-view.js +557 -0
  41. package/src/lib/coverage-model.js +29 -6
  42. package/src/lib/delivery.js +2 -2
  43. package/src/lib/docs-impact.js +1 -1
  44. package/src/lib/fs-ops.js +80 -12
  45. package/src/lib/gates.js +7 -1
  46. package/src/lib/git.js +18 -0
  47. package/src/lib/index-json.js +10 -1
  48. package/src/lib/index-merge.js +90 -0
  49. package/src/lib/install.js +80 -0
  50. package/src/lib/intake-model.js +4 -1
  51. package/src/lib/ledger.js +2 -3
  52. package/src/lib/lock.js +58 -0
  53. package/src/lib/merge-setup.js +55 -0
  54. package/src/lib/metrics-model.js +2 -2
  55. package/src/lib/onboard.js +197 -0
  56. package/src/lib/preset.js +177 -0
  57. package/src/lib/prompt.js +1 -1
  58. package/src/lib/properties.js +149 -0
  59. package/src/lib/runtime.js +19 -7
  60. package/src/lib/scan.js +13 -8
  61. package/src/lib/spec-ops.js +2 -2
  62. package/src/lib/task-card.js +1 -1
  63. package/src/lib/templates-model.js +3 -2
  64. package/src/lib/triage-model.js +75 -0
  65. package/src/lib/validation-model.js +23 -5
  66. package/src/lib/views.js +2 -1
  67. package/src/lib/workspace-agents.js +2 -2
  68. package/src/lib/workspace-tools.js +41 -1
  69. package/src/lib/workspace.js +1 -1
  70. package/templates/AGENTS.md.template +8 -4
  71. package/templates/README.md +2 -1
  72. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  73. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  74. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  75. package/templates/adapters/kiro/README.md +22 -0
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.21.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
32
  Orchestrate workspace init|add|attach|detach|list|tool|guard · delivery new|route|list|status|tick|check|close
33
- Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
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.21.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": [
@@ -19,16 +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";
24
27
  import { scanText } from "../lib/clarify-scan.js";
25
28
 
26
- const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "delta", "card"];
29
+ const SUBCOMMANDS = ["new", "apply", "archive", "check", "tick", "abandon", "renumber", "delta", "card"];
27
30
 
28
31
  // Flags this command accepts. Declared HERE, with the command, so
29
32
  // adding a command never requires editing the entrypoint — the gap that
30
33
  // let six flags ship undeclared and silently swallow a positional (C3).
31
- 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"] };
32
35
 
33
36
  export async function run(positional, flags) {
34
37
  const sub = positional[0];
@@ -46,6 +49,8 @@ export async function run(positional, flags) {
46
49
  return changeTick(positional.slice(1), flags);
47
50
  case "abandon":
48
51
  return await changeAbandon(positional.slice(1), flags);
52
+ case "renumber":
53
+ return changeRenumber(positional.slice(1));
49
54
  case "delta":
50
55
  return changeDelta(positional.slice(1), flags);
51
56
  case "card":
@@ -89,7 +94,7 @@ function changeCard(args) {
89
94
  const { card, reason } = taskCard(projectRoot, id, ordinal);
90
95
  if (!card) {
91
96
  console.log(c.gray(reason));
92
- return reason.startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
97
+ return (reason ?? "").startsWith("every task") ? EXIT.OK : EXIT.PRECONDITION;
93
98
  }
94
99
  const L = [];
95
100
  L.push(`# Task card — ${card.change}, task ${card.ordinal} of ${card.total}` + (card.checked ? " (already ticked)" : ""));
@@ -242,21 +247,53 @@ function changeApply(args, flags) {
242
247
  const proposalPath = path.join(changeDir, "proposal.md");
243
248
  const alreadyApplied = isFile(proposalPath)
244
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;
245
259
  if (alreadyApplied && !flagBool(flags, "force", false)) {
246
- console.log(c.gray("skip ") + `change "${id}" is already applied — nothing to do`);
247
- console.log(c.gray("The ops verbs are additive: applying twice duplicates every"));
248
- console.log(c.gray("requirement this delta carries. Re-apply anyway with --force"));
249
- console.log(c.gray("only after reverting the specs to their pre-apply state."));
250
- 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;
251
288
  }
252
289
 
253
290
  // The skip above comes FIRST (change 0221): the apply gate is a pre-apply
254
291
  // question, and on an applied change its ADDED check reads the content the
255
292
  // apply wrote as a conflict — which refused the resume of a killed close.
256
- const applyGate = enforceTransition(projectRoot, id, changeDir, "apply", flags);
293
+ const applyGate = lateOnly ? { ok: true, forced: null } : enforceTransition(projectRoot, id, changeDir, "apply", flags);
257
294
  if (!applyGate.ok) return 1;
258
295
 
259
- const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
296
+ const deltaFiles = lateOnly ? [...lateOnly.keys()] : allDeltas;
260
297
  const date = today();
261
298
  let errors = 0;
262
299
  let writes = 0;
@@ -310,7 +347,8 @@ function changeApply(args, flags) {
310
347
  // `ops` block (set-header / bump-version / set-criterion / ...; F3);
311
348
  // otherwise it falls back to the historical manual-merge pointer, so
312
349
  // prose deltas keep working unchanged.
313
- const ops = extractOps(text);
350
+ const only = lateOnly?.get(deltaPath) ?? null;
351
+ const ops = extractOps(text).filter((_op, i) => only === null || only.has(i));
314
352
  if (ops.length === 0) {
315
353
  console.log(c.yellow("manual[MODIFIED]") + ` merge ${relPath(projectRoot, deltaPath)} into ${relPath(projectRoot, targetPath)} by hand (no ops block)`);
316
354
  manual += 1;
@@ -353,6 +391,10 @@ function changeApply(args, flags) {
353
391
  console.log(c.green("status") + " proposal.md → applied");
354
392
  flippedToApplied = true;
355
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 });
356
398
  }
357
399
  }
358
400
 
@@ -520,6 +562,9 @@ function changeDelta(args, flags) {
520
562
  const bump = flagString(flags, "bump");
521
563
  for (const r of flagAll(flags, "requirement")) lines.push(`append-requirement ${r}`);
522
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, "")}`);
523
568
  lines.push(...raw);
524
569
  if (bump) lines.push(`bump-version ${bump}`);
525
570
 
@@ -531,7 +576,23 @@ function changeDelta(args, flags) {
531
576
  return r.code;
532
577
  }
533
578
  console.log(c.green(r.created ? "created" : "updated") + ` ${r.deltaRel}` +
534
- 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
+ }
535
596
  console.log(c.gray("Preview the merge: ") + c.cyan(`doctrina change check ${id} --verbose`));
536
597
  return EXIT.OK;
537
598
  }
@@ -827,6 +888,34 @@ async function changeAbandon(args, flags) {
827
888
  return 0;
828
889
  }
829
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
+
830
919
  // The per-delta preview: what applying this delta would do to its target.
831
920
  // ADDED reports the body it would write, REMOVED the spec it would delete,
832
921
  // MODIFIED a line diff against the current spec.
@@ -953,15 +1042,23 @@ Subcommands:
953
1042
  or every one with --all. No args = list only.
954
1043
  abandon <id> Delete an open change folder and its index entry, and
955
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.
956
1049
  delta <id> <capability> [--requirement "<section>: <text>"] [--criterion "<text>"]
957
- [--bump major|minor|patch] [--op "<op line>" | <op line>]
1050
+ [--property "<text>"] [--bump major|minor|patch] [--op "<op line>" | <op line>]
958
1051
  Write ops into the change's MODIFIED delta for the
959
1052
  capability — created from the template when absent —
960
1053
  parsed by the grammar apply reads and dry-run against
961
1054
  the spec first: nothing is written unless the whole
962
1055
  block applies. --requirement, --criterion and --op
963
1056
  repeat, one op each, in order; a second --bump
964
- replaces the first.
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).
965
1062
  card <id> [<n>] Print task <n> (default: the next open one) as a
966
1063
  self-contained card for an executor: the task and the
967
1064
  lines indented under it (Files:, Requirement:, Verify:),
@@ -15,7 +15,7 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
15
15
  // guessing; persist it with .doctrina/config.json { "language": "pt-BR" }.
16
16
  function forcedLang(flags) {
17
17
  const raw = flagString(flags, "lang");
18
- if (!raw || raw === true) return null;
18
+ if (!raw) return null;
19
19
  const lang = String(raw).toLowerCase();
20
20
  if (lang.startsWith("pt")) return "pt";
21
21
  if (lang.startsWith("en")) return "en";
@@ -73,7 +73,7 @@ export async function run(positional, flags) {
73
73
  // and clarify each. ADRs are immutable and the archive is history, so
74
74
  // neither is included; AGENTS.md is dense command text, a different
75
75
  // register from prose specs.
76
- function clarifyAll(projectRoot, lang = null) {
76
+ function clarifyAll(projectRoot, lang = /** @type {string | null} */ (null)) {
77
77
  if (!exists(path.join(projectRoot, ".doctrina"))) {
78
78
  throw notADoctrinaProject();
79
79
  }
@@ -463,7 +463,7 @@ async function closeOne(projectRoot, id, flags) {
463
463
  if (isFile(ledgerPath)) {
464
464
  // Written in the ledger's own entry grammar (change 0046): a waived
465
465
  // gate no reader can find is the same as an unrecorded one.
466
- appendLedgerLine(projectRoot, docsGapLine(id, docsGap.signals));
466
+ appendLedgerLine(projectRoot, docsGapLine(id, /** @type {{ signals: string[] }} */ (/** @type {unknown} */ (docsGap)).signals));
467
467
  console.log(c.yellow("ledger") + " recorded the docs gap");
468
468
  }
469
469
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { spawnSync } from "node:child_process";
5
5
  import { exists, isFile, read, relPath } from "../lib/fs-ops.js";
6
+ import { propertyCandidates, projectPropertyLibrary } from "../lib/properties.js";
6
7
  import { flagBool, flagString } from "../lib/args.js";
7
8
  import { c } from "../lib/colors.js";
8
9
  import { emitJson } from "../lib/json-out.js";
@@ -39,7 +40,7 @@ export { collect, summarize, deriveImplementation, derivedImplementations, imple
39
40
  // wrap it in the generic envelope.
40
41
  export const jsonNative = true;
41
42
 
42
- export const flags = { boolean: ["json", "run", "strict"], string: ["only"] };
43
+ export const flags = { boolean: ["json", "run", "strict", "properties"], string: ["only"] };
43
44
 
44
45
  export async function run(_positional, flags) {
45
46
  const projectRoot = process.cwd();
@@ -77,6 +78,11 @@ export async function run(_positional, flags) {
77
78
  }
78
79
  }
79
80
 
81
+ // --properties: which requirements read as claims about EVERY input, and
82
+ // so ask for a property-based test (change 0254). A readout, exit 0: it
83
+ // proposes; the `[property]` mark on a criterion is what the gate enforces.
84
+ if (flagBool(flags, "properties", false)) return listProperties(projectRoot, only, json);
85
+
80
86
  const reports = collect(projectRoot, { only });
81
87
 
82
88
  // --run: execute the cited evidence instead of only checking it exists —
@@ -153,8 +159,8 @@ export async function run(_positional, flags) {
153
159
  if (r.unverified) {
154
160
  console.log(` ${c.gray("○")} #${r.n} proof resolves but the criterion is still marked [unverified] — flip the mark when the test proves it (spec set <cap> --criterion ${r.n}:verified)`);
155
161
  }
156
- if (r.missing?.length > 0) {
157
- console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
162
+ if ((r.missing ?? []).length > 0) {
163
+ console.log(` ${c.yellow("!")} #${r.n} also cites evidence that does not resolve: ${(r.missing ?? []).map((m) => `\`${m}\``).join(", ")}`);
158
164
  }
159
165
  continue;
160
166
  }
@@ -165,9 +171,9 @@ export async function run(_positional, flags) {
165
171
  } else if (r.kind === "conditional") {
166
172
  console.log(` ${c.yellow("!")} #${r.n} evidence is a skipped test (proves nothing): ${r.skipped.map((m) => `\`${m}\``).join(", ")}`);
167
173
  } else if (r.kind === "unguarded") {
168
- console.log(` ${c.red("✗")} #${r.n} orchestration claim is not fail-closed: ${r.reason}`);
174
+ console.log(` ${c.red("✗")} #${r.n} ${r.label ?? "orchestration claim is not fail-closed"}: ${r.reason}`);
169
175
  } else {
170
- console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${r.missing.map((m) => `\`${m}\``).join(", ")}`);
176
+ console.log(` ${c.yellow("!")} #${r.n} evidence not found on disk: ${(r.missing ?? []).map((m) => `\`${m}\``).join(", ")}`);
171
177
  }
172
178
  }
173
179
  }
@@ -250,8 +256,49 @@ function runEvidence(projectRoot, reports, strict) {
250
256
  }
251
257
 
252
258
 
259
+ const CANDIDATES_SHOWN = 5;
260
+
261
+ function listProperties(projectRoot, only, json) {
262
+ const caps = propertyCandidates(projectRoot, { only });
263
+ const lib = projectPropertyLibrary(projectRoot);
264
+ if (json) {
265
+ emitJson("coverage", { properties: caps, library: lib });
266
+ return 0;
267
+ }
268
+ const total = caps.reduce((n, cap) => n + cap.candidates.length, 0);
269
+ const proven = caps.reduce((n, cap) => n + cap.properties, 0);
270
+ console.log(c.bold("Property candidates") + c.gray(" — requirements that claim something of every input"));
271
+ console.log("");
272
+ if (total === 0) {
273
+ console.log(c.gray("no requirement here reads as an invariant (always/never, at most, unique, round trip, twice, in order)"));
274
+ return 0;
275
+ }
276
+ for (const cap of caps) {
277
+ if (cap.candidates.length === 0) continue;
278
+ console.log(`${c.cyan(cap.cap)} ${cap.candidates.length} requirement${cap.candidates.length === 1 ? "" : "s"} read${cap.candidates.length === 1 ? "s" : ""} as invariant${cap.candidates.length === 1 ? "" : "s"}, ` +
279
+ `${cap.properties} criteri${cap.properties === 1 ? "on is a property" : "a are properties"}`);
280
+ for (const cand of cap.candidates.slice(0, CANDIDATES_SHOWN)) {
281
+ const text = cand.text.length > 110 ? `${cand.text.slice(0, 107)}…` : cand.text;
282
+ console.log(` ${cand.ref.padEnd(13)} ${text}`);
283
+ console.log(c.gray(` ${"".padEnd(13)} ${cand.shape}: ${cand.property}`));
284
+ }
285
+ if (cap.candidates.length > CANDIDATES_SHOWN) {
286
+ console.log(c.gray(` … and ${cap.candidates.length - CANDIDATES_SHOWN} more — doctrina coverage --properties --only ${cap.cap} --json`));
287
+ }
288
+ }
289
+ console.log("");
290
+ console.log(`${total} candidate${total === 1 ? "" : "s"}, ${proven} propert${proven === 1 ? "y" : "ies"} claimed. A candidate is a reading of the words, not a verdict: choose the ones whose failure would be a bug.`);
291
+ const where = lib
292
+ ? `${lib.name}${lib.declared ? "" : " (not declared by this project yet — add it as a test dependency)"}`
293
+ : "the property library of the project's language";
294
+ console.log(`Write each as a test that generates its inputs with ${where}, then claim it:`);
295
+ console.log(c.cyan(" doctrina change delta <id> <capability> --property \"<what holds for every input> — verified by \`<the property test>\`\""));
296
+ console.log(c.gray("A criterion marked [property] counts as proven only when the test it cites is property-based."));
297
+ return 0;
298
+ }
299
+
253
300
  export const help = `
254
- Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--json]
301
+ Usage: doctrina coverage [--strict] [--only <cap,cap>] [--run] [--properties] [--json]
255
302
 
256
303
  Report how many acceptance criteria across .doctrina/specs/ cite an
257
304
  artifact or test that exists on disk. Each numbered criterion may cite
@@ -277,6 +324,22 @@ That check must declare an "expect" guard (see \`doctrina verify --help\`).
277
324
  A cited check with no guard is reported UNGUARDED and fails --strict: a
278
325
  check that exits 0 having run nothing would otherwise satisfy the claim.
279
326
 
327
+ PROPERTY criteria. A requirement that claims something of EVERY input —
328
+ "never returns a code already in use", "at most 100 items", "decoding
329
+ gives back what was encoded" — is not proven by three examples. Mark the
330
+ criterion [property] and cite a property-based test:
331
+
332
+ 4. [property] no two codes collide, whatever the URLs —
333
+ verified by \`tests/test_codes_property.py\`
334
+
335
+ It counts as covered only when that test generates its inputs
336
+ (Hypothesis @given, fast-check fc.property, jqwik @Property, proptest,
337
+ rapid, FsCheck, ScalaCheck); cited to an example test it is reported and
338
+ fails --strict. \`doctrina coverage --properties\` lists, per capability,
339
+ the requirements whose words read as an invariant, the property each
340
+ suggests and the library this project would write it with — a readout,
341
+ never a gate.
342
+
280
343
  A spec that declares a deliberate deferral —
281
344
  \`Implementation: planned — <why>\`, the same escape hatch validate
282
345
  honours — has its unproven criteria reported as DEFERRED: visible, but
@@ -19,6 +19,11 @@ import { OPERATIONS } from "../lib/commands.js";
19
19
  import { coverageLabel } from "../lib/views.js";
20
20
  import { workspaceFindings, workspaceRootAt, workspaceSnapshot } from "../lib/workspace-view.js";
21
21
  import { cdThen } from "../lib/shell.js";
22
+ import { cliVersion } from "../lib/version.js";
23
+ import { installsOnPath, runningPackageDir } from "../lib/install.js";
24
+ import { missingAttributes, configuredDriver } from "../lib/merge-setup.js";
25
+ import { isRepo } from "../lib/git.js";
26
+ import { shadowRootOf } from "../lib/workspace.js";
22
27
 
23
28
  // How many never-invoked operations the usage row names before pointing at
24
29
  // `metrics --commands` for the rest. The spec states this number, so it is
@@ -276,9 +281,9 @@ export async function run(_positional, _flags) {
276
281
  " (run `doctrina verify` to execute)";
277
282
  if (stale > 0) {
278
283
  const parts = [];
279
- if (so.expired) parts.push(`${so.expired} expired`);
280
- if (so.unverifiable) parts.push(`${so.unverifiable} unverifiable`);
281
- if (so.pending) parts.push(`${so.pending} unsigned`);
284
+ if (so?.expired) parts.push(`${so.expired} expired`);
285
+ if (so?.unverifiable) parts.push(`${so.unverifiable} unverifiable`);
286
+ if (so?.pending) parts.push(`${so.pending} unsigned`);
282
287
  row("warn", "verify config", `${detail} — ${parts.join(", ")}`,
283
288
  "doctrina verify (each names the re-sign command)");
284
289
  warningsTotal += 1;
@@ -306,7 +311,7 @@ export async function run(_positional, _flags) {
306
311
  if (cfg.errors.length > 0) {
307
312
  row("fail", "config", cfg.errors[0], "fix .doctrina/config.json, then `doctrina validate`");
308
313
  } else if (cfg.unknown.length > 0) {
309
- row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules");
314
+ row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules, surface");
310
315
  warningsTotal += 1;
311
316
  } else {
312
317
  row("ok", "config", summary);
@@ -316,6 +321,45 @@ export async function run(_positional, _flags) {
316
321
  }
317
322
  },
318
323
 
324
+ // 9b. The CLI that is running, and the one a bare `doctrina` finds.
325
+ cli: () => {
326
+ const version = cliVersion();
327
+ const installs = installsOnPath();
328
+ const first = installs[0];
329
+ const here = runningPackageDir();
330
+ const others = installs.filter((i) => !i.running);
331
+ const label = (/** @type {{ bin: string, version: string | null }} */ i) => `${i.bin} (${i.version ?? "version unknown"})`;
332
+ if (first && !first.running) {
333
+ row("warn", "cli", `${version} is running from ${here}, but \`doctrina\` on the PATH is ${label(first)} — the hooks and every bare \`doctrina\` run that one`,
334
+ `remove or update the other install (npm uninstall -g doctrina-cli under the Node that owns it), or put this one first on the PATH`);
335
+ warningsTotal += 1;
336
+ } else if (others.length > 0) {
337
+ row("warn", "cli", `${version} at ${first.bin}; ${others.length} more on the PATH: ${others.map(label).join(", ")}`,
338
+ "remove the copies you do not use — a shell whose PATH orders them differently runs another version");
339
+ warningsTotal += 1;
340
+ } else if (!first && callsDoctrinaByName(projectRoot)) {
341
+ row("warn", "cli", `${version} is running from ${here}, and no \`doctrina\` is on the PATH — this project's agent hooks call it by name`,
342
+ "npm install -g doctrina-cli (or add its bin directory to the PATH)");
343
+ warningsTotal += 1;
344
+ } else {
345
+ row("ok", "cli", first ? `${version} at ${first.bin} — the only one on the PATH` : `${version}, run from ${here} (no \`doctrina\` on the PATH)`);
346
+ }
347
+ },
348
+
349
+ // 9c. Whether two branches that each close a change merge by themselves.
350
+ merges: () => {
351
+ if (!isRepo(projectRoot) || shadowRootOf(projectRoot) !== null) return;
352
+ const attributes = missingAttributes(projectRoot).length === 0;
353
+ const driver = configuredDriver(projectRoot) !== null;
354
+ if (attributes && driver) {
355
+ row("ok", "merges", "index.json and the ledger merge by themselves (driver and .gitattributes in place)");
356
+ return;
357
+ }
358
+ const missing = !attributes && !driver ? "nothing is set up" : !attributes ? ".gitattributes lacks the two lines" : "this clone has no merge driver";
359
+ row("warn", "merges", `${missing} — two branches that each close a change stop on index.json and the ledger`, "doctrina hooks install (once per clone; commit .gitattributes)");
360
+ warningsTotal += 1;
361
+ },
362
+
319
363
  // 10. Which of the catalog's operations have never been reached for —
320
364
  // but ONLY when the operator turned the log on. `lib/usage.js` was
321
365
  // built to instrument the surface before shrinking it, and the only
@@ -377,6 +421,14 @@ export async function run(_positional, _flags) {
377
421
  return 0;
378
422
  }
379
423
 
424
+ /** Does an agent hook in this project call `doctrina` by its bare name? */
425
+ function callsDoctrinaByName(projectRoot) {
426
+ return [".cursor/hooks.json", ".claude/settings.json"].some((rel) => {
427
+ const file = path.join(projectRoot, rel);
428
+ return isFile(file) && /"(command)":\s*"doctrina /.test(read(file));
429
+ });
430
+ }
431
+
380
432
  export const help = `
381
433
  Usage: doctrina doctor
382
434
 
@@ -391,6 +443,12 @@ exact remediation command.
391
443
  enums. Reports membership only; a rejected value is never
392
444
  printed, so this is safe to run and to paste.
393
445
 
446
+ Two rows read the machine and the clone, not the artifacts: \`cli\` names
447
+ the install that is running and any other \`doctrina\` the PATH holds — the
448
+ agent hooks call it by name, so the first one found is the one they run —
449
+ and \`merges\` says whether \`doctrina hooks install\` has set this clone up
450
+ to merge index.json and the ledger without a manual step.
451
+
394
452
  A driver over the same collections the gates themselves render; it adds
395
453
  no checks of its own, so it can never disagree with the gates it fronts,
396
454
  and it runs entirely in this process — no subprocess, no re-parsing of
@@ -11,6 +11,9 @@ import { flagBool } from "../lib/args.js";
11
11
  import { c } from "../lib/colors.js";
12
12
  import { suggest } from "../lib/suggest.js";
13
13
  import { EXIT } from "../lib/exit-codes.js";
14
+ import { writeAttributes, configureDriver, configuredDriver } from "../lib/merge-setup.js";
15
+ import { shadowRootOf } from "../lib/workspace.js";
16
+ import { gitPathOf, repoPrefix } from "../lib/git.js";
14
17
 
15
18
  const SUBCOMMANDS = ["install"];
16
19
 
@@ -33,16 +36,49 @@ export async function run(positional, flags) {
33
36
  const force = flagBool(flags, "force", false);
34
37
  const projectRoot = process.cwd();
35
38
 
36
- if (!exists(path.join(projectRoot, ".git"))) {
39
+ // Asked of git, not of `.git/` (seventh audit, change 0250). In a worktree
40
+ // and in a submodule `.git` is a file, so `mkdir .git/hooks` crashed with
41
+ // ENOTDIR after the attributes were already written; and a project in a
42
+ // subfolder of its repository has no `.git` beside it at all, so it was
43
+ // told to `git init` inside a repository — while `doctor` kept sending it
44
+ // back here.
45
+ const prefix = repoPrefix(projectRoot);
46
+ const hooksDir = prefix === null ? null : gitPathOf(projectRoot, "hooks");
47
+ if (prefix === null || hooksDir === null) {
37
48
  // A PRECONDITION, not a gate: nothing was measured and found wanting,
38
49
  // the place to put the hook simply does not exist yet. Class 1 told an
39
50
  // agent to fix its work and retry the same command, which never clears.
40
- console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
51
+ console.error(c.red("error:") + " not a git repository — git does not answer for this folder");
41
52
  console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
42
53
  return EXIT.PRECONDITION;
43
54
  }
44
55
 
45
- const hooksDir = path.join(projectRoot, ".git", "hooks");
56
+ // The CLI that installed this, pinned by path (change 0116). `.git/` is
57
+ // local to the clone, so an absolute path is the right thing to write;
58
+ // a bare `doctrina` ran whichever release was on the PATH — a global
59
+ // 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
60
+ // `DOCTRINA=` in the environment overrides it for the hook.
61
+ const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
62
+ const bin = `node \"${entry}\"`;
63
+
64
+ // How the artifacts merge (change 0242). Before the hook, and whatever
65
+ // becomes of it: a pre-commit hook somebody else owns is no reason for
66
+ // two branches to stop on index.json. A shadowed repository keeps its
67
+ // `.doctrina/` out of its own git, so there is nothing of it to merge here.
68
+ if (shadowRootOf(projectRoot) === null) {
69
+ const added = writeAttributes(projectRoot);
70
+ if (added > 0) console.log(c.green("wrote") + ` .gitattributes — ${added} line${added === 1 ? "" : "s"}: index.json merges as data, the ledger as a list (commit it)`);
71
+ const before = configuredDriver(projectRoot);
72
+ const command = `node "${entry}"`;
73
+ if (before !== `${command} index merge %O %A %B`) {
74
+ if (configureDriver(projectRoot, command)) {
75
+ console.log(c.green("set") + " the index merge driver in this clone's git config (every clone runs `doctrina hooks install` once)");
76
+ } else {
77
+ console.log(c.yellow("warn:") + " git refused the merge driver; two branches that close changes will stop on index.json — `doctrina index rebuild` resolves it");
78
+ }
79
+ }
80
+ }
81
+
46
82
  mkdirp(hooksDir);
47
83
 
48
84
  const target = path.join(hooksDir, "pre-commit");
@@ -53,13 +89,13 @@ export async function run(positional, flags) {
53
89
 
54
90
  const templatesDir = locateTemplatesDir();
55
91
  const tplPath = path.join(templatesDir, "hooks", "pre-commit.sample");
56
- // The hook pins the CLI that installed it (change 0116). `.git/hooks/` is
57
- // local to the clone, so an absolute path is the right thing to write;
58
- // a bare `doctrina` ran whichever release was on the PATH — a global
59
- // 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
60
- // `DOCTRINA=` in the environment overrides it.
61
- const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
62
- const body = substitute(read(tplPath), { DOCTRINA_BIN: `node \"${entry}\"` });
92
+ let body = substitute(read(tplPath), { DOCTRINA_BIN: bin });
93
+ // git runs a hook from the top of the repository. A project that lives
94
+ // below it is entered first, or `validate` would answer for a folder that
95
+ // is not a Doctrina project.
96
+ if (prefix !== "") {
97
+ body = body.replace(/^DOCTRINA=/m, `# This Doctrina project lives below the repository root.\ncd "${prefix}" || exit 1\n\nDOCTRINA=`);
98
+ }
63
99
  write(target, body, { force: true });
64
100
  chmodSync(target, 0o755);
65
101
  console.log(c.green("installed") + ` ${relPath(projectRoot, target)}`);
@@ -72,7 +108,16 @@ export async function run(positional, flags) {
72
108
  export const help = `
73
109
  Usage: doctrina hooks install [--force]
74
110
 
75
- Install the Doctrina pre-commit hook into .git/hooks/pre-commit.
111
+ Set the repository up for commits and merges:
112
+
113
+ - .gitattributes gains two lines (commit them): index.json merges through
114
+ \`doctrina index merge\`, as a list of artifacts, and the ledger through
115
+ git's own \`union\`, so two branches that each closed a change merge
116
+ without conflict markers in either file;
117
+ - this clone's git config gains the index merge driver, pinned to the CLI
118
+ that installed it — every clone runs \`doctrina hooks install\` once;
119
+ - .git/hooks/pre-commit gains the Doctrina hook.
120
+
76
121
  The hook runs \`doctrina validate --fix\`: it regenerates index.json
77
122
  from the tree (healing the most common gate failure — a hand-edited
78
123
  header that drifted the index — and re-staging the repaired index)