doctrina-cli 0.16.0 → 0.18.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 (71) hide show
  1. package/README.md +31 -73
  2. package/package.json +3 -3
  3. package/src/commands/adapter.js +3 -2
  4. package/src/commands/analyze.js +8 -0
  5. package/src/commands/change.js +86 -80
  6. package/src/commands/clarify.js +8 -1
  7. package/src/commands/close.js +136 -34
  8. package/src/commands/completion.js +8 -2
  9. package/src/commands/context.js +6 -4
  10. package/src/commands/decision.js +12 -0
  11. package/src/commands/doctor.js +1 -1
  12. package/src/commands/hooks.js +6 -1
  13. package/src/commands/index-rebuild.js +52 -2
  14. package/src/commands/init.js +19 -4
  15. package/src/commands/intake.js +55 -7
  16. package/src/commands/intent.js +14 -1
  17. package/src/commands/metrics.js +13 -5
  18. package/src/commands/next.js +2 -1
  19. package/src/commands/prime.js +4 -5
  20. package/src/commands/report.js +9 -20
  21. package/src/commands/review.js +47 -8
  22. package/src/commands/skill.js +8 -139
  23. package/src/commands/spec.js +13 -7
  24. package/src/commands/status.js +39 -20
  25. package/src/commands/templates.js +4 -10
  26. package/src/commands/upgrade.js +31 -6
  27. package/src/commands/validate.js +32 -8
  28. package/src/commands/verify.js +59 -6
  29. package/src/commands/work.js +63 -8
  30. package/src/index.js +34 -6
  31. package/src/lib/actions.js +38 -28
  32. package/src/lib/args.js +14 -0
  33. package/src/lib/change-ops.js +95 -1
  34. package/src/lib/commands.js +96 -15
  35. package/src/lib/coverage-model.js +40 -1
  36. package/src/lib/doc-model.js +47 -6
  37. package/src/lib/docs-impact.js +74 -0
  38. package/src/lib/ears.js +24 -1
  39. package/src/lib/gates.js +53 -9
  40. package/src/lib/git.js +59 -2
  41. package/src/lib/index-json.js +40 -5
  42. package/src/lib/intake-model.js +71 -2
  43. package/src/lib/lexicon.js +38 -1
  44. package/src/lib/metrics-model.js +30 -0
  45. package/src/lib/playbook.js +1 -1
  46. package/src/lib/project.js +49 -1
  47. package/src/lib/runtime.js +6 -0
  48. package/src/lib/scan.js +139 -14
  49. package/src/lib/signoff.js +24 -0
  50. package/src/lib/skill-candidates.js +162 -0
  51. package/src/lib/snapshot.js +61 -2
  52. package/src/lib/spec-ops.js +141 -6
  53. package/src/lib/templates-model.js +35 -8
  54. package/src/lib/validation-model.js +117 -35
  55. package/src/lib/verify-cache.js +76 -0
  56. package/src/lib/views.js +10 -2
  57. package/src/lib/work-model.js +57 -12
  58. package/templates/AGENTS.md.template +3 -4
  59. package/templates/README.md +2 -0
  60. package/templates/adapters/claude/.claude/commands/doctrina-work.md.template +6 -3
  61. package/templates/adapters/cursor/.cursor/commands/doctrina-work.md.template +4 -3
  62. package/templates/change/proposal.md.template +15 -0
  63. package/templates/change/spec-delta.md.template +2 -0
  64. package/templates/change/tasks.md.template +3 -8
  65. package/templates/contract.md.template +2 -2
  66. package/templates/hooks/pre-commit.sample +10 -0
  67. package/templates/playbooks/bootstrap.md.template +6 -2
  68. package/templates/playbooks/chore.md.template +9 -4
  69. package/templates/playbooks/consolidate.md.template +28 -0
  70. package/templates/playbooks/work.md.template +13 -3
  71. package/src/commands/constitution.js +0 -68
@@ -2,26 +2,28 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { appendFileSync } from "node:fs";
5
- import { exists, isDir, isFile, read, walk } from "../lib/fs-ops.js";
5
+ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { flagBool } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
- import { parseCapabilityFromDelta } from "../lib/doc-model.js";
9
8
  import { printAdrCheckpoint, acceptedDecisionCount } from "../lib/adr-guard.js";
10
- import { docsRemedy, checkDocsImpact } from "../lib/docs-impact.js";
9
+ import { docsRemedy, checkDocsImpact, checkChangelogImpact } from "../lib/docs-impact.js";
11
10
  import { collectRuntimeFindings } from "../lib/runtime.js";
12
11
  import { derivedImplementations, implementationMismatch, summarize } from "../lib/coverage-model.js";
