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,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
+ "change check": () => import("./change.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
  `;
@@ -1,14 +1,11 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
- import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
- import { listHeader } from "../lib/scan.js";
7
- import { collectStatus } from "./status.js";
8
- import { computeActions } from "./next.js";
9
- import { acceptedDecisions, productSection } from "./constitution.js";
10
- import { c } from "../lib/colors.js";
4
+ import { exists } from "../lib/fs-ops.js";
5
+ import { flagBool } from "../lib/args.js";
11
6
  import { notADoctrinaProject } from "../lib/exit-codes.js";
7
+ import { collectSnapshot } from "../lib/snapshot.js";
8
+ import { renderView } from "../lib/views.js";
12
9
 
13
10
  // Session primer: the ~40-line read that orients an agent at the start of a
14
11
  // session — where things stand, what the standing rules are, what work is
@@ -16,102 +13,30 @@ import { notADoctrinaProject } from "../lib/exit-codes.js";
16
13
  // It sits between `status` (numbers only) and `context --concat` (everything):
17
14
  // enough to act, cheap enough to run every session. Strictly read-only; every
18
15
  // line is assembled from artifacts that already own the fact (no new home).
16
+ //
17
+ // Since change 0037 the primer is a VIEW over lib/snapshot.js, rendered by
18
+ // lib/views.js and reachable as `doctrina status --view prime`. This module
19
+ // is the name AGENTS.md tells agents to run, and nothing else: no collection,
20
+ // no formatting, and nothing for another command to import out of.
19
21
 
20
22
  // Flags this command accepts. Declared HERE, with the command, so
21
23
  // adding a command never requires editing the entrypoint — the gap that
22
24
  // let six flags ship undeclared and silently swallow a positional (C3).
23
- export const flags = { boolean: ["json"], string: [] };
25
+ export const flags = { boolean: ["json", "rules"], string: [] };
24
26
 
25
- export async function run(_positional, _flags) {
27
+ export async function run(_positional, flags) {
26
28
  const projectRoot = process.cwd();
27
29
  if (!exists(path.join(projectRoot, ".doctrina"))) {
28
30
  throw notADoctrinaProject();
29
31
  }
30
-
31
- const s = collectStatus(projectRoot);
32
-
33
- console.log(c.bold("Doctrina prime") + c.gray(` — ${s.project} (framework ${s.stamp ?? "—"} / CLI ${s.cli})`));
34
- console.log("");
35
-
36
- // One-line gate digest.
37
- const cov = `${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria})`;
38
- const tr = s.trace.anchors === 0 ? "no anchors" : `${s.trace.realized}/${s.trace.anchors}`;
39
- const verify = s.verify.configured ? `${s.verify.checks} verify checks` : "verify not configured";
40
- console.log(c.bold("Gates ") + `index ${s.indexState} · coverage ${cov} · trace ${tr} · ${verify}`);
41
- const implBreak = Object.entries(s.specs.impl).map(([k, v]) => `${v} ${k}`).join(", ");
42
- console.log(
43
- c.bold("Work ") +
44
- `${s.specs.total} specs${implBreak ? ` (${implBreak})` : ""} · ${s.specs.openChanges} open change${s.specs.openChanges === 1 ? "" : "s"} · ` +
45
- `${s.decisions.total} decisions · ${s.skills} skills`,
46
- );
47
-
48
- // Standing rules, titles only — `constitution` has the full text.
49
- const adrs = acceptedDecisions(projectRoot);
50
- console.log("");
51
- console.log(c.bold("Rules") + c.gray(` (${adrs.length} accepted ADRs — \`doctrina constitution\` for detail)`));
52
- for (const a of adrs) console.log(` ${c.cyan(a.id)} ${a.title}`);
53
- const nonGoals = productSection(projectRoot, "Non-goals");
54
- if (nonGoals.length > 0) {
55
- console.log(c.gray(` + ${nonGoals.length} non-goal${nonGoals.length === 1 ? "" : "s"} declared in product.md`));
56
- }
57
-
58
- // Open work, with unchecked-task counts — the resume points.
59
- console.log("");
60
- console.log(c.bold("Open work"));
61
- const open = openChanges(projectRoot);
62
- if (open.length === 0) {
63
- console.log(c.gray(" none — `doctrina work \"<prompt>\"` opens the next change"));
64
- } else {
65
- for (const ch of open) {
66
- const tasks = ch.tasksTotal > 0 ? ` · tasks ${ch.tasksDone}/${ch.tasksTotal}` : "";
67
- console.log(` ${c.cyan(ch.id)} ${ch.title ?? ""}${c.gray(` (${ch.status}${tasks})`)}`);
68
- }
69
- }
70
-
71
- // Next actions (top 5 — `doctrina next` for the full list).
72
- const actions = computeActions(projectRoot);
73
- console.log("");
74
- console.log(c.bold("Next"));
75
- if (actions.length === 0) {
76
- console.log(c.gray(" nothing pending — pick up new work"));
77
- } else {
78
- actions.slice(0, 5).forEach((a, i) => console.log(` ${i + 1}. ${a}`));
79
- if (actions.length > 5) console.log(c.gray(` … ${actions.length - 5} more — \`doctrina next\``));
80
- }
81
-
82
- console.log("");
83
- console.log(c.gray("Read deeper: `doctrina context [<cap>] --concat` · `doctrina why <cap>` · `doctrina show <ref>`"));
32
+ // --rules: the standing rules in full, which is what the removed
33
+ // `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
34
+ // prints both lists, from the same collection, so the two cannot disagree.
35
+ const view = flagBool(flags, "rules", false) ? "rules" : "prime";
36
+ for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
84
37
  return 0;
