doctrina-cli 0.21.0 → 0.22.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 (75) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/change.js +111 -14
  4. package/src/commands/clarify.js +2 -2
  5. package/src/commands/close.js +1 -1
  6. package/src/commands/coverage.js +69 -6
  7. package/src/commands/doctor.js +62 -4
  8. package/src/commands/hooks.js +56 -11
  9. package/src/commands/index-rebuild.js +47 -1
  10. package/src/commands/init.js +51 -7
  11. package/src/commands/metrics.js +1 -1
  12. package/src/commands/onboard.js +113 -0
  13. package/src/commands/review.js +1 -1
  14. package/src/commands/search.js +2 -2
  15. package/src/commands/templates.js +2 -1
  16. package/src/commands/triage.js +16 -1
  17. package/src/commands/ui.js +512 -0
  18. package/src/commands/upgrade.js +46 -6
  19. package/src/commands/verify.js +3 -3
  20. package/src/commands/why.js +1 -1
  21. package/src/commands/work.js +53 -40
  22. package/src/commands/workspace.js +16 -3
  23. package/src/index.js +9 -0
  24. package/src/lib/actions.js +19 -0
  25. package/src/lib/adapters.js +23 -3
  26. package/src/lib/agent-bridge.js +400 -0
  27. package/src/lib/applied-deltas.js +82 -0
  28. package/src/lib/args.js +1 -1
  29. package/src/lib/change-numbers.js +115 -0
  30. package/src/lib/change-ops.js +2 -2
  31. package/src/lib/clarify-scan.js +3 -3
  32. package/src/lib/code-inventory.js +22 -5
  33. package/src/lib/commands.js +50 -4
  34. package/src/lib/config.js +27 -8
  35. package/src/lib/conflict-markers.js +61 -0
  36. package/src/lib/console-model.js +668 -0
  37. package/src/lib/console-prefs.js +67 -0
  38. package/src/lib/console-room.js +201 -0
  39. package/src/lib/console-state.js +150 -0
  40. package/src/lib/console-view.js +557 -0
  41. package/src/lib/coverage-model.js +29 -6
  42. package/src/lib/delivery.js +2 -2
  43. package/src/lib/docs-impact.js +1 -1
  44. package/src/lib/fs-ops.js +80 -12
  45. package/src/lib/gates.js +7 -1
  46. package/src/lib/git.js +18 -0
  47. package/src/lib/index-json.js +10 -1
  48. package/src/lib/index-merge.js +90 -0
  49. package/src/lib/install.js +80 -0
  50. package/src/lib/intake-model.js +4 -1
  51. package/src/lib/ledger.js +2 -3
  52. package/src/lib/lock.js +58 -0
  53. package/src/lib/merge-setup.js +55 -0
  54. package/src/lib/metrics-model.js +2 -2
  55. package/src/lib/onboard.js +197 -0
  56. package/src/lib/preset.js +177 -0
  57. package/src/lib/prompt.js +1 -1
  58. package/src/lib/properties.js +149 -0
  59. package/src/lib/runtime.js +19 -7
  60. package/src/lib/scan.js +13 -8
  61. package/src/lib/spec-ops.js +2 -2
  62. package/src/lib/task-card.js +1 -1
  63. package/src/lib/templates-model.js +3 -2
  64. package/src/lib/triage-model.js +75 -0
  65. package/src/lib/validation-model.js +23 -5
  66. package/src/lib/views.js +2 -1
  67. package/src/lib/workspace-agents.js +2 -2
  68. package/src/lib/workspace-tools.js +41 -1
  69. package/src/lib/workspace.js +1 -1
  70. package/templates/AGENTS.md.template +8 -4
  71. package/templates/README.md +2 -1
  72. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  73. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  74. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  75. package/templates/adapters/kiro/README.md +22 -0
@@ -10,8 +10,10 @@ import { c } from "../lib/colors.js";
10
10
  import { suggest } from "../lib/suggest.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
12
  import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
13
+ import { mergeIndex } from "../lib/index-merge.js";
14
+ import { readFileSync, writeFileSync } from "node:fs";
13
15
 