13
- import { specHeader, dependentsOf } from "../lib/scan.js";
14
- import { sequence, stepRerun } from "../lib/gates.js";
12
+ import { specHeader, dependentsOf, deltaCapabilities } from "../lib/scan.js";
13
+ import { sequence, sequenceLabels, stepRerun } from "../lib/gates.js";
15
14
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
15
+ import { refuseChangeRef } from "../lib/project.js";
16
+ import { unfinishedChanges } from "../lib/snapshot.js";
16
17
  import * as analyze from "./analyze.js";
17
18
  import * as change from "./change.js";
18
19
  import * as verify from "./verify.js";
19
20
  import * as coverage from "./coverage.js";
20
21
  import * as trace from "./trace.js";
21
22
  import * as validate from "./validate.js";
23
+ import * as indexRebuild from "./index-rebuild.js";
22
24
  import { appendLedgerLine, docsGapLine, ledgerPath as ledgerFile } from "../lib/ledger.js";
23
25
  import * as review from "./review.js";
24
- import * as skill from "./skill.js";
26
+ import { skillCandidates } from "../lib/skill-candidates.js";
25
27
 
26
28
  // One-command close (review 2026-06-27 passive-user feature #2). The work
27
29
  // playbook lists the closing sequence — analyze → apply → runtime → verify →
@@ -63,10 +65,65 @@ export async function run(positional, flags) {
63
65
  ? c.green("ok") + ` all ${positional.length} changes closed`
64
66
  : c.red("fail") + " at least one close stopped — see above");
65
67
  }
68
+ reportSkills(projectRoot, positional);
69
+ reportUnfinished(projectRoot, positional);
66
70
  return worst;
67
71
  }
68
72
 
