doctrina-cli 0.15.1 → 0.16.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 (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -212
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +56 -4
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +181 -23
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +191 -5
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -2,12 +2,10 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { exists } from "../lib/fs-ops.js";
5
- import { collectStatus } from "./status.js";
6
- import { computeActions } from "./next.js";
7
- import { openChanges } from "./prime.js";
8
- import { today } from "../lib/dates.js";
9
5
  import { c } from "../lib/colors.js";
10
6
  import { notADoctrinaProject } from "../lib/exit-codes.js";
7
+ import { collectSnapshot } from "../lib/snapshot.js";
8
+ import { renderView } from "../lib/views.js";
11
9
 
12
10
  // Session handoff note: everything the NEXT session (a fresh agent, a
13
11
  // teammate, tomorrow's you) needs to resume without re-reading the tree —
@@ -17,6 +15,10 @@ import { notADoctrinaProject } from "../lib/exit-codes.js";
17
15
  // change. It is a VIEW derived from the tree at call time, deliberately not
18
16
  // a stored file: a saved note goes stale the moment work continues, the
19
17
  // tree never does (no new home for facts).
18
+ //
19
+ // Since change 0037 that word "view" is literal: the note is rendered by
20
+ // lib/views.js from the one snapshot lib/snapshot.js collects, and is equally
21
+ // reachable as `doctrina status --view handoff`.
20
22
 
21
23
  // Flags this command accepts. Declared HERE, with the command, so
22
24
  // adding a command never requires editing the entrypoint — the gap that
@@ -28,59 +30,7 @@ export async function run(_positional, _flags) {
28
30
  if (!exists(path.join(projectRoot, ".doctrina"))) {
29
31
  throw notADoctrinaProject();
30
32
  }
31
-
32
- const s = collectStatus(projectRoot);
33
- const open = openChanges(projectRoot);
34
- const actions = computeActions(projectRoot);
35
-
36
- console.log(`# Doctrina handoff — ${s.project} (${today()})`);
37
- console.log("");
38
-
39
- // Gate digest — the red ones are what the resuming session fixes first.
40
- console.log("## Where things stand");
41
- console.log("");
42
- console.log(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
43
- console.log(`- coverage: ${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria` +
44
- (s.coverage.totalDangling ? `, ${s.coverage.totalDangling} dangling` : "") +
45
- (s.coverage.totalConditional ? `, ${s.coverage.totalConditional} conditional` : "") + ")");
46
- const tr = s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`;
47
- console.log(`- trace: ${tr}` + (s.trace.untraceable ? ` (${s.trace.untraceable} untraceable)` : ""));
48
- console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared — run \`doctrina verify\`` : "not configured"}`);
49
-
50
- // The in-flight work, task by task.
51
- console.log("");
52
- if (open.length === 0) {
53
- console.log("## Open work");
54
- console.log("");
55
- console.log("- none — the tree is at rest; start with `doctrina work \"<prompt>\"`");
56
- } else {
57
- for (const ch of open) {
58
- console.log(`## Open change \`${ch.id}\`${ch.title ? ` — ${ch.title}` : ""}`);
59
- console.log("");
60
- console.log(`- proposal status: ${ch.status}`);
61
- if (ch.tasksTotal > 0) {
62
- console.log(`- tasks: ${ch.tasksDone}/${ch.tasksTotal} checked`);
63
- for (const t of ch.unchecked.slice(0, 8)) console.log(` - [ ] ${t}`);
64
- if (ch.unchecked.length > 8) console.log(` - … ${ch.unchecked.length - 8} more in tasks.md`);
65
- } else {
66
- console.log("- tasks: no tasks.md checklist found");
67
- }
68
- console.log(`- resume with: \`doctrina work --resume ${ch.id}\` · close with: \`doctrina close ${ch.id}\``);
69
- console.log("");
70
- }
71
- }
72
-
73
- // What to do, in priority order.
74
- console.log("## Next actions");
75
- console.log("");
76
- if (actions.length === 0) {
77
- console.log("1. nothing pending — `doctrina next` will confirm; pick up new work");
78
- } else {
79
- actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
80
- }
81
-
82
- console.log("");
83
- console.log("*Generated read-only from the tree — regenerate anytime with `doctrina handoff`.*");
33
+ for (const line of renderView("handoff", collectSnapshot(projectRoot))) console.log(line);
84
34
  if (process.stdout.isTTY) {
85
35
  console.log("");
86
36
  console.log(c.gray("tip: pipe it — `doctrina handoff > note.md` — or paste it as the first message of the next session"));
@@ -96,6 +46,9 @@ with its task-by-task progress and the exact resume command, and the
96
46
  prioritised next actions. Made to be pasted as the first message of the
97
47
  next agent session (or piped to a file).
98
48
 
49
+ The same view as \`doctrina status --view handoff\`; both render one
50
+ collection of the tree.
51
+
99
52
  Deliberately a derived view, not a stored file — the tree is the truth
100
53
  and never goes stale; regenerate on demand. Read-only; always exits 0.
101
54
  `;
@@ -3,7 +3,10 @@ import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { chmodSync } from "node:fs";
5
5
  import { exists, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
6
- import { locateTemplatesDir } from "../lib/templates.js";
6
+ import { locateTemplatesDir, substitute } from "../lib/templates.js";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const here = path.dirname(fileURLToPath(import.meta.url));
7
10
  import { flagBool } from "../lib/args.js";
8
11
  import { c } from "../lib/colors.js";
9
12
  import { suggest } from "../lib/suggest.js";
@@ -45,7 +48,13 @@ export async function run(positional, flags) {
45
48
 
46
49
  const templatesDir = locateTemplatesDir();
47
50
  const tplPath = path.join(templatesDir, "hooks", "pre-commit.sample");
48
- const body = read(tplPath);
51
+ // The hook pins the CLI that installed it (change 0116). `.git/hooks/` is
52
+ // local to the clone, so an absolute path is the right thing to write;
53
+ // a bare `doctrina` ran whichever release was on the PATH — a global
54
+ // 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
55
+ // `DOCTRINA=` in the environment overrides it.
56
+ const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
57
+ const body = substitute(read(tplPath), { DOCTRINA_BIN: `node \"${entry}\"` });
49
58
  write(target, body, { force: true });
50
59
  chmodSync(target, 0o755);
51
60
  console.log(c.green("installed") + ` ${relPath(projectRoot, target)}`);
@@ -3,8 +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 { deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
7
- import { cliVersion } from "../lib/version.js";
6
+ import { collectIndexDrift } from "../lib/scan.js";
8
7
  import { today } from "../lib/dates.js";
9
8
  import { flagBool } from "../lib/args.js";
10
9
  import { c } from "../lib/colors.js";
@@ -38,39 +37,30 @@ function rebuild(flags) {
38
37
  throw notADoctrinaProject();
39
38
  }
40
39
 
41
- let current = null;
42
- try {
43
- current = idx.load(projectRoot);
44
- } catch (err) {
40
+ const state = collectIndexDrift(projectRoot);
41
+ if (state.unreadable) {
45
42
  if (check) {
46
- console.error(c.red("error:") + ` ${err.message}`);
43
+ console.error(c.red("error:") + ` ${state.unreadable}`);
47
44
  return 1;
48
45
  }
49
- console.log(c.yellow("warn:") + ` ${err.message} — rebuilding from scratch`);
46
+ console.log(c.yellow("warn:") + ` ${state.unreadable} — rebuilding from scratch`);
50
47
  }
51
48
 
52
- const derived = deriveIndex(projectRoot, current);
53
- // Migrate the framework stamp to the running CLI (3.6). deriveIndex carries
54
- // the old value over (so `next` does not nag on a version-only difference);
55
- // overriding it here lets a stale stamp count as drift, so `index rebuild`
56
- // both reports and fixes it instead of short-circuiting on "nothing to do".
57
- derived.framework_version = cliVersion();
58
-
59
- if (indexesMatch(derived, current)) {
49
+ if (state.ok) {
60
50
  console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
61
51
  return 0;
62
52
  }
63
53
 
64
- const drift = describeDrift(current, derived);
65
- for (const line of drift) console.log((check ? c.yellow("drift: ") : c.gray("sync: ")) + line);
54
+ for (const line of state.drift) console.log((check ? c.yellow("drift: ") : c.gray("sync: ")) + line);
66
55
 
67
56
  if (check) {
68
57
  console.log("");
69
- console.log(c.red("fail") + ` index.json has drifted from the tree (${drift.length} difference${drift.length === 1 ? "" : "s"})`);
58
+ console.log(c.red("fail") + ` index.json has drifted from the tree (${state.drift.length} difference${state.drift.length === 1 ? "" : "s"})`);
70
59
  console.log(c.gray("hint: ") + "run `doctrina index rebuild` to regenerate it");
71
60
  return 1;
72
61
  }
73
62
 
63
+ const derived = state.derived;
74
64
  derived.last_updated = today();
75
65
  idx.save(projectRoot, derived);
76
66
  const a = derived.artifacts;
@@ -80,35 +70,6 @@ function rebuild(flags) {
80
70
  return 0;
81
71
  }
82
72
 
83
- // Human-readable category-level drift between the on-disk index and the
84
- // derived one: added / removed / changed entry ids.
85
- function describeDrift(current, derived) {
86
- const lines = [];
87
- if (!current) return ["index.json missing or unreadable"];
88
- if ((current.framework_version ?? null) !== (derived.framework_version ?? null)) {
89
- lines.push(`framework_version: ${current.framework_version ?? "unset"} -> ${derived.framework_version}`);
90
- }
91
- const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
92
- for (const cat of categories) {
93
- const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
94
- const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
95
- for (const id of der.keys()) {
96
- if (!cur.has(id)) lines.push(`${cat}: "${id}" on disk but not in index`);
97
- else if (stableStringify(cur.get(id)) !== stableStringify(der.get(id))) {
98
- lines.push(`${cat}: "${id}" metadata differs from the files`);
99
- }
100
- }
101
- for (const id of cur.keys()) {
102
- if (!der.has(id)) lines.push(`${cat}: "${id}" in index but not on disk`);
103
- }
104
- }
105
- if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
106
- lines.push("product: metadata differs");
107
- }
108
- if (lines.length === 0) lines.push("structural difference (key order or missing category)");
109
- return lines;
110
- }
111
-
112
73
  export const help = `
113
74
  Usage: doctrina index <subcommand>
114
75
 
@@ -7,13 +7,14 @@ import {
7
7
  surfaceBlock, findSurfaceBlock, agentChangelogBlock, findAgentChangelogBlock,
8
8
  } from "../lib/commands.js";
9
9
  import * as idx from "../lib/index-json.js";
10
+ import { scaffoldConfig } from "../lib/config.js";
10
11
  import { today } from "../lib/dates.js";
11
12
  import { cliVersion } from "../lib/version.js";
12
- import { flagBool, flagString } from "../lib/args.js";
13
+ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
13
14
  import { c } from "../lib/colors.js";
14
15
  import { EXIT } from "../lib/exit-codes.js";
15
16
  import { ask, isInteractive } from "../lib/prompt.js";
16
- import { writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "./intake.js";
17
+ import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake } from "../lib/intake-model.js";
17
18
 
18
19
  const SUPPORTED_AGENTS = [
19
20
  "claude",
@@ -32,7 +33,7 @@ const SUPPORTED_AGENTS = [
32
33
 
33
34
  export const flags = {
34
35
  boolean: ["json", "force", "non-interactive", "overwrite-content"],
35
- string: ["project-name", "project-description", "agent", "intake", "from", "date"],
36
+ string: ["project-name", "project-description", "agent", "intake", "intake-text", "from", "date"],
36
37
  };
37
38
 
38
39
  export async function run(_positional, flags) {
@@ -45,17 +46,56 @@ export async function run(_positional, flags) {
45
46
  // --intake hands over the FULL description up front (ADR 0005); the
46
47
  // one-line summary is then derived instead of prompted for.
47
48
  const intakeFile = flagString(flags, "intake");
49
+ const intakeText = flagString(flags, "intake-text");
48
50
  let intakeBody = null;
51
+ let intakeSource = null;
52
+ // Onboarding is ONE moment, so it gets one command (change 0051). The
53
+ // separation between `init` and `intake` is an architectural fact — `init`
54
+ // refuses to read language, ADR 0005 — and it was being charged to the
55
+ // user as two steps of setup. Both commands stay: `intake` is still the
56
+ // door for converting a description in a project that already exists.
57
+ if (intakeFile && intakeText !== undefined) {
58
+ console.error(c.red("error:") + " pass --intake <file> or --intake-text \"<text>\", not both");
59
+ return EXIT.USAGE;
60
+ }
61
+ // A flag written without a value is a usage error, never a silent fallback
62
+ // to "no intake": the operator asked for one and would get a project
63
+ // without it, and nothing would say so.
64
+ for (const name of ["intake", "intake-text"]) {
65
+ if (flagGivenWithoutValue(flags, name)) {
66
+ console.error(c.red("error:") + ` --${name} needs a value`);
67
+ console.error(c.gray("hint: ") + (name === "intake"
68
+ ? "doctrina init --intake ./description.md"
69
+ : "doctrina init --intake-text \"<the whole project description>\""));
70
+ return EXIT.USAGE;
71
+ }
72
+ }
73
+ if (typeof intakeText === "string" && intakeText.trim() !== "") {
74
+ intakeBody = intakeText;
75
+ intakeSource = "inline (--intake-text)";
76
+ }
49
77
  if (intakeFile) {
50
78
  const intakeAbs = path.resolve(projectRoot, intakeFile);
51
- if (!isFile(intakeAbs)) {
79
+ if (isFile(intakeAbs)) {
80
+ intakeBody = read(intakeAbs);
81
+ intakeSource = relPath(projectRoot, intakeAbs);
82
+ if (intakeBody.trim().length === 0) {
83
+ console.error(c.red("error:") + ` --intake file is empty: ${intakeFile}`);
84
+ return 1;
85
+ }
86
+ } else if (looksLikePath(intakeFile)) {
52
87
  console.error(c.red("error:") + ` --intake file not found: ${intakeFile}`);
88
+ console.error(c.gray("hint: ") +
89
+ 'to pass the description itself, use `--intake-text "<description>"`');
53
90
  return 1;
54
- }
55
- intakeBody = read(intakeAbs);
56
- if (intakeBody.trim().length === 0) {
57
- console.error(c.red("error:") + ` --intake file is empty: ${intakeFile}`);
58
- return 1;
91
+ } else {
92
+ // The same rule `intake` applies to its positional (change 0071): the
93
+ // two commands answer the same input shape the same way, so an agent
94
+ // that learned one is not surprised by the other.
95
+ intakeBody = intakeFile;
96
+ intakeSource = "inline";
97
+ console.error(c.gray("note: ") +
98
+ 'read as the description itself, not as a file — `--intake-text "<description>"` says so explicitly');
59
99
  }
60
100
  }
61
101
 
@@ -214,6 +254,20 @@ export async function run(_positional, flags) {
214
254
  console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
215
255
  }
216
256
 
257
+ // config.json, written from the module that READS it (lib/config.js) for
258
+ // the same reason index.json is written from its schema: a template copy
259
+ // is how a project is born already needing a scaffold update. Before this
260
+ // the file was created by nothing, named in no surface block and reported
261
+ // by no command — it existed only for someone who had read the source, and
262
+ // a pt-BR project sat permanently red under `clarify` with no clue why.
263
+ {
264
+ const dest = path.join(doctrinaDir, "config.json");
265
+ if (!isFile(dest) || force) {
266
+ write(dest, scaffoldConfig(), { force });
267
+ console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
268
+ }
269
+ }
270
+
217
271
  // Adapters
218
272
  if (agentSelector) {
219
273
  const agents = agentSelector === "all" ? SUPPORTED_AGENTS : [agentSelector];
@@ -229,7 +283,7 @@ export async function run(_positional, flags) {
229
283
  if (intakeBody) {
230
284
  const intakePath = writeIntakeFile(projectRoot, {
231
285
  body: intakeBody,
232
- source: relPath(projectRoot, path.resolve(projectRoot, intakeFile)),
286
+ source: intakeSource,
233
287
  projectName,
234
288
  date,
235
289
  force,
@@ -361,9 +415,12 @@ Options:
361
415
  --project-description <text> One-sentence description
362
416
  --agent <name> Install adapter for one agent
363
417
  (claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|all)
364
- --intake <file> Full project description; stored verbatim at .doctrina/intake.md
365
- and used to derive the one-line description when absent.
366
- Follow up with \`doctrina intake\` for the bootstrap playbook
418
+ --intake <file> Full project description; stored verbatim at .doctrina/intake.md,
419
+ used to derive the one-line description when absent, and the
420
+ bootstrap playbook is printed inline — no second command
421
+ --intake-text "<text>" The same, inline, with no file to write first. Mutually
422
+ exclusive with --intake; either one written without a value
423
+ is a usage error, not a project scaffolded without an intake
367
424
  --from <path> Local conventions directory; if it contains AGENTS.md and/or
368
425
  .doctrina/product.md, the content is folded into the new project
369
426
  --date <YYYY-MM-DD> Override the system date
@@ -8,6 +8,11 @@ import * as idx from "../lib/index-json.js";
8
8
  import { today } from "../lib/dates.js";
9
9
  import { flagBool, flagString } from "../lib/args.js";
10
10
  import { c } from "../lib/colors.js";
11
+ import { looksLikePath, warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
12
+
13
+ // `init --intake` performs the same operations; they live in
14
+ // lib/intake-model.js so neither command imports out of the other (F7).
15
+ export { warnIfThinIntake, writeIntakeFile, printBootstrapPlaybook } from "../lib/intake-model.js";
11
16
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
17
 
13
18
  // `intake` is the first half of the no-ceremony path (ADR 0005): store the
@@ -58,12 +63,24 @@ export async function run(positional, flags) {
58
63
  source = "inline (--text)";
59
64
  } else {
60
65
  const abs = path.resolve(projectRoot, sourceFile);
61
- if (!isFile(abs)) {
66
+ if (isFile(abs)) {
67
+ body = read(abs);
68
+ source = relPath(projectRoot, abs);
69
+ } else if (looksLikePath(sourceFile)) {
62
70
  console.error(c.red("error:") + ` description file not found: ${sourceFile}`);
71
+ console.error(c.gray("hint: ") +
72
+ 'to pass the description itself, use `doctrina intake --text "<description>"`');
63
73
  return 1;
74
+ } else {
75
+ // Prose, not a path (change 0071). The surface block says "store the
76
+ // intent", so passing the intent is what its wording invites; answering
77
+ // "description file not found:" and echoing the author's whole sentence
78
+ // back as a filename was the CLI blaming the reader for reading it.
79
+ body = sourceFile;
80
+ source = "inline";
81
+ console.error(c.gray("note: ") +
82
+ 'read as the description itself, not as a file — `--text "<description>"` says so explicitly');
64
83
  }
65
- body = read(abs);
66
- source = relPath(projectRoot, abs);
67
84
  }
68
85
  if (body.trim().length === 0) {
69
86
  console.error(c.red("error:") + " the description is empty");
@@ -74,6 +91,16 @@ export async function run(positional, flags) {
74
91
  console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to overwrite)`);
75
92
  return 1;
76
93
  }
94
+ // After conversion the specs are the only source of truth, and the intake
95
+ // is never edited to change requirements (change 0112). `--force` was the
96
+ // CLI-sanctioned way to do exactly that: it rewrote the file to `pending`
97
+ // and printed the bootstrap playbook as if the tree were empty. The door
98
+ // for new intent is `intent add`; for a change of behaviour, `work`.
99
+ if (exists(intakePath) && (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase() === "converted") {
100
+ console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} is already converted — the specs are the source of truth now, and --force does not reopen it`);
101
+ console.error(c.gray("hint: ") + "new product intent: `doctrina intent add \"<text>\"` · a change of behaviour: `doctrina work \"<prompt>\"`");
102
+ return EXIT.PRECONDITION;
103
+ }
77
104
 
78
105
  const projectName = idx.load(projectRoot).project ?? path.basename(projectRoot);
79
106
  writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
@@ -84,95 +111,6 @@ export async function run(positional, flags) {
84
111
  return 0;
85
112
  }
86
113
 
87
- // Clarification gate (review Topic A). A description too thin to spec from is
88
- // the moment to ask the user, not to let the agent invent requirements. Prints
89
- // the specific gaps before the bootstrap playbook; advisory, never blocking —
90
- // the intake is still captured verbatim (the playbook step 6 resolves it).
91
- export function warnIfThinIntake(body) {
92
- const assessment = assessBrief(body, { kind: "intake" });
93
- if (!assessment.thin) return;
94
- console.log(c.yellow("⚠ thin intake — clarify with the user before converting to specs:"));
95
- for (const reason of assessment.reasons) console.log(` - ${reason}`);
96
- console.log("");
97
- }
98
-
99
- // Shared with `init --intake`. Writes the intake verbatim under a small
100
- // status header; the agent flips Status to converted at the end of the
101
- // playbook, which is how `next` knows the bootstrap is done.
102
- export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
103
- const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
104
- const content = [
105
- `# Intake — ${projectName}`,
106
- "",
107
- "- **Status:** pending",
108
- `- **Date:** ${date}`,
109
- `- **Source:** ${source}`,
110
- "",
111
- "<!-- Raw project intent, stored verbatim. The bootstrap playbook",
112
- " (doctrina intake) converts it into product.md and capability",
113
- " specs, then flips Status to converted. After conversion the",
114
- " specs are the only source of truth; never edit this file to",
115
- " change requirements. -->",
116
- "",
117
- "---",
118
- "",
119
- body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
120
- ].join("\n");
121
- write(intakePath, content, { force });
122
- return intakePath;
123
- }
124
-
125
- // Exported so `init --intake` can print the same playbook inline instead
126
- // of bouncing the user to a second command.
127
- export function printBootstrapPlaybook(projectRoot) {
128
- const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
129
- console.log(c.bold("Bootstrap playbook") + c.gray(" — agent-executed (ADR 0005); the CLI does no interpretation."));
130
- console.log("");
131
- console.log("Execute in order, in a single linear pass:");
132
- console.log("");
133
- console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
134
- console.log("");
135
- console.log("2. Fill every product.md section from the intake: Vision, Problem,");
136
- console.log(" Target users, Scope (in/out), Non-goals, Success criteria, and the");
137
- console.log(" Delivery order — name the ONE end-to-end walking skeleton to build");
138
- console.log(" and verify before fanning out (depth before breadth).");
139
- console.log(" One fact, one home — product.md holds vision, never requirements.");
140
- console.log("");
141
- console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
142
- console.log(" (e.g. auth, billing, reports). For each capability:");
143
- console.log(` ${c.cyan("doctrina spec new <capability>")}`);
144
- console.log(" then replace the template placeholders in");
145
- console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
146
- console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
147
- console.log(" Unwanted-behavior / Optional). Keep the two axes honest — leave");
148
- console.log(" Implementation: planned for what is not built yet; keep aspiration");
149
- console.log(" under ## Maturity → Future, not in EARS; write concrete Acceptance");
150
- console.log(" criteria, each [unverified] until a cited test proves it.");
151
- console.log("");
152
- console.log("4. If the project spans services/front-ends, own the seams between");
153
- console.log(" them (port map, env, API/WS/event shapes) as a contract:");
154
- console.log(` ${c.cyan("doctrina contract new <name>")} → fill it → ${c.cyan("doctrina contract check")}`);
155
- console.log("");
156
- console.log("5. Record any architectural decision the intake forces (cite Evidence):");
157
- console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
158
- console.log("");
159
- console.log("6. Quality gates — fix everything they report before moving on:");
160
- console.log(` ${c.cyan("doctrina clarify --all")}`);
161
- console.log(` ${c.cyan("doctrina validate")}`);
162
- console.log(` ${c.cyan("doctrina coverage")} (and ${c.cyan("doctrina verify --init")} to declare the build gate)`);
163
- console.log(" If something in the intake is genuinely ambiguous, ask the user");
164
- console.log(" before assuming.");
165
- console.log("");
166
- console.log("7. Mark the intake consumed: in .doctrina/intake.md flip");
167
- console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
168
- console.log("");
169
- console.log(`8. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
170
- console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
171
- if (!hasSpecs) {
172
- console.log("");
173
- console.log(c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.");
174
- }
175
- }
176
114
 
177
115
  export const help = `
178
116
  Usage: doctrina intake [<file>] [--text "<description>"] [--force]