85
38
  }
86
39
 
87
- // Open changes with their proposal status/title and task progress.
88
- export function openChanges(projectRoot) {
89
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
90
- const out = [];
91
- if (!isDir(changesDir)) return out;
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
- const proposal = isFile(proposalPath) ? read(proposalPath) : null;
97
- const title = proposal?.match(/^#\s+(?:Change\s+[^—-]*[—-]\s*)?(.+)$/m)?.[1]?.trim() ?? null;
98
- const status = proposal ? (listHeader(proposal, "Status") ?? "proposed") : "no proposal.md";
99
- const tasksPath = path.join(changesDir, id, "tasks.md");
100
- let tasksDone = 0, tasksTotal = 0, unchecked = [];
101
- if (isFile(tasksPath)) {
102
- for (const line of read(tasksPath).split(/\r?\n/)) {
103
- const m = line.match(/^-\s+\[([ xX])\]\s+(.*)$/);
104
- if (!m) continue;
105
- tasksTotal += 1;
106
- if (m[1] === " ") unchecked.push(m[2].trim());
107
- else tasksDone += 1;
108
- }
109
- }
110
- out.push({ id, title, status, tasksDone, tasksTotal, unchecked });
111
- }
112
- return out;
113
- }
114
-
115
40
  export const help = `
116
41
  Usage: doctrina prime
117
42
 
@@ -120,6 +45,13 @@ titles + non-goal count), open work with task progress, and the top
120
45
  next actions — the ~40-line read that orients an agent at session
121
46
  start without paying for the full context pack.
122
47
 
48
+ The same view as \`doctrina status --view prime\`; both render one
49
+ collection of the tree, so they cannot disagree.
50
+
51
+ --rules Print the standing rules in full instead of the primer: every
52
+ accepted ADR and every declared non-goal — what the removed
53
+ \`doctrina constitution\` printed, from the same collection.
54
+
123
55
  Read-only; always exits 0. Deeper reads: \`doctrina context\`,
124
56
  \`doctrina why <cap>\`, \`doctrina show <ref>\`.
125
57
  `;
@@ -1,24 +1,28 @@
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 { exists } from "../lib/fs-ops.js";
6
- import * as idx from "../lib/index-json.js";
7
- import { collectStatus } from "./status.js";
8
- import { openChanges } from "./prime.js";
9
- import { flagString } from "../lib/args.js";
10
- import { today } from "../lib/dates.js";
5
+ import { flagBool, flagString, parsePositiveInt } from "../lib/args.js";
11
6
  import { c } from "../lib/colors.js";
12
7
  import { notADoctrinaProject } from "../lib/exit-codes.js";
8
+ import { collectSnapshot, collectReportWindow } from "../lib/snapshot.js";
9
+ import { renderView } from "../lib/views.js";
10
+ import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
11
+ import { cliVersion } from "../lib/version.js";
13
12
 
14
13
  // Periodic digest in Markdown — the standup/PR-description view: what was
15
14
  // archived, where the gates stand, and what local git says about the period.