14
- const SUBCOMMANDS = ["rebuild"];
16
+ const SUBCOMMANDS = ["rebuild", "merge"];
15
17
 
16
18
  // Flags this command accepts. Declared HERE, with the command, so
17
19
  // adding a command never requires editing the entrypoint — the gap that
@@ -20,6 +22,7 @@ export const flags = { boolean: ["json", "check", "staged"], string: [] };
20
22
 
21
23
  export async function run(positional, flags) {
22
24
  const sub = positional[0];
25
+ if (sub === "merge") return merge(positional.slice(1));
23
26
  if (sub !== "rebuild") {
24
27
  console.error(c.red("error:") + ` unknown index subcommand "${sub ?? ""}"`);
25
28
  const guess = suggest(sub, SUBCOMMANDS);
@@ -98,6 +101,43 @@ function rebuild(flags) {
98
101
  return 0;
99
102
  }
100
103
 
104
+ // The merge driver git calls for `.doctrina/index.json` (change 0242):
105
+ // `doctrina index merge %O %A %B`. It writes the merged index over <ours>
106
+ // and exits 0, which is how a driver tells git there is nothing to resolve.
107
+ // A side that is not JSON is the one case it leaves to git, with exit 1.
108
+ function merge(args) {
109
+ const [basePath, oursPath, theirsPath] = args;
110
+ if (!basePath || !oursPath || !theirsPath) {
111
+ console.error(c.red("error:") + " index merge takes three files: <base> <ours> <theirs>");
112
+ console.error(c.gray("hint: ") + "git calls it as a merge driver — `doctrina hooks install` sets it up");
113
+ return 2;
114
+ }
115
+ const load = (/** @type {string} */ file, /** @type {string} */ side) => {
116
+ let text = "";
117
+ try {
118
+ text = readFileSync(file, "utf8");
119
+ } catch {
120
+ if (side === "base") return {};
121
+ throw new Error(`cannot read the ${side} index at ${file}`);
122
+ }
123
+ if (text.trim() === "") return {};
124
+ try {
125
+ return JSON.parse(text);
126
+ } catch (err) {
127
+ throw new Error(`the ${side} index is not valid JSON (${err.message})`);
128
+ }
129
+ };
130
+ let merged;
131
+ try {
132
+ merged = mergeIndex(load(basePath, "base"), load(oursPath, "ours"), load(theirsPath, "theirs"));
133
+ } catch (err) {
134
+ console.error(c.red("error:") + ` ${err.message} — left for a manual merge; \`doctrina index rebuild\` derives it from the tree afterwards`);
135
+ return 1;
136
+ }
137
+ writeFileSync(oursPath, JSON.stringify(merged, null, 2) + "\n");
138
+ return 0;
139
+ }
140
+
101
141
  // The drift check, asked of what git will record. `index.json` is derived
102
142
  // from the WHOLE tree, so it cannot describe a subset of it: stage part of
103
143
  // `.doctrina/` and the index riding along names artifacts the commit does
@@ -135,6 +175,12 @@ export const help = `
135
175
  Usage: doctrina index <subcommand>
136
176
 
137
177
  Subcommands:
178
+ merge <base> <ours> <theirs>
179
+ The merge driver git runs for index.json once
180
+ \`doctrina hooks install\` has set it up: two branches
181
+ that each closed a change merge as lists of artifacts,
182
+ so the file never stops a merge with conflict markers.
183
+ Writes the result over <ours>.
138
184
  rebuild Regenerate .doctrina/index.json from the artifacts on
139
185
  disk (spec headers, ADR headers, change proposals,
140
186
  archive folders, skill frontmatter). The files are the
@@ -5,10 +5,12 @@ import process from "node:process";
5
5
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
6
6
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
7
7
  import {
8
- surfaceBlock, findSurfaceBlock, agentChangelogBlock, findAgentChangelogBlock,
8
+ surfaceBlock, findSurfaceBlock, agentChangelogBlock, findAgentChangelogBlock, SURFACE_PROFILES,
9
9
  } from "../lib/commands.js";
10
10
  import * as idx from "../lib/index-json.js";
11
11
  import { scaffoldConfig } from "../lib/config.js";
12
+ import { applyPreset, describePreset, planPreset, readPreset } from "../lib/preset.js";
13
+ import { deriveIndex } from "../lib/scan.js";
12
14
  import { today } from "../lib/dates.js";
13
15
  import { cliVersion } from "../lib/version.js";
14
16
  import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
@@ -31,11 +33,12 @@ const SUPPORTED_AGENTS = [
31
33
  "devin",
32
34
  "factory",
33
35
  "jules",
36
+ "kiro",
34
37
  ];
35
38
 
36
39
  export const flags = {
37
40
  boolean: ["json", "force", "non-interactive", "overwrite-content"],
38
- string: ["project-name", "project-description", "agent", "intake", "intake-text", "from", "date"],
41
+ string: ["project-name", "project-description", "agent", "intake", "intake-text", "from", "date", "surface", "preset"],
39
42
  };
40
43
 
41
44
  export async function run(_positional, flags) {
@@ -142,6 +145,30 @@ export async function run(_positional, flags) {
142
145
  }
143
146
  }
144
147
 
148
+ // Which commands AGENTS.md lists (change 0251). A new project is born with
149
+ // the session loop only — `core` — and the rest one `--help` away; the
150
+ // choice is written into config.json, where `upgrade` reads it.
151
+ // The organisation's preset (change 0253), read BEFORE anything is
152
+ // written: a folder that is not one stops the init with nothing scaffolded.
153
+ let preset = null;
154
+ if (flags.has("preset")) {
155
+ try {
156
+ const dir = flagString(flags, "preset");
157
+ if (!dir) throw new Error("--preset needs the preset's folder");
158
+ preset = readPreset(dir);
159
+ } catch (err) {
160
+ console.error(c.red("error:") + ` ${/** @type {Error} */ (err).message}`);
161
+ return 2;
162
+ }
163
+ }
164
+ // The preset's surface is the default when the flag names none: the block
165
+ // is generated once, from the profile the project will keep.
166
+ const surface = flagString(flags, "surface") ?? preset?.config.surface ?? "core";
167
+ if (!SURFACE_PROFILES.includes(surface) || flagGivenWithoutValue(flags, "surface")) {
168
+ console.error(c.red("error:") + ` --surface takes ${SURFACE_PROFILES.join(" or ")}${flagGivenWithoutValue(flags, "surface") ? "" : ` (got "${surface}")`}`);
169
+ return 2;
170
+ }
171
+
145
172
  let agentSelector = flagString(flags, "agent");
146
173
  // Interactive wizard step: when no --agent was given and we are on a real
147
174
  // terminal, offer the adapter install instead of silently skipping it —
@@ -202,7 +229,7 @@ export async function run(_positional, flags) {
202
229
  // if the static template lags a release (ADR 0015).
203
230
  const agentsTemplatePath = path.join(templatesDir, "AGENTS.md.template");
204
231
  const agentsTemplateBody = read(agentsTemplatePath);
205
- const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens));
232
+ const projectAgents = refreshSurfaceBlock(substitute(agentsTemplateBody, tokens), surface);
206
233
 
207
234
  // --force used to mean "overwrite everything", which made it a content
208
235
  // shredder: the only way to add an adapter to an existing project was
@@ -285,11 +312,21 @@ export async function run(_positional, flags) {
285
312
  {
286
313
  const dest = path.join(doctrinaDir, "config.json");
287
314
  if (!isFile(dest) || force) {
288
- write(dest, scaffoldConfig(), { force });
315
+ write(dest, scaffoldConfig({ surface }), { force });
289
316
  console.log(c.green("created") + ` ${relPath(projectRoot, dest)}`);
290
317
  }
291
318
  }
292
319
 
320
+ // The preset, over the scaffold: what it adds is indexed with the rest.
321
+ if (preset) {
322
+ const plan = planPreset(projectRoot, preset);
323
+ applyPreset(projectRoot, preset, plan);
324
+ for (const line of describePreset(preset, plan, true)) console.log(line);
325
+ const derived = deriveIndex(projectRoot, idx.load(projectRoot));
326
+ derived.last_updated = date;
327
+ idx.save(projectRoot, derived);
328
+ }
329
+
293
330
  // Adapters
294
331
  if (agentSelector) {
295
332
  const agents = agentSelector === "all" ? SUPPORTED_AGENTS : [agentSelector];
@@ -384,10 +421,10 @@ function normaliseScaffold(text) {
384
421
  // Replace the template's doctrina:surface span with the one generated from
385
422
  // the running CLI's catalog; pass the text through untouched when the
386
423
  // template carries no markers (older custom templates).
387
- function refreshSurfaceBlock(text) {
424
+ function refreshSurfaceBlock(text, profile = "full") {
388
425
  const block = findSurfaceBlock(text);
389
426
  if (!block) return text;
390
- let out = text.slice(0, block.start) + surfaceBlock() + text.slice(block.end);
427
+ let out = text.slice(0, block.start) + surfaceBlock(profile) + text.slice(block.end);
391
428
 
392
429
  // The agent-facing changelog is version-specific, so it is GENERATED at
393
430
  // init like the surface block — not carried in the template. Writing it
@@ -453,8 +490,15 @@ Scaffold AGENTS.md and the .doctrina/ skeleton in the current directory.
453
490
  Options:
454
491
  --project-name <name> Override the project name (default: basename of cwd)
455
492
  --project-description <text> One-sentence description
493
+ --surface core|full Which commands AGENTS.md lists: core (default) is the session
494
+ loop, with the rest deferred to \`doctrina --help\`; full lists
495
+ every command. Stored in .doctrina/config.json.
496
+ --preset <dir> Lay an organisation's preset over the scaffold: a folder with
497
+ preset.json (name, config) and optional templates/, decisions/,
498
+ skills/ and verify.json. Additive — see \`doctrina upgrade --preset\`
499
+ for a project that already exists.
456
500
  --agent <name> Install adapter for one agent
457
- (claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|all)
501
+ (claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|kiro|all)
458
502
  --intake <file> Full project description; stored verbatim at .doctrina/intake.md,
459
503
  used to derive the one-line description when absent, and the
460
504
  bootstrap playbook is printed inline — no second command
@@ -54,7 +54,7 @@ export async function run(_positional, flags) {
54
54
  if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
55
55
  // The window is the invocation's: refused the same with or without history.
56
56
  const sinceRaw = flagString(flags, "since", "90");
57
- const since = sinceWindow(sinceRaw);
57
+ const since = sinceWindow(sinceRaw ?? "90");
58
58
  if (since === null) {
59
59
  console.error(c.red("error:") + ` --since expects a day count, a date (YYYY-MM-DD) or "<n> <unit>s ago", got "${sinceRaw}"`);
60
60
  console.error(c.gray("hint: ") + "examples: --since 30 · --since 2026-01-01 · --since \"3 months ago\"");
@@ -0,0 +1,113 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { flagBool } from "../lib/args.js";
4
+ import { c } from "../lib/colors.js";
5
+ import { emitJson } from "../lib/json-out.js";
6
+ import { EXIT } from "../lib/exit-codes.js";
7
+ import { TRACKS } from "../lib/triage-model.js";
8
+ import { firstTaskCandidates, onboardState, onboardSteps, tourLines } from "../lib/onboard.js";
9
+
10
+ // `onboard` walks the whole cycle once, on this repository (change 0256).
11
+ // It reads where the project stands and prints the walk; the agent is the
12
+ // guide and the human the one being shown. It writes nothing and works
13
+ // before `init`, because the first step is the install.
14
+
15
+ export const jsonNative = true;
16
+
17
+ export const flags = { boolean: ["json"], string: [] };
18
+
19
+ export async function run(_positional, cmdFlags) {
20
+ const projectRoot = process.cwd();
21
+ const state = onboardState(projectRoot);
22
+ const { steps, current } = onboardSteps(state);
23
+ const wantsCandidates = current === "first-task" && state.installed;
24
+ const candidates = wantsCandidates ? firstTaskCandidates(projectRoot) : [];
25
+
26
+ if (flagBool(cmdFlags, "json", false)) {
27
+ emitJson("onboard", {
28
+ current,
29
+ steps: steps.map((s) => ({ id: s.id, title: s.title, done: s.done, run: s.run, ask: s.ask || null })),
30
+ candidates,
31
+ closed: state.closed,
32
+ });
33
+ return EXIT.OK;
34
+ }
35
+
36
+ console.log(c.bold("Doctrina onboarding") + c.gray(" — the whole cycle once, on this repository"));
37
+ console.log("");
38
+ console.log("You are the guide. Do the step marked →, say in one sentence what it did,");
39
+ console.log("stop where it says ASK, then run `doctrina onboard` again: it reads the tree.");
40
+ console.log("");
41
+ steps.forEach((s, i) => {
42
+ const mark = s.done ? c.green("✓") : (s.id === current ? c.cyan("→") : " ");
43
+ console.log(` ${mark} ${i + 1}. ${s.done ? s.title.padEnd(31) + c.gray(s.evidence) : s.title}`);
44
+ });
45
+ console.log("");
46
+
47
+ if (current === null) {
48
+ console.log(c.bold("The cycle has run here") + c.gray(` — ${state.closed} change${state.closed === 1 ? "" : "s"} closed. For someone new to the project:`));
49
+ console.log("");
50
+ for (const line of tourLines(state)) console.log(` ${line}`);
51
+ console.log("");
52
+ printTracks();
53
+ return EXIT.OK;
54
+ }
55
+
56
+ const at = steps.findIndex((s) => s.id === current);
57
+ const step = steps[at];
58
+ console.log(c.bold(`Step ${at + 1} — ${step.title}`));
59
+ console.log(` ${c.gray("Tell the human:")} ${step.say}`);
60
+ if (wantsCandidates) {
61
+ console.log("");
62
+ if (candidates.length === 0) {
63
+ console.log(c.gray(" No candidate stands out in this repository (every criterion cites proof, no TODO in the code)."));
64
+ } else {
65
+ console.log(" Candidates read from this repository:");
66
+ for (const cand of candidates) console.log(` - ${cand.where.padEnd(24)} ${cand.text}`);
67
+ }
68
+ }
69
+ const after_ = "askAfter" in step && step.askAfter === true;
70
+ if (step.ask && !after_) console.log(` ${c.yellow("ASK")} the human ${step.ask}`);
71
+ console.log(step.ask && !after_ ? " Then run:" : " Run:");
72
+ for (const cmd of step.run) console.log(` ${c.cyan(cmd)}`);
73
+ if (step.ask && after_) console.log(` Then ${c.yellow("ASK")} the human ${step.ask}`);
74
+ if (current === "first-task") {
75
+ console.log("");
76
+ printTracks();
77
+ }
78
+ const after = steps[at + 1];
79
+ if (after) console.log(c.gray(`\nAfter it: step ${at + 2}, ${after.title.toLowerCase()} — \`doctrina onboard\` shows it.`));
80
+ return EXIT.OK;
81
+ }
82
+
83
+ function printTracks() {
84
+ console.log(c.gray("Not every edit is a change — the tracks, by what a request changes:"));
85
+ for (const t of TRACKS) console.log(c.gray(` ${t.id.padEnd(7)} ${t.changes}`));
86
+ }
87
+
88
+ export const help = `
89
+ Usage: doctrina onboard [--json]
90
+
91
+ Walk the whole Doctrina cycle once, on this repository. Prints the six
92
+ steps — install, describe the project, pick a first task and open it,
93
+ write the delta and the tasks, implement and prove, close — each marked
94
+ done when the tree says so, and for the first one that is not: what to
95
+ tell the human, where to stop and ask, and the commands to run. Run it
96
+ again after each step; it stores nothing and reads the tree every time.
97
+
98
+ At "pick a first task" it lists candidates read from the repository: an
99
+ acceptance criterion that cites no proof (or a proof that is missing),
100
+ then a TODO or FIXME left in the code. They are suggestions; the human
101
+ chooses, or names a task of their own.
102
+
103
+ In a project that already closed a change, every step is done and it
104
+ prints the tour for someone new to the project instead: the commands to
105
+ read the project with, and the last closed change as a worked example.
106
+
107
+ It works before \`doctrina init\` (the first step is the install) and
108
+ writes nothing.
109
+
110
+ Options:
111
+ --json Machine-readable: the steps with their state, the current
112
+ step, and the first-task candidates
113
+ `;
@@ -116,7 +116,7 @@ export async function run(_positional, flags) {
116
116
  // capability whose spec.md itself changed.
117
117
  const specChanged = new Set(
118
118
  all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
119
- .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
119
+ .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)?.[1] ?? ""),
120
120
  );
