doctrina-cli 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +24 -1
  4. package/src/commands/analyze.js +8 -212
  5. package/src/commands/change.js +143 -217
  6. package/src/commands/ci.js +182 -0
  7. package/src/commands/clarify.js +58 -21
  8. package/src/commands/close.js +291 -42
  9. package/src/commands/constitution.js +14 -91
  10. package/src/commands/context.js +278 -107
  11. package/src/commands/contract.js +95 -75
  12. package/src/commands/coverage.js +58 -273
  13. package/src/commands/decision.js +54 -7
  14. package/src/commands/doctor.js +292 -129
  15. package/src/commands/handoff.js +10 -57
  16. package/src/commands/hooks.js +11 -2
  17. package/src/commands/index-rebuild.js +9 -48
  18. package/src/commands/init.js +70 -13
  19. package/src/commands/intake.js +30 -92
  20. package/src/commands/metrics.js +53 -84
  21. package/src/commands/next.js +105 -147
  22. package/src/commands/prime.js +24 -91
  23. package/src/commands/report.js +50 -108
  24. package/src/commands/review.js +68 -36
  25. package/src/commands/search.js +18 -4
  26. package/src/commands/show.js +24 -9
  27. package/src/commands/skill.js +16 -25
  28. package/src/commands/spec.js +26 -5
  29. package/src/commands/status.js +51 -172
  30. package/src/commands/templates.js +31 -138
  31. package/src/commands/trace.js +36 -71
  32. package/src/commands/triage.js +4 -82
  33. package/src/commands/validate.js +14 -871
  34. package/src/commands/verify.js +60 -138
  35. package/src/commands/why.js +2 -2
  36. package/src/commands/work.js +234 -263
  37. package/src/index.js +106 -7
  38. package/src/lib/actions.js +484 -0
  39. package/src/lib/adapters.js +0 -11
  40. package/src/lib/adr-guard.js +19 -0
  41. package/src/lib/agent-changelog.js +133 -0
  42. package/src/lib/analysis.js +264 -0
  43. package/src/lib/change-ops.js +131 -0
  44. package/src/lib/commands.js +56 -4
  45. package/src/lib/config.js +206 -0
  46. package/src/lib/constitution-model.js +102 -0
  47. package/src/lib/coverage-model.js +392 -0
  48. package/src/lib/criteria.js +23 -0
  49. package/src/lib/doc-model.js +326 -0
  50. package/src/lib/docs-impact.js +196 -27
  51. package/src/lib/flag-catalog.js +0 -16
  52. package/src/lib/fs-ops.js +58 -1
  53. package/src/lib/gates.js +181 -23
  54. package/src/lib/git.js +132 -0
  55. package/src/lib/index-json.js +2 -19
  56. package/src/lib/intake-model.js +86 -0
  57. package/src/lib/json-out.js +100 -2
  58. package/src/lib/ledger.js +192 -0
  59. package/src/lib/lexicon.js +191 -0
  60. package/src/lib/metrics-model.js +159 -0
  61. package/src/lib/names.js +42 -0
  62. package/src/lib/playbook.js +97 -0
  63. package/src/lib/project.js +57 -0
  64. package/src/lib/reproducibility.js +137 -0
  65. package/src/lib/runtime.js +191 -5
  66. package/src/lib/scan.js +204 -13
  67. package/src/lib/signoff.js +147 -0
  68. package/src/lib/snapshot.js +209 -0
  69. package/src/lib/spec-ops.js +50 -5
  70. package/src/lib/templates-model.js +262 -0
  71. package/src/lib/trace-model.js +95 -0
  72. package/src/lib/triage-model.js +121 -0
  73. package/src/lib/usage.js +6 -4
  74. package/src/lib/validation-model.js +1299 -0
  75. package/src/lib/version.js +22 -0
  76. package/src/lib/views.js +410 -0
  77. package/src/lib/work-model.js +77 -0
  78. package/templates/AGENTS.md.template +20 -26
  79. package/templates/README.md +35 -0
  80. package/templates/change/proposal.md.template +1 -0
  81. package/templates/hooks/pre-commit.sample +6 -2
  82. package/templates/playbooks/bootstrap.md.template +43 -0
  83. package/templates/playbooks/chore.md.template +19 -0
  84. package/templates/playbooks/work.md.template +76 -0
