doctrina-cli 0.15.1 → 0.17.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 (93) hide show
  1. package/README.md +27 -72
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +27 -3
  4. package/src/commands/analyze.js +16 -212
  5. package/src/commands/change.js +144 -267
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +65 -21
  8. package/src/commands/close.js +349 -45
  9. package/src/commands/completion.js +8 -2
  10. package/src/commands/context.js +284 -111
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +66 -7
  14. package/src/commands/doctor.js +293 -130
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +17 -3
  17. package/src/commands/index-rebuild.js +55 -44
  18. package/src/commands/init.js +88 -16
  19. package/src/commands/intake.js +85 -99
  20. package/src/commands/intent.js +14 -1
  21. package/src/commands/metrics.js +65 -88
  22. package/src/commands/next.js +105 -147
  23. package/src/commands/prime.js +23 -91
  24. package/src/commands/report.js +40 -109
  25. package/src/commands/review.js +68 -36
  26. package/src/commands/search.js +18 -4
  27. package/src/commands/show.js +24 -9
  28. package/src/commands/skill.js +23 -39
  29. package/src/commands/spec.js +38 -11
  30. package/src/commands/status.js +69 -171
  31. package/src/commands/templates.js +35 -148
  32. package/src/commands/trace.js +36 -71
  33. package/src/commands/triage.js +4 -82
  34. package/src/commands/upgrade.js +31 -6
  35. package/src/commands/validate.js +44 -877
  36. package/src/commands/verify.js +60 -138
  37. package/src/commands/why.js +2 -2
  38. package/src/commands/work.js +243 -267
  39. package/src/index.js +139 -12
  40. package/src/lib/actions.js +490 -0
  41. package/src/lib/adapters.js +0 -11
  42. package/src/lib/adr-guard.js +19 -0
  43. package/src/lib/agent-changelog.js +133 -0
  44. package/src/lib/analysis.js +264 -0
  45. package/src/lib/args.js +14 -0
  46. package/src/lib/change-ops.js +133 -0
  47. package/src/lib/commands.js +135 -10
  48. package/src/lib/config.js +206 -0
  49. package/src/lib/constitution-model.js +102 -0
  50. package/src/lib/coverage-model.js +431 -0
  51. package/src/lib/criteria.js +23 -0
  52. package/src/lib/doc-model.js +370 -3
  53. package/src/lib/docs-impact.js +270 -27
  54. package/src/lib/ears.js +24 -1
  55. package/src/lib/flag-catalog.js +0 -16
  56. package/src/lib/fs-ops.js +58 -1
  57. package/src/lib/gates.js +231 -29
  58. package/src/lib/git.js +132 -0
  59. package/src/lib/index-json.js +42 -24
  60. package/src/lib/intake-model.js +155 -0
  61. package/src/lib/json-out.js +100 -2
  62. package/src/lib/ledger.js +192 -0
  63. package/src/lib/lexicon.js +228 -0
  64. package/src/lib/metrics-model.js +189 -0
  65. package/src/lib/names.js +42 -0
  66. package/src/lib/playbook.js +97 -0
  67. package/src/lib/project.js +105 -0
  68. package/src/lib/reproducibility.js +137 -0
  69. package/src/lib/runtime.js +191 -5
  70. package/src/lib/scan.js +328 -25
  71. package/src/lib/signoff.js +147 -0
  72. package/src/lib/snapshot.js +231 -0
  73. package/src/lib/spec-ops.js +72 -8
  74. package/src/lib/templates-model.js +289 -0
  75. package/src/lib/trace-model.js +95 -0
  76. package/src/lib/triage-model.js +121 -0
  77. package/src/lib/usage.js +6 -4
  78. package/src/lib/validation-model.js +1381 -0
  79. package/src/lib/version.js +22 -0
  80. package/src/lib/views.js +413 -0
  81. package/src/lib/work-model.js +102 -0
  82. package/templates/AGENTS.md.template +21 -28
  83. package/templates/README.md +35 -0
  84. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  85. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  86. package/templates/change/proposal.md.template +16 -0
  87. package/templates/change/tasks.md.template +3 -8
  88. package/templates/contract.md.template +2 -2
  89. package/templates/hooks/pre-commit.sample +16 -2
  90. package/templates/playbooks/bootstrap.md.template +47 -0
  91. package/templates/playbooks/chore.md.template +24 -0
  92. package/templates/playbooks/work.md.template +83 -0
  93. package/src/commands/constitution.js +0 -145
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.15.1 — current release. Zero runtime dependencies
6
+ **Status:** v0.17.0 — current release. Zero runtime dependencies
7
7
  (Node.js standard library only).
