doctrina-cli 0.18.0 → 0.21.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 (68) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +84 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +164 -17
  6. package/src/commands/clarify.js +1 -204
  7. package/src/commands/close.js +18 -1
  8. package/src/commands/context.js +81 -9
  9. package/src/commands/contract.js +1 -0
  10. package/src/commands/delivery.js +356 -0
  11. package/src/commands/doctor.js +21 -1
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/index-rebuild.js +23 -0
  14. package/src/commands/init.js +28 -1
  15. package/src/commands/intake.js +60 -3
  16. package/src/commands/metrics.js +43 -3
  17. package/src/commands/next.js +41 -6
  18. package/src/commands/prime.js +125 -7
  19. package/src/commands/review.js +39 -3
  20. package/src/commands/skill.js +2 -1
  21. package/src/commands/status.js +28 -0
  22. package/src/commands/upgrade.js +33 -0
  23. package/src/commands/validate.js +18 -0
  24. package/src/commands/verify.js +32 -4
  25. package/src/commands/work.js +126 -7
  26. package/src/commands/workspace.js +419 -0
  27. package/src/index.js +100 -66
  28. package/src/lib/actions.js +44 -4
  29. package/src/lib/adapters.js +143 -1
  30. package/src/lib/args.js +33 -1
  31. package/src/lib/clarify-scan.js +221 -0
  32. package/src/lib/code-inventory.js +770 -0
  33. package/src/lib/commands.js +52 -5
  34. package/src/lib/context-slice.js +149 -0
  35. package/src/lib/delivery.js +286 -0
  36. package/src/lib/docs-impact.js +36 -1
  37. package/src/lib/fs-ops.js +23 -0
  38. package/src/lib/intake-model.js +1 -1
  39. package/src/lib/json-out.js +9 -1
  40. package/src/lib/metrics-model.js +23 -1
  41. package/src/lib/playbook.js +1 -1
  42. package/src/lib/shell.js +45 -0
  43. package/src/lib/skill-export.js +95 -0
  44. package/src/lib/snapshot.js +43 -2
  45. package/src/lib/task-card.js +195 -0
  46. package/src/lib/views.js +52 -3
  47. package/src/lib/work-model.js +66 -2
  48. package/src/lib/workspace-agents.js +306 -0
  49. package/src/lib/workspace-guard.js +65 -0
  50. package/src/lib/workspace-locate.js +141 -0
  51. package/src/lib/workspace-tools.js +160 -0
  52. package/src/lib/workspace-view.js +391 -0
  53. package/src/lib/workspace.js +323 -0
  54. package/templates/AGENTS.md.template +17 -14
  55. package/templates/README.md +11 -1
  56. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  57. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  58. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  59. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  60. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  61. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  62. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  63. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  64. package/templates/delivery.md.template +39 -0
  65. package/templates/playbooks/absorb.md.template +63 -0
  66. package/templates/playbooks/bootstrap.md.template +18 -6
  67. package/templates/playbooks/delivery.md.template +44 -0
  68. package/templates/playbooks/work.md.template +1 -1
@@ -12,6 +12,8 @@ import { notADoctrinaProject } from "../lib/exit-codes.js";
12
12
  import { USAGE_ENV, summarise } from "../lib/usage.js";
13
13
  import { OPERATIONS } from "../lib/commands.js";
14
14
  import { REEDIT_WINDOW_DAYS, sinceWindow, computeSnapshot, parseLog, readSeries, latestSnapshot, trend, round } from "../lib/metrics-model.js";
15
+ import { workspaceRootAt, workspaceSnapshot } from "../lib/workspace-view.js";
16
+ import { cdThen } from "../lib/shell.js";
15
17
 
16
18
  // Local-only adoption metrics derived from git history. No network calls,
17
19
  // no telemetry: the numbers stay in the repository, versioned like any
