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,7 +2,6 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { spawnSync } from "node:child_process";
6
5
  import { exists, isDir, isFile, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
7
6
  import { flagString, flagBool } from "../lib/args.js";
8
7
  import * as idx from "../lib/index-json.js";
@@ -10,8 +9,14 @@ import { c } from "../lib/colors.js";
10
9
  import { assessBrief } from "../lib/clarity.js";
11
10
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
12
11
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
13
- import { changeNew } from "./change.js";
14
- import { classify } from "./triage.js";
12
+ import { changeNew, reindexChange } from "../lib/change-ops.js";
13
+ import { classify } from "../lib/triage-model.js";
14
+ import { printPlaybookTemplate } from "../lib/playbook.js";
15
+ import { changedFiles } from "../lib/git.js";
16
+ import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
17
+ import { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
18
+ import { isChangeId, refuseChangeRef } from "../lib/project.js";
19
+ export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
15
20
 
16
21
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
17
22
  // prompt ("add login") becomes a fully scaffolded change plus a playbook
@@ -24,7 +29,7 @@ import { classify } from "./triage.js";
24
29
  // Flags this command accepts. Declared HERE, with the command, so
25
30
  // adding a command never requires editing the entrypoint — the gap that
26
31
  // let six flags ship undeclared and silently swallow a positional (C3).
27
- export const flags = { boolean: ["json", "chore", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
32
+ export const flags = { boolean: ["json", "chore", "design", "force", "from-diff", "no-spec", "quiet"], string: ["capability", "id", "resume", "title"] };
28
33
 
29
34
  export async function run(positional, flags) {
30
35
  const prompt = positional.join(" ").trim();
@@ -46,7 +51,7 @@ export async function run(positional, flags) {
46
51
  const chore = flagBool(flags, "chore", false) || flagBool(flags, "no-spec", false);
47
52
  let files = [];
48
53
  if (fromDiff) {
49
- files = changedFiles(projectRoot);
54
+ files = changedFiles(projectRoot).files;
50
55
  if (files.length === 0) {
51
56
  console.error(c.red("error:") + " --from-diff found no working-tree changes to backfill from");
52
57
  console.error(c.gray("hint: ") + "make (or stage) the code changes first, then run `doctrina work --from-diff`");
@@ -80,9 +85,16 @@ export async function run(positional, flags) {
80
85
  // prompt is stopped here, once, with the diagnosis path named. It is a
81
86
  // deterministic term match and it can be wrong, so --force proceeds and
82
87
  // the message says so.
83
- if (!fromDiff && !chore && !flagBool(flags, "force", false)) {
84
- const verdict = classify(prompt);
85
- if (verdict.lane === "runtime" && verdict.confident) {
88
+ // The verdict is computed for EVERY prompt, not only the ones the hold
89
+ // applies to (change 0042). It used to be calculated, printed and thrown
90
+ // away, so an archived proposal never recorded which lane the change was
91
+ // born in — no report could say what kind of work the team does, and the
92
+ // classifier had no set of right and wrong answers to be calibrated
93
+ // against. It is recorded as HISTORY: a gate never reads it to decide.
94
+ const verdict = prompt ? classify(prompt) : null;
95
+ const forced = flagBool(flags, "force", false);
96
+ if (!fromDiff && !chore && !forced) {
97
+ if (verdict && verdict.lane === "runtime" && verdict.confident) {
86
98
  console.error(c.yellow("hold:") + " this reads as a RUNTIME problem, not a change of behaviour");
87
99
  console.error(c.gray(`signals: ${[...new Set(verdict.scores.runtime.hits)].slice(0, 6).join(", ")}`));
88
100
  console.error("");
@@ -109,9 +121,17 @@ export async function run(positional, flags) {
109
121
  // drives the slug and the proposal H1; the whole prompt still lands, intact,
110
122
  // under ## Why. Without it a long prompt used to become a 900-char H1.
111
123
  const title = flagString(flags, "title") ?? effPrompt;
112
- const slug = prompt || flags.has("title") ? slugify(title) : "backfill";
124
+ // The id is what a person types and what sorts a backlog, so it stays short;
125
+ // the title is what a person reads, so it stays whole (change 0070). With
126
+ // `--title` the author has already made that split, so the slug follows the
127
+ // title they chose; without it, the slug is the prompt's first content words
128
+ // and the H1's title half is the prompt, so the two halves stop being the
129
+ // same sentence twice.
130
+ const slug = flags.has("title")
131
+ ? slugify(title)
132
+ : (prompt ? slugFromPrompt(prompt) : "backfill");
113
133
  const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
114
- if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
134
+ if (!isChangeId(id)) {
115
135
  console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
116
136
  return 2;
117
137
  }
@@ -119,6 +139,21 @@ export async function run(positional, flags) {
119
139
  const code = changeNew([id, title], flags);
120
140
  if (code !== 0) return code;
121
141
 
142
+ // The CLI reduces a prompt to an id deterministically; it cannot write a
143
+ // good short name, and ADR 0005 says it must not try. So it says how to get
144
+ // one, once, where the author is already looking.
145
+ if (!flags.has("title") && !flags.has("id") && prompt) {
146
+ console.error(c.gray("note: ") +
147
+ `id derived from the prompt — \`--title "<short name>"\` gives a shorter one ` +
148
+ "and keeps the full prompt under ## Why");
149
+ }
150
+
151
+ // Stamp the lane the classifier read, and — when the operator went a
152
+ // different way — what they did instead. Recording only the agreements
153
+ // would make the calibration set exactly the one that needs no
154
+ // calibrating; the disagreements are the data.
155
+ writeLane(projectRoot, id, verdict, { forced, chore, fromDiff });
156
+
122
157
  // Record the prompt verbatim as the change's Why — the raw intent has
123
158
  // one home, and it is the proposal, not the playbook output. For --from-diff,
124
159
  // also record the changed files so the backfill's input is in the artifact.
@@ -140,34 +175,43 @@ export async function run(positional, flags) {
140
175
  if (updated !== txt) write(proposalPath, updated, { force: true });
141
176
  }
142
177
 
143
- // A pinned capability is an explicit statement of intent, so the delta —
144
- // historically the only 100% hand-authored file in the flow, and the one
145
- // whose missing **Operation:** header exploded days later at analyze
146
- // (operator review 2026-07-19 §3.2) — is scaffolded from the template with
147
- // the Operation prefilled: MODIFIED when the spec exists, ADDED when it
148
- // does not. Never scaffolded from a ranked GUESS (that would put a wrong
149
- // capability's delta in the change); only from --capability.
150
- if (pinned && !chore) {
151
- const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", pinned, "delta.md");
152
- if (!exists(deltaPath)) {
153
- const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
154
- const op = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md")) ? "MODIFIED" : "ADDED";
155
- const body = substitute(tpl, { CAPABILITY: pinned })
156
- .replace(/^\*\*Operation:\*\*.*$/m, `**Operation:** ${op}`);
157
- mkdirp(path.dirname(deltaPath));
158
- write(deltaPath, body);
159
- console.log(c.green("created") + ` ${relPath(projectRoot, deltaPath)}` + c.gray(` (Operation: ${op} prefilled)`));
160
- }
161
- }
162
-
163
178
  // Capability hint: term overlap for a prompt, changed-file overlap for a diff
164
179
  // (review F10 — rank by what the working tree touched, not just prompt words).
165
- const allChanged = fromDiff ? files : changedFiles(projectRoot);
180
+ const allChanged = fromDiff ? files : changedFiles(projectRoot).files;
166
181
  const diffMatches = pinned ? [] : rankCapabilitiesByDiff(projectRoot, allChanged);
167
- const matches = pinned ? [] : (fromDiff ? diffMatches : rankCapabilities(projectRoot, effPrompt));
182
+ const promptMatches = pinned || fromDiff ? [] : rankCapabilities(projectRoot, effPrompt);
183
+ const matches = pinned ? [] : (fromDiff ? diffMatches : promptMatches);
168
184
  const capability = pinned ?? matches[0]?.id ?? null;
169
185
  const clarity = assessBrief(effPrompt, { kind: "prompt" });
170
186
 
187
+ // The delta is scaffolded whenever the CLI can NAME the capability — from
188
+ // --capability, or from a prompt ranking whose winner leads the runner-up
189
+ // by a real margin (change 0044). It was historically the only 100%
190
+ // hand-authored file in the flow, and the one whose missing **Operation:**
191
+ // header exploded days later at analyze (operator review 2026-07-19 §3.2);
192
+ // leaving the default path hand-authoring it left the failure in place for
193
+ // every run that did not pin. A wrong, obvious file costs less than an
194
+ // absent, silent one — so the guess is MARKED as one, and below the margin
195
+ // nothing is written at all: a weak guess in the wrong folder is worse
196
+ // than no file.
197
+ //
198
+ // Only the PROMPT ranking scaffolds. The diff ranker scores on its own
199
+ // scale (path/citation points, no density term), so CONFIDENT_MARGIN does
200
+ // not transfer to it, and a backfill normally touches several capabilities
201
+ // at once — one scaffolded winner would be the wrong shape there.
202
+ const winner = promptMatches[0];
203
+ const guess = winner && winner.margin >= CONFIDENT_MARGIN ? winner : null;
204
+ const scaffolded = chore || !capability || (!pinned && !guess)
205
+ ? null
206
+ : scaffoldDelta(projectRoot, { id, capability, guess, runnerUp: promptMatches[1] ?? null });
207
+
208
+ // The proposal is written in two passes — `changeNew` scaffolds it, the
209
+ // lines above stamp the lane and the pinned specs — so its index entry is
210
+ // re-derived HERE, from the finished file. Indexing at the end of the first
211
+ // pass is what made every `doctrina work` leave the tree failing `validate`
212
+ // on the very next command (change 0076).
213
+ reindexChange(projectRoot, id);
214
+
171
215
  // --quiet: registering backlog, not starting now (operator review §3.7 — 19
172
216
  // works printed 19 identical 50-line playbooks). One line per change; the
173
217
  // playbook is reprintable on demand with --resume.
@@ -183,27 +227,91 @@ export async function run(positional, flags) {
183
227
  printChorePlaybook(projectRoot, { id, prompt: effPrompt });
184
228
  } else {
185
229
  printPlaybook(projectRoot, {
186
- id, prompt: effPrompt, pinned, matches, capability, clarity,
230
+ id, prompt: effPrompt, pinned, matches, capability, clarity, scaffolded,
187
231
  fromDiff, diffMatches: fromDiff ? [] : diffMatches,
188
232
  });
189
233
  }
190
234
  return 0;
191
235
  }
192
236
 
193
- // Changed files in the working tree (tracked + untracked), for the diff-based
194
- // capability hint and `--from-diff` backfill. Empty outside a git repo or with
195
- // no changes. Read-only: never invokes a mutating git command.
196
- function changedFiles(projectRoot) {
197
- const run = (args) => {
198
- const r = spawnSync("git", args, { cwd: projectRoot, encoding: "utf8" });
199
- if (r.error || r.status !== 0) return [];
200
- return r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
201
- };
202
- const set = new Set([
203
- ...run(["diff", "--name-only", "HEAD"]), // tracked, staged + unstaged
204
- ...run(["ls-files", "--others", "--exclude-standard"]), // untracked
205
- ]);
206
- return [...set];
237
+ // Write the change's spec delta from the template with **Operation:**
238
+ // prefilled — MODIFIED when the target spec exists, ADDED when it does not.
239
+ //
240
+ // A GUESS (the ranked winner rather than a pinned capability) can only ever
241
+ // be MODIFIED: the ranker scores existing specs, so it cannot name one that
242
+ // is not there. It carries a comment saying it is a guess and how to correct
243
+ // it, because the whole trade this makes is that a wrong file the agent can
244
+ // see beats a missing file nothing reports.
245
+ //
246
+ // Returns what was written, or null when the file already exists.
247
+ function scaffoldDelta(projectRoot, { id, capability, guess = null, runnerUp = null }) {
248
+ const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
249
+ if (exists(deltaPath)) return null;
250
+ const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
251
+ const op = isFile(path.join(projectRoot, ".doctrina", "specs", capability, "spec.md")) ? "MODIFIED" : "ADDED";
252
+ let body = substitute(tpl, { CAPABILITY: capability })
253
+ .replace(/^\*\*Operation:\*\*.*$/m, `**Operation:** ${op}`);
254
+ if (guess) body = body.replace(/(\*\*Target spec on apply:\*\*.*\r?\n)/, `$1\n${guessNote(id, guess, runnerUp)}\n`);
255
+ mkdirp(path.dirname(deltaPath));
256
+ write(deltaPath, body);
257
+ console.log(c.green("created") + ` ${relPath(projectRoot, deltaPath)}` +
258
+ c.gray(guess
259
+ ? ` (Operation: ${op} prefilled — ranked guess, correct it if wrong)`
260
+ : ` (Operation: ${op} prefilled)`));
261
+ return { capability, op, guess: Boolean(guess) };
262
+ }
263
+
264
+ // The words that make a guessed delta self-describing on disk: --resume
265
+ // prints nothing it did not write itself, so the file is the only place a
266
+ // later session can learn the capability was ranked rather than chosen.
267
+ const GUESS_MARK = "RANKED GUESS";
268
+
269
+ // The mark a guessed delta carries. It names the evidence (the score and the
270
+ // runner-up it beat) and the exact correction, so the agent can overrule the
271
+ // ranking in one step instead of inheriting it silently.
272
+ function guessNote(id, guess, runnerUp) {
273
+ const beat = runnerUp
274
+ ? `beating \`${runnerUp.id}\` (${runnerUp.score})`
275
+ : "the only spec the prompt matched";
276
+ return [
277
+ `<!-- ${GUESS_MARK} — no --capability was given. \`doctrina work\` picked`,
278
+ ` \`${guess.id}\` by deterministic term overlap (score ${guess.score}, ${beat}).`,
279
+ " It is a hint, never a decision (ADR 0005). If it is the wrong",
280
+ " capability, delete this folder and write the right one instead:",
281
+ ` rm -r .doctrina/changes/${id}/specs/${guess.id}`,
282
+ ` .doctrina/changes/${id}/specs/<capability>/delta.md`,
283
+ " Delete this comment once the capability is confirmed. -->",
284
+ ].join("\n");
285
+ }
286
+
287
+ // Render the Lane header: the classifier's verdict, how sure it was, the
288
+ // signals that decided it, and any operator override. One line, so the
289
+ // proposal header stays a header.
290
+ function laneRecord(verdict, { forced, chore, fromDiff }) {
291
+ const override = chore ? "chore" : fromDiff ? "backfill" : null;
292
+ if (!verdict) return override ? `${override} (no prompt to classify)` : "";
293
+ const hits = [...new Set(verdict.scores[verdict.lane]?.hits ?? [])].slice(0, 6);
294
+ const detail = hits.length > 0 ? `; signals: ${hits.join(", ")}` : "";
295
+ let line = `${verdict.lane} (${verdict.confident ? "confident" : "uncertain"}${detail})`;
296
+ // The operator disagreed with the classifier, or overrode its hold. That is
297
+ // the row calibration actually needs: recording only the agreements would
298
+ // make the set exactly the one that needs no calibrating.
299
+ if (override) line += ` — opened as ${override}`;
300
+ else if (forced && verdict.lane === "runtime") line += " — opened anyway (--force)";
301
+ return line;
302
+ }
303
+
304
+ function writeLane(projectRoot, id, verdict, opts) {
305
+ const line = laneRecord(verdict, opts);
306
+ if (!line) return;
307
+ const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
308
+ if (!isFile(proposalPath)) return;
309
+ const text = read(proposalPath);
310
+ // CRLF-safe: the scaffolded proposals are CRLF, and a `.*$` pattern
311
+ // silently matches nothing against them — the `patch-doctrina-files-as-crlf`
312
+ // skill exists for exactly this mistake.
313
+ const updated = text.replace(/^(-[ \t]+\*\*Lane:\*\*)[^\r\n]*/m, `$1 ${line}`);
314
+ if (updated !== text) write(proposalPath, updated, { force: true });
207
315
  }
208
316
 
209
317
  // Next sequential NNNN across open changes and the archive, so work-driven
@@ -279,6 +387,8 @@ function resumeChange(projectRoot, resumeId) {
279
387
  if (open.length) console.error(c.gray("open: ") + open.map((ch) => ch.id).join(", "));
280
388
  return 2;
281
389
  }
390
+ const refused = refuseChangeRef(resumeId);
391
+ if (refused !== null) return refused;
282
392
  const entry = open.find((ch) => ch.id === resumeId);
283
393
  if (!entry && !isDir(path.join(projectRoot, ".doctrina", "changes", resumeId))) {
284
394
  console.error(c.red("error:") + ` no open change "${resumeId}"`);
@@ -289,9 +399,17 @@ function resumeChange(projectRoot, resumeId) {
289
399
  const matches = rankCapabilities(projectRoot, prompt);
290
400
  const capability = matches[0]?.id ?? null;
291
401
  const clarity = assessBrief(prompt, { kind: "prompt" });
402
+ // --resume creates nothing, but it must not tell the agent to write a delta
403
+ // the first run already scaffolded (pinned or guessed): it reads the folder.
404
+ const deltaPath = capability
405
+ ? path.join(projectRoot, ".doctrina", "changes", resumeId, "specs", capability, "delta.md")
406
+ : null;
407
+ const existing = deltaPath && isFile(deltaPath)
408
+ ? { capability, op: null, guess: read(deltaPath).includes(GUESS_MARK) }
409
+ : null;
292
410
  console.log(c.bold(`Resuming change ${resumeId}`) + c.gray(" — agent-executed (ADR 0005)."));
293
411
  console.log("");
294
- printPlaybook(projectRoot, { id: resumeId, prompt, pinned: null, matches, capability, clarity });
412
+ printPlaybook(projectRoot, { id: resumeId, prompt, pinned: null, matches, capability, clarity, scaffolded: existing });
295
413
  return 0;
296
414
  }
297
415
 
@@ -314,229 +432,80 @@ export function slugify(text) {
314
432
  return slug.length > 0 ? slug : "task";
315
433
  }
316
434
 
317
- // Deterministic term overlap: fold prompt and spec text to ASCII lowercase,
318
- // drop short/stop words, score name hits heavily and body hits lightly.
319
- // This is a hint for the agent, never a decision (ADR 0005).
320
- const STOPWORDS = new Set([
321
- // en
322
- "the", "and", "for", "with", "from", "that", "this", "into", "when",
323
- "then", "shall", "should", "must", "can", "will", "make", "add", "new",
324
- "use", "create", "implement", "feature", "system", "user", "users",
325
- // pt (ASCII-folded)
326
- "uma", "umas", "uns", "dos", "das", "nos", "nas", "por", "para", "com",
327
- "que", "sem", "aos", "faca", "fazer", "criar", "crie", "novo", "nova",
328
- "adicionar", "adicione", "implementar", "implemente", "funcionalidade",
329
- "sistema", "usuario", "usuarios",
330
- ]);
331
-
332
- function fold(text) {
333
- return text.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase();
334
- }
335
-
336
- function promptTerms(prompt) {
337
- return [...new Set(
338
- fold(prompt)
339
- .split(/[^a-z0-9]+/)
340
- .filter((t) => t.length >= 3 && !STOPWORDS.has(t)),
341
- )];
342
- }
343
-
344
- export function rankCapabilities(projectRoot, prompt) {
345
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
346
- const terms = promptTerms(prompt);
347
- if (terms.length === 0 || !isDir(specsDir)) return [];
348
-
349
- const ranked = [];
350
- for (const cap of readdirSync(specsDir).sort()) {
351
- const specPath = path.join(specsDir, cap, "spec.md");
352
- if (!isFile(specPath)) continue;
353
- const body = fold(read(specPath));
354
- const nameTokens = cap.split("-");
355
- let score = 0;
356
- for (const term of terms) {
357
- if (nameTokens.includes(term)) score += 5;
358
- const hits = body.split(term).length - 1;
359
- score += Math.min(hits, 5);
360
- }
361
- if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
362
- }
363
- return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
364
- }
365
-
366
- // Rank capabilities by the working tree, not the prompt (review F10): a changed
367
- // file scores its capability when the file sits under a path segment named for
368
- // it, or when the spec cites the file as evidence. A deterministic overlap
369
- // hint for the agent — never a decision (ADR 0005).
370
- export function rankCapabilitiesByDiff(projectRoot, files, { limit = 3 } = {}) {
371
- const specsDir = path.join(projectRoot, ".doctrina", "specs");
372
- if (!files || files.length === 0 || !isDir(specsDir)) return [];
373
- const norm = files.map((f) => f.replace(/\\/g, "/"));
374
-
375
- const ranked = [];
376
- for (const cap of readdirSync(specsDir).sort()) {
377
- const specPath = path.join(specsDir, cap, "spec.md");
378
- if (!isFile(specPath)) continue;
379
- const specText = read(specPath);
380
- let score = 0;
381
- for (const f of norm) {
382
- if (f.split("/").includes(cap)) score += 3; // src/<cap>/... etc.
383
- if (specText.includes(f)) score += 5; // file cited in the spec
384
- else if (specText.includes(path.basename(f))) score += 2; // filename cited
385
- }
386
- if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
387
- }
388
- // `limit` keeps the work-playbook hint short (top 3); review passes
389
- // Infinity — truncating there is how it missed 5 of 8 touched capabilities
390
- // in the 0.11.0 field session.
391
- return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, limit);
392
- }
393
-
394
435
  // A chore is a spec-less change (review F9): the playbook drops the spec-delta
395
436
  // steps and goes straight to implement → verify → archive → validate, so the
396
437
  // agent is not prompted to invent a delta for infra/docs/build work.
438
+ // Both playbooks are rendered from templates (lib/playbook.js), resolved
439
+ // project-over-bundled like every other scaffold. What stays here is the only
440
+ // part that is not prose: computing the variable blocks. Each is PRE-RENDERED
441
+ // into a token, so `substitute` stays a plain string map and the template
442
+ // stays a document rather than a language with conditionals in it.
397
443
  function printChorePlaybook(projectRoot, { id, prompt }) {
398
- console.log(c.bold(`Chore playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005); no spec deltas."));
399
- console.log("");
400
- console.log(`Prompt: "${prompt}"`);
401
- console.log("");
402
- console.log("Execute in order, in a single linear pass:");
403
- console.log("");
404
- console.log(`1. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
405
- console.log(" with small, checkable steps; implement them, checking each box.");
406
- console.log(c.gray(" (Do it before implementing — analyze/close refuse leftover scaffold"));
407
- console.log(c.gray(" placeholders, and `change tick` will not tick an empty box.)"));
408
- console.log("2. Prove it (archive refuses unchecked boxes):");
409
- console.log(` ${c.cyan("doctrina verify")} — the project's typecheck/test/build gate`);
410
- console.log(` ${c.cyan("doctrina verify --clean")} — clean-checkout reproducibility lint`);
411
- console.log(" Then check the proposal's ## Verification boxes and every task.");
412
- console.log("3. This is a chore: no spec changes. If you find yourself needing a");
413
- console.log(` spec delta, it is not a chore — reopen with ${c.cyan("doctrina work \"<prompt>\"")}.`);
414
- console.log(`4. ${c.cyan(`doctrina change apply ${id}`)} (zero deltas → flips to applied).`);
415
- console.log(`5. ${c.cyan(`doctrina change archive ${id}`)}, then ${c.cyan("doctrina validate")}.`);
416
- console.log(c.gray(" If this fix taught a lesson worth not relearning, capture it: ") +
417
- c.cyan("doctrina skill new <slug>") + c.gray("."));
444
+ printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
418
445
  }
419
446
 
420
- function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, fromDiff = false, diffMatches = [] }) {
421
- const capToken = capability ?? "<capability>";
422
- const title = fromDiff ? "Backfill playbook" : "Work playbook";
423
- console.log(c.bold(`${title} — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
424
- console.log("");
425
- console.log(`Prompt: "${prompt}"`);
426
- if (fromDiff) {
427
- console.log(c.gray("Code-first: write the spec that describes what the working tree already does."));
428
- }
429
-
430
- // Clarification gate (review Topic A): a thin prompt is the moment to ask
431
- // the user, not to invent a spec. Advisory — the change is still scaffolded
432
- // (it is a draft), but the agent is told to resolve the gaps first.
433
- if (clarity?.thin && !fromDiff) {
434
- console.log("");
435
- console.log(c.yellow("⚠ thin prompt — clarify with the user before writing spec deltas:"));
436
- for (const r of clarity.reasons) console.log(` - ${r}`);
437
- }
447
+ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, scaffolded = null, fromDiff = false, diffMatches = [] }) {
448
+ printPlaybookTemplate(projectRoot, "work", {
449
+ TITLE: fromDiff ? "Backfill playbook" : "Work playbook",
450
+ CHANGE_ID: id,
451
+ PROMPT: prompt,
452
+ CAPABILITY: capability ?? "<capability>",
453
+ FROM_DIFF_NOTE: fromDiff
454
+ ? c.gray("Code-first: write the spec that describes what the working tree already does.")
455
+ : "",
456
+ // Clarification gate (review Topic A): a thin prompt is the moment to ask
457
+ // the user, not to invent a spec. Advisory — the change is still
458
+ // scaffolded (it is a draft), but the agent is told to resolve the gaps
459
+ // first. The leading blank line belongs to the block, so an absent
460
+ // warning leaves no gap behind.
461
+ THIN_WARNING: clarity?.thin && !fromDiff
462
+ ? ["", c.yellow("⚠ thin prompt — clarify with the user before writing spec deltas:"),
463
+ ...clarity.reasons.map((r) => ` - ${r}`)].join("\n")
464
+ : "",
465
+ CAPABILITY_BLOCK: capabilityBlock(projectRoot, { pinned, matches, fromDiff }),
466
+ // Extra signal (review F10): even for a prompt-driven change, show which
467
+ // capabilities the working tree touched — often the truer hint.
468
+ DIFF_MATCHES: !fromDiff && diffMatches.length > 0
469
+ ? c.gray("Also touched by your working tree: ") + diffMatches.map((m) => c.cyan(m.id)).join(", ")
470
+ : "",
471
+ STEP3_INTRO: scaffolded
472
+ ? "3. A delta is already scaffolded (Operation prefilled) at\n" +
473
+ ` .doctrina/changes/${id}/specs/${scaffolded.capability}/delta.md — fill its body.\n` +
474
+ (scaffolded.guess
475
+ // Coloured per line: a span left open across a newline survives a
476
+ // terminal but not every pager the output gets piped into.
477
+ ? c.yellow(" It was RANKED, not pinned — confirm the capability first; the file") + "\n" +
478
+ c.yellow(" says how to correct it if the ranking got it wrong.") + "\n"
479
+ : "") +
480
+ " Add one more delta per additional affected capability:"
481
+ : "3. Write one delta per affected capability at\n" +
482
+ ` .doctrina/changes/${id}/specs/<capability>/delta.md:`,
483
+ FROM_DIFF_DELTA_NOTE: fromDiff
484
+ ? [c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"),
485
+ c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"),
486
+ c.gray(" diff is tested). The changed files are listed in the proposal's ## Why.")].join("\n")
487
+ : "",
488
+ });
489
+ }
438
490
 
491
+ // Which capability the change is about, as far as the CLI can tell: pinned by
492
+ // the operator, ranked by term overlap, or nothing — a hint in every case,
493
+ // never a decision (ADR 0005).
494
+ function capabilityBlock(projectRoot, { pinned, matches, fromDiff }) {
439
495
  if (pinned) {
440
496
  const hasSpec = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md"));
441
- console.log(`Capability (pinned): ${c.cyan(pinned)}` +
442
- (hasSpec ? "" : c.yellow(" — no spec yet; create it in step 2")));
443
- } else if (matches.length > 0) {
444
- const how = fromDiff ? "touched by your working tree" : "deterministic term match";
445
- console.log(`Likely capabilities (${how} — a hint, not a decision):`);
446
- for (const m of matches) {
447
- console.log(` ${c.cyan(m.id.padEnd(20))} score ${String(m.score).padStart(3)} ${c.gray(m.path)}`);
448
- }
449
- } else {
450
- console.log(c.gray(fromDiff
451
- ? "No existing spec matches the changed files — likely a new capability."
452
- : "No existing spec matches the prompt — likely a new capability."));
453
- }
454
-
455
- // Extra signal (review F10): even for a prompt-driven change, show which
456
- // capabilities the working tree touched — often the truer hint.
457
- if (!fromDiff && diffMatches.length > 0) {
458
- console.log(c.gray("Also touched by your working tree: ") +
459
- diffMatches.map((m) => c.cyan(m.id)).join(", "));
460
- }
461
-
462
- console.log("");
463
- console.log("Execute in order, in a single linear pass:");
464
- console.log("");
465
- console.log("1. Read the context pack and confirm (or correct) the capability:");
466
- console.log(` ${c.cyan(`doctrina context ${capToken} --concat`)}`);
467
- console.log("");
468
- console.log("2. If the capability has no spec yet:");
469
- console.log(` ${c.cyan("doctrina spec new <capability>")}`);
470
- console.log(c.gray(" Trace it to product intent: tag the product.md success-criteria bullet"));
471
- console.log(c.gray(" it serves with an anchor (\"- [SC1] ...\") and set the spec's"));
472
- console.log(c.gray(" \"**Realizes:** SC1\" header (or \"n/a — <why>\"). `doctrina validate` warns"));
473
- console.log(c.gray(" on an active spec with no Realizes; `doctrina trace` reports the link."));
474
- console.log("");
475
- if (pinned) {
476
- console.log(`3. A delta is already scaffolded (Operation prefilled) at`);
477
- console.log(` .doctrina/changes/${id}/specs/${pinned}/delta.md — fill its body.`);
478
- console.log(" Add one more delta per additional affected capability:");
479
- } else {
480
- console.log("3. Write one delta per affected capability at");
481
- console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
497
+ return `Capability (pinned): ${c.cyan(pinned)}` +
498
+ (hasSpec ? "" : c.yellow(" — no spec yet; create it in step 2"));
482
499
  }
483
- console.log(c.gray(" # Spec Delta — capability: <capability>"));
484
- console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
485
- console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
486
- console.log(c.gray(" ---"));
487
- console.log(c.gray(" <EARS body. Keep the two axes honest (Status vs Implementation),"));
488
- console.log(c.gray(" keep aspiration under ## Maturity → Future, and cite the proof per"));
489
- console.log(c.gray(" criterion: \"1. [unverified] <signal> — verified by `path/to/test`\">"));
490
- console.log(c.gray(" New capability? `spec new` then an ADDED delta with the FULL body — apply"));
491
- console.log(c.gray(" replaces the untouched scaffold. Bookkeeping edits? MODIFIED with an ops"));
492
- console.log(c.gray(" block, applied mechanically (all ops or none):"));
493
- console.log(c.gray(" ```ops"));
494
- console.log(c.gray(" set-header Implementation: verified — `src/x.js`"));
495
- console.log(c.gray(" bump-version minor"));
496
- console.log(c.gray(" set-criterion 1: verified"));
497
- console.log(c.gray(" append-criterion [unverified] new signal — verified by `test/y.test.js`"));
498
- console.log(c.gray(" append-requirement event: When <trigger>, the system shall <action>."));
499
- console.log(c.gray(" replace-requirement ubiquitous 2: The system shall <action>."));
500
- console.log(c.gray(" ```"));
501
- console.log(c.gray(" append-* ops number/position at APPLY time, so concurrent changes"));
502
- console.log(c.gray(" appending to the same spec never collide. Only free-prose rewrites"));
503
- console.log(c.gray(" (Purpose, Maturity, ...) stay a by-hand merge."));
504
- if (fromDiff) {
505
- console.log(c.gray(" --from-diff: the code already exists — describe its CURRENT behaviour, and"));
506
- console.log(c.gray(" mark each criterion [unverified] until a test proves it (don't assume the"));
507
- console.log(c.gray(" diff is tested). The changed files are listed in the proposal's ## Why."));
500
+ if (matches.length > 0) {
501
+ const how = fromDiff ? "touched by your working tree" : "deterministic term match";
502
+ return [`Likely capabilities (${how} — a hint, not a decision):`,
503
+ ...matches.map((m) => ` ${c.cyan(m.id.padEnd(20))} score ${String(m.score).padStart(3)} ${c.gray(m.path)}`),
504
+ ].join("\n");
508
505
  }
509
- console.log("");
510
- console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
511
- console.log(" with small, checkable implementation tasks (a few hours each, max),");
512
- console.log(" and record the change's What/Scope in its proposal.md. Do this BEFORE");
513
- console.log(" implementing — analyze and close refuse a change whose scaffold");
514
- console.log(" placeholders were never replaced, and `change tick` will not tick them.");
515
- console.log("");
516
- console.log("5. Implement task by task, checking each box as it lands. Advance the");
517
- console.log(" spec's Implementation: planned → partial → implemented as code lands.");
518
- console.log(" If the prompt is genuinely ambiguous, ask the user before assuming.");
519
- console.log("");
520
- console.log(`6. ADR checkpoint — does this change decide something structural`);
521
- console.log(" (an architecture, a boundary, a trade-off a future session must");
522
- console.log(` not relitigate)? If yes: ${c.cyan("doctrina decision new \"<title>\"")} now,`);
523
- console.log(" before closing — the definition of done requires it recorded.");
524
- console.log(c.gray(" (close re-checks this: it warns when the change touches capabilities"));
525
- console.log(c.gray(" cited by an accepted ADR — amend via decision supersede, not silence.)"));
526
- console.log(" Touching an integration surface (ports, env vars, public endpoints)?");
527
- console.log(` Own it in a contract: ${c.cyan("doctrina contract new <id>")} · ${c.cyan("doctrina contract check")}.`);
528
- console.log("");
529
- console.log(`7. Close in one attested pass (preferred — runs every gate and stops`);
530
- console.log(" at the first failure with the exact rerun command):");
531
- console.log(` ${c.cyan(`doctrina close ${id}`)}`);
532
- console.log(c.gray(" (equivalent, step by step: ") +
533
- c.gray(`analyze → change apply → verify → coverage → trace → change archive → validate)`));
534
- console.log(" Before it: check the proposal's ## Verification boxes and every task,");
535
- console.log(" closing steps included, and bump Implementation to verified.");
536
- console.log("");
537
- console.log(`8. ${c.cyan("doctrina next")} for the follow-up.`);
538
- console.log(c.gray(" If this change taught a reusable lesson (a fix you'd hate to relearn,"));
539
- console.log(c.gray(" a recurring convention), capture it: ") + c.cyan("doctrina skill new <slug>") + c.gray("."));
506
+ return c.gray(fromDiff
507
+ ? "No existing spec matches the changed files — likely a new capability."
508
+ : "No existing spec matches the prompt — likely a new capability.");
540
509
  }
541
510
 
542
511
  export const help = `
@@ -548,9 +517,16 @@ change id (NNNN-<slug>) from the prompt, opens the change folder,
548
517
  records the prompt under the proposal's "## Why", ranks existing specs
549
518
  by term overlap as a capability hint, flags a thin/under-specified prompt
550
519
  so you clarify before writing deltas (advisory), and prints the steps:
551
- context → spec delta → tasks → implement → analyze → apply → verify
552
- (verify + coverage) → archive → validate. No natural-language
553
- interpretation happens in the CLI.
520
+ context, spec delta, tasks, implement, then \`doctrina change check <id>\`
521
+ to preview the close and \`doctrina close <id>\` to finish, which runs the
522
+ whole closing sequence in one pass. No natural-language interpretation
523
+ happens in the CLI.
524
+
525
+ The change's spec delta is scaffolded with **Operation:** prefilled
526
+ whenever the CLI can name the capability: from --capability, or from the
527
+ ranking when the winner leads the runner-up by a whole matched term. A
528
+ ranked delta is always MODIFIED and says in the file that it is a guess,
529
+ with the command that corrects it. Below that margin nothing is written.
554
530
 
555
531
  When the prompt is a bare "continue"/"prossiga"/"next" and a change is
556
532
  already open, work suggests resuming it instead of opening a junk change
@@ -559,10 +535,8 @@ named after that word (review G1). Use --resume to do so directly.
559
535
  Options:
560
536
  --title "<short>" Short display title: drives the slug and the proposal
561
537
  H1; the full prompt still lands under ## Why
562
- --capability <cap> Pin the capability instead of ranking matches. Also
563
- scaffolds specs/<cap>/delta.md in the change with the
564
- Operation header prefilled (MODIFIED when the spec
565
- exists, ADDED when it does not)
538
+ --capability <cap> Pin the capability instead of ranking matches, and
539
+ drop the guess comment from the scaffolded delta
566
540
  --quiet Register the change and print one line — no playbook
567
541
  (backlog entry; reprint later with --resume <id>)
568
542
  --id <id> Override the derived change id
@@ -571,6 +545,8 @@ Options:
571
545
  prompt needed) and print a code-first playbook (F8)
572
546
  --chore, --no-spec Open a spec-less chore change (infra/docs/build) with a
573
547
  playbook that skips the spec-delta steps (F9)
548
+ --design Also scaffold design.md, for a change with non-trivial
549
+ choices (opt-in, as with \`change new --design\`)
574
550
  --force Open a new change even when one is open, overwrite an
575
551
  existing change folder, and proceed past the lane hold
576
552
  below