@@ -1,28 +1,26 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { spawnSync } from "node:child_process";
5
4
  import { readdirSync } from "node:fs";
6
5
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
7
6
  import { today } from "../lib/dates.js";
8
7
  import { flagBool, flagString } from "../lib/args.js";
9
8
  import { c } from "../lib/colors.js";
10
9
  import { EXIT } from "../lib/exit-codes.js";
11
- import { GIT_STATE, historyState } from "../lib/git.js";
10
+ import { GIT_STATE, historyState, git } from "../lib/git.js";
12
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
13
12
  import { USAGE_ENV, summarise } from "../lib/usage.js";
14
13
  import { OPERATIONS } from "../lib/commands.js";
14
+ import { REEDIT_WINDOW_DAYS, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
15
15
 
16
16
  // Local-only adoption metrics derived from git history. No network calls,
17
17
  // no telemetry: the numbers stay in the repository, versioned like any
18
18
  // other artifact. This is the tooling half of docs/en/validation.md.
19
19
 
20
- const REEDIT_WINDOW_DAYS = 21;
21
-
22
20
  // Flags this command accepts. Declared HERE, with the command, so
23
21
  // adding a command never requires editing the entrypoint — the gap that
24
22
  // let six flags ship undeclared and silently swallow a positional (C3).
25
- export const flags = { boolean: ["json", "save", "commands"], string: ["since"] };
23
+ export const flags = { boolean: ["json", "save", "commands", "trend"], string: ["since"] };
26
24
 
27
25
  export async function run(_positional, flags) {
28
26
  const projectRoot = process.cwd();
@@ -33,6 +31,12 @@ export async function run(_positional, flags) {
33
31
  // the command surface are actually reached for (M8). Separate report,
34
32
  // separate source, so it never dilutes the adoption metrics.
35
33
  if (flagBool(flags, "commands", false)) return commandUsage();
34
+ // --trend reads the SERIES of saved snapshots rather than git. The
35
+ // snapshots are a time series versioned in the repository, and for two
36
+ // releases the only thing that read them was a delta against the most
37
+ // recent one (audit finding F15) — a year of measurement answering one
38
+ // question. Reading the series is what makes the saving worth doing.
39
+ if (flagBool(flags, "trend", false)) return printTrend(projectRoot);
36
40
  const probe = git(projectRoot, ["rev-parse", "--is-inside-work-tree"]);
37
41
  // "No history yet" is a VALID state, not a failure — a brand-new project
38
42
  // is the most common state in which someone explores this command, and it
@@ -59,7 +63,7 @@ export async function run(_positional, flags) {
59
63
  "log", `--since=${since}`, "--date=unix",
60
64
  "--pretty=format:@@%H|%ct|%s", "--name-only",
61
65
  ]);
62
- if (log.status !== 0) {
66
+ if (log.state !== GIT_STATE.OK) {
63
67
  console.error(c.red("error:") + ` git log failed: ${log.stderr.trim()}`);
64
68
  return EXIT.ENVIRONMENT;
65
69
  }
@@ -78,7 +82,7 @@ export async function run(_positional, flags) {
78
82
  if (save) {
79
83
  const dir = path.join(projectRoot, ".doctrina", "metrics");
80
84
  const file = path.join(dir, `${today()}.json`);
81
- const previous = latestSnapshot(dir, path.basename(file));
85
+ const previous = latestSnapshot(projectRoot, path.basename(file));
82
86
  write(file, JSON.stringify(snapshot, null, 2) + "\n", { force: true });
83
87
  console.log("");
84
88
  console.log(c.green("saved") + ` ${relPath(projectRoot, file)}`);
@@ -93,49 +97,45 @@ export async function run(_positional, flags) {
93
97
  return 0;
94
98
  }
95
99
 
96
- function computeSnapshot(commits, since) {
97
- const total = commits.length;
98
- const reverts = commits.filter((cm) => /^Revert\b/.test(cm.subject)).length;
99
- const fixes = commits.filter((cm) => /^fix[(:!]/.test(cm.subject)).length;
100
-
101
- // File churn: commits per file, top offenders first.
102
- const churn = new Map();
103
- for (const cm of commits) {
104
- for (const f of cm.files) churn.set(f, (churn.get(f) ?? 0) + 1);
100
+ // The saved series, oldest first, with the direction each rate moved across
101
+ // the whole span. No verdict attached: a revert rate moves with team size,
102
+ // release cadence and how the window was drawn, and the CLI cannot tell an
103
+ // improvement from a quieter quarter.
104
+ function printTrend(projectRoot) {
105
+ const series = readSeries(projectRoot);
106
+ console.log(c.bold("Doctrina metrics") + c.gray(" — saved snapshots (.doctrina/metrics/)"));
107
+ console.log("");
108
+ if (series.length === 0) {
109
+ console.log(c.gray("no saved snapshots yet — `doctrina metrics --save` writes the first."));
110
+ return 0;
105
111
  }
106
- const topChurn = [...churn.entries()]
107
- .sort((a, b) => b[1] - a[1])
108
- .slice(0, 5)
109
- .map(([file, count]) => ({ file, commits: count }));
110
-
111
- // Re-edit proxy: a commit counts when it touches a file another commit
112
- // already touched in the previous REEDIT_WINDOW_DAYS. This is a proxy
113
- // for rework, not a verdict — iterative work also trips it.
114
- const windowSec = REEDIT_WINDOW_DAYS * 86400;
115
- const lastTouch = new Map();
116
- let reedits = 0;
117
- for (const cm of [...commits].sort((a, b) => a.ts - b.ts)) {
118
- const isReedit = cm.files.some((f) => {
119
- const prev = lastTouch.get(f);
120
- return prev !== undefined && cm.ts - prev <= windowSec;
121
- });
122
- if (isReedit) reedits += 1;
123
- for (const f of cm.files) lastTouch.set(f, cm.ts);
112
+ console.log(c.gray("date commits reverts fix share re-edit"));
113
+ for (const snap of series) {
114
+ console.log(
115
+ `${String(snap.generated).padEnd(12)} ${String(snap.commits ?? "—").padStart(7)} ` +
116
+ `${pct(snap.revert_rate).padStart(7)} ${pct(snap.fix_share).padStart(9)} ${pct(snap.reedit_rate).padStart(7)}`,
117
+ );
124
118
  }
125
-
126
- return {
127
- generated: today(),
128
- window: since,
129
- commits: total,
130
- reverts,
131
- revert_rate: round(reverts / total),
132
- fix_share: round(fixes / total),
133
- reedit_window_days: REEDIT_WINDOW_DAYS,
134
- reedit_rate: round(reedits / total),
135
- top_churn: topChurn,
136
- };
119
+ const t = trend(series);
120
+ if (t.rows.length === 0) {
121
+ console.log("");
122
+ console.log(c.gray(`one snapshot — a trend needs two. Save another with \`doctrina metrics --save\`.`));
123
+ return 0;
124
+ }
125
+ console.log("");
126
+ console.log(c.bold(`Across ${t.span} snapshots (${t.from} → ${t.to}):`));
127
+ for (const row of t.rows) printDelta(LABELS[row.rate] ?? row.rate, row.first, row.last);
128
+ console.log("");
129
+ console.log(c.gray("A direction, not a verdict: these rates move with team size and cadence."));
130
+ return 0;
137
131
  }
138
132
 
133
+ const LABELS = {
134
+ revert_rate: "revert rate",
135
+ fix_share: "fix share",
136
+ reedit_rate: `${REEDIT_WINDOW_DAYS}-day re-edit rate`,
137
+ };
138
+
139
139
  function printReport(s) {
140
140
  console.log(`commits ${s.commits}`);
141
141
  console.log(`reverts ${s.reverts} (${pct(s.revert_rate)})`);
@@ -158,50 +158,16 @@ function printDelta(label, before, after) {
158
158
  console.log(` ${label}: ${pct(before)} → ${pct(after)} (${arrow})`);
159
159
  }
160
160
 
161
- function latestSnapshot(dir, excludeBase) {
162
- if (!isDir(dir)) return null;
163
- const files = readdirSync(dir)
164
- .filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f) && f !== excludeBase)
165
- .sort();
166
- if (files.length === 0) return null;
167
- try {
168
- return JSON.parse(read(path.join(dir, files[files.length - 1])));
169
- } catch {
170
- return null;
171
- }
172
- }
173
-
174
- function parseLog(stdout) {
175
- const commits = [];
176
- let current = null;
177
- for (const line of stdout.split("\n")) {
178
- if (line.startsWith("@@")) {
179
- const [hash, ts, ...rest] = line.slice(2).split("|");
180
- current = { hash, ts: Number(ts), subject: rest.join("|"), files: [] };
181
- commits.push(current);
182
- } else if (line.trim() !== "" && current) {
183
- current.files.push(line.trim());
184
- }
185
- }
186
- return commits;
187
- }
188
-
189
- function git(cwd, args) {
190
- return spawnSync("git", args, { cwd, encoding: "utf8" });
191
- }
192
-
193
- function round(x) {
194
- return Math.round(x * 1000) / 1000;
195
- }
196
161
 
197
162
  function pct(x) {
163
+ if (typeof x !== "number") return "—";
198
164
  return `${(x * 100).toFixed(1)}%`;
199
165
  }
200
166
 
201
167
  // Which operations were actually invoked, from the opt-in usage log. The
202
- // surface is 36 commands and 59 operations; the audit's charge is that some
203
- // exist because they were easy to add rather than because anyone reaches for
204
- // them. This is the evidence for that argument — or against it. Zero samples
168
+ // audit's charge is that some of the surface exists because it was easy to
169
+ // add rather than because anyone reaches for it (how big that surface is,
170
+ // lib/commands.js owns — a count copied into a comment only rots). This is the evidence for that argument — or against it. Zero samples
205
171
  // is a CANDIDATE for cutting, never a verdict: a command used once a quarter
206
172
  // and a command nobody wants look identical over one week.
207
173
  function commandUsage() {
@@ -256,6 +222,9 @@ Flags:
256
222
  date ("2026-01-01", "3 months ago").
257
223
  --save Write .doctrina/metrics/YYYY-MM-DD.json and print the
258
224
  deltas against the most recent prior snapshot.
225
+ --trend Read the saved snapshots instead of git: every snapshot
226
+ in date order, and the direction each rate moved across
227
+ the whole span (first to last, not last to previous).
259
228
 
260
229
  This is the tooling half of the empirical A/B protocol in
261
230
  docs/en/validation.md: snapshot before adopting Doctrina, snapshot each
@@ -2,13 +2,18 @@
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
4
  import { readdirSync } from "node:fs";
5
- import { exists, isDir, isFile, read, relPath, walk } from "../lib/fs-ops.js";
6
- import * as idx from "../lib/index-json.js";
7
- import { deriveIndex, indexesMatch, listHeader } from "../lib/scan.js";
5
+ import { exists, isDir } from "../lib/fs-ops.js";
8
6
  import { flagBool } from "../lib/args.js";
9
7
  import { c } from "../lib/colors.js";
10
8
  import { emitJson } from "../lib/json-out.js";
11
- import { collectRuntimeFindings } from "../lib/runtime.js";
9
+ import { EXIT } from "../lib/exit-codes.js";
10
+ import { action, computeActions, invocation } from "../lib/actions.js";
11
+
12
+ // `next` answers "what now?". The ANSWER lives in `lib/actions.js` as
13
+ // records; this module renders them and, under `--run`, executes one
14
+ // (change 0032). Before that the answer WAS the sentence, so `--json`
15
+ // returned prose in quotes and an agent had to re-read English to re-issue
16
+ // a command the CLI had already assembled.
12
17
 
13
18
  // Flags this command accepts. Declared HERE, with the command, so
14
19
  // adding a command never requires editing the entrypoint — the gap that
@@ -17,186 +22,127 @@ import { collectRuntimeFindings } from "../lib/runtime.js";
17
22
  // wrap it in the generic envelope.
18
23
  export const jsonNative = true;
19
24
 
20
- export const flags = { boolean: ["json"], string: [] };
25
+ export const flags = { boolean: ["json", "run"], string: [] };
21
26
 
22
27
  export async function run(_positional, flags) {
23
28
  const projectRoot = process.cwd();
24
29
  const json = flagBool(flags, "json", false);
25
30
  if (!exists(path.join(projectRoot, ".doctrina"))) {
26
- const first = "doctrina init — this directory is not a Doctrina project yet";
31
+ const first = action({
32
+ id: "not-a-project",
33
+ command: "init",
34
+ runnable: false,
35
+ why: "this directory is not a Doctrina project yet",
36
+ });
27
37
  if (json) emitJson("next", { actions: [first] });
28
- else console.log(`1. ${first}`);
38
+ else console.log(`1. ${first.text}`);
29
39
  return 0;
30
40
  }
31
41
 
32
42
  const actions = computeActions(projectRoot);
33
43
 
44
+ if (flagBool(flags, "run", false)) {
45
+ return runFirst(actions);
46
+ }
47
+
34
48
  if (json) {
35
49
  emitJson("next", { actions });
36
50
  return 0;
37
51
  }
38
52
 
39
53
  if (actions.length === 0) {
40
- console.log(c.green("ok") + " no open work.");
54
+ // The two doors AGENTS.md tells an agent to use, not the two hand-authoring
55
+ // commands it tells them to avoid (second audit). `intake` first for a
56
+ // project with nothing specced yet, `work` for one that has.
57
+ const specced = isDir(path.join(projectRoot, ".doctrina", "specs"))
58
+ && readdirSync(path.join(projectRoot, ".doctrina", "specs"), { withFileTypes: true })
59
+ .some((e) => e.isDirectory());
60
+ console.log(c.green("ok") + " every gate is satisfied and no change is open.");
41
61
  console.log("");
42
62
  console.log("Start something:");
43
- console.log(` doctrina change new <id> "<title>" open a change proposal`);
44
- console.log(` doctrina spec new <capability> spec a new capability`);
63
+ if (!specced) {
64
+ console.log(` doctrina intake --text "<what this project is>" turn intent into specs`);
65
+ }
66
+ console.log(` doctrina work "<prompt>" open the next change`);
45
67
  return 0;
46
68
  }
47
69
 
48
70
  console.log(c.bold("Next actions") + c.gray(" (in priority order):"));
49
71
  console.log("");
50
- actions.forEach((a, i) => console.log(`${i + 1}. ${a}`));
72
+ actions.forEach((a, i) => console.log(`${i + 1}. ${a.text}`));
51
73
  return 0;
52
74
  }
53
75
 
54
- // The priority-ordered action list, render-free — shared with `prime`,
55
- // `handoff`, and the --json output.
56
- export function computeActions(projectRoot) {
57
- const actions = [];
58
-
59
- // RUNTIME FIRST (change 0029). A declared wiring that does not hold is
60
- // not a queue item — it is the reason the last run lied. After a job goes
61
- // green having executed nothing, "open a change on the observability
62
- // capability" sends the agent to polish the Markdown of an empty-state
63
- // message while the cause sits in a file no other action names. A broken
64
- // declaration outranks every artifact chore below it.
65
- try {
66
- const runtime = collectRuntimeFindings(projectRoot);
67
- const errs = runtime.findings.filter((f) => f.level === "error");
68
- if (errs.length > 0) {
69
- actions.push(
70
- `doctrina triage — ${errs.length} runtime declaration${errs.length === 1 ? " does" : "s do"} not hold ` +
71
- `(first: ${errs[0].code} ${errs[0].message.slice(0, 80)}${errs[0].message.length > 80 ? "…" : ""})`,
72
- );
76
+ // The command modules `--run` may dispatch to, loaded on demand. Static
77
+ // imports here would make every module that imports `next` (prime, handoff,
78
+ // watch) pay for the whole command tree just to render a list.
79
+ export const RUNNERS = {
80
+ triage: () => import("./triage.js"),
81
+ intake: () => import("./intake.js"),
82
+ analyze: () => import("./analyze.js"),
83
+ "change apply": () => import("./change.js"),
84
+ "change archive": () => import("./change.js"),
85
+ "index rebuild": () => import("./index-rebuild.js"),
86
+ };
87
+
88
+ /**
89
+ * Execute the first runnable action, and only that one.
90
+ *
91
+ * ONE action, not the queue: the list is recomputed from the tree after
92
+ * every change to it, so running two in a row would act on a list the
93
+ * second half of which was computed before the first half ran. Stopping
94
+ * also keeps the human's approval point where they put it — `close` is the
95
+ * command that deliberately runs a whole sequence.
96
+ */
97
+ async function runFirst(actions) {
98
+ const next = actions.find((a) => a.runnable);
99
+ if (!next) {
100
+ const blocked = actions.find((a) => !a.runnable);
101
+ if (!blocked) {
102
+ console.log(c.green("ok") + " no open work to run.");
103
+ return EXIT.OK;
73
104
  }
74
- } catch {
75
- // A malformed contract is `validate`'s finding to make, not a reason
76
- // for `next` to fall over.
77
- }
78
-
79
- // A pending intake is the very first thing to resolve: until it is
80
- // converted, product.md and the specs are still empty scaffolding.
81
- const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
82
- if (isFile(intakePath)) {
83
- const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
84
- if (status !== "converted") {
85
- actions.push("doctrina intake — a pending intake awaits conversion into product.md and specs");
105
+ // Everything left needs authorship, not execution. Say what, and say
106
+ // it is not a failure of the command.
107
+ console.log(c.yellow("nothing to run") + " — the next action needs a person, not a command:");
108
+ console.log("");
109
+ console.log(` ${blocked.text}`);
110
+ const inv = invocation(blocked);
111
+ if (inv) {
112
+ console.log("");
113
+ console.log(c.gray("When you have made the call, record it with: ") + c.cyan(inv));
86
114
  }
115
+ return EXIT.OK;
87
116
  }
88
117
 
89
- // Open changes drive the loop: finish what is started before opening more.
90
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
91
- if (isDir(changesDir)) {
92
- for (const id of readdirSync(changesDir).sort()) {
93
- if (id === "archive" || id.startsWith(".")) continue;
94
- if (!isDir(path.join(changesDir, id))) continue;
95
- const proposalPath = path.join(changesDir, id, "proposal.md");
96
- if (!isFile(proposalPath)) {
97
- actions.push(`add a proposal.md to .doctrina/changes/${id}/ (open changes need one)`);
98
- continue;
99
- }
100
- const status = listHeader(read(proposalPath), "Status") ?? "proposed";
101
- if (status === "applied") {
102
- actions.push(`doctrina change archive ${id} — applied but not archived`);
103
- continue;
104
- }
105
- const tasksPath = path.join(changesDir, id, "tasks.md");
106
- const unchecked = isFile(tasksPath)
107
- ? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
108
- : 0;
109
- if (unchecked > 0) {
110
- actions.push(`complete ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md`);
111
- continue;
112
- }
113
- const deltas = walk(path.join(changesDir, id, "specs")).filter((p) => p.endsWith("delta.md"));
114
- if (deltas.length > 0) {
115
- actions.push(`doctrina analyze ${id}, then doctrina change apply ${id} — tasks done, ${deltas.length} delta${deltas.length === 1 ? "" : "s"} ready`);
116
- } else {
117
- actions.push(`add spec deltas under .doctrina/changes/${id}/specs/, or apply as metadata-only: doctrina change apply ${id}`);
118
- }
119
- }
118
+ const loader = RUNNERS[next.command];
119
+ if (!loader) {
120
+ // A runnable action whose command has no runner is a bug in this map,
121
+ // not something to paper over by silently doing nothing.
122
+ console.error(c.red("error:") + ` no runner for "${next.command}"`);
123
+ console.error(c.gray("hint: ") + `run it yourself: ${invocation(next)}`);
124
+ return EXIT.GATE;
120
125
  }
121
126
 
122
- // ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
123
- // the rot the review flagged — a decision with nothing behind it (no Evidence,
124
- // no Landed) is drift waiting to be superseded. Surface the proposed ones
125
- // first (a pending decision blocks more than a missing stamp).
126
- const adrDir = path.join(projectRoot, ".doctrina", "decisions");
127
- const landNudges = [];
128
- const isBareValue = (v) => {
129
- const t = (v ?? "").trim();
130
- return t === "" || t === "—" || t === "-";
131
- };
132
- for (const f of walk(adrDir)) {
133
- if (!f.endsWith(".md")) continue;
134
- const m = path.basename(f).match(/^(\d{4})-/);
135
- if (!m) continue;
136
- const text = read(f);
137
- const status = listHeader(text, "Status");
138
- if (status && status.toLowerCase() === "proposed") {
139
- actions.push(`review ADR ${m[1]} (${relPath(projectRoot, f)}): doctrina decision accept ${m[1]}, or supersede it`);
140
- } else if (status && status.toLowerCase() === "accepted") {
141
- // Bare only when BOTH anchors are empty (Evidence header present-but-bare
142
- // and no Landed stamp). An ADR that opts out of Evidence entirely
143
- // (header absent) is not nagged.
144
- const evidence = listHeader(text, "Evidence");
145
- const landed = listHeader(text, "Landed");
146
- if (evidence !== null && isBareValue(evidence) && isBareValue(landed)) {
147
- landNudges.push(`record what proves ADR ${m[1]}: cite its **Evidence:**, or once it ships, doctrina decision land ${m[1]}`);
148
- }
149
- }
150
- }
151
- for (const n of landNudges) actions.push(n);
152
-
153
- // No procedural memory captured yet, but the history shows a fix-shaped
154
- // change — exactly the lesson a skill exists to keep from being relearned
155
- // (review §5: the change-0003 "tolerate LLM code fences" case). One gentle
156
- // nudge, only when skills are empty, so it never nags a project that opted in.
157
- const skillNudge = suggestSkillCapture(projectRoot);
158
- if (skillNudge) actions.push(skillNudge);
159
-
160
- // Index drift is silent rot; surface it last.
161
- try {
162
- const current = idx.load(projectRoot);
163
- if (!indexesMatch(deriveIndex(projectRoot, current), current)) {
164
- actions.push("doctrina index rebuild — index.json has drifted from the tree");
165
- }
166
- } catch {
167
- actions.push("doctrina index rebuild — index.json is missing or unreadable");
168
- }
169
-
170
- return actions;
171
- }
127
+ console.log(c.bold("Running") + ` ${c.cyan(invocation(next))}` + c.gray(` — ${next.why}`));
128
+ console.log("");
129
+ const mod = await loader();
130
+ // A sub-operation ("change archive") is passed as a positional, exactly
131
+ // as the entrypoint would.
132
+ const [, ...sub] = next.command.split(" ");
133
+ const code = await mod.run([...sub, ...next.args], new Map());
172
134
 
173
- // Return a single skill-capture nudge, or null. Fires only when no skill has
174
- // been written yet (skills/ holds nothing but .gitkeep) AND the archive shows a
175
- // fix-shaped change whose lesson is the textbook case for a skill. Deterministic
176
- // pattern match on the archived folder name — a hint, never a decision (ADR 0005).
177
- const FIX_SHAPED = /(?:^|-)(fix|bug|hotfix|patch|parse|parsing|tolerate|workaround|race|deadlock|flaky|retry|escape|sanitize|sanitise)(?:-|$)/;
178
-
179
- function suggestSkillCapture(projectRoot) {
180
- const skillsDir = path.join(projectRoot, ".doctrina", "skills");
181
- if (isDir(skillsDir)) {
182
- const hasSkill = walk(skillsDir).some((f) => f.endsWith(".md"));
183
- if (hasSkill) return null; // opted in already — never nag
184
- }
185
- const archiveDir = path.join(projectRoot, ".doctrina", "changes", "archive");
186
- if (!isDir(archiveDir)) return null;
187
- for (const name of readdirSync(archiveDir).sort()) {
188
- if (!isDir(path.join(archiveDir, name))) continue;
189
- const id = name.replace(/^\d{4}-\d{2}-\d{2}-/, "");
190
- if (FIX_SHAPED.test(id)) {
191
- return `capture a skill from past fixes (e.g. "${id}"): doctrina skill new <slug> — ` +
192
- `on-demand procedural memory so the next agent does not relearn it (none exist yet)`;
193
- }
135
+ console.log("");
136
+ if (code === EXIT.OK) {
137
+ console.log(c.green("✓ ran ") + c.cyan(invocation(next)) + c.gray(" — `doctrina next` for what follows"));
138
+ } else {
139
+ console.log(c.red("✗ ") + c.cyan(invocation(next)) + c.gray(` exited ${code} — fix it, then rerun`));
194
140
  }
195
- return null;
141
+ return code;
196
142
  }
197
143
 
198
144
  export const help = `
199
- Usage: doctrina next [--json]
145
+ Usage: doctrina next [--json] [--run]
200
146
 
201
147
  Inspect the .doctrina/ tree and print the recommended next workflow
202
148
  actions in priority order: runtime declarations that no longer hold
@@ -205,8 +151,20 @@ run lied), open changes (missing proposal, unchecked
205
151
  tasks, deltas ready to apply, applied-but-unarchived), ADRs still in
206
152
  proposed status, accepted ADRs with nothing proving them (cite Evidence
207
153
  or run "decision land"), a skill-capture nudge when a past fix went
208
- uncaptured, and index drift last. Read-only; always exits 0.
154
+ uncaptured, and index drift last.
209
155
 
210
156
  Intended use: agents and humans run it to resume work without
211
157
  re-reading the whole tree.
158
+
159
+ Options:
160
+ --json Emit the actions as records — { id, command, args, why, gate,
161
+ severity, runnable, text }. Branch on \`command\`/\`args\`; \`text\`
162
+ is the same line the terminal prints.
163
+ --run Execute the first RUNNABLE action in-process and stop. An action
164
+ that needs a person to decide — accept an ADR, write a proposal,
165
+ complete a task, capture a skill — is never runnable however
166
+ mechanical its edit would be, and \`--run\` names it instead.
167
+ Exits with the executed command's own code.
168
+
169
+ Read-only without --run, and always exits 0 then.
212
170
  `;