@@ -24,6 +26,19 @@ export const flags = { boolean: ["json", "save", "commands", "trend"], string: [
24
26
 
25
27
  export async function run(_positional, flags) {
26
28
  const projectRoot = process.cwd();
29
+ // At the workspace root (change 0235): adoption is measured per repository;
30
+ // the deliveries' own lead time is `delivery list`.
31
+ const wsRoot = workspaceRootAt(projectRoot);
32
+ if (wsRoot && !flagBool(flags, "commands", false)) {
33
+ const snap = workspaceSnapshot(wsRoot);
34
+ console.log(c.bold("Doctrina metrics") + c.gray(` — workspace "${snap.ws.name}"`));
35
+ console.log("");
36
+ console.log(`Deliveries ${snap.open.length} open · ${snap.deliveries.length - snap.open.length} closed — lead time per delivery: ${c.cyan("doctrina delivery list")}`);
37
+ console.log("");
38
+ console.log("Adoption is measured in each repository:");
39
+ for (const p of snap.projects.filter((x) => x.state !== "missing")) console.log(" " + c.cyan(cdThen(p.project.path, "doctrina metrics")));
40
+ return EXIT.OK;
41
+ }
27
42
  if (!exists(path.join(projectRoot, ".doctrina"))) {
28
43
  throw notADoctrinaProject();
29
44
  }
@@ -98,6 +113,9 @@ export async function run(_positional, flags) {
98
113
  printDelta("revert rate", previous.revert_rate, snapshot.revert_rate);
99
114
  printDelta("fix share", previous.fix_share, snapshot.fix_share);
100
115
  printDelta(`${REEDIT_WINDOW_DAYS}-day re-edit rate`, previous.reedit_rate, snapshot.reedit_rate);
116
+ if (typeof previous.change_adherence === "number" && typeof snapshot.change_adherence === "number") {
117
+ printDelta("commits with a change", previous.change_adherence, snapshot.change_adherence);
118
+ }
101
119
  }
102
120
  }
103
121
  return 0;
@@ -140,6 +158,7 @@ const LABELS = {
140
158
  revert_rate: "revert rate",
141
159
  fix_share: "fix share",
142
160
  reedit_rate: `${REEDIT_WINDOW_DAYS}-day re-edit rate`,
161
+ change_adherence: "commits with a change",
143
162
  };
144
163
 
145
164
  function printReport(s) {
@@ -148,6 +167,10 @@ function printReport(s) {
148
167
  console.log(`fix-typed commits ${pct(s.fix_share)} of commits (Conventional Commits "fix")`);
149
168
  console.log(`${s.reedit_window_days}-day re-edit rate ${pct(s.reedit_rate)} of commits touch a file edited in the prior ${s.reedit_window_days} days`);
150
169
  console.log(c.gray(" (proxy for rework — iterative work also counts)"));
170
+ if (s.change_adherence !== null && s.change_adherence !== undefined) {
171
+ console.log(`commits with a change ${pct(s.change_adherence)} of ${s.code_commits} code commits touched .doctrina/changes/ or named a change`);
172
+ console.log(c.gray(" (workflow adherence — a one-line fix may rightly need none)"));
173
+ }
151
174
  if (s.top_churn.length > 0) {
152
175
  console.log("");
153
176
  console.log("top churn:");
@@ -200,6 +223,20 @@ function commandUsage() {
200
223
  for (const [op, n] of used) {
201
224
  console.log(` ${String(n).padStart(5)} ${c.cyan(op)}`);
202
225
  }
226
+ // The cycle, read off the sample (change 0218): did a change get its read
227
+ // and its close? A field review found `prime` and `work` used, `context`
228
+ // and `triage` skipped. Ratios, not verdicts — one context can serve two
229
+ // works, and a close can be retried.
230
+ const count = (op) => used.find(([o]) => o === op)?.[1] ?? 0;
231
+ const works = count("work");
232
+ if (works > 0) {
233
+ console.log("");
234
+ console.log(c.bold("Workflow") + c.gray(` — per \`work\` (${works})`));
235
+ console.log(` context ${(count("context") / works).toFixed(1)} close ${(count("close") / works).toFixed(1)} prime ${(count("prime") / works).toFixed(1)}`);
236
+ if (count("context") < works) {
237
+ console.log(c.gray(" fewer reads than changes: the pack is the spec, `prime` is only the state"));
238
+ }
239
+ }
203
240
  console.log("");
204
241
  console.log(c.gray(`${unused.length} of ${catalog.length} operations never invoked in this sample:`));
205
242
  for (let i = 0; i < unused.length; i += 4) {
@@ -216,12 +253,15 @@ export const help = `
216
253
  Usage: doctrina metrics [--since <days|date>] [--save] [--commands]
217
254
 
218
255
  Derive adoption metrics from LOCAL git history: commit count, revert
219
- count and rate, Conventional-Commit fix share, top-churn files, and a
220
- ${REEDIT_WINDOW_DAYS}-day re-edit proxy rate. No network calls; nothing leaves the repo.
256
+ count and rate, Conventional-Commit fix share, top-churn files, a
257
+ ${REEDIT_WINDOW_DAYS}-day re-edit proxy rate, and workflow adherence — the share of
258
+ code commits that carried a change (touched .doctrina/changes/ or named
259
+ one). No network calls; nothing leaves the repo.
221
260
 
222
261
  Flags:
223
262
  --commands Report which operations were actually invoked, from the
224
- opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
263
+ opt-in usage log, and how many context, close and prime
264
+ runs there were per work. Recording is OFF unless DOCTRINA_USAGE_LOG
225
265
  names a file; only the operation is recorded, never
226
266
  arguments, and nothing leaves the machine.
227
267
  --since <n|date> Window: a day count (default 90), a calendar date
@@ -8,6 +8,7 @@ import { c } from "../lib/colors.js";
8
8
  import { emitJson } from "../lib/json-out.js";
9
9
  import { EXIT } from "../lib/exit-codes.js";
10
10
  import { action, computeActions, invocation } from "../lib/actions.js";
11
+ import { attachCommand, contextRouting, shadowedCloneAt, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
11
12
 
12
13
  // `next` answers "what now?". The ANSWER lives in `lib/actions.js` as
13
14
  // records; this module renders them and, under `--run`, executes one
@@ -22,18 +23,48 @@ import { action, computeActions, invocation } from "../lib/actions.js";
22
23
  // wrap it in the generic envelope.
23
24
  export const jsonNative = true;
24
25
 
26
+ // How many actions `next` prints at a workspace root.
27
+ const ROOT_ACTIONS_SHOWN = 10;
28
+
25
29
  export const flags = { boolean: ["json", "run"], string: [] };
26
30
 
27
31
  export async function run(_positional, flags) {
28
32
  const projectRoot = process.cwd();
29
33
  const json = flagBool(flags, "json", false);
34
+ // At a workspace root the planner's next step is the workspace's — never
35
+ // `init`, which would create a project inside the workspace (change 0230).
36
+ const wsRoot = workspaceRootAt(projectRoot);
37
+ if (wsRoot) {
38
+ const actions = workspaceActions(workspaceSnapshot(wsRoot));
39
+ if (json) emitJson("next", { workspace: wsRoot, actions });
40
+ else {
41
+ console.log(c.bold("Next actions in the workspace") + c.gray(" (in priority order):"));
42
+ console.log("");
43
+ // The first actions, not all of them (fifth audit, change 0236): forty
44
+ // open deliveries printed 162 lines into the planner's context. The
45
+ // order already puts what unblocks most first; --json keeps every one.
46
+ actions.slice(0, ROOT_ACTIONS_SHOWN).forEach((a, i) => console.log(`${i + 1}. ${a.text}\n ${c.cyan(a.command)}`));
47
+ if (actions.length > ROOT_ACTIONS_SHOWN) {
48
+ console.log("");
49
+ console.log(c.gray(`… and ${actions.length - ROOT_ACTIONS_SHOWN} more — every delivery's state: \`doctrina delivery list\`; every action: \`doctrina next --json\``));
50
+ }
51
+ }
52
+ return 0;
53
+ }
30
54
  if (!exists(path.join(projectRoot, ".doctrina"))) {
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
- });
55
+ const clone = shadowedCloneAt(projectRoot);
56
+ const first = clone
57
+ ? action({
58
+ id: "shadowed-clone",
59
+ why: `this repository is the workspace's shadowed "${clone.name}" at another path — link its context here, never init it`,
60
+ text: `${attachCommand(clone, projectRoot)} — this repository is the workspace's shadowed "${clone.name}"${clone.same ? " and its links are missing" : ` (registered at ${clone.path})`}; never \`doctrina init\` here`,
61
+ })
62
+ : action({
63
+ id: "not-a-project",
64
+ command: "init",
65
+ runnable: false,
66
+ why: "this directory is not a Doctrina project yet",
67
+ });
37
68
  if (json) emitJson("next", { actions: [first] });
38
69
  else console.log(`1. ${first.text}`);
39
70
  return 0;
@@ -154,6 +185,10 @@ proposed status, accepted ADRs with nothing proving them (cite Evidence
154
185
  or run "decision land"), a skill-capture nudge when a past fix went
155
186
  uncaptured, and index drift last.
156
187
 
188
+ At a workspace root it lists the workspace's next actions instead —
189
+ absorb, route, open, hand the next card, close, clear a delivery's
190
+ record, close the delivery — and never names init there.
191
+
157
192
  Intended use: agents and humans run it to resume work without
158
193
  re-reading the whole tree.
159
194
 
@@ -1,11 +1,16 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { exists } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
6
- import { notADoctrinaProject } from "../lib/exit-codes.js";
7
- import { collectSnapshot } from "../lib/snapshot.js";
8
- import { renderView } from "../lib/views.js";
4
+ import { exists, isFile, read } from "../lib/fs-ops.js";
5
+ import { findWorkspace, readWorkspace } from "../lib/workspace.js";
6
+ import { listDeliveries } from "../lib/delivery.js";
7
+ import { workspacePrimer } from "../lib/workspace-agents.js";
8
+ import { projectWorkspaceLine } from "../lib/workspace-view.js";
9
+ import { flagBool, flagString } from "../lib/args.js";
10
+ import { c } from "../lib/colors.js";
11
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { collectSnapshot, openChanges, recentlyClosed } from "../lib/snapshot.js";
13
+ import { renderView, pulse } from "../lib/views.js";
9
14
 
10
15
  // Session primer: the ~40-line read that orients an agent at the start of a
11
16
  // session — where things stand, what the standing rules are, what work is
@@ -22,23 +27,125 @@ import { renderView } from "../lib/views.js";
22
27
  // Flags this command accepts. Declared HERE, with the command, so
23
28
  // adding a command never requires editing the entrypoint — the gap that
24
29
  // let six flags ship undeclared and silently swallow a positional (C3).
25
- export const flags = { boolean: ["json", "rules"], string: [] };
30
+ export const flags = { boolean: ["json", "rules", "pulse"], string: ["hook"] };
31
+
32
+ // What an agent hook accepts on stdout (change 0211). Claude Code adds the
33
+ // plain text of a SessionStart hook to the conversation; Cursor's
34
+ // sessionStart reads `additional_context` from a JSON object. Both cap what
35
+ // they keep — Claude at 10,000 characters — so the primer is cut short of
36
+ // that rather than replaced by a file path the agent is never asked to read.
37
+ const HOOK_AGENTS = ["claude", "cursor"];
38
+ const HOOK_MAX_CHARS = 9000;
26
39
 
27
40
  export async function run(_positional, flags) {
28
41
  const projectRoot = process.cwd();
29
42
  if (!exists(path.join(projectRoot, ".doctrina"))) {
43
+ // At a workspace root the session belongs to the orchestrator (change
44
+ // 0223): the primer is the workspace's.
45
+ const found = findWorkspace(projectRoot);
46
+ if (found && found.via !== "pointer") return primeWorkspace(found.dir, flags);
30
47
  throw notADoctrinaProject();
31
48
  }
32
49
  // --rules: the standing rules in full, which is what the removed
33
50
  // `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
34
51
  // prints both lists, from the same collection, so the two cannot disagree.
52
+ // --pulse: one line for a per-prompt hook — the open changes only, read
53
+ // without collecting the gates, because it runs before every prompt.
54
+ if (flagBool(flags, "pulse", false)) {
55
+ // Run by Cursor as the Claude adapter's UserPromptSubmit (sixth audit,
56
+ // change 0237): Cursor imports `.claude/settings.json` hooks and runs it
57
+ // as beforeSubmitPrompt, whose answer is `{"continue": …}` and cannot add
58
+ // context. The plain-text line Claude Code reads is not that.
59
+ if (underCursor()) {
60
+ console.log(JSON.stringify({ continue: true }));
61
+ return 0;
62
+ }
63
+ for (const line of pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))) console.log(line);
64
+ return 0;
65
+ }
66
+ if (flags.has("hook")) {
67
+ const agent = flagString(flags, "hook");
68
+ if (!agent || !HOOK_AGENTS.includes(agent)) {
69
+ console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
70
+ return EXIT.USAGE;
71
+ }
72
+ const as = hookFlavour(agent, projectRoot);
73
+ if (as === null) return 0;
74
+ const text = ["Doctrina session primer (`doctrina prime`) — the project's gates, rules and open work:", "",
75
+ ...renderView("prime", collectSnapshot(projectRoot)), ...workspaceLines(projectRoot), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
76
+ .join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
77
+ console.log(as === "cursor" ? JSON.stringify({ additional_context: text }) : text);
78
+ return 0;
79
+ }
35
80
  const view = flagBool(flags, "rules", false) ? "rules" : "prime";
36
81
  for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
82
+ if (view === "prime") for (const line of workspaceLines(projectRoot)) console.log(line);
83
+ return 0;
84
+ }
85
+
86
+ // Cursor runs the hooks Claude Code's `.claude/settings.json` declares too
87
+ // (its "third-party" import, on by default), and sets CURSOR_VERSION and
88
+ // CURSOR_PROJECT_DIR for every hook it runs (sixth audit, change 0237).
89
+ function underCursor(env = process.env) {
90
+ return Boolean(env.CURSOR_VERSION && env.CURSOR_PROJECT_DIR);
91
+ }
92
+
93
+ /**
94
+ * The format a session hook answers in, or null to answer nothing. The
95
+ * Claude adapter's SessionStart, run by Cursor, printed plain text where
96
+ * Cursor reads JSON — and beside the Cursor adapter's own sessionStart it
97
+ * put the primer in twice. Under Cursor it yields to that hook when the
98
+ * folder has one, and speaks Cursor's format when it has not.
99
+ * @returns {"claude" | "cursor" | null}
100
+ */
101
+ function hookFlavour(agent, dir) {
102
+ if (agent !== "claude" || !underCursor()) return /** @type {"claude" | "cursor"} */ (agent);
103
+ const own = path.join(dir, ".cursor", "hooks.json");
104
+ return isFile(own) && /prime --hook cursor/.test(read(own)) ? null : "cursor";
105
+ }
106
+
107
+ // A project inside a workspace says so (change 0230): a session opened in
108
+ // the repository — an executor's, or the human's — otherwise never learns a
109
+ // delivery is open on it, or where its planner works.
110
+ function workspaceLines(projectRoot) {
111
+ const w = projectWorkspaceLine(projectRoot);
112
+ if (!w) return [];
113
+ const out = ["", `Workspace ${w.name} (${w.dir}) — this repository is "${w.project}"`];
114
+ for (const d of w.deliveries) {
115
+ out.push(` open delivery ${d.id} — ${d.title}: ${d.changes.length ? `this project's change ${d.changes.join(", ")}` : "no change here yet"}`);
116
+ }
117
+ if (w.deliveries.length === 0) out.push(" no open delivery routes here");
118
+ return out;
119
+ }
120
+
121
+ function primeWorkspace(wsDir, flags) {
122
+ const ws = readWorkspace(wsDir);
123
+ const open = listDeliveries(wsDir).filter((d) => d.status !== "closed");
124
+ if (flagBool(flags, "pulse", false)) {
125
+ console.log(open.length === 0
126
+ ? "Doctrina workspace: no delivery is open — an item of work across repositories opens one with `doctrina delivery new \"<title>\" --ref <id>`."
127
+ : open.map((d) => `open delivery: ${d.id} — "${d.title}" (${d.projects.join(", ") || "not routed"}). Name it in your first sentence to the human; \`doctrina delivery status ${d.id}\`.`).join(" "));
128
+ return 0;
129
+ }
130
+ const lines = workspacePrimer(ws, wsDir, open);
131
+ if (flags.has("hook")) {
132
+ const agent = flagString(flags, "hook");
133
+ if (!agent || !HOOK_AGENTS.includes(agent)) {
134
+ console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
135
+ return EXIT.USAGE;
136
+ }
137
+ const as = hookFlavour(agent, wsDir);
138
+ if (as === null) return 0;
139
+ const text = [...lines, "", read(path.join(wsDir, "AGENTS.md"))].join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
140
+ console.log(as === "cursor" ? JSON.stringify({ additional_context: text }) : text);
141
+ return 0;
142
+ }
143
+ for (const line of lines) console.log(line);
37
144
  return 0;
38
145
  }
39
146
 
40
147
  export const help = `
41
- Usage: doctrina prime
148
+ Usage: doctrina prime [--rules | --pulse | --hook claude|cursor]
42
149
 
43
150
  Print the session primer: gate digest, standing rules (accepted ADR
44
151
  titles + non-goal count), open work with task progress, and the top
@@ -51,6 +158,17 @@ collection of the tree, so they cannot disagree.
51
158
  --rules Print the standing rules in full instead of the primer: every
52
159
  accepted ADR and every declared non-goal — what the removed
53
160
  \`doctrina constitution\` printed, from the same collection.
161
+ --pulse One line for a per-prompt agent hook: the open change(s), or
162
+ that a request changing behaviour opens one with \`work\`.
163
+ --hook <agent>
164
+ The primer plus the pulse, as the agent's session-start hook
165
+ reads it: plain text for claude, {"additional_context"} JSON
166
+ for cursor; cut at 9,000 characters. \`doctrina adapter add
167
+ claude|cursor\` installs the hooks that call these.
168
+
169
+ At a workspace root (no .doctrina/, a workspace.json) it is the
170
+ orchestrator's primer: the projects and their state, the open deliveries,
171
+ the tools — and with --hook, the workspace's AGENTS.md after it.
54
172
 
55
173
  Read-only; always exits 0. Deeper reads: \`doctrina context\`,
56
174
  \`doctrina why <cap>\`, \`doctrina show <ref>\`.
@@ -6,6 +6,7 @@ import { exists, isDir, isFile, read } from "../lib/fs-ops.js";
6
6
  import { flagBool, flagString } from "../lib/args.js";
7
7
  import { c } from "../lib/colors.js";
8
8
  import { rankCapabilitiesByDiff } from "../lib/work-model.js";
9
+ import { applyOps, extractOps } from "../lib/spec-ops.js";
9
10
  import { readLedger, churnByCapability } from "../lib/ledger.js";
10
11
  import { changedFiles, windowCutoff, historyState, refExists } from "../lib/git.js";
11
12
  import { dependentsOf, deltaCapabilities } from "../lib/scan.js";
@@ -67,6 +68,8 @@ export async function run(_positional, flags) {
67
68
  const changeId = flagString(flags, "change");
68
69
  /** @type {Set<string> | null} */
69
70
  let changeCaps = null;
71
+ /** @type {Map<string, string>} the change's delta text per capability */
72
+ const pending = new Map();
70
73
  if (changeId !== undefined) {
71
74
  const refused = refuseChangeRef(changeId);
72
75
  if (refused !== null) return refused;
@@ -76,6 +79,19 @@ export async function run(_positional, flags) {
76
79
  return EXIT.USAGE;
77
80
  }
78
81
  changeCaps = new Set(deltaCapabilities(changeDir));
82
+ // The specs as this change leaves them: an ops delta applied in memory,
83
+ // as `change check` dry-runs it; a delta without ops (a manual merge)
84
+ // read beside the spec, since nothing can apply it.
85
+ for (const cap of changeCaps) {
86
+ const delta = path.join(changeDir, "specs", cap, "delta.md");
87
+ const spec = path.join(projectRoot, ".doctrina", "specs", cap, "spec.md");
88
+ if (!isFile(delta)) continue;
89
+ const deltaText = read(delta);
90
+ const current = isFile(spec) ? read(spec) : "";
91
+ const ops = extractOps(deltaText);
92
+ const after = ops.length > 0 ? applyOps(current, ops) : null;
93
+ pending.set(cap, after && after.errors.length === 0 ? after.text : `${current}\n${deltaText}`);
94
+ }
79
95
  }
80
96
 
81
97
  const { all, sourceFiles } = reviewScope(projectRoot, against);
@@ -102,11 +118,19 @@ export async function run(_positional, flags) {
102
118
  all.filter((f) => /(^|\/)\.doctrina\/specs\/[^/]+\/spec\.md$/.test(f.replace(/\\/g, "/")))
103
119
  .map((f) => f.replace(/\\/g, "/").match(/\.doctrina\/specs\/([^/]+)\/spec\.md$/)[1]),
104
120
  );
105
- const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity });
121
+ const ranked = rankCapabilitiesByDiff(projectRoot, sourceFiles, { limit: Infinity, pending });
106
122
  const touched = new Set([...specChanged, ...ranked.map((m) => m.id)]);
107
123
  const stale = ranked.map((m) => m.id).filter((id) => !specChanged.has(id) && !changeCaps?.has(id));
124
+ // Without --change, an OPEN change that carries a delta for the capability
125
+ // updates its spec at close (fifth audit, change 0236): the bare review
126
+ // reported "its spec was not updated" for exactly the work a planner had
127
+ // in flight — the real agent read it as a break and went to fix the spec.
128
+ const inFlight = changeCaps === null ? openDeltaOwners(projectRoot) : new Map();
108
129
  if (changeCaps === null) {
109
- for (const id of stale) {
130
+ for (const id of stale.filter((x) => inFlight.has(x))) {
131
+ notes.push(`code under capability "${id}" changed; its spec is updated when ${inFlight.get(id)?.join(", ")} closes (\`doctrina review --change <id>\` reviews it as that close does)`);
132
+ }
133
+ for (const id of stale.filter((x) => !inFlight.has(x))) {
110
134
  breaks.push(`code under capability "${id}" changed, but its spec was not updated — confirm \`.doctrina/specs/${id}/spec.md\` still describes the code (update it, or note why not)`);
111
135
  }
112
136
  } else if (stale.length > 0) {
@@ -151,7 +175,7 @@ export async function run(_positional, flags) {
151
175
  // file in the change. A review of an adapter change reported
152
176
  // "Capabilities touched: docs" and said nothing about the adapter.
153
177
  const orphans = sourceFiles.filter(
154
- (f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1 }).length === 0,
178
+ (f) => rankCapabilitiesByDiff(projectRoot, [f], { limit: 1, pending }).length === 0,
155
179
  );
156
180
  if (orphans.length > 0) {
157
181
  const shown = orphans.slice(0, ORPHAN_LIST_LIMIT).map((f) => `\`${f}\``).join(", ");
@@ -274,3 +298,15 @@ from specs that declare no Source.
274
298
  Read-only. Exits 0 as a report; with --strict, exits 1 when any hard break
275
299
  exists (CI gate). Notes (advisory) never fail the gate.
276
300
  `;
301
+
302
+ /** Capability → the open changes that carry a delta for it. */
303
+ function openDeltaOwners(projectRoot) {
304
+ const out = new Map();
305
+ const changes = path.join(projectRoot, ".doctrina", "changes");
306
+ if (!isDir(changes)) return out;
307
+ for (const id of readdirSync(changes).sort()) {
308
+ if (id === "archive" || id.startsWith(".") || !isDir(path.join(changes, id))) continue;
309
+ for (const cap of deltaCapabilities(path.join(changes, id))) out.set(cap, [...(out.get(cap) ?? []), id]);
310
+ }
311
+ return out;
312
+ }
@@ -455,7 +455,8 @@ knowledge loaded only when relevant.
455
455
 
456
456
  Subcommands:
457
457
  new <name> Scaffold a new skill from the template and index
458
- it. Name must match [a-z][a-z0-9-]*.
458
+ it. Name must match [a-z][a-z0-9-]*. --force
459
+ overwrites an existing skill.
459
460
  list Print one line per skill with name + description.
460
461
  Read-only.
461
462
  sync Copy each skill's frontmatter description into
@@ -11,6 +11,7 @@ import { renderView, VIEWS } from "../lib/views.js";
11
11
  import { draftAgentChangelog, renderDraft } from "../lib/agent-changelog.js";
12
12
  import { cliVersion } from "../lib/version.js";
13
13
  import { suggest } from "../lib/suggest.js";
14
+ import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
14
15
 
15
16
  // One-glance project health (review 2026-06-27 passive-user feature #1): a
16
17
  // dashboard that answers "where do things stand?" in a single read, so neither
@@ -38,6 +39,8 @@ const STATUS_VIEWS = [...VIEWS, "agent-changelog"];
38
39
 
39
40
  export async function run(_positional, flags) {
40
41
  const projectRoot = process.cwd();
42
+ const wsRoot = workspaceRootAt(projectRoot);
43
+ if (wsRoot) return workspaceStatusView(wsRoot, flags);
41
44
  if (!exists(path.join(projectRoot, ".doctrina"))) {
42
45
  throw notADoctrinaProject();
43
46
  }
@@ -90,6 +93,9 @@ signals (index drift, framework stamp, coverage %, trace anchors, whether
90
93
  verify is configured) and the artifact counts (specs by implementation
91
94
  state, open changes, decisions, skills). Read-only; always exits 0.
92
95
 
96
+ At a workspace root it prints the workspace instead: projects ready,
97
+ pending and missing, open changes, deliveries open and closed, tools.
98
+
93
99
  Flags:
94
100
  --view <name> Render a different shape of the same snapshot:
95
101
  dashboard the default one-glance health board
@@ -116,3 +122,25 @@ It is a fast summary, not the authoritative gate: run \`doctrina validate\`
116
122
  and \`doctrina verify\` for the full structural and build checks, and
117
123
  \`doctrina next\` for the recommended next action.
118
124
  `;
125
+
126
+ // At a workspace root: one glance at the workspace (change 0230).
127
+ function workspaceStatusView(wsRoot, flags) {
128
+ const snap = workspaceSnapshot(wsRoot);
129
+ const st = workspaceStatus(snap);
130
+ if (flagBool(flags, "json", false)) {
131
+ emitJson("status", { workspace: wsRoot, ...st });
132
+ return 0;
133
+ }
134
+ console.log(c.bold(`Doctrina workspace "${st.name}"`) + c.gray(` — ${wsRoot}`));
135
+ console.log("");
136
+ console.log(`Projects ${st.ready}/${st.projects} ready` + (st.pending.length ? c.yellow(` · pending: ${st.pending.join(", ")}`) : "")
137
+ + (st.missing.length ? c.red(` · missing: ${st.missing.join(", ")}`) : "") + ` · ${st.openChanges} open change${st.openChanges === 1 ? "" : "s"}`);
138
+ console.log(`Deliveries ${st.deliveries.open} open · ${st.deliveries.closed} closed`);
139
+ console.log(`Tools ${st.tools} declared`);
140
+ const first = workspaceActions(snap)[0];
141
+ if (first) {
142
+ console.log("");
143
+ console.log(c.gray("next: ") + `${first.text} — ` + c.cyan(first.command));
144
+ }
145
+ return 0;
146
+ }
@@ -9,6 +9,10 @@ import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { today } from "../lib/dates.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
13
+ import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
14
+ import { projectName } from "../lib/project.js";
15
+ import { relinkShadow, shadowRootOf } from "../lib/workspace.js";
12
16
  import { collectFindings } from "../lib/templates-model.js";
13
17
  import * as templates from "./templates.js";
14
18
  import * as validate from "./validate.js";
@@ -74,6 +78,13 @@ export async function run(_positional, flags) {
74
78
  console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
75
79
  }
76
80
 
81
+ // The skill exports (change 0212) follow the index: regenerated by the
82
+ // write, reported by the preview.
83
+ const exportPlan = planSkillExport(projectRoot);
84
+ // Adapter files that moved (change 0238): Cursor's slash commands became skills.
85
+ const movePlan = planAdapterMigration(projectRoot, { PROJECT_NAME: projectName(projectRoot), PROJECT_DESCRIPTION: "", DATE: today() }, shadowRootOf(projectRoot) ?? projectRoot);
86
+ const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
87
+
77
88
  // 3. Index: rebuild from the tree, which also migrates the framework stamp.
78
89
  console.log("");
79
90
  console.log(c.gray("──── 2/3 index + framework stamp"));
@@ -81,6 +92,28 @@ export async function run(_positional, flags) {
81
92
  const current = idx.load(projectRoot);
82
93
  const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
83
94
  const staleStamp = (current.framework_version ?? null) !== running;
95
+ if (exportChanges > 0) {
96
+ if (writeMode) {
97
+ applySkillExport(projectRoot, exportPlan);
98
+ console.log(c.green("export ") + ` ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
99
+ } else {
100
+ console.log(c.yellow("would ") + `export ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
101
+ pending += 1;
102
+ }
103
+ }
104
+ const moves = movePlan.writes.length + movePlan.removes.length;
105
+ if (moves > 0) {
106
+ if (writeMode) {
107
+ const gone = applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
108
+ relinkShadow(projectRoot, gone);
109
+ console.log(c.green("moved ") + ` ${movePlan.writes.length} adapter file${movePlan.writes.length === 1 ? "" : "s"} to the skills the agent reads now` +
110
+ (movePlan.removes.length ? `, ${movePlan.removes.length} retired cop${movePlan.removes.length === 1 ? "y" : "ies"} removed` : ""));
111
+ } else {
112
+ console.log(c.yellow("would ") + `move ${movePlan.removes.length || movePlan.writes.length} Cursor command${(movePlan.removes.length || movePlan.writes.length) === 1 ? "" : "s"} to .cursor/skills/ (Cursor reads skills now)`);
113
+ pending += 1;
114
+ }
115
+ }
116
+ for (const rel of movePlan.kept) console.log(c.yellow("kept ") + ` ${rel} — edited since install; its skill replaces it, so move your edits and remove it`);
84
117
  if (!drifted && !staleStamp) {
85
118
  console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
86
119
  } else if (writeMode) {
@@ -7,6 +7,7 @@ import { c } from "../lib/colors.js";
7
7
  import { emitJson } from "../lib/json-out.js";
8
8
  import { notADoctrinaProject } from "../lib/exit-codes.js";
9
9
  import { collectValidation } from "../lib/validation-model.js";
10
+ import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
10
11
 
11
12
  // The structural gate. The checks themselves live in lib/validation-model.js
12
13
  // so a driver can ASK for them instead of spawning this binary and parsing
@@ -27,6 +28,19 @@ export async function run(_positional, flags) {
27
28
  // Outside a Doctrina project this is a PRECONDITION, not a pile of
28
29
  // gate failures about missing files: the remedy is `doctrina init`,
29
30
  // not editing artifacts that do not exist yet (C7).
31
+ // At a workspace root: the workspace's own integrity — registry, shadow
32
+ // links, deliveries (change 0230). Each project validates inside itself.
33
+ const wsRoot = workspaceRootAt(projectRoot);
34
+ if (wsRoot) {
35
+ const { errors, warnings } = workspaceFindings(workspaceSnapshot(wsRoot));
36
+ for (const e of errors) console.log(c.red("error: ") + e);
37
+ for (const w of warnings) console.log(c.yellow("warn: ") + w);
38
+ console.log("");
39
+ console.log(errors.length === 0
40
+ ? c.green("ok") + ` the workspace is consistent${warnings.length ? ` (${warnings.length} warning${warnings.length === 1 ? "" : "s"})` : ""} — each project validates inside itself: \`cd <path> && doctrina validate\``
41
+ : c.red("fail") + ` ${errors.length} workspace error${errors.length === 1 ? "" : "s"}`);
42
+ return errors.length > 0 || (flagBool(flags, "strict", false) && warnings.length > 0) ? 1 : 0;
43
+ }
30
44
  if (!isDir(path.join(projectRoot, ".doctrina"))) throw notADoctrinaProject();
31
45
 
32
46
  const { errors, warnings, fixes } = collectValidation(projectRoot, {
@@ -82,6 +96,10 @@ Run schema, artifact-existence, and structural checks against the
82
96
  .doctrina/ tree in the current working directory. Exits 0 if no errors
83
97
  (warnings allowed), 1 otherwise. With --strict, a warning fails it too.
84
98
 
99
+ At a workspace root it checks the workspace instead — registered
100
+ projects and their dependencies, shadow links, deliveries — and leaves
101
+ each project's validation to \`cd <path> && doctrina validate\`.
102
+
85
103
  Two structural checks worth naming:
86
104
 
87
105
  Pipeline a spec's optional "### Pipeline" block declares ordered
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
5
5
  import { wantsJson } from "../lib/json-out.js";
6
6
  import { spawn, spawnSync } from "node:child_process";
7
7
  import { exists, isDir, isFile, read, write, relPath } from "../lib/fs-ops.js";
8
+ import { inventory } from "../lib/code-inventory.js";
8
9
  import { flagBool, flagString } from "../lib/args.js";
9
10
  import { today } from "../lib/dates.js";
10
11
  import { c } from "../lib/colors.js";
@@ -38,6 +39,16 @@ const STARTER = {
38
39
  ],
39
40
  };
40
41
 
42
+ const SEEDED_COMMENT =
43
+ "doctrina verify runs each check below, in order, through your shell. " +
44
+ "These were seeded from the project's own manifests by the code " +
45
+ "inventory — confirm each one runs what its name says, and remove or add " +
46
+ "checks until this file is the project's executable definition of 'the " +
47
+ "code actually works'. A non-zero exit fails the gate. A check may add an " +
48
+ "\"expect\" block — {\"fail_if_output_matches\": \"<regex>\"} and/or " +
49
+ "{\"require_output_matches\": \"<regex>\"} — so that a run which exits 0 " +
50
+ "having executed NOTHING fails the gate instead of passing it.";
51
+
41
52
  // Flags this command accepts. Declared HERE, with the command, so
42
53
  // adding a command never requires editing the entrypoint — the gap that
43
54
  // let six flags ship undeclared and silently swallow a positional (C3).
@@ -69,9 +80,24 @@ export async function run(_positional, flags) {
69
80
  console.error(c.red("error:") + ` ${CONFIG_REL} already exists (pass --force to overwrite)`);
70
81
  return 1;
71
82
  }
72
- write(configPath, JSON.stringify(STARTER, null, 2) + "\n", { force: true });
73
- console.log(c.green("created") + ` ${CONFIG_REL}`);
74
- console.log(c.gray("Edit the `checks` array with your real commands, then run `doctrina verify`."));
83
+ // Seeded from the code inventory when it found the project's own build
84
+ // and test commands (change 0228): the absorb playbook says "declare the
85
+ // build gate from the inventory's candidate commands", and the inventory
86
+ // lists them under that heading — while --init wrote `exit 1` anyway,
87
+ // leaving the agent to copy by hand what the CLI had just read. With no
88
+ // candidate the starter stays fail-closed: a gate nobody declared must
89
+ // not pass.
90
+ // A check that belongs to one unit of a monorepo runs there (`cwd`).
91
+ const seeded = inventory(projectRoot).checks.map((ch) => ({ name: ch.name, run: ch.command, ...(ch.cwd ? { cwd: ch.cwd } : {}) }));
92
+ const config = seeded.length > 0
93
+ ? { ...STARTER, $comment: SEEDED_COMMENT, checks: seeded }
94
+ : STARTER;
95
+ write(configPath, JSON.stringify(config, null, 2) + "\n", { force: true });
96
+ console.log(c.green("created") + ` ${CONFIG_REL}` +
97
+ (seeded.length > 0 ? c.gray(` — seeded from the code inventory: ${seeded.map((s) => s.cwd ? `${s.run} (in ${s.cwd}/)` : s.run).join(", ")}`) : ""));
98
+ console.log(c.gray(seeded.length > 0
99
+ ? "Confirm each check runs what its name says (add an \"expect\" block where a run could execute nothing), then run `doctrina verify`."
100
+ : "Edit the `checks` array with your real commands, then run `doctrina verify`."));
75
101
  return 0;
76
102
  }
77
103
 
@@ -488,7 +514,9 @@ copy accumulates for the match. An "expect" pattern that is not a valid
488
514
  regular expression fails at config time, not silently.
489
515
 
490
516
  Flags:
491
- --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force)
517
+ --init Scaffold a starter ${CONFIG_REL} (refuses to overwrite without --force):
518
+ the build and test commands the code inventory finds in the
519
+ project's manifests, or a fail-closed example when it finds none
492
520
  --list Print the configured checks without running them
493
521
  --clean Don't run checks; lint the project's package.json files for
494
522
  reproducibility footguns (entry points in a build dir with no