8
8
 
9
9
  ## Install
@@ -18,59 +18,31 @@ Requires Node.js 20.12 or later.
18
18
 
19
19
  ## Commands
20
20
 
21
+ Start with `doctrina init` in your repository, then tell your AI agent to
22
+ read `AGENTS.md` and run `doctrina next` — it drives the rest. The
23
+ surface, by the moment you reach for it:
24
+
21
25
  ```
22
- doctrina init scaffold AGENTS.md + .doctrina/ in cwd
23
- doctrina intake [<file>] store the full description + bootstrap playbook (clarification gate)
24
- doctrina work "<prompt>" brief prompt -> scaffolded change + work playbook (clarification gate)
25
- doctrina work --resume <id> reprint an open change's playbook (no new change)
26
- doctrina work --from-diff backfill a spec from working-tree changes (code-first)
27
- doctrina work "<prompt>" --chore spec-less chore (infra/docs/build): no spec-delta steps
28
- doctrina spec new <capability> create a capability spec
29
- doctrina spec list list specs with version, status, size
30
- doctrina spec set <cap> [--bump ...] edit spec headers/criterion + resync index (no lockstep)
31
- doctrina change new <id> "<title>" open a change proposal
32
- doctrina change apply <id> apply spec deltas (ADDED/REMOVED auto; MODIFIED ops block applies)
33
- doctrina change archive <id> archive an applied change
34
- doctrina change abandon <id> delete an open change + its index entry (ledgered)
35
- doctrina change diff <id> preview spec deltas (line diff for MODIFIED)
36
- doctrina contract new <name> own the integration surface (ports, env, interfaces)
37
- doctrina contract check verify port collisions, env drift, referenced specs
38
- doctrina decision new "<title>" create the next sequential ADR
39
- doctrina decision accept <num> flip a proposed ADR to accepted
40
- doctrina decision land <num> [path...] stamp an accepted ADR as implemented (non-mutating Landed:)
41
- doctrina decision supersede <num> "<t>" supersede an existing ADR
42
- doctrina decision list list ADRs with status, date, title
43
- doctrina skill new <name> scaffold an on-demand procedural-memory skill
44
- doctrina skill list list skills with descriptions (read-only)
45
- doctrina skill sync mirror skill frontmatter descriptions into index.json
46
- doctrina analyze <change-id> inspect a change folder before applying
47
- doctrina clarify <path> smell-test a Markdown file for ambiguity (--all: tree)
48
- doctrina context [<capability>] print the context pack in read order (--concat)
49
- doctrina search <term> [...] search artifacts, grouped by category (ranked)
50
- doctrina validate schema + artifact-existence + index-drift checks (--fix rebuilds)
51
- doctrina coverage acceptance criteria with linked evidence (--strict gates; skips = conditional)
52
- doctrina trace intent provenance: product intent -> specs (--strict gates)
53
- doctrina verify run project typecheck/test/build checks (the real gate)
54
- doctrina verify --clean reproducibility lint: clean-checkout footguns (dist/codegen)
55
- doctrina templates list enumerate shipped templates
56
- doctrina templates check compare project against recommended template shape
57
- doctrina templates update additive fixer for check findings (preview; --write applies)
58
- doctrina hooks install install pre-commit hook
59
- doctrina index rebuild regenerate index.json from the files (--check for CI)
60
- doctrina next print the recommended next workflow actions
61
- doctrina metrics local git-derived adoption metrics (no network)
62
- doctrina --help | -h print the command surface
63
- doctrina --version | -v print the package version
26
+ Bootstrap init · intake · adapter list|add|remove
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
29
+ contract new|list|check · decision new|accept|land|supersede|list|scope
30
+ skill new|list|suggest · intent add|list
31
+ Gate clarify · validate · coverage · trace · review · verify · close · doctor
32
+ Maintain upgrade · index rebuild · hooks install · watch · metrics · completion · ci · templates list
64
33
  ```
65
34
 
