doctrina-cli 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. package/templates/playbooks/delivery.md.template +12 -6
@@ -1,25 +1,30 @@
1
1
  // @ts-check
2
2
  import process from "node:process";
3
- import { relPath, write } from "../lib/fs-ops.js";
4
- import { flagString } from "../lib/args.js";
3
+ import { eolOf, read, relPath, withEol, write } from "../lib/fs-ops.js";
4
+ import { flagBool, flagString } from "../lib/args.js";
5
5
  import { c } from "../lib/colors.js";
6
+ import { emitJson } from "../lib/json-out.js";
6
7
  import { suggest } from "../lib/suggest.js";
7
8
  import { today } from "../lib/dates.js";
8
9
  import { EXIT, PreconditionError } from "../lib/exit-codes.js";
9
10
  import { readTemplate, substitute } from "../lib/templates.js";
10
11
  import { renderPlaybook } from "../lib/playbook.js";
11
- import { findWorkspace, readWorkspace } from "../lib/workspace.js";
12
+ import { absorbCommand, findWorkspace, readWorkspace } from "../lib/workspace.js";
12
13
  import {
13
14
  deliveryBlockers, deliveryChanges, deliveryPath, dependencyOrder, listDeliveries, nextDeliveryId,
14
15
  rankProjects, readDelivery, resolveDeliveryId, setDeliveryHeader, daysBetween, recordBlockers,
16
+ releaseBoxes, tickRelease, unroutable, otherWriters,
15
17
  } from "../lib/delivery.js";
18
+ import { cdThen, qarg } from "../lib/shell.js";
16
19
 
17
20
  // `doctrina delivery` — one item of work across repositories (ADR 0030).
18
21
  // Decisions in lib/delivery.js; this module renders them.
19
22
 
20
- const SUBCOMMANDS = ["new", "route", "list", "status", "check", "close"];
23
+ const SUBCOMMANDS = ["new", "route", "list", "status", "tick", "check", "close"];
21
24
 
22
- export const flags = { boolean: ["json"], string: ["ref", "projects", "date"] };
25
+ export const flags = { boolean: ["json", "all", "force"], string: ["ref", "projects", "date"] };
26
+ // `delivery status` builds its own payload (change 0234); the rest answer in the envelope.
27
+ export const jsonNative = (/** @type {string[]} */ args) => args[0] === "status";
23
28
 
24
29
  export async function run(positional, flags) {
25
30
  const sub = positional[0];
@@ -43,8 +48,9 @@ export async function run(positional, flags) {
43
48
  console.error(c.gray("hint: ") + "`doctrina delivery list` shows them");
44
49
  return EXIT.USAGE;
45
50
  }
46
- if (sub === "route") return deliveryRoute(wsDir, ws, id, positional[2] ?? flagString(flags, "projects"));
47
- if (sub === "status") return deliveryStatus(wsDir, ws, id);
51
+ if (sub === "route") return deliveryRoute(wsDir, ws, id, positional[2] ?? flagString(flags, "projects"), flags);
52
+ if (sub === "tick") return deliveryTick(wsDir, id, positional.slice(2), flags);
53
+ if (sub === "status") return deliveryStatus(wsDir, ws, id, flagBool(flags, "json", false));
48
54
  if (sub === "check") return deliveryCheck(wsDir, ws, id);
49
55
  return deliveryClose(wsDir, ws, id, flags);
50
56
  }
@@ -55,6 +61,20 @@ function parseProjects(ws, raw) {
55
61
  return { names, unknown };
56
62
  }
57
63
 
