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
@@ -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,10 +3,14 @@ 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";
13
+ import { EXIT } from "../lib/exit-codes.js";
10
14
 
11
15
  const SUBCOMMANDS = ["install"];
12
16
 
@@ -30,8 +34,12 @@ export async function run(positional, flags) {
30
34
  const projectRoot = process.cwd();
31
35
 
32
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.
33
40
  console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
34
- return 1;
41
+ console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
42
+ return EXIT.PRECONDITION;
35
43
  }
36
44
 
37
45
  const hooksDir = path.join(projectRoot, ".git", "hooks");
@@ -45,7 +53,13 @@ export async function run(positional, flags) {
45
53
 
46
54
  const templatesDir = locateTemplatesDir();
47
55
  const tplPath = path.join(templatesDir, "hooks", "pre-commit.sample");
48
- const body = read(tplPath);
56
+ // The hook pins the CLI that installed it (change 0116). `.git/hooks/` is
57
+ // local to the clone, so an absolute path is the right thing to write;
58
+ // a bare `doctrina` ran whichever release was on the PATH — a global
59
+ // 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
60
+ // `DOCTRINA=` in the environment overrides it.
61
+ const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
62
+ const body = substitute(read(tplPath), { DOCTRINA_BIN: `node \"${entry}\"` });
49
63
  write(target, body, { force: true });
50
64
  chmodSync(target, 0o755);
51
65
  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, collectStagedIndexDrift } 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";
@@ -16,7 +15,7 @@ const SUBCOMMANDS = ["rebuild"];
16
15
  // Flags this command accepts. Declared HERE, with the command, so
17
16
  // adding a command never requires editing the entrypoint — the gap that
18
17
  // let six flags ship undeclared and silently swallow a positional (C3).
19
- export const flags = { boolean: ["json", "check"], string: [] };
18
+ export const flags = { boolean: ["json", "check", "staged"], string: [] };
20
19
 