73
+ // Capture the lesson while it is fresh (operator review §4.7) — the lesson
74
+ // THIS close produced (change 0202). The close used to print the whole
75
+ // `skill suggest` listing, per id: the same thirteen candidates after every
76
+ // close, which a field review of 0.17 named as noise nobody read. Now a
77
+ // candidate born from a change just closed gets its line; every other one is
78
+ // a count pointing at the full listing. Once per run, batch or not.
79
+ // Advisory — a listing, never a gate, and a failure never un-closes anything.
80
+ function reportSkills(projectRoot, closing) {
81
+ let candidates;
82
+ try {
83
+ candidates = skillCandidates(projectRoot);
84
+ } catch {
85
+ return;
86
+ }
87
+ if (candidates.length === 0) return;
88
+ const ids = new Set(closing);
89
+ const fresh = candidates.filter((cand) => cand.source === "change" && ids.has(cand.id));
90
+ const waiting = candidates.length - fresh.length;
91
+ console.log("");
92
+ console.log(c.gray("──── skills (advisory)"));
93
+ for (const cand of fresh) {
94
+ console.log(c.yellow(" ! ") + `${cand.id} is fix-shaped — capture the lesson while it is fresh: ` +
95
+ c.cyan(`doctrina skill new ${cand.slug}`));
96
+ }
97
+ if (waiting > 0) {
98
+ console.log(c.gray(` ${waiting} ${fresh.length > 0 ? "older " : ""}candidate${waiting === 1 ? "" : "s"} waiting — \`doctrina skill suggest\``));
99
+ }
100
+ }
101
+
102
+ // The other open changes, said at the one moment someone is looking (change
103
+ // 0201). A field review of 0.17: change 0078 shipped, stayed open, and no
104
+ // close after it said a word. A change that looks finished — applied, or
105
+ // every task ticked — and one open a week or more get a line each; the rest
106
+ // are a count. Advisory: it never moves the exit code.
107
+ function reportUnfinished(projectRoot, closing) {
108
+ const others = unfinishedChanges(projectRoot, { exclude: closing });
109
+ if (others.length === 0) return;
110
+ const flagged = others.filter((ch) => ch.reason !== null);
111
+ console.log("");
112
+ console.log(c.gray(`──── other open changes (advisory) — ${others.length}`));
113
+ for (const ch of flagged) {
114
+ const age = ch.ageDays !== null ? `, opened ${ch.ageDays}d ago` : "";
115
+ const how = ch.finished ? ` — ${c.cyan(`doctrina close ${ch.id}`)}` : "";
116
+ console.log(c.yellow(" ! ") + `${ch.id}: ${ch.reason}${age}${how}`);
117
+ }
118
+ const quiet = others.length - flagged.length;
119
+ if (quiet > 0) {
120
+ console.log(c.gray(` ${quiet} more open and recent — \`doctrina next\``));
121
+ }
122
+ }
123
+
69
124
  async function closeOne(projectRoot, id, flags) {
125
+ const refused = refuseChangeRef(id);
126
+ if (refused !== null) return refused;
70
127
  // A change id that does not resolve is the USAGE class, before any step
71
128
  // is sequenced (change 0114) — every sibling that takes a change id
72
129
  // answers 2; `close` answered 1 through the analyze step it ran first.
@@ -134,13 +191,17 @@ async function closeOne(projectRoot, id, flags) {
134
191
  },
135
192
 
136
193
  // The review runs against the working tree, which for a close IS the
137
- // change's diff: the work is done and not yet archived. Its exit code is
194
+ // change's diff: the work is done and not yet archived. It is told which
195
+ // change it is reviewing (change 0200): the deltas it carries are the
196
+ // spec updates, merged one step later by `apply`, and code under a
197
+ // capability the change carries no delta for is one line, not one
198
+ // break per capability. Its exit code is
138
199
  // discarded on purpose — the step is declared advisory, and an advisory
139
200
  // step that could still move the close's result would be advisory in name
140
201
  // only. A crash is caught by the loop and reported the same way.
141
202
  review: {
142
203
  run: async () => {
143
- await review.run([], new Map());
204
+ await review.run([], new Map([["change", id]]));
144
205
  return 0;
145
206
  },
146
207
  },
@@ -233,7 +294,10 @@ async function closeOne(projectRoot, id, flags) {
233
294
  // verify is conditional: skipped with a loud note when no verify.json is
234
295
  // declared, because a project may not have wired the real gate yet.
235
296
  verify: verifyConfigured
236
- ? { run: () => verify.run([], new Map()) }
297
+ // --reuse (change 0203): the suite the agent just ran by hand, or the
298
+ // one the previous id of this batch ran, on byte-identical code, is
299
+ // not run a second time. Anything that differs runs everything.
300
+ ? { run: () => verify.run([], new Map([["reuse", true]])) }
237
301
  : { skip: "no .doctrina/verify.json — declare the real gate with `doctrina verify --init`" },
238
302
 
239
303
  coverage: {
@@ -257,10 +321,26 @@ async function closeOne(projectRoot, id, flags) {
257
321
  // escape hatch archive offers, and records the gap in the ledger.
258
322
  docs: {
259
323
  run: async () => {
260
- const r = checkDocsImpact(projectRoot, path.join(projectRoot, ".doctrina", "changes", id));
324
+ const changeDir = path.join(projectRoot, ".doctrina", "changes", id);
325
+ const r = checkDocsImpact(projectRoot, changeDir);
261
326
  if (r.ok) {
262
327
  console.log(c.green("ok") + ` ${r.reason}`);
263
- return 0;
328
+ // Documentation says how it works; the changelog says that it
329
+ // changed. Asking only the first is how thirteen changes closed in
330
+ // one session with an empty `## [Unreleased]` behind them.
331
+ const log = checkChangelogImpact(projectRoot, changeDir);
332
+ if (log.ok) {
333
+ // One line when both answers are the same sentence (change 0202):
334
+ // "touches no documented surface" twice read as two checks.
335
+ if (log.reason !== r.reason) console.log(c.green("ok") + ` ${log.reason}`);
336
+ return 0;
337
+ }
338
+ console.error(c.red("error:") + ` this change ${log.reason}:`);
339
+ for (const s of log.signals) console.error(` - ${s}`);
340
+ console.error(c.gray("hint: ") + "add an entry under the changelog's unreleased heading" +
341
+ ", or pass --force to close anyway (records the gap)");
342
+ docsGap = log;
343
+ return 1;
264
344
  }
265
345
  console.error(c.red("error:") + ` this change ${r.reason}:`);
266
346
  for (const s of r.signals) console.error(` - ${s}`);
@@ -268,8 +348,21 @@ async function closeOne(projectRoot, id, flags) {
268
348
  // Doctrina's own repository (change 0058): an adopting project with
269
349
  // no `docs/` was being told to write English AND Portuguese and to
270
350
  // read a skill it does not have.
271
- console.error(c.gray("hint: ") + docsRemedy(projectRoot) +
272
- ", or pass --force to close anyway (records the gap)");
351
+ console.error(c.gray("hint: ") + docsRemedy(projectRoot) + ".");
352
+ // THE SECOND ANSWER, NAMED WHERE IT IS NEEDED.
353
+ //
354
+ // The gate reads names, and a name is all it can see: a change that
355
+ // explains an effect, or a Scope boundaries line saying what it does
356
+ // NOT touch, reads exactly like one that alters the command. When the
357
+ // only remedy on offer was `--force`, an author looking at a false
358
+ // positive took the worst of the two exits — the ledger fills with
359
+ // gaps that were never gaps, and a real one stops standing out.
360
+ //
361
+ // The declaration exists for precisely this (change 0139) and was
362
+ // named nowhere the author would be looking.
363
+ console.error(c.gray(" ") + "if the names above are only MENTIONED, say so on the record: " +
364
+ "add `- **Documented surface:** n/a — <why>` to the proposal.");
365
+ console.error(c.gray(" ") + "or pass --force to close anyway (records the gap).");
273
366
  docsGap = r;
274
367
  return 1;
275
368
  },
@@ -280,6 +373,10 @@ async function closeOne(projectRoot, id, flags) {
280
373
  run: () => change.run(["archive", id], archiveFlags),
281
374
  },
282
375
 
376
+ // In-process, like every other runner here: the close starts no subprocess
377
+ // of its own binary, and a criterion holds it to that.
378
+ "index-drift": { run: () => indexRebuild.run(["rebuild"], new Map([["check", true]])) },
379
+
283
380
  validate: { run: () => validate.run([], new Map()) },
284
381
  };
285
382
 
@@ -369,16 +466,6 @@ async function closeOne(projectRoot, id, flags) {
369
466
  }
370
467
  }
371
468
 
372
- // Capture the lesson while it is fresh (operator review §4.7): surface the
373
- // fix-shaped lessons not yet written as skills, right where the operator
374
- // just proved they finish what they start. Advisory — a listing, never a
375
- // gate, and a suggest failure never un-closes the change.
376
- console.log("");
377
- console.log(c.gray("──── skills (advisory)"));
378
- try {
379
- await skill.run(["suggest"], new Map());
380
- } catch { /* advisory only */ }
381
-
382
469
  console.log("");
383
470
  console.log(c.green(`✓ change ${id} closed`) + c.gray(closingClaim(ran, skippedSteps)));
384
471
  console.log(c.gray("Next: ") + c.cyan("doctrina next"));
@@ -449,14 +536,27 @@ function reportDependentCoverage(projectRoot, touched) {
449
536
  }
450
537
 
451
538
  function touchedCapabilities(projectRoot, id) {
452
- const specsDir = path.join(projectRoot, ".doctrina", "changes", id, "specs");
453
- const caps = new Set();
454
- for (const deltaPath of walk(specsDir)) {
455
- if (!deltaPath.endsWith("delta.md")) continue;
456
- const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
457
- if (cap) caps.add(cap);
539
+ return deltaCapabilities(path.join(projectRoot, ".doctrina", "changes", id));
540
+ }
541
+
542
+ // The sequence is DECLARED once, in lib/gates.js, and rendered here. It used
543
+ // to be typed out in this string, which is how the help came to name ten
544
+ // steps of thirteen — review, implementation, docs and the index-drift check
545
+ // were added over time and the paragraph was not.
546
+ const SEQUENCE = wrapArrows(sequenceLabels("close"), 70, " ");
547
+
548
+ function wrapArrows(labels, width, indent) {
549
+ const lines = [];
550
+ let line = "";
551
+ for (const label of labels) {
552
+ const piece = line === "" ? label : `${line} → ${label}`;
553
+ if (piece.length + indent.length > width && line !== "") {
554
+ lines.push(indent + line + " →");
555
+ line = label;
556
+ } else line = piece;
458
557
  }
459
- return [...caps].sort();
558
+ if (line !== "") lines.push(indent + line);
559
+ return lines.join("\n");
460
560
  }
461
561
 
462
562
  export const help = `
@@ -465,9 +565,11 @@ Usage: doctrina close <id...> [--force]
465
565
  Run the whole closing sequence for a change in one pass, stopping at the
466
566
  first failure with the exact command to rerun:
467
567
 
468
- analyze → ADR checkpoint (advisory) → change apply → runtime → verify →
469
- coverage --strict (scoped to the change's touched capabilities) → trace →
470
- change archive → validate → skill suggest (advisory)
568
+ ${SEQUENCE}
569
+
570
+ then two advisory notes, once per run: the skill candidate a just-closed
571
+ fix-shaped change produced (the others are a count), and the other open
572
+ changes that look finished or are a week old.
471
573
 
472
574
  The coverage gate is scoped to the capabilities the change's deltas touch,
473
575
  so a deliberately deferred spec elsewhere cannot block an unrelated close;
@@ -481,7 +583,7 @@ trace, the ADR checkpoint
481
583
  (accepted ADRs citing the touched capabilities — amend via \`decision
482
584
  supersede\`, not silence), and the closing skill-suggest listing are
483
585
  advisory (reports, never blockers). This is a driver over the existing
484
- commands so the agent makes one call instead of nine and the human
586
+ commands so the agent makes one call instead of thirteen and the human
485
587
  approves once.
486
588
 
487
589
  Multiple ids close in sequence, each independently; the exit code is the
@@ -1,15 +1,21 @@
1
1
  // @ts-check
2
- import { OPERATIONS, COMMAND_NAMES } from "../lib/commands.js";
2
+ import { DEPRECATED, OPERATIONS } from "../lib/commands.js";
3
3
  import { c } from "../lib/colors.js";
4
4
 
5
5
  // Shell completions, generated from the same OPERATIONS catalog that feeds
6
6
  // `--help` and the docs drift tests — so the completion script can never
7
7
  // know a different surface than the CLI ships. Static output by design:
8
8
  // paste it into your shell config once; regenerate after upgrading.
9
+ //
10
+ // Deprecated operations are left out (change 0188): they still run, with a
11
+ // notice, for a script that already types them — but tab completion is how
12
+ // a person discovers the surface, and it should offer the replacement only.
13
+ const LIVE = OPERATIONS.filter(([op]) => !DEPRECATED[op]);
14
+ const COMMAND_NAMES = [...new Set(LIVE.map(([op]) => op.split(" ")[0]))];
9
15
 
10
16
  function subcommandMap() {
11
17
  const map = new Map();
12
- for (const [op] of OPERATIONS) {
18
+ for (const [op] of LIVE) {
13
19
  const [cmd, sub] = op.split(" ");
14
20
  if (!sub) continue;
15
21
  if (!map.has(cmd)) map.set(cmd, []);
@@ -7,7 +7,7 @@ import { listHeader, parseDependsOn, parseAdrScope, adrSummary, knownCapabilitie
7
7
  import { suggest } from "../lib/suggest.js";
8
8
  import { checklistProgress, getTitle, getSectionParagraph } from "../lib/doc-model.js";
9
9
  import { parseFrontmatter } from "../lib/doc-model.js";
10
- import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
10
+ import { flagBool, flagString, flagGivenWithoutValue, parsePositiveInt } from "../lib/args.js";
11
11
  import { c } from "../lib/colors.js";
12
12
  import { loadConfig, DEFAULTS } from "../lib/config.js";
13
13
  import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
@@ -466,16 +466,18 @@ function capabilitiesInPlay(projectRoot) {
466
466
  return [...caps];
467
467
  }
468
468
 
469
+ // Line endings are not content: a Windows checkout stores every line with a
470
+ // CR, which added ~2% to the same pack, so the budget gate — run on every OS
471
+ // in CI — could pass on Linux and fail on Windows for the same tree.
469
472
  function estimateTokens(text) {
470
- return Math.round(text.length / TOKEN_DIVISOR);
473
+ return Math.round(text.replace(/\r\n/g, "\n").length / TOKEN_DIVISOR);
471
474
  }
472
475
 
473
476
  // Flag beats project config beats the built-in default. Returns null for a
474
477
  // malformed --budget so the caller can report a usage error.
475
478
  function resolveBudget(projectRoot, budgetRaw) {
476
479
  if (budgetRaw !== undefined) {
477
- const n = Number.parseInt(budgetRaw, 10);
478
- return Number.isFinite(n) && n > 0 ? n : null;
480
+ return parsePositiveInt(budgetRaw);
479
481
  }
480
482
  // One configuration reader (lib/config.js, change 0047): `.doctrina/
481
483
  // config.json` is the declared home and index.json's `config` block is
@@ -152,8 +152,20 @@ function decisionSupersede(args) {
152
152
  });
153
153
  // Inject Supersedes link
154
154
  body = body.replace(/(\*\*Supersedes:\*\*)\s+—/, `$1 ${padded}`);
155
+ // Carry the predecessor's Scope. A successor built from the bare template
156
+ // had none, so it was GLOBAL: refining a decision that governed one
157
+ // capability, then accepting the refinement, loaded it into every
158
+ // capability's pack — ones the original never governed — and nothing said
159
+ // so. A replacement decides the same subject; its scope starts where the
160
+ // predecessor's was, written into the file where the author can widen it
161
+ // on purpose (ADR 0022: a pack is retrieval, not a dump).
162
+ const inheritedScope = parseAdrScope(oldText);
163
+ if (inheritedScope.length > 0) body = insertScopeHeader(body, inheritedScope) ?? body;
155
164
  write(newPath, body, { force: false });
156
165
  console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
166
+ if (inheritedScope.length > 0) {
167
+ console.log(c.gray("scope: ") + `inherited from ${padded} — ${inheritedScope.join(", ")} (edit the Scope: header to change it)`);
168
+ }
157
169
 
158
170
  // Mutate ONLY the Status: and Superseded by: headers of the old ADR.
159
171
  // Both go through the document model, so an ADR written with a slightly
@@ -188,7 +188,7 @@ export async function run(_positional, _flags) {
188
188
  ? tmplFindings[0].message
189
189
  : `${tmplFindings.length} template findings`;
190
190
  const remedy = [...byRemedy.keys()].filter((k) => k !== "(manual repair)");
191
- row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina templates check (each finding names its own fix)");
191
+ row("warn", "templates", summary, remedy.length === 1 ? remedy[0] : "doctrina upgrade (each finding names its own fix)");
192
192
  warningsTotal += 1;
193
193
  },
194
194
 
@@ -10,6 +10,7 @@ const here = path.dirname(fileURLToPath(import.meta.url));
10
10
  import { flagBool } from "../lib/args.js";
11
11
  import { c } from "../lib/colors.js";
12
12
  import { suggest } from "../lib/suggest.js";
13
+ import { EXIT } from "../lib/exit-codes.js";
13
14
 
14
15
  const SUBCOMMANDS = ["install"];
15
16
 
@@ -33,8 +34,12 @@ export async function run(positional, flags) {
33
34
  const projectRoot = process.cwd();
34
35
 
35
36
  if (!exists(path.join(projectRoot, ".git"))) {
37
+ // A PRECONDITION, not a gate: nothing was measured and found wanting,
38
+ // the place to put the hook simply does not exist yet. Class 1 told an
39
+ // agent to fix its work and retry the same command, which never clears.
36
40
  console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
37
- return 1;
41
+ console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
42
+ return EXIT.PRECONDITION;
38
43
  }
39
44
 
40
45
  const hooksDir = path.join(projectRoot, ".git", "hooks");
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists } from "../lib/fs-ops.js";
5
5
  import * as idx from "../lib/index-json.js";
6
- import { collectIndexDrift } from "../lib/scan.js";
6
+ import { collectIndexDrift, collectStagedIndexDrift } from "../lib/scan.js";
7
7
  import { today } from "../lib/dates.js";
8
8
  import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
@@ -15,7 +15,7 @@ const SUBCOMMANDS = ["rebuild"];
15
15
  // Flags this command accepts. Declared HERE, with the command, so
16
16
  // adding a command never requires editing the entrypoint — the gap that
17
17
  // let six flags ship undeclared and silently swallow a positional (C3).
18
- export const flags = { boolean: ["json", "check"], string: [] };
18
+ export const flags = { boolean: ["json", "check", "staged"], string: [] };
19
19
 
20
20
  export async function run(positional, flags) {
21
21
  const sub = positional[0];
@@ -32,11 +32,21 @@ export async function run(positional, flags) {
32
32
 
33
33
  function rebuild(flags) {
34
34
  const check = flagBool(flags, "check", false);
35
+ const staged = flagBool(flags, "staged", false);
35
36
  const projectRoot = process.cwd();
36
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
37
38
  throw notADoctrinaProject();
38
39
  }
39
40
 
41
+ if (staged) {
42
+ if (!check) {
43
+ console.error(c.red("error:") + " --staged reads the commit, so it can only check; it never writes");
44
+ console.error(c.gray("hint: ") + "run `doctrina index rebuild --check --staged`");
45
+ return 2;
46
+ }
47
+ return checkStaged(projectRoot);
48
+ }
49
+
40
50
  const state = collectIndexDrift(projectRoot);
41
51
  if (state.unreadable) {
42
52
  if (check) {
@@ -70,6 +80,39 @@ function rebuild(flags) {
70
80
  return 0;
71
81
  }
72
82
 
83
+ // The drift check, asked of what git will record. `index.json` is derived
84
+ // from the WHOLE tree, so it cannot describe a subset of it: stage part of
85
+ // `.doctrina/` and the index riding along names artifacts the commit does
86
+ // not carry. Every other gate reads the working tree, where those artifacts
87
+ // are still sitting, so none of them can see it.
88
+ function checkStaged(projectRoot) {
89
+ const state = collectStagedIndexDrift(projectRoot);
90
+ if (!state.applicable) {
91
+ console.log(c.green("ok") + ` nothing to check — ${state.reason}`);
92
+ return 0;
93
+ }
94
+ if (state.ok) {
95
+ console.log(c.green("ok") + " the staged index matches the staged tree");
96
+ return 0;
97
+ }
98
+ if (state.reason) {
99
+ console.error(c.red("error:") + ` ${state.reason}`);
100
+ return 1;
101
+ }
102
+ for (const p of state.missing) {
103
+ console.log(c.yellow("staged: ") + `${p} — named by the staged index, absent from the commit`);
104
+ }
105
+ for (const p of state.unindexed) {
106
+ console.log(c.yellow("staged: ") + `${p} — in the commit, absent from the staged index`);
107
+ }
108
+ const n = state.missing.length + state.unindexed.length;
109
+ console.log("");
110
+ console.log(c.red("fail") + ` the staged index does not describe this commit (${n} difference${n === 1 ? "" : "s"})`);
111
+ console.log(c.gray("hint: ") + "stage the artifacts above with the index, or unstage the index — "
112
+ + "index.json is derived from the whole tree, so a partial commit of .doctrina/ cannot carry a matching one");
113
+ return 1;
114
+ }
115
+
73
116
  export const help = `
74
117
  Usage: doctrina index <subcommand>
75
118
 
@@ -80,6 +123,13 @@ Subcommands:
80
123
  source of truth; the index is derived.
81
124
  rebuild --check Write nothing; exit 1 with a drift summary when the
82
125
  index no longer matches the tree. CI-friendly.
126
+ rebuild --check --staged
127
+ Ask the same question of the COMMIT rather than the
128
+ disk: every artifact the staged index names must be in
129
+ the commit, and every staged artifact must be in the
130
+ index. A partial commit of .doctrina/ cannot satisfy
131
+ both, because the index is derived from the whole tree.
132
+ Exits 0 when nothing relevant is staged.
83
133
 
84
134
  Fields with no on-disk source (project name, framework_version, product
85
135
  metadata) are carried over from the existing index.
@@ -14,7 +14,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
14
14
  import { c } from "../lib/colors.js";
15
15
  import { EXIT } from "../lib/exit-codes.js";
16
16
  import { ask, isInteractive } from "../lib/prompt.js";
17
- import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "../lib/intake-model.js";
17
+ import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
18
18
 
19
19
  const SUPPORTED_AGENTS = [
20
20
  "claude",
@@ -107,9 +107,20 @@ export async function run(_positional, flags) {
107
107
  // isTTY check the adapter prompt already had. A non-interactive caller
108
108
  // that forgot --non-interactive read EOF, got an empty answer, and
109
109
  // scaffolded a project with a blank description — silently (C9).
110
+ // At a terminal, ONE question, and its answer is the intake (change 0173):
111
+ // it was asked as a one-line description, written into product.md, and
112
+ // then `next` asked for the same thing again as an intake. A person
113
+ // describes the project once.
114
+ let intakeFromTty = false;
110
115
  if (!description && !nonInteractive) {
111
116
  if (isInteractive()) {
112
- description = await ask("One-sentence project description:");
117
+ const answer = await ask("Describe the project — what it is, who it is for, what it must do:");
118
+ if (answer && answer.trim() !== "") {
119
+ intakeBody = answer.trim();
120
+ intakeSource = "typed at `doctrina init`";
121
+ intakeFromTty = true;
122
+ description = firstLineSummary(intakeBody);
123
+ }
113
124
  }
114
125
  if (!description) {
115
126
  console.error(c.red("error:") + " no project description given and no terminal to ask on");
@@ -293,7 +304,11 @@ export async function run(_positional, flags) {
293
304
 
294
305
  console.log("");
295
306
  console.log(c.bold("Doctrina initialised."));
296
- if (intakeBody) {
307
+ if (intakeFromTty) {
308
+ // A person at a terminal gets the one instruction, not the agent's
309
+ // playbook: the agent reprints it with `doctrina intake`.
310
+ for (const line of nextStepAfterInit("tty")) console.log(line);
311
+ } else if (intakeBody) {
297
312
  console.log(`Next: execute the bootstrap playbook below — it converts the intake into`);
298
313
  console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
299
314
  console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
@@ -301,7 +316,7 @@ export async function run(_positional, flags) {
301
316
  warnIfThinIntake(intakeBody);
302
317
  printBootstrapPlaybook(projectRoot);
303
318
  } else {
304
- console.log(`Next: edit ${c.cyan("AGENTS.md")} and ${c.cyan(".doctrina/product.md")} for your project.`);
319
+ for (const line of nextStepAfterInit(null)) console.log(line);
305
320
  }
306
321
  return 0;
307
322
  }
@@ -2,17 +2,19 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
+ import { ensureDoctrinaProject, projectName } from "../lib/project.js";
5
6
  import { listHeader } from "../lib/scan.js";
6
7
  import { assessBrief } from "../lib/clarity.js";
7
8
  import * as idx from "../lib/index-json.js";
8
9
  import { today } from "../lib/dates.js";
9
10
  import { flagBool, flagString } from "../lib/args.js";
10
11
  import { c } from "../lib/colors.js";
11
- import { looksLikePath, warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
12
+ import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
12
13
 
13
14
  // `init --intake` performs the same operations; they live in
14
15
  // lib/intake-model.js so neither command imports out of the other (F7).
15
16
  export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
17
+ import { collectValidation } from "../lib/validation-model.js";
16
18
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
17
19
 
18
20
  // `intake` is the first half of the no-ceremony path (ADR 0005): store the
@@ -24,10 +26,47 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
24
26
  // Flags this command accepts. Declared HERE, with the command, so
25
27
  // adding a command never requires editing the entrypoint — the gap that
26
28
  // let six flags ship undeclared and silently swallow a positional (C3).
27
- export const flags = { boolean: ["json", "force"], string: ["text"] };
29
+ export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
28
30
 
29
31
  export async function run(positional, flags) {
30
32
  const projectRoot = process.cwd();
33
+
34
+ // Close the bootstrap through the CLI, not by hand.
35
+ //
36
+ // Step 7 of the playbook used to read "flip Status to converted in
37
+ // .doctrina/intake.md" — the one place this framework told an agent to
38
+ // hand-author a metadata header, against its own standing rule, and the one
39
+ // header nothing read back. A mistyped word left `next` asking forever for
40
+ // a bootstrap that was already done.
41
+ if (flagBool(flags, "converted", false)) {
42
+ ensureDoctrinaProject(projectRoot);
43
+ // Converting is the claim that the bootstrap is done — step 6 of the
44
+ // playbook, "fix everything the gates report", made checkable (change
45
+ // 0194). It used to convert over a tree `validate` failed, so the specs
46
+ // became "the source of truth" while an error still said they were not
47
+ // well-formed. --force converts anyway, for a tree whose errors are not
48
+ // the bootstrap's to fix.
49
+ if (isFile(path.join(projectRoot, ".doctrina", "intake.md")) && !flagBool(flags, "force", false)) {
50
+ const { errors } = collectValidation(projectRoot);
51
+ if (errors.length > 0) {
52
+ console.error(c.red("error:") + ` the bootstrap is not done — \`doctrina validate\` reports ${errors.length} error${errors.length === 1 ? "" : "s"}:`);
53
+ for (const e of errors.slice(0, 5)) console.error(" - " + e);
54
+ if (errors.length > 5) console.error(` … and ${errors.length - 5} more`);
55
+ console.error(c.gray("hint: ") + "fix them (`doctrina validate --fix` heals index drift), then rerun; `--force` converts anyway");
56
+ return EXIT.GATE;
57
+ }
58
+ }
59
+ const written = markIntakeConverted(projectRoot);
60
+ if (!written) {
61
+ console.error(c.red("error:") + " no intake to mark converted at .doctrina/intake.md");
62
+ console.error(c.gray("hint: ") + 'store one first: `doctrina intake --text "<description>"`');
63
+ return EXIT.PRECONDITION;
64
+ }
65
+ console.log(c.green("converted") + ` ${relPath(projectRoot, written)} — specs are the source of truth now`);
66
+ console.log(c.gray("next: ") + 'doctrina next');
67
+ return EXIT.OK;
68
+ }
69
+
31
70
  if (!exists(path.join(projectRoot, ".doctrina"))) {
32
71
  throw notADoctrinaProject();
33
72
  }
@@ -102,8 +141,8 @@ export async function run(positional, flags) {
102
141
  return EXIT.PRECONDITION;
103
142
  }
104
143
 
105
- const projectName = idx.load(projectRoot).project ?? path.basename(projectRoot);
106
- writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
144
+ const name = projectName(projectRoot);
145
+ writeIntakeFile(projectRoot, { body, source, projectName: name, date: today(), force });
107
146
  console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
108
147
  console.log("");
109
148
  warnIfThinIntake(body);
@@ -127,11 +166,20 @@ Forms:
127
166
  doctrina intake --text "<text>" Ingest an inline description
128
167
  doctrina intake Reprint the playbook for the
129
168
  existing pending intake
169
+ doctrina intake --converted Close the bootstrap: flip the stored
170
+ intake's Status to converted
130
171
 
131
172
  Options:
132
173
  --text "<description>" Inline description instead of a file
133
- --force Overwrite an existing .doctrina/intake.md
174
+ --force Overwrite an existing .doctrina/intake.md; with
175
+ --converted, convert although validate fails
176
+ --converted Mark the stored intake converted (ends the
177
+ bootstrap; nothing else writes that header).
178
+ Refuses (exit 1) while validate reports an error
134
179
 
135
- After conversion the agent flips the intake header to
136
- "- **Status:** converted"; specs become the only source of truth.
180
+ Close the bootstrap with \`doctrina intake --converted\` rather than editing
181
+ the header by hand: \`next\` branches on that value, and \`validate\` reports a
182
+ Status that is neither pending nor converted, because every other word read
183
+ as pending in silence. After conversion the specs are the only source of
184
+ truth.
137
185
  `;