121
121
  const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity, pending });
122
122
  const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
@@ -59,8 +59,8 @@ export async function run(positional, flags) {
59
59
  for (const cat of categories) {
60
60
  const files = cat.file
61
61
  ? [path.join(projectRoot, cat.file)].filter(exists)
62
- : isDir(path.join(projectRoot, cat.dir))
63
- ? walk(path.join(projectRoot, cat.dir)).filter((f) => f.endsWith(".md"))
62
+ : isDir(path.join(projectRoot, cat.dir ?? ""))
63
+ ? walk(path.join(projectRoot, cat.dir ?? "")).filter((f) => f.endsWith(".md"))
64
64
  : [];
65
65
 
66
66
  const findings = [];
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
+ import { loadConfig } from "../lib/config.js";
3
4
  import process from "node:process";
4
5
  import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "../lib/fs-ops.js";
5
6
  import { locateTemplatesDir, listResolvedTemplates } from "../lib/templates.js";
@@ -71,7 +72,7 @@ function updateTemplates(flags) {
71
72
  if (isFile(agentsMdPath)) {
72
73
  const text = read(agentsMdPath);
73
74
  const block = findSurfaceBlock(text);
74
- const fresh = surfaceBlock();
75
+ const fresh = surfaceBlock(loadConfig(projectRoot).surface);
75
76
  if (block) {
76
77
  if (normalizeBlock(text.slice(block.start, block.end)) !== normalizeBlock(fresh)) {
77
78
  plan.push({
@@ -4,7 +4,7 @@ import process from "node:process";
4
4
  import { exists } from "../lib/fs-ops.js";
5
5
  import { flagBool } from "../lib/args.js";
6
6
  import { c } from "../lib/colors.js";
7
- import { classify } from "../lib/triage-model.js";
7
+ import { classify, directSignals, trackOf } from "../lib/triage-model.js";
8
8
 
9
9
  // The classifier is shared with `work`; see lib/triage-model.js.
10
10
  export { classify } from "../lib/triage-model.js";
@@ -67,6 +67,7 @@ export async function run(positional, cmdFlags) {
67
67
  emitJson("triage", {
68
68
  lane: verdict?.lane ?? null,
69
69
  confident: verdict?.confident ?? null,
70
+ track: verdict ? trackOf(prompt, verdict) : null,
70
71
  contracts: runtime.contracts,
71
72
  declared: runtime.declared,
72
73
  findings,
@@ -87,6 +88,20 @@ function renderLane(prompt, verdict) {
87
88
  runtime: c.yellow("RUNTIME"),
88
89
  chore: c.cyan("CHORE"),
89
90
  };
91
+ // The track first (change 0255): an edit that changes nothing a spec says
92
+ // needs neither a lane's playbook nor a change.
93
+ const direct = directSignals(prompt, verdict);
94
+ if (direct.length > 0) {
95
+ console.log(c.bold("Track") + c.gray(" — deterministic term match; a hint, not a decision"));
96
+ console.log("");
97
+ console.log(` ${c.green("DIRECT")}${c.gray(" nothing a spec says changes")}`);
98
+ console.log(c.gray(` signals: ${[...new Set(direct)].slice(0, 6).join(", ")}`));
99
+ console.log("");
100
+ console.log(`Edit it, run ${c.cyan("doctrina verify")}, and commit: no change is opened.`);
101
+ console.log(c.gray(" If it does change behaviour: ") + c.cyan(`doctrina work --force "${truncate(prompt)}"`));
102
+ console.log("");
103
+ return;
104
+ }
90
105
  console.log(c.bold("Lane") + c.gray(" — deterministic term match; a hint, not a decision"));
91
106
  console.log("");
92
107
  console.log(` ${LABEL[verdict.lane]}${verdict.confident ? "" : c.gray(" (weak signal — read the playbook and override if it is wrong)")}`);