64
+ // A project with no spec, or whose repository is gone, cannot take a change
65
+ // for the delivery yet (change 0226). Refused unless --force, naming the fix.
66
+ function refuseUnroutable(wsDir, ws, names, flags) {
67
+ const blocked = unroutable(wsDir, ws, names);
68
+ if (blocked.length === 0 || flagBool(flags, "force", false)) return false;
69
+ for (const b of blocked) {
70
+ console.error(c.red("error:") + ` ${b.name} is ${b.state} (${b.reason}) — a change there would have no spec to change`);
71
+ console.error(c.gray("hint: ") + (b.state === "missing"
72
+ ? `point the workspace at the clone: \`doctrina workspace attach ${b.name} --path <dir>\``
73
+ : `absorb it first: \`${absorbCommand(b, b.reason)}\` — or route anyway with --force`));
74
+ }
75
+ return true;
76
+ }
77
+
58
78
  function deliveryNew(wsDir, ws, title, flags) {
59
79
  if (!title) {
60
80
  console.error(c.red("error:") + " delivery new needs a title");
@@ -69,6 +89,7 @@ function deliveryNew(wsDir, ws, title, flags) {
69
89
  console.error(c.red("error:") + ` --projects names no registered project: ${unknown.join(", ")}`);
70
90
  return EXIT.USAGE;
71
91
  }
92
+ if (refuseUnroutable(wsDir, ws, names, flags)) return EXIT.PRECONDITION;
72
93
  const id = nextDeliveryId(wsDir, title);
73
94
  const ref = flagString(flags, "ref") ?? "—";
74
95
  const ordered = dependencyOrder(ws, names);
@@ -95,6 +116,7 @@ function playbookTokens(wsDir, ws, delivery) {
95
116
  return {
96
117
  DELIVERY_ID: delivery.id,
97
118
  DELIVERY_TITLE: delivery.title,
119
+ DELIVERY_TITLE_ARG: qarg(delivery.title),
98
120
  DELIVERY_REF: delivery.ref === "—" ? "(no --ref given: ask the human which item this is)" : delivery.ref,
99
121
  DELIVERY_FILE: relPath(wsDir, deliveryPath(wsDir, delivery.id)),
100
122
  ROUTING: routing,
@@ -103,7 +125,7 @@ function playbookTokens(wsDir, ws, delivery) {
103
125
  };
104
126
  }
105
127
 
106
- function deliveryRoute(wsDir, ws, id, raw) {
128
+ function deliveryRoute(wsDir, ws, id, raw, flags) {
107
129
  const { names, unknown } = parseProjects(ws, raw);
108
130
  if (names.length === 0) {
109
131
  console.error(c.red("error:") + " delivery route needs the projects, comma-separated");
@@ -114,15 +136,35 @@ function deliveryRoute(wsDir, ws, id, raw) {
114
136
  console.error(c.red("error:") + ` no registered project named ${unknown.join(", ")}`);
115
137
  return EXIT.USAGE;
116
138
  }
139
+ if (refuseUnroutable(wsDir, ws, names, flags)) return EXIT.PRECONDITION;
140
+ // Routing a project OUT while it carries an open change for the delivery
141
+ // (third audit, change 0234) orphaned the change: it kept its Delivery
142
+ // header, the delivery stopped seeing it, and the delivery could close
143
+ // around work still in flight.
144
+ const before = readDelivery(wsDir, id);
145
+ const dropped = before.projects.filter((n) => !names.includes(n));
146
+ const carrying = deliveryChanges(wsDir, ws, { ...before, projects: dropped })
147
+ .flatMap((row) => row.changes.filter((ch) => !ch.archived).map((ch) => ({ project: row.project, id: ch.id })));
148
+ if (carrying.length > 0 && !flagBool(flags, "force", false)) {
149
+ for (const x of carrying) {
150
+ console.error(c.red("error:") + ` ${x.project} carries ${x.id} for ${id} — routing it out would orphan the change`);
151
+ }
152
+ console.error(c.gray("hint: ") + `close or abandon it there first (\`doctrina change abandon <id> --reason "<why>" --force\`), or pass --force to route it out anyway`);
153
+ return EXIT.PRECONDITION;
154
+ }
117
155
  const ordered = dependencyOrder(ws, names);
118
156
  setDeliveryHeader(wsDir, id, "Projects", ordered.join(", "));
119
157
  const d = readDelivery(wsDir, id);
120
158
  console.log(c.green("routed") + ` ${id} → ${ordered.join(" → ")}` + c.gray(" (dependencies first)"));
159
+ for (const x of carrying) console.log(c.yellow("warn") + ` ${x.project}'s ${x.id} still names ${id} in its Delivery header — routed out with --force`);
121
160
  console.log("");
161
+ const rows = deliveryChanges(wsDir, ws, d);
122
162
  console.log("Open one change in each, in this order:");
123
163
  for (const name of ordered) {
124
164
  const p = ws.projects.find((x) => x.name === name);
125
- console.log(" " + c.cyan(`cd ${p?.path} && doctrina work "${d.title}" --delivery ${id}`));
165
+ const has = rows.find((r) => r.project === name)?.changes ?? [];
166
+ if (has.length > 0) console.log(" " + c.gray(`${name} already carries ${has.map((ch) => ch.id).join(", ")}`));
167
+ else console.log(" " + c.cyan(cdThen(p?.path ?? "", `doctrina work ${qarg(d.title)} --delivery ${id}`)));
126
168
  }
127
169
  return EXIT.OK;
128
170
  }
@@ -151,19 +193,47 @@ function deliveryList(wsDir, ws) {
151
193
  return EXIT.OK;
152
194
  }
153
195
 
154
- function deliveryStatus(wsDir, ws, id) {
196
+ function deliveryStatus(wsDir, ws, id, json = false) {
155
197
  const d = readDelivery(wsDir, id);
198
+ // A structured payload for the planner's loop (third audit, change 0234):
199
+ // the envelope's text lines were all an agent got to branch on.
200
+ if (json) {
201
+ emitJson("delivery status", {
202
+ id: d.id, title: d.title, ref: d.ref, status: d.status, date: d.date ?? null, projects: d.projects,
203
+ rows: deliveryChanges(wsDir, ws, d).map((r) => ({
204
+ project: r.project, path: r.path ?? null, missing: r.missing ?? null, absent: r.absent ?? false,
205
+ changes: r.changes, abandoned: r.abandoned ?? [],
206
+ })),
207
+ otherWriters: otherWriters(wsDir, ws, d),
208
+ release: releaseBoxes(d.text).map(({ ordinal, checked, text }) => ({ ordinal, checked, text })),
209
+ blockers: deliveryBlockers(wsDir, ws, d),
210
+ });
211
+ return EXIT.OK;
212
+ }
156
213
  console.log(c.bold(`Delivery ${d.id}`) + ` — ${d.title}` + c.gray(` (${d.status}, ref ${d.ref}, opened ${d.date ?? "?"})`));
157
214
  console.log("");
158
215
  for (const row of deliveryChanges(wsDir, ws, d)) {
159
216
  console.log(` ${c.cyan(row.project)}${row.path ? c.gray(" " + row.path) : ""}`);
160
217
  if (row.missing) console.log(c.red(` ${row.missing}`));
161
- else if (row.changes.length === 0) console.log(c.yellow(" no change yet") + c.gray(` — cd ${row.path} && doctrina work "${d.title}" --delivery ${d.id}`));
218
+ // A repository that moved: a `cd` into its old path fails (change 0234).
219
+ else if (row.absent) console.log(c.red(" repository not at its path") + c.gray(` — doctrina workspace attach ${row.project} --path <dir>`));
220
+ else if (row.changes.length === 0 && row.abandoned?.length) console.log(c.yellow(` abandoned ${row.abandoned.map((a) => a.id).join(", ")}`) + c.gray(` — re-route without ${row.project}, or ${cdThen(row.path, `doctrina work ${qarg(d.title)} --delivery ${d.id}`)}`));
221
+ else if (row.changes.length === 0) console.log(c.yellow(" no change yet") + c.gray(` — ${cdThen(row.path, `doctrina work ${qarg(d.title)} --delivery ${d.id}`)}`));
162
222
  for (const ch of row.changes) {
163
223
  const colour = ch.archived ? c.green : c.yellow;
164
224
  console.log(` ${colour(ch.status.padEnd(9))} ${ch.id}` + c.gray(` tasks ${ch.tasksDone}/${ch.tasksTotal}`));
225
+ // The card is read from inside the repository (change 0226): from the
226
+ // workspace root there is no .doctrina/ to read it from.
227
+ if (!ch.archived && ch.placeholders > 0) console.log(c.gray(` tasks not written yet (placeholders): ${cdThen(row.path, `doctrina work --resume ${ch.id}`)}`));
228
+ else if (!ch.archived && ch.tasksDone < ch.tasksTotal) console.log(c.gray(` next card: ${cdThen(row.path, `doctrina change card ${ch.id}`)}`));
165
229
  }
166
230
  }
231
+ const others = otherWriters(wsDir, ws, d);
232
+ if (others.length > 0) {
233
+ console.log("");
234
+ console.log(c.yellow(" one writer per repository:") + c.gray(" these changes are open in the same repositories — sequence them, never two writers at once"));
235
+ for (const o of others) console.log(c.gray(` ${o.project}: ${o.id}${o.delivery ? ` (delivery ${o.delivery})` : " (no delivery)"}`));
236
+ }
167
237
  const record = recordBlockers(d);
168
238
  if (record.length > 0) {
169
239
  console.log(` ${c.cyan("record")}`);
@@ -177,6 +247,43 @@ function deliveryStatus(wsDir, ws, id) {
177
247
  return EXIT.OK;
178
248
  }
179
249
 
250
+ // The Release claims are ticked through the CLI, like a change's boxes
251
+ // (change 0226): Story, Sources, Plan and Observability are prose the planner
252
+ // writes; a Release box is a claim `delivery check` holds it to.
253
+ function deliveryTick(wsDir, id, args, flags) {
254
+ const file = deliveryPath(wsDir, id);
255
+ const text = read(file);
256
+ const boxes = releaseBoxes(text);
257
+ if (boxes.length === 0) {
258
+ console.log(c.gray(`${id} has no box under ## Release`));
259
+ return EXIT.OK;
260
+ }
261
+ const all = flagBool(flags, "all", false);
262
+ if (!all && args.length === 0) {
263
+ console.log(c.bold(`Release boxes in ${id}`) + c.gray(` (${boxes.filter((b) => !b.checked).length} of ${boxes.length} unticked):`));
264
+ for (const b of boxes) console.log(` ${String(b.ordinal).padStart(2)}. [${b.checked ? "x" : " "}] ${b.text}`);
265
+ console.log(c.gray("tick: ") + c.cyan(`doctrina delivery tick ${id} <n...>`) + c.gray(" · all: ") + c.cyan(`doctrina delivery tick ${id} --all`));
266
+ return EXIT.OK;
267
+ }
268
+ const ordinals = all ? "all" : args.map(Number);
269
+ if (ordinals !== "all") {
270
+ const bad = args.filter((a, i) => !Number.isInteger(ordinals[i]) || ordinals[i] < 1 || ordinals[i] > boxes.length);
271
+ if (bad.length > 0) {
272
+ console.error(c.red("error:") + ` no Release box ${bad.join(", ")} (1..${boxes.length} — \`doctrina delivery tick ${id}\` lists them)`);
273
+ return EXIT.USAGE;
274
+ }
275
+ }
276
+ // Count what this call changes: a box already ticked, or named twice, is
277
+ // not news, and "ticked 2" for one new tick is a claim the file contradicts.
278
+ const fresh = boxes.filter((b) => !b.checked && (ordinals === "all" || ordinals.includes(b.ordinal)));
279
+ if (fresh.length > 0) write(file, withEol(tickRelease(text, ordinals), eolOf(text)), { force: true });
280
+ const n = fresh.length;
281
+ console.log(n === 0
282
+ ? c.gray(`nothing to tick in ${id} — every named Release box is already ticked`)
283
+ : c.green("ticked") + ` ${n} Release box${n === 1 ? "" : "es"} in ${id}`);
284
+ return EXIT.OK;
285
+ }
286
+
180
287
  function deliveryCheck(wsDir, ws, id) {
181
288
  const d = readDelivery(wsDir, id);
182
289
  const blockers = deliveryBlockers(wsDir, ws, d);
@@ -206,7 +313,7 @@ function deliveryClose(wsDir, ws, id, flags) {
206
313
  }
207
314
 
208
315
  export const help = `
209
- Usage: doctrina delivery <new|route|list|status|check|close> [options]
316
+ Usage: doctrina delivery <new|route|list|status|tick|check|close> [options]
210
317
 
211
318
  One item of work across repositories (ADR 0030) — a story, a screen, a fix.
212
319
  It lives in the workspace's deliveries/ and holds what no single repository
@@ -214,24 +321,35 @@ owns. Each project it touches gets ONE change, opened there with
214
321
  \`doctrina work "<title>" --delivery <id>\`; the delivery's state is read
215
322
  from those changes, never copied into it.
216
323
 
217
- new "<title>" [--ref <tracker-id>] [--projects <a,b>]
324
+ new "<title>" [--ref <tracker-id>] [--projects <a,b>] [--force]
218
325
  Create deliveries/<NNNN-slug>/delivery.md and print the orchestration
219
326
  playbook: read the story, route, open one change per repository,
220
327
  delegate the task cards, close each change, release, watch, close.
221
328
  Without --projects it ranks the workspace's projects against the title.
222
- route <id> <a,b>
329
+ route <id> <a,b> [--force]
223
330
  Set the projects (dependencies first) and print the \`work\` line for each.
331
+ A project still pending (no spec) or missing is refused — absorb it with
332
+ \`doctrina intake --from-code\` first, or pass --force.
333
+ Dropping a project that carries an open change for the delivery is
334
+ refused too — close or abandon the change there first, or --force.
224
335
  list
225
336
  Every delivery with its status, ref, closed/total changes and age —
226
337
  lead time once closed, with the median over the closed ones.
227
338
  status <id>
228
- Per project: the changes carrying this delivery, their state and tasks.
339
+ Per project: the changes carrying this delivery, their state and tasks,
340
+ the command that reads the next task card, and any other open change
341
+ in the same repository — one writer per repository at a time.
342
+ --json: the same as a payload (rows, release boxes, blockers).
343
+ tick <id> [<n>... | --all]
344
+ List the ## Release boxes, or tick them: they are claims the check
345
+ holds you to, so they are ticked through the CLI.
229
346
  check <id>
230
347
  Exit 1 while a routed project has no change for it or one still open,
231
348
  while ## Story is empty, a ## Release box is unticked, or
232
349
  ## Observability records nothing.
233
- close <id>
234
- The check, then Status: closed and the Closed date.
350
+ close <id> [--date <YYYY-MM-DD>]
351
+ The check, then Status: closed and the Closed date (--date overrides
352
+ today, as it does for the Date that \`new\` writes).
235
353
 
236
354
  <id> is the folder name or its number (\`1\`, \`0001\`).
237
355
  `;
@@ -8,7 +8,7 @@ import { c } from "../lib/colors.js";
8
8
  import { flagBool } from "../lib/args.js";
9
9
  import { collectRuntimeFindings, checkLocalEnv } from "../lib/runtime.js";
10
10
  import { sequence, stepRerun } from "../lib/gates.js";
11
- import { notADoctrinaProject } from "../lib/exit-codes.js";
11
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
12
  import { agentsMdBudget, collectValidation } from "../lib/validation-model.js";
13
13
  import { collectIndexDrift } from "../lib/scan.js";
14
14
  import { collectReproducibility } from "../lib/reproducibility.js";
@@ -17,6 +17,13 @@ import { USAGE_ENV, summarise } from "../lib/usage.js";
17
17
  import { OPERATIONS } from "../lib/commands.js";
18
18
 
19
19
  import { coverageLabel } from "../lib/views.js";
20
+ import { workspaceFindings, workspaceRootAt, workspaceSnapshot } from "../lib/workspace-view.js";
21
+ import { cdThen } from "../lib/shell.js";
22
+ import { cliVersion } from "../lib/version.js";
23
+ import { installsOnPath, runningPackageDir } from "../lib/install.js";
24
+ import { missingAttributes, configuredDriver } from "../lib/merge-setup.js";
25
+ import { isRepo } from "../lib/git.js";
26
+ import { shadowRootOf } from "../lib/workspace.js";
20
27
 
21
28
  // How many never-invoked operations the usage row names before pointing at
22
29
  // `metrics --commands` for the rest. The spec states this number, so it is
@@ -43,6 +50,24 @@ export const flags = { boolean: ["json", "env"], string: [] };
43
50
 
44
51
  export async function run(_positional, _flags) {
45
52
  const projectRoot = process.cwd();
53
+ // At the workspace root (fourth audit, change 0235): the planner reaches
54
+ // for doctor when something looks wrong, and was told to `doctrina init`
55
+ // — a project inside the workspace. The root's diagnosis is the
56
+ // workspace's own integrity; each repository diagnoses itself.
57
+ const wsRoot = workspaceRootAt(projectRoot);
58
+ if (wsRoot) {
59
+ const snap = workspaceSnapshot(wsRoot);
60
+ const { errors, warnings } = workspaceFindings(snap);
61
+ console.log(c.bold("Doctrina doctor") + c.gray(` — workspace "${snap.ws.name}" (read-only):`));
62
+ console.log("");
63
+ for (const e of errors) console.log(` ${c.red("error")} ${e}`);
64
+ for (const w of warnings) console.log(` ${c.yellow("warn ")} ${w}`);
65
+ if (errors.length + warnings.length === 0) console.log(` ${c.green("ok ")} the workspace is consistent`);
66
+ console.log("");
67
+ console.log("Each repository diagnoses itself:");
68
+ for (const p of snap.projects.filter((x) => x.state !== "missing")) console.log(" " + c.cyan(cdThen(p.project.path, "doctrina doctor")));
69
+ return errors.length > 0 ? EXIT.GATE : EXIT.OK;
70
+ }
46
71
  if (!exists(path.join(projectRoot, ".doctrina"))) {
47
72
  throw notADoctrinaProject();
48
73
  }
@@ -256,9 +281,9 @@ export async function run(_positional, _flags) {
256
281
  " (run `doctrina verify` to execute)";
257
282
  if (stale > 0) {
258
283
  const parts = [];
259
- if (so.expired) parts.push(`${so.expired} expired`);
260
- if (so.unverifiable) parts.push(`${so.unverifiable} unverifiable`);
261
- if (so.pending) parts.push(`${so.pending} unsigned`);
284
+ if (so?.expired) parts.push(`${so.expired} expired`);
285
+ if (so?.unverifiable) parts.push(`${so.unverifiable} unverifiable`);
286
+ if (so?.pending) parts.push(`${so.pending} unsigned`);
262
287
  row("warn", "verify config", `${detail} — ${parts.join(", ")}`,
263
288
  "doctrina verify (each names the re-sign command)");
264
289
  warningsTotal += 1;
@@ -286,7 +311,7 @@ export async function run(_positional, _flags) {
286
311
  if (cfg.errors.length > 0) {
287
312
  row("fail", "config", cfg.errors[0], "fix .doctrina/config.json, then `doctrina validate`");
288
313
  } else if (cfg.unknown.length > 0) {
289
- row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules");
314
+ row("warn", "config", `${summary}; unknown key${cfg.unknown.length === 1 ? "" : "s"} ${cfg.unknown.map((k) => `"${k}"`).join(", ")} ignored`, "the keys are language, context_budget, rules, surface");
290
315
  warningsTotal += 1;
291
316
  } else {
292
317
  row("ok", "config", summary);
@@ -296,6 +321,45 @@ export async function run(_positional, _flags) {
296
321
  }
297
322
  },
298
323
 
324
+ // 9b. The CLI that is running, and the one a bare `doctrina` finds.
325
+ cli: () => {
326
+ const version = cliVersion();
327
+ const installs = installsOnPath();
328
+ const first = installs[0];
329
+ const here = runningPackageDir();
330
+ const others = installs.filter((i) => !i.running);
331
+ const label = (/** @type {{ bin: string, version: string | null }} */ i) => `${i.bin} (${i.version ?? "version unknown"})`;
332
+ if (first && !first.running) {
333
+ row("warn", "cli", `${version} is running from ${here}, but \`doctrina\` on the PATH is ${label(first)} — the hooks and every bare \`doctrina\` run that one`,
334
+ `remove or update the other install (npm uninstall -g doctrina-cli under the Node that owns it), or put this one first on the PATH`);
335
+ warningsTotal += 1;
336
+ } else if (others.length > 0) {
337
+ row("warn", "cli", `${version} at ${first.bin}; ${others.length} more on the PATH: ${others.map(label).join(", ")}`,
338
+ "remove the copies you do not use — a shell whose PATH orders them differently runs another version");
339
+ warningsTotal += 1;
340
+ } else if (!first && callsDoctrinaByName(projectRoot)) {
341
+ row("warn", "cli", `${version} is running from ${here}, and no \`doctrina\` is on the PATH — this project's agent hooks call it by name`,
342
+ "npm install -g doctrina-cli (or add its bin directory to the PATH)");
343
+ warningsTotal += 1;
344
+ } else {
345
+ row("ok", "cli", first ? `${version} at ${first.bin} — the only one on the PATH` : `${version}, run from ${here} (no \`doctrina\` on the PATH)`);
346
+ }
347
+ },
348
+
349
+ // 9c. Whether two branches that each close a change merge by themselves.
350
+ merges: () => {
351
+ if (!isRepo(projectRoot) || shadowRootOf(projectRoot) !== null) return;
352
+ const attributes = missingAttributes(projectRoot).length === 0;
353
+ const driver = configuredDriver(projectRoot) !== null;
354
+ if (attributes && driver) {
355
+ row("ok", "merges", "index.json and the ledger merge by themselves (driver and .gitattributes in place)");
356
+ return;
357
+ }
358
+ const missing = !attributes && !driver ? "nothing is set up" : !attributes ? ".gitattributes lacks the two lines" : "this clone has no merge driver";
359
+ row("warn", "merges", `${missing} — two branches that each close a change stop on index.json and the ledger`, "doctrina hooks install (once per clone; commit .gitattributes)");
360
+ warningsTotal += 1;
361
+ },
362
+
299
363
  // 10. Which of the catalog's operations have never been reached for —
300
364
  // but ONLY when the operator turned the log on. `lib/usage.js` was
301
365
  // built to instrument the surface before shrinking it, and the only
@@ -357,6 +421,14 @@ export async function run(_positional, _flags) {
357
421
  return 0;
358
422
  }
359
423
 
424
+ /** Does an agent hook in this project call `doctrina` by its bare name? */
425
+ function callsDoctrinaByName(projectRoot) {
426
+ return [".cursor/hooks.json", ".claude/settings.json"].some((rel) => {
427
+ const file = path.join(projectRoot, rel);
428
+ return isFile(file) && /"(command)":\s*"doctrina /.test(read(file));
429
+ });
430
+ }
431
+
360
432
  export const help = `
361
433
  Usage: doctrina doctor
362
434
 
@@ -371,6 +443,12 @@ exact remediation command.
371
443
  enums. Reports membership only; a rejected value is never
372
444
  printed, so this is safe to run and to paste.
373
445
 
446
+ Two rows read the machine and the clone, not the artifacts: \`cli\` names
447
+ the install that is running and any other \`doctrina\` the PATH holds — the
448
+ agent hooks call it by name, so the first one found is the one they run —
449
+ and \`merges\` says whether \`doctrina hooks install\` has set this clone up
450
+ to merge index.json and the ledger without a manual step.
451
+
374
452
  A driver over the same collections the gates themselves render; it adds
375
453
  no checks of its own, so it can never disagree with the gates it fronts,
376
454
  and it runs entirely in this process — no subprocess, no re-parsing of
@@ -6,6 +6,8 @@ import { c } from "../lib/colors.js";
6
6
  import { notADoctrinaProject } from "../lib/exit-codes.js";
7
7
  import { collectSnapshot } from "../lib/snapshot.js";
8
8
  import { renderView } from "../lib/views.js";
9
+ import { contextRouting, workspaceActions, workspaceFindings, workspaceHandoff, workspaceRootAt, workspaceSnapshot, workspaceStatus } from "../lib/workspace-view.js";
10
+ import { today } from "../lib/dates.js";
9
11
 
10
12
  // Session handoff note: everything the NEXT session (a fresh agent, a
11
13
  // teammate, tomorrow's you) needs to resume without re-reading the tree —
@@ -27,10 +29,14 @@ export const flags = { boolean: ["json"], string: [] };
27
29
 
28
30
  export async function run(_positional, _flags) {
29
31
  const projectRoot = process.cwd();
30
- if (!exists(path.join(projectRoot, ".doctrina"))) {
32
+ const wsRoot = workspaceRootAt(projectRoot);
33
+ if (!wsRoot && !exists(path.join(projectRoot, ".doctrina"))) {
31
34
  throw notADoctrinaProject();
32
35
  }
33
- for (const line of renderView("handoff", collectSnapshot(projectRoot))) console.log(line);
36
+ // At a workspace root: the planner's resume note — projects, open
37
+ // deliveries and their changes, the next actions (change 0230).
38
+ const lines = wsRoot ? workspaceHandoff(workspaceSnapshot(wsRoot), today()) : renderView("handoff", collectSnapshot(projectRoot));
39
+ for (const line of lines) console.log(line);
34
40
  if (process.stdout.isTTY) {
35
41
  console.log("");
36
42
  console.log(c.gray("tip: pipe it — `doctrina handoff > note.md` — or paste it as the first message of the next session"));
@@ -46,6 +52,9 @@ with its task-by-task progress and the exact resume command, and the
46
52
  prioritised next actions. Made to be pasted as the first message of the
47
53
  next agent session (or piped to a file).
48
54
 
55
+ At a workspace root it is the planner's note instead: projects and
56
+ their state, open deliveries with each repository's changes, next actions.
57
+
49
58
  The same view as \`doctrina status --view handoff\`; both render one
50
59
  collection of the tree.
51
60
 
@@ -11,6 +11,9 @@ import { flagBool } from "../lib/args.js";
11
11
  import { c } from "../lib/colors.js";
12
12
  import { suggest } from "../lib/suggest.js";
13
13
  import { EXIT } from "../lib/exit-codes.js";
14
+ import { writeAttributes, configureDriver, configuredDriver } from "../lib/merge-setup.js";
15
+ import { shadowRootOf } from "../lib/workspace.js";
16
+ import { gitPathOf, repoPrefix } from "../lib/git.js";
14
17
 
15
18
  const SUBCOMMANDS = ["install"];
16
19
 
@@ -33,16 +36,49 @@ export async function run(positional, flags) {
33
36
  const force = flagBool(flags, "force", false);
34
37
  const projectRoot = process.cwd();
35
38
 
36
- if (!exists(path.join(projectRoot, ".git"))) {
39
+ // Asked of git, not of `.git/` (seventh audit, change 0250). In a worktree
40
+ // and in a submodule `.git` is a file, so `mkdir .git/hooks` crashed with
41
+ // ENOTDIR after the attributes were already written; and a project in a
42
+ // subfolder of its repository has no `.git` beside it at all, so it was
43
+ // told to `git init` inside a repository — while `doctor` kept sending it
44
+ // back here.
45
+ const prefix = repoPrefix(projectRoot);
46
+ const hooksDir = prefix === null ? null : gitPathOf(projectRoot, "hooks");
47
+ if (prefix === null || hooksDir === null) {
37
48
  // A PRECONDITION, not a gate: nothing was measured and found wanting,
38
49
  // the place to put the hook simply does not exist yet. Class 1 told an
39
50
  // agent to fix its work and retry the same command, which never clears.
40
- console.error(c.red("error:") + " not a git repository (no .git/ in cwd)");
51
+ console.error(c.red("error:") + " not a git repository — git does not answer for this folder");
41
52
  console.error(c.gray("hint: ") + "run `git init` here, then `doctrina hooks install`");
42
53
  return EXIT.PRECONDITION;
43
54
  }
44
55
 
45
- const hooksDir = path.join(projectRoot, ".git", "hooks");
56
+ // The CLI that installed this, pinned by path (change 0116). `.git/` is
57
+ // local to the clone, so an absolute path is the right thing to write;
58
+ // a bare `doctrina` ran whichever release was on the PATH — a global
59
+ // 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
60
+ // `DOCTRINA=` in the environment overrides it for the hook.
61
+ const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
62
+ const bin = `node \"${entry}\"`;
63
+
64
+ // How the artifacts merge (change 0242). Before the hook, and whatever
65
+ // becomes of it: a pre-commit hook somebody else owns is no reason for
66
+ // two branches to stop on index.json. A shadowed repository keeps its
67
+ // `.doctrina/` out of its own git, so there is nothing of it to merge here.
68
+ if (shadowRootOf(projectRoot) === null) {
69
+ const added = writeAttributes(projectRoot);
70
+ if (added > 0) console.log(c.green("wrote") + ` .gitattributes — ${added} line${added === 1 ? "" : "s"}: index.json merges as data, the ledger as a list (commit it)`);
71
+ const before = configuredDriver(projectRoot);
72
+ const command = `node "${entry}"`;
73
+ if (before !== `${command} index merge %O %A %B`) {
74
+ if (configureDriver(projectRoot, command)) {
75
+ console.log(c.green("set") + " the index merge driver in this clone's git config (every clone runs `doctrina hooks install` once)");
76
+ } else {
77
+ console.log(c.yellow("warn:") + " git refused the merge driver; two branches that close changes will stop on index.json — `doctrina index rebuild` resolves it");
78
+ }
79
+ }
80
+ }
81
+
46
82
  mkdirp(hooksDir);
47
83
 
48
84
  const target = path.join(hooksDir, "pre-commit");
@@ -53,13 +89,13 @@ export async function run(positional, flags) {
53
89
 
54
90
  const templatesDir = locateTemplatesDir();
55
91
  const tplPath = path.join(templatesDir, "hooks", "pre-commit.sample");
56
- // The hook pins the CLI that installed it (change 0116). `.git/hooks/` is
57
- // local to the clone, so an absolute path is the right thing to write;
58
- // a bare `doctrina` ran whichever release was on the PATH — a global
59
- // 0.15.0 under a 0.15.1 tree rewrote the stamp backwards on every commit.
60
- // `DOCTRINA=` in the environment overrides it.
61
- const entry = path.resolve(here, "..", "index.js").replaceAll("\\", "/");
62
- const body = substitute(read(tplPath), { DOCTRINA_BIN: `node \"${entry}\"` });
92
+ let body = substitute(read(tplPath), { DOCTRINA_BIN: bin });
93
+ // git runs a hook from the top of the repository. A project that lives
94
+ // below it is entered first, or `validate` would answer for a folder that
95
+ // is not a Doctrina project.
96
+ if (prefix !== "") {
97
+ body = body.replace(/^DOCTRINA=/m, `# This Doctrina project lives below the repository root.\ncd "${prefix}" || exit 1\n\nDOCTRINA=`);
98
+ }
63
99
  write(target, body, { force: true });
64
100
  chmodSync(target, 0o755);
65
101
  console.log(c.green("installed") + ` ${relPath(projectRoot, target)}`);
@@ -72,7 +108,16 @@ export async function run(positional, flags) {
72
108
  export const help = `
73
109
  Usage: doctrina hooks install [--force]
74
110
 
75
- Install the Doctrina pre-commit hook into .git/hooks/pre-commit.
111
+ Set the repository up for commits and merges:
112
+
113
+ - .gitattributes gains two lines (commit them): index.json merges through
114
+ \`doctrina index merge\`, as a list of artifacts, and the ledger through
115
+ git's own \`union\`, so two branches that each closed a change merge
116
+ without conflict markers in either file;
117
+ - this clone's git config gains the index merge driver, pinned to the CLI
118
+ that installed it — every clone runs \`doctrina hooks install\` once;
119
+ - .git/hooks/pre-commit gains the Doctrina hook.
120
+
76
121
  The hook runs \`doctrina validate --fix\`: it regenerates index.json
77
122
  from the tree (healing the most common gate failure — a hand-edited
78
123
  header that drifted the index — and re-staging the repaired index)
@@ -10,8 +10,10 @@ import { c } from "../lib/colors.js";
10
10
  import { suggest } from "../lib/suggest.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
12
  import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
13
+ import { mergeIndex } from "../lib/index-merge.js";
14
+ import { readFileSync, writeFileSync } from "node:fs";
13
15
 
14
- const SUBCOMMANDS = ["rebuild"];
16
+ const SUBCOMMANDS = ["rebuild", "merge"];
15
17
 
16
18
  // Flags this command accepts. Declared HERE, with the command, so
17
19
  // adding a command never requires editing the entrypoint — the gap that
@@ -20,6 +22,7 @@ export const flags = { boolean: ["json", "check", "staged"], string: [] };
20
22
 
21
23
  export async function run(positional, flags) {
22
24
  const sub = positional[0];
25
+ if (sub === "merge") return merge(positional.slice(1));
23
26
  if (sub !== "rebuild") {
24
27
  console.error(c.red("error:") + ` unknown index subcommand "${sub ?? ""}"`);
25
28
  const guess = suggest(sub, SUBCOMMANDS);
@@ -98,6 +101,43 @@ function rebuild(flags) {
98
101
  return 0;
99
102
  }
100
103
 
104
+ // The merge driver git calls for `.doctrina/index.json` (change 0242):
105
+ // `doctrina index merge %O %A %B`. It writes the merged index over <ours>
106
+ // and exits 0, which is how a driver tells git there is nothing to resolve.
107
+ // A side that is not JSON is the one case it leaves to git, with exit 1.
108
+ function merge(args) {
109
+ const [basePath, oursPath, theirsPath] = args;
110
+ if (!basePath || !oursPath || !theirsPath) {
111
+ console.error(c.red("error:") + " index merge takes three files: <base> <ours> <theirs>");
112
+ console.error(c.gray("hint: ") + "git calls it as a merge driver — `doctrina hooks install` sets it up");
113
+ return 2;
114
+ }
115
+ const load = (/** @type {string} */ file, /** @type {string} */ side) => {
116
+ let text = "";
117
+ try {
118
+ text = readFileSync(file, "utf8");
119
+ } catch {
120
+ if (side === "base") return {};
121
+ throw new Error(`cannot read the ${side} index at ${file}`);
122
+ }
123
+ if (text.trim() === "") return {};
124
+ try {
125
+ return JSON.parse(text);
126
+ } catch (err) {
127
+ throw new Error(`the ${side} index is not valid JSON (${err.message})`);
128
+ }
129
+ };
130
+ let merged;
131
+ try {
132
+ merged = mergeIndex(load(basePath, "base"), load(oursPath, "ours"), load(theirsPath, "theirs"));
133
+ } catch (err) {
134
+ console.error(c.red("error:") + ` ${err.message} — left for a manual merge; \`doctrina index rebuild\` derives it from the tree afterwards`);
135
+ return 1;
136
+ }
137
+ writeFileSync(oursPath, JSON.stringify(merged, null, 2) + "\n");
138
+ return 0;
139
+ }
140
+
101
141
  // The drift check, asked of what git will record. `index.json` is derived
102
142
  // from the WHOLE tree, so it cannot describe a subset of it: stage part of
103
143
  // `.doctrina/` and the index riding along names artifacts the commit does
@@ -135,6 +175,12 @@ export const help = `
135
175
  Usage: doctrina index <subcommand>
136
176
 
137
177
  Subcommands:
178
+ merge <base> <ours> <theirs>
179
+ The merge driver git runs for index.json once
180
+ \`doctrina hooks install\` has set it up: two branches
181
+ that each closed a change merge as lists of artifacts,
182
+ so the file never stops a merge with conflict markers.
183
+ Writes the result over <ours>.
138
184
  rebuild Regenerate .doctrina/index.json from the artifacts on
139
185
  disk (spec headers, ADR headers, change proposals,
140
186
  archive folders, skill frontmatter). The files are the