66
- Per-command help is available with `doctrina <command> --help`.
35
+ `doctrina --help` prints the same grouping with one line per command, and
36
+ `doctrina <command> --help` the flags of each. Every command, flag and exit
37
+ code is in the
38
+ [CLI reference](https://github.com/GCarin1/Doctrina/blob/main/docs/en/cli-reference.md).
67
39
 
68
40
  ## `init` flags
69
41
 
70
42
  | Flag | Purpose |
71
43
  |------|---------|
72
44
  | `--project-name <name>` | Override the project name (defaults to cwd basename) |
73
- | `--project-description <text>` | One-sentence description (prompted if omitted) |
45
+ | `--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 |
74
46
  | `--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` |
75
47
  | `--date <YYYY-MM-DD>` | Override the system date in artifacts |
76
48
  | `--force` | Overwrite existing files |
@@ -100,7 +72,8 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
100
72
  - **REMOVED:** deletes the target spec.
101
73
  - **MODIFIED:** when the delta carries a fenced ` ```ops ` block
102
74
  (`set-header` / `bump-version` / `set-criterion` / `replace-criterion`
103
- / `append-criterion`), applies it mechanically to the target spec — all
75
+ / `append-criterion` / `append-requirement` / `replace-requirement`),
76
+ applies it mechanically to the target spec — all
104
77
  ops or none — and regenerates the index (ADR 0007). Without an ops
105
78
  block it prints a manual-merge pointer and does not write: auto-merging
106
79
  arbitrary prose is the kind of implicit decision Doctrina avoids.
@@ -108,35 +81,17 @@ For each spec delta found under `.doctrina/changes/<id>/specs/`:
108
81
  On any spec write, the index is rebuilt from the tree so it never drifts
109
82
  from the applied spec. `doctrina change abandon <id>` is the inverse of
110
83
  `change new`: it deletes an open change and its index entry and ledgers
111
- the abandonment.
84
+ the abandonment. `doctrina close <id>` runs apply together with verify,
85
+ coverage, archive and validate in one attested pass — the definition of
86
+ done.
112
87
 
113
88
  ## `validate` checks
114
89
 
115
- 1. `AGENTS.md` exists at root and is ≤ 200 lines (warns at > 150).
116
- 2. `.doctrina/product.md` exists.
117
- 3. `.doctrina/index.json` parses and matches the v0.1 shape.
118
- 4. Every artifact referenced in `index.json` exists.
119
- 5. Every ADR has a parseable `Status:` header.
120
- 6. Every adapter template under `.doctrina/templates/adapters/` is ≤ 30 lines.
121
- 7. Every open change has a `proposal.md`.
122
- 8. Capability specs are ≤ 400 lines (warns above).
123
- 9. ADRs are ≤ 300 lines (warns above).
124
- 10. Spec/ADR files on disk are present in `index.json` (orphan warn).
125
- 11. Markdown links inside specs/ADRs resolve to real files (stale ref warn).
126
- 12. Skills carry the required frontmatter triple (`name`, `description`, `when`).
127
- 13. Skills are ≤ 200 lines (warns at > 150).
128
- 14. Each skill's `name:` matches its filename slug.
129
- 15. Indexed artifact metadata (spec/decision/change/contract) matches its
130
- file — a mismatch is an **error** so a green `validate` can't hide the
131
- drift `index rebuild --check` would catch (G5; `validate --fix` rebuilds).
132
- 16. Each skill's frontmatter description matches `index.json` (drift warn; `skill sync` fixes).
133
- 17. EARS grammar shape per section in specs declaring `## Requirements (EARS)` (warn).
134
- 18. Nested `AGENTS.md` files obey the root size caps (warn > 150, error > 200).
135
- 19. No two ADR files share the same `NNNN` number (merge-collision error).
136
- 20. `index.json` records a `framework_version` matching the running CLI
137
- (stamp-divergence warn; `index rebuild` migrates it).
138
- 21. Accepted ADRs anchor to reality via `Evidence:` and/or `Landed:`; cited
139
- paths must exist on disk (drift warn).
90
+ Structure, schema, EARS grammar, index drift, size caps, links, skills,
91
+ ADR anchoring and more — the full, numbered list with the reason for each
92
+ check is in the
93
+ [CLI reference](https://github.com/GCarin1/Doctrina/blob/main/docs/en/cli-reference.md#doctrina-validate).
94
+ A second copy here fell behind it once; there is now one list.
140
95
 
141
96
  Exit code: 0 if no errors (warnings allowed), 1 otherwise. `--fix`
142
97
  regenerates `index.json` from the tree before validating.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doctrina-cli",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "Doctrina CLI — spec-driven, AGENTS.md-native framework for multi-agent AI development.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -15,7 +15,7 @@
15
15
  ],