21
20
  export async function run(positional, flags) {
22
21
  const sub = positional[0];
@@ -33,44 +32,45 @@ export async function run(positional, flags) {
33
32
 
34
33
  function rebuild(flags) {
35
34
  const check = flagBool(flags, "check", false);
35
+ const staged = flagBool(flags, "staged", false);
36
36
  const projectRoot = process.cwd();
37
37
  if (!exists(path.join(projectRoot, ".doctrina"))) {
38
38
  throw notADoctrinaProject();
39
39
  }
40
40
 
41
- let current = null;
42
- try {
43
- current = idx.load(projectRoot);
44
- } catch (err) {
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
+
50
+ const state = collectIndexDrift(projectRoot);
51
+ if (state.unreadable) {
45
52
  if (check) {
46
- console.error(c.red("error:") + ` ${err.message}`);
53
+ console.error(c.red("error:") + ` ${state.unreadable}`);
47
54
  return 1;
48
55
  }
49
- console.log(c.yellow("warn:") + ` ${err.message} — rebuilding from scratch`);
56
+ console.log(c.yellow("warn:") + ` ${state.unreadable} — rebuilding from scratch`);
50
57
  }
51
58
 
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)) {
59
+ if (state.ok) {
60
60
  console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
61
61
  return 0;
62
62
  }
63
63
 
64
- const drift = describeDrift(current, derived);
65
- for (const line of drift) console.log((check ? c.yellow("drift: ") : c.gray("sync: ")) + line);
64
+ for (const line of state.drift) console.log((check ? c.yellow("drift: ") : c.gray("sync: ")) + line);
66
65
 
67
66
  if (check) {
68
67
  console.log("");
69
- console.log(c.red("fail") + ` index.json has drifted from the tree (${drift.length} difference${drift.length === 1 ? "" : "s"})`);
68
+ console.log(c.red("fail") + ` index.json has drifted from the tree (${state.drift.length} difference${state.drift.length === 1 ? "" : "s"})`);
70
69
  console.log(c.gray("hint: ") + "run `doctrina index rebuild` to regenerate it");
71
70
  return 1;
72
71
  }
73
72
 
73
+ const derived = state.derived;
74
74
  derived.last_updated = today();
75
75
  idx.save(projectRoot, derived);
76
76
  const a = derived.artifacts;
@@ -80,33 +80,37 @@ function rebuild(flags) {
80
80
  return 0;
81
81
  }
82
82
 
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}`);
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;
90
93
  }
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
- }
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
104
  }
105
- if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
106
- lines.push("product: metadata differs");
105
+ for (const p of state.unindexed) {
106
+ console.log(c.yellow("staged: ") + `${p} — in the commit, absent from the staged index`);
107
107
  }
108
- if (lines.length === 0) lines.push("structural difference (key order or missing category)");
109
- return lines;
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;
110
114
  }
111
115
 
112
116
  export const help = `
@@ -119,6 +123,13 @@ Subcommands:
119
123
  source of truth; the index is derived.
120
124
  rebuild --check Write nothing; exit 1 with a drift summary when the
121
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.
122
133
 
123
134
  Fields with no on-disk source (project name, framework_version, product
124
135
  metadata) are carried over from the existing index.
@@ -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, nextStepAfterInit } 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
 
@@ -67,9 +107,20 @@ export async function run(_positional, flags) {
67
107
  // isTTY check the adapter prompt already had. A non-interactive caller
68
108
  // that forgot --non-interactive read EOF, got an empty answer, and
69
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;
70
115
  if (!description && !nonInteractive) {
71
116
  if (isInteractive()) {
72
- 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
+ }
73
124
  }
74
125
  if (!description) {
75
126
  console.error(c.red("error:") + " no project description given and no terminal to ask on");
@@ -214,6 +265,20 @@ export async function run(_positional, flags) {
214
265
  console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
215
266
  }
216
267
 
268
+ // config.json, written from the module that READS it (lib/config.js) for
269
+ // the same reason index.json is written from its schema: a template copy
270
+ // is how a project is born already needing a scaffold update. Before this
271
+ // the file was created by nothing, named in no surface block and reported
272
+ // by no command — it existed only for someone who had read the source, and
273
+ // a pt-BR project sat permanently red under `clarify` with no clue why.
274
+ {
275
+ const dest = path.join(doctrinaDir, "config.json");
276
+ if (!isFile(dest) || force) {
277
+ write(dest, scaffoldConfig(), { force });
278
+ console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
279
+ }
280
+ }
281
+
217
282
  // Adapters
218
283
  if (agentSelector) {
219
284
  const agents = agentSelector === "all" ? SUPPORTED_AGENTS : [agentSelector];
@@ -229,7 +294,7 @@ export async function run(_positional, flags) {
229
294
  if (intakeBody) {
230
295
  const intakePath = writeIntakeFile(projectRoot, {
231
296
  body: intakeBody,
232
- source: relPath(projectRoot, path.resolve(projectRoot, intakeFile)),
297
+ source: intakeSource,
233
298
  projectName,
234
299
  date,
235
300
  force,
@@ -239,7 +304,11 @@ export async function run(_positional, flags) {
239
304
 
240
305
  console.log("");
241
306
  console.log(c.bold("Doctrina initialised."));
242
- 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) {
243
312
  console.log(`Next: execute the bootstrap playbook below — it converts the intake into`);
244
313
  console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
245
314
  console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
@@ -247,7 +316,7 @@ export async function run(_positional, flags) {
247
316
  warnIfThinIntake(intakeBody);
248
317
  printBootstrapPlaybook(projectRoot);
249
318
  } else {
250
- 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);
251
320
  }
252
321
  return 0;
253
322
  }
@@ -361,9 +430,12 @@ Options:
361
430
  --project-description <text> One-sentence description
362
431
  --agent <name> Install adapter for one agent
363
432
  (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
433
+ --intake <file> Full project description; stored verbatim at .doctrina/intake.md,
434
+ used to derive the one-line description when absent, and the
435
+ bootstrap playbook is printed inline — no second command
436
+ --intake-text "<text>" The same, inline, with no file to write first. Mutually
437
+ exclusive with --intake; either one written without a value
438
+ is a usage error, not a project scaffolded without an intake
367
439
  --from <path> Local conventions directory; if it contains AGENTS.md and/or
368
440
  .doctrina/product.md, the content is folded into the new project
369
441
  --date <YYYY-MM-DD> Override the system date