16
15
  // Read-only, no network; the git section degrades silently outside a repo.
16
+ //
17
+ // Since change 0037 it is a VIEW over the shared snapshot (lib/snapshot.js →
18
+ // lib/views.js), also reachable as `doctrina status --view report --since N`,
19
+ // and its two git probes go through lib/git.js rather than a private
20
+ // spawnSync that could read a refusal as an empty answer.
17
21
 
18
22
  // Flags this command accepts. Declared HERE, with the command, so
19
23
  // adding a command never requires editing the entrypoint — the gap that
20
24
  // let six flags ship undeclared and silently swallow a positional (C3).
21
- export const flags = { boolean: ["json"], string: ["since"] };
25
+ export const flags = { boolean: ["json", "agent-changelog"], string: ["since"] };
22
26
 
23
27
  export async function run(_positional, flags) {
24
28
  const projectRoot = process.cwd();
@@ -26,117 +30,32 @@ export async function run(_positional, flags) {
26
30
  throw notADoctrinaProject();
27
31
  }
28
32
 
29
- const sinceRaw = flagString(flags, "since") ?? "7";
30
- const days = Number.parseInt(sinceRaw, 10);
31
- if (!Number.isFinite(days) || days <= 0) {
33
+ const sinceRaw = flagString(flags, "since");
34
+ const days = parsePositiveInt(sinceRaw ?? "7");
35
+ if (days === null) {
32
36
  console.error(c.red("error:") + ` --since expects a positive day count, got "${sinceRaw}"`);
33
37
  return 2;
34
38
  }
35
- const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
36
- const cutoffIso = cutoff.toISOString().slice(0, 10);
37
39
 
38
- const s = collectStatus(projectRoot);
39
-
40
- console.log(`# Doctrina report — ${s.project} (${cutoffIso} → ${today()})`);
41
- console.log("");
42
-
43
- // Gates.
44
- console.log("## Gates");
45
- console.log("");
46
- console.log(`- index: ${s.indexState} · framework stamp: ${s.stamp ?? "—"} (CLI ${s.cli})`);
47
- console.log(`- coverage: ${s.coverage.pct}% (${s.coverage.totalCovered}/${s.coverage.totalCriteria} criteria)` +
48
- (s.coverage.totalDangling ? ` — ${s.coverage.totalDangling} dangling` : "") +
49
- (s.coverage.totalConditional ? ` — ${s.coverage.totalConditional} conditional` : ""));
50
- console.log(`- trace: ${s.trace.anchors === 0 ? "no anchors declared" : `${s.trace.realized}/${s.trace.anchors} anchors realized`}`);
51
- console.log(`- verify: ${s.verify.configured ? `${s.verify.checks} checks declared` : "not configured"}`);
52
-
53
- // Work shipped in the window (from the index ledger) + work in flight.
54
- console.log("");
55
- console.log("## Changes");
56
- console.log("");
57
- const archived = archivedSince(projectRoot, cutoffIso);
58
- if (archived.length === 0) {
59
- console.log(`- archived in period: none`);
60
- } else {
61
- console.log(`- archived in period: ${archived.length}`);
62
- for (const ch of archived) console.log(` - ${ch.applied} — \`${ch.id}\` ${ch.title}`);
63
- }
64
- const open = openChanges(projectRoot);
65
- if (open.length === 0) {
66
- console.log("- open now: none");
67
- } else {
68
- console.log(`- open now: ${open.length}`);
69
- for (const ch of open) {
70
- const tasks = ch.tasksTotal > 0 ? ` (tasks ${ch.tasksDone}/${ch.tasksTotal})` : "";
71
- console.log(` - \`${ch.id}\`${ch.title ? ` ${ch.title}` : ""}${tasks}`);
72
- }
40
+ // --agent-changelog: a DRAFT of the block `upgrade --write` writes into
41
+ // AGENTS.md, proposed from what the archived changes said they touched.
42
+ // A different report for a different audience — five bullets for an
43
+ // arriving agent, not a digest for a standup — so it replaces the view
44
+ // rather than appending to it. Authorship stays human (ADR 0005): the
45
+ // command proposes, a person cuts and rewrites.
46
+ if (flagBool(flags, "agent-changelog", false)) {
47
+ const draft = draftAgentChangelog(projectRoot, { days: sinceRaw === undefined ? null : days });
48
+ for (const line of renderDraft(draft, cliVersion())) console.log(line);
49
+ return 0;
73
50
  }
74
51
 
75
- // Artifact counts, for trend-watching between reports.
76
- console.log("");
77
- console.log("## Artifacts");
78
- console.log("");
79
- console.log(`- specs: ${s.specs.total} · decisions: ${s.decisions.total}` +
80
- (s.decisions.proposed ? ` (${s.decisions.proposed} proposed)` : "") +
81
- ` · skills: ${s.skills}`);
82
-
83
- // Local git window — commits, fix share, top churn. No network.
84
- console.log("");
85
- console.log("## Git (local, last " + days + " days)");
86
- console.log("");
87
- const git = gitWindow(projectRoot, days);
88
- if (!git) {
89
- console.log("- no git history available (not a repository, or git not installed)");
90
- } else {
91
- console.log(`- commits: ${git.commits} (${git.fixes} fix-shaped, ${git.commits ? Math.round((git.fixes / git.commits) * 100) : 0}%)`);
92
- if (git.churn.length > 0) {
93
- console.log(`- top-churn files:`);
94
- for (const [file, n] of git.churn) console.log(` - ${file} (${n} touches)`);
95
- }
96
- }
97
-
98
- console.log("");
99
- console.log(`*Generated read-only by \`doctrina report --since ${days}\`; deeper numbers: \`doctrina metrics\`.*`);
52
+ // One collector with `status --view report` (change 0176), so the
53
+ // deprecated name and its survivor cannot print different digests.
54
+ const options = collectReportWindow(projectRoot, days);
55
+ for (const line of renderView("report", collectSnapshot(projectRoot), options)) console.log(line);
100
56
  return 0;
101
57
  }
102
58
 
103
- function archivedSince(projectRoot, cutoffIso) {
104
- let index;
105
- try {
106
- index = idx.load(projectRoot);
107
- } catch {
108
- return [];
109
- }
110
- return (index.artifacts?.changes_archive ?? [])
111
- .filter((ch) => typeof ch.applied === "string" && ch.applied >= cutoffIso)
112
- .sort((a, b) => String(a.applied).localeCompare(String(b.applied)));
113
- }
114
-
115
- function gitWindow(projectRoot, days) {
116
- const log = spawnSync("git", ["log", `--since=${days} days ago`, "--pretty=%s"], {
117
- cwd: projectRoot,
118
- encoding: "utf8",
119
- });
120
- if (log.status !== 0) return null;
121
- const subjects = log.stdout.split(/\r?\n/).filter((l) => l.trim().length > 0);
122
- const fixes = subjects.filter((sub) => /^(fix|bug|hotfix|patch)(\(|:|!)/i.test(sub)).length;
123
-
124
- const files = spawnSync("git", ["log", `--since=${days} days ago`, "--name-only", "--pretty=format:"], {
125
- cwd: projectRoot,
126
- encoding: "utf8",
127
- });
128
- const counts = new Map();
129
- if (files.status === 0) {
130
- for (const line of files.stdout.split(/\r?\n/)) {
131
- const f = line.trim();
132
- if (!f) continue;
133
- counts.set(f, (counts.get(f) ?? 0) + 1);
134
- }
135
- }
136
- const churn = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
137
- return { commits: subjects.length, fixes, churn };
138
- }
139
-
140
59
  export const help = `
141
60
  Usage: doctrina report [--since <days>]
142
61
 
@@ -146,4 +65,16 @@ work with task progress, artifact counts, and a local-git summary
146
65
  (commits, fix share, top-churn files). Made for standups and PR
147
66
  descriptions. Read-only; no network. \`doctrina metrics\` has the
148
67
  deeper git-derived numbers.
68
+
69
+ Deprecated: this is \`doctrina status --view report --since <days>\`, and
70
+ \`--agent-changelog\` is \`doctrina status --view agent-changelog\`.
71
+
72
+ --agent-changelog Draft the AGENTS.md "What changed" block instead of
73
+ the digest: one candidate bullet per archived change
74
+ that touched a documented surface (a command, a flag,
75
+ an exit code), newest first, capped at the block's
76
+ five bullets. The window is "since the last tag"
77
+ unless --since names one. It PROPOSES; a person cuts
78
+ and rewrites, because what an agent must do
79
+ differently is a judgement the CLI does not make.
149
80
  `;