16
16
  "type": "module",
17
17
  "bin": {
18
- "doctrina": "./src/index.js"
18
+ "doctrina": "src/index.js"
19
19
  },
20
20
  "scripts": {
21
21
  "test": "node --test",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
35
- "url": "https://github.com/gcarin1/doctrina.git",
35
+ "url": "git+https://github.com/GCarin1/Doctrina.git",
36
36
  "directory": "packages/doctrina-cli"
37
37
  },
38
38
  "publishConfig": {
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { exists, isFile, mkdirp, read, relPath, remove, write } from "../lib/fs-ops.js";
4
+ import { exists, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, write } from "../lib/fs-ops.js";
5
5
  import {
6
6
  ADAPTER_STATES, adapterFiles, describeAdapter, listAdapterNames, renderAdapterFile,
7
7
  } from "../lib/adapters.js";
@@ -10,6 +10,7 @@ import { today } from "../lib/dates.js";
10
10
  import { c } from "../lib/colors.js";
11
11
  import { suggest } from "../lib/suggest.js";
12
12
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
+ import { projectName } from "../lib/project.js";
13
14
 
14
15
  // `doctrina adapter` — add, remove, and inventory agent adapters (audit
15
16
  // item C1).
@@ -96,7 +97,7 @@ function adapterAdd(projectRoot, name, cmdFlags) {
96
97
 
97
98
  const force = flagBool(cmdFlags, "force", false);
98
99
  const tokens = {
99
- PROJECT_NAME: path.basename(projectRoot),
100
+ PROJECT_NAME: projectName(projectRoot),
100
101
  PROJECT_DESCRIPTION: "",
101
102
  DATE: today(),
102
103
  };
@@ -145,9 +146,13 @@ function adapterRemove(projectRoot, name, cmdFlags) {
145
146
  // Only delete files this adapter created AND that still match what it
146
147
  // would write. A file the user edited is theirs now; --force overrides.
147
148
  const force = flagBool(cmdFlags, "force", false);
148
- const tokens = { PROJECT_NAME: path.basename(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() };
149
+ const tokens = { PROJECT_NAME: projectName(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() };
149
150
  let removed = 0;
150
151
  let kept = 0;
152
+ // Directories a removed file lived in, innermost first. Only these are
153
+ // considered for pruning: a directory the adapter never wrote into is not
154
+ // this command's to tidy.
155
+ const vacated = new Set();
151
156
  for (const file of spec.files) {
152
157
  const dest = path.join(projectRoot, file.relativePath);
153
158
  if (!isFile(dest)) continue;
@@ -160,9 +165,28 @@ function adapterRemove(projectRoot, name, cmdFlags) {
160
165
  remove(dest);
161
166
  console.log(c.green("removed") + ` ${file.relativePath}`);
162
167
  removed += 1;
168
+ vacated.add(path.dirname(dest));
163
169
  }
170
+
171
+ // `add` calls mkdirp on the way in, so `remove` unmakes what it made on
172
+ // the way out. Without this the six files went and `.claude/` plus
173
+ // `.claude/commands/` stayed — and an empty `.claude/` is not neutral: it
174
+ // is a configuration root the next agent finds and treats as present.
175
+ // Only EMPTY directories are pruned, walking up and stopping at the first
176
+ // one that still holds anything, so a kept (edited) file or anything of
177
+ // the user's keeps its whole directory chain alive.
178
+ const pruned = [];
179
+ for (const dir of [...vacated].sort((a, b) => b.length - a.length)) {
180
+ for (const gone of pruneEmptyDirs(dir, projectRoot)) {
181
+ pruned.push(relPath(projectRoot, gone).replaceAll("\\", "/"));
182
+ console.log(c.green("removed") + ` ${relPath(projectRoot, gone).replaceAll("\\", "/")}/ ` +
183
+ c.gray("(empty)"));
184
+ }
185
+ }
186
+
164
187
  console.log("");
165
188
  console.log(`${c.bold("Adapter " + name)} — ${removed} file${removed === 1 ? "" : "s"} removed` +
189
+ (pruned.length > 0 ? `, ${pruned.length} empty director${pruned.length === 1 ? "y" : "ies"} pruned` : "") +
166
190
  (kept > 0 ? `, ${kept} kept (edited)` : ""));
167
191
  return 0;
168
192
  }
@@ -1,10 +1,15 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
4
+ import { isDir, relPath } from "../lib/fs-ops.js";
5
5
  import { c } from "../lib/colors.js";
6
- import { isUntouchedScaffold } from "./change.js";
7
- import { collectBudgets } from "../lib/runtime.js";
6
+ import { collectAnalysis } from "../lib/analysis.js";
7
+ import { EXIT } from "../lib/exit-codes.js";
8
+ import { ensureDoctrinaProject, refuseChangeRef } from "../lib/project.js";
9
+
10
+ // The findings themselves live in lib/analysis.js, where the gate map reads
11
+ // them; this command is their renderer (audit finding F7).
12
+ export { collectAnalysis } from "../lib/analysis.js";
8
13
 
9
14
  // Flags this command accepts. Declared HERE, with the command, so
10
15
  // adding a command never requires editing the entrypoint — the gap that
@@ -19,10 +24,17 @@ export async function run(positional, _flags) {
19
24
  }
20
25
 
21
26
  const projectRoot = process.cwd();
27
+ // The project first, then the reference. Asked the other way round, a
28
+ // directory that is not a project at all answered `change "x" not found`
29
+ // — which points at creating a change somewhere there is nothing to
30
+ // create it in.
31
+ ensureDoctrinaProject(projectRoot);
32
+ const refused = refuseChangeRef(id);
33
+ if (refused !== null) return refused;
22
34
  const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
23
35
  if (!isDir(changeDir)) {
24
36
  console.error(c.red("error:") + ` change "${id}" not found at ${relPath(projectRoot, changeDir)}`);
25
- return 1;
37
+ return EXIT.USAGE;
26
38
  }
27
39
 
28
40
  console.log(`analyzing ${relPath(projectRoot, changeDir)}/`);
@@ -41,214 +53,6 @@ export async function run(positional, _flags) {
41
53
  return failed === 0 ? 0 : 1;
42
54
  }
43
55
 
44
- // The structural findings for a change, as data. Exported so the shared
45
- // gate map (`lib/gates.js`) can enforce the SAME checks on every
46
- // transition that needs them, instead of each command deciding for itself
47
- // — `change apply` used to mutate a change that `analyze` had just
48
- // refused (audit item C6).
49
- export function collectAnalysis(projectRoot, changeDir) {
50
- const results = [];
51
-
52
- // proposal.md
53
- const proposalPath = path.join(changeDir, "proposal.md");
54
- if (!isFile(proposalPath)) {
55
- results.push(fail("proposal.md missing"));
56
- } else {
57
- results.push(pass("proposal.md present"));
58
- const text = read(proposalPath);
59
- if (/^##\s+Why\b/m.test(text)) results.push(pass(`proposal.md has a "## Why" section`));
60
- else results.push(fail(`proposal.md missing "## Why" section`));
61
-
62
- // A section that still holds only its scaffold comment was never
63
- // written. The check above only proved the HEADING survived, which is
64
- // why six changes closed in one session with every rationale section
65
- // empty: the planning step wrote to the file with a pattern that did
66
- // not match its line endings, and nothing downstream looked inside.
67
- // For a framework whose whole premise is recoverable provenance, an
68
- // archived change that cannot say why it happened is the defect.
69
- // Only `Why` and `What` are required. They are what makes a change
70
- // recoverable a year later — the reason it exists and the shape it
71
- // took. `Scope boundaries` and `Open questions` are legitimately empty
72
- // on a change that has neither, and demanding the word "None." there
73
- // is friction that buys nothing.
74
- const hollow = [];
75
- for (const m of text.matchAll(/^##\s+(.+?)[ \t]*\r?$/gm)) {
76
- const heading = m[1].trim();
77
- if (!/^(Why|What)$/i.test(heading)) continue;
78
- const start = m.index + m[0].length;
79
- const next = text.slice(start).search(/^##\s+/m);
80
- const body = (next < 0 ? text.slice(start) : text.slice(start, start + next));
81
- // Strip comments; anything left that is not whitespace is real prose.
82
- if (body.replace(/<!--[\s\S]*?-->/g, "").trim() === "") hollow.push(heading);
83
- }
84
- if (hollow.length > 0) {
85
- results.push(fail(
86
- `proposal.md has ${hollow.length} unwritten section${hollow.length === 1 ? "" : "s"} ` +
87
- `(${hollow.join(", ")}) — a heading that survived is not a section that was written`,
88
- ));
89
- } else {
90
- results.push(pass("proposal.md states why and what"));
91
- }
92
- }
93
-
94
- // tasks.md
95
- const tasksPath = path.join(changeDir, "tasks.md");
96
- if (!isFile(tasksPath)) {
97
- results.push(fail("tasks.md missing"));
98
- } else {
99
- results.push(pass("tasks.md present"));
100
- const text = read(tasksPath);
101
- // Scaffold placeholders never replaced ("- [ ]" with no text, checked or
102
- // not) mean the change was OPENED but never PLANNED — the agent went
103
- // straight to implementing on a hollow change and nothing barked
104
- // (operator report 2026-07-19). A hard failure here blocks `change
105
- // check` and `close` until the plan is real; ticking an empty box does
106
- // not help, `change tick` refuses those too.
107
- const placeholders = (text.match(/^\s*-\s*\[[ xX]\]\s*$/gm) ?? []).length;
108
- if (placeholders > 0) {
109
- results.push(fail(`tasks.md still carries ${placeholders} scaffold placeholder task${placeholders === 1 ? "" : "s"} ("- [ ]" with no text) — plan the change before implementing: replace them with real tasks (or delete the lines)`));
110
- }
111
- if (/^\s*-\s*\[\s\]/m.test(text)) results.push(pass("tasks.md has at least one unchecked task"));
112
- else results.push(info("tasks.md has no unchecked tasks (already done?)"));
113
- }
114
-
115
- // design.md (optional)
116
- const designPath = path.join(changeDir, "design.md");
117
- if (isFile(designPath)) results.push(info("design.md present"));
118
- else results.push(info("design.md absent (optional)"));
119
-
120
- // Spec deltas
121
- const deltaFiles = walk(path.join(changeDir, "specs")).filter((p) => p.endsWith("delta.md"));
122
- if (deltaFiles.length === 0) {
123
- results.push(info("0 spec deltas (metadata-only change)"));
124
- } else {
125
- results.push(pass(`${deltaFiles.length} spec delta${deltaFiles.length === 1 ? "" : "s"}:`));
126
- for (const deltaPath of deltaFiles) {
127
- const rel = relPath(projectRoot, deltaPath);
128
- const text = read(deltaPath);
129
- const opMatch = text.match(/^\*\*Operation:\*\*\s*([A-Z]+)/m);
130
- const op = opMatch ? opMatch[1] : null;
131
- const capMatch = text.match(/^#\s+Spec Delta\s*[—-]\s*capability:\s*([a-z][a-z0-9-]*)/m);
132
- const cap = capMatch ? capMatch[1] : path.basename(path.dirname(deltaPath));
133
-
134
- if (!op) {
135
- results.push(fail(` ${rel}: Operation header missing or malformed`));
136
- continue;
137
- }
138
- if (!["ADDED", "MODIFIED", "REMOVED"].includes(op)) {
139
- results.push(fail(` ${rel}: Operation "${op}" is not one of ADDED|MODIFIED|REMOVED`));
140
- continue;
141
- }
142
- const targetSpec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
143
- const targetRel = relPath(projectRoot, targetSpec);
144
- if (op === "ADDED") {
145
- // Mirrors `change apply`: an existing target that is still the
146
- // untouched `spec new` scaffold is the canonical flow (spec new →
147
- // ADDED delta), so it passes as a replacement; only real content fails.
148
- if (exists(targetSpec) && !isUntouchedScaffold(read(targetSpec), cap)) {
149
- results.push(fail(` ${cap} (ADDED) but target ${targetRel} has real content — use MODIFIED or remove it first`, "pre-apply"));
150
- } else if (exists(targetSpec)) {
151
- results.push(pass(` ${cap} (ADDED) → ${targetRel} (replaces the untouched scaffold)`));
152
- } else {
153
- results.push(pass(` ${cap} (ADDED) → ${targetRel} (new)`));
154
- }
155
- } else if (op === "MODIFIED") {
156
- if (!exists(targetSpec)) {
157
- results.push(fail(` ${cap} (MODIFIED) but target ${targetRel} does not exist`, "pre-apply"));
158
- } else {
159
- results.push(pass(` ${cap} (MODIFIED) → ${targetRel}`));
160
- }
161
- } else {
162
- if (!exists(targetSpec)) {
163
- results.push(fail(` ${cap} (REMOVED) but target ${targetRel} does not exist`, "pre-apply"));
164
- } else {
165
- results.push(pass(` ${cap} (REMOVED) → ${targetRel}`));
166
- }
167
- }
168
- }
169
- }
170
-
171
- // BUDGET DISCIPLINE (change 0029). A declared ceiling is a contract, not
172
- // a preference — and the tempting fix for "the output blew the limit" is
173
- // to raise the limit. That buys headroom by truncating what mattered
174
- // instead of sending less, and it is the discussion that comes back every
175
- // quarter because nothing ever recorded that it had been settled. Raising
176
- // an INPUT ceiling is a normal trade-off; raising an OUTPUT ceiling to
177
- // resolve an overflow is the move this refuses.
178
- for (const r of checkBudgetRaises(projectRoot, changeDir)) results.push(r);
179
-
180
- return results;
181
- }
182
-
183
- // Compare the ceilings a change's proposal/deltas propose against the ones
184
- // the contracts declare today. Text-scanned: a change states a new value in
185
- // prose long before any code moves, which is exactly when it should be
186
- // argued about.
187
- function checkBudgetRaises(projectRoot, changeDir) {
188
- const results = [];
189
- let declared;
190
- try {
191
- declared = collectBudgets(projectRoot);
192
- } catch {
193
- return results;
194
- }
195
- if (declared.size === 0) return results;
196
-
197
- const texts = [];
198
- for (const f of walk(changeDir)) {
199
- if (f.endsWith(".md")) texts.push({ rel: relPath(projectRoot, f), text: read(f) });
200
- }
201
-
202
- for (const [name, budget] of declared) {
203
- if (budget.direction !== "output") continue;
204
- for (const { rel, text } of texts) {
205
- // "<limit> ... <number>" on one line: the shape a proposal uses when
206
- // it restates a ceiling ("raise ai-summary to 2000").
207
- const re = new RegExp(`${escapeRe(name)}[^\\n]{0,60}?(\\d{2,})`, "gi");
208
- for (const m of text.matchAll(re)) {
209
- const proposed = Number.parseInt(m[1], 10);
210
- if (!Number.isFinite(proposed) || proposed <= budget.value) continue;
211
- results.push(fail(
212
- `${rel} raises the OUTPUT ceiling "${name}" from ${budget.value} to ${proposed} ` +
213
- `(declared in ${budget.contract}) — raising an output budget buys headroom by ` +
214
- `truncating what mattered. Send less, or supersede the budget deliberately in the contract`,
215
- ));
216
- break;
217
- }
218
- }
219
- }
220
- return results;
221
- }
222
-
223
- // A limit name comes from a contract table cell, so it is user text and
224
- // must not be spliced into a pattern unescaped.
225
- function escapeRe(s) {
226
- return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
227
- }
228
-
229
- function pass(msg) {
230
- return { kind: "pass", line: c.green("✓ ") + msg };
231
- }
232
- // `scope` says WHEN a failure is a real answer.
233
- //
234
- // Most of these questions are true at every point in a change's life: a
235
- // proposal with an unwritten `## What` is hollow before an apply and just as
236
- // hollow after one. A few are only meaningful BEFORE the apply — "an ADDED
237
- // delta's target must not already hold real content" is proof of a problem
238
- // beforehand and proof the apply WORKED afterwards.
239
- //
240
- // The gate map used to exclude the whole structure gate from `archive` to
241
- // dodge that handful, which also dropped the hollow-proposal check — the
242
- // exact defect this repository's own history records as having "shipped six
243
- // hollow proposals past every gate". Tagging the pre-apply ones lets
244
- // `archive` keep the rest.
245
- function fail(msg, scope = "always") {
246
- return { kind: "fail", scope, line: c.red("✗ ") + msg };
247
- }
248
- function info(msg) {
249
- return { kind: "info", line: c.gray("- ") + msg };
250
- }
251
-
252
56
  export const help = `
253
57
  Usage: doctrina analyze <change-id>
254
58