doctrina-cli 0.20.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 (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +92 -14
  5. package/src/commands/clarify.js +1 -204
  6. package/src/commands/context.js +33 -2
  7. package/src/commands/contract.js +1 -0
  8. package/src/commands/delivery.js +135 -17
  9. package/src/commands/doctor.js +21 -1
  10. package/src/commands/handoff.js +11 -2
  11. package/src/commands/init.js +13 -1
  12. package/src/commands/intake.js +16 -5
  13. package/src/commands/metrics.js +15 -0
  14. package/src/commands/next.js +41 -6
  15. package/src/commands/prime.js +53 -4
  16. package/src/commands/review.js +39 -3
  17. package/src/commands/skill.js +2 -1
  18. package/src/commands/status.js +28 -0
  19. package/src/commands/upgrade.js +18 -0
  20. package/src/commands/validate.js +18 -0
  21. package/src/commands/verify.js +32 -4
  22. package/src/commands/work.js +43 -8
  23. package/src/commands/workspace.js +73 -63
  24. package/src/index.js +98 -62
  25. package/src/lib/actions.js +43 -3
  26. package/src/lib/adapters.js +63 -1
  27. package/src/lib/args.js +33 -1
  28. package/src/lib/clarify-scan.js +221 -0
  29. package/src/lib/code-inventory.js +576 -103
  30. package/src/lib/commands.js +8 -0
  31. package/src/lib/context-slice.js +34 -3
  32. package/src/lib/delivery.js +88 -10
  33. package/src/lib/docs-impact.js +36 -1
  34. package/src/lib/fs-ops.js +23 -0
  35. package/src/lib/intake-model.js +1 -1
  36. package/src/lib/json-out.js +9 -1
  37. package/src/lib/shell.js +45 -0
  38. package/src/lib/snapshot.js +5 -1
  39. package/src/lib/task-card.js +100 -7
  40. package/src/lib/views.js +3 -3
  41. package/src/lib/work-model.js +8 -2
  42. package/src/lib/workspace-agents.js +65 -18
  43. package/src/lib/workspace-guard.js +65 -0
  44. package/src/lib/workspace-locate.js +141 -0
  45. package/src/lib/workspace-tools.js +63 -16
  46. package/src/lib/workspace-view.js +391 -0
  47. package/src/lib/workspace.js +82 -130
  48. package/templates/AGENTS.md.template +1 -1
  49. package/templates/README.md +2 -1
  50. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  51. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  52. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  53. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  54. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  55. package/templates/playbooks/absorb.md.template +39 -16
  56. package/templates/playbooks/bootstrap.md.template +18 -6
  57. 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,8 @@ 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";
20
22
 
21
23
  // How many never-invoked operations the usage row names before pointing at
22
24
  // `metrics --commands` for the rest. The spec states this number, so it is
@@ -43,6 +45,24 @@ export const flags = { boolean: ["json", "env"], string: [] };
43
45
 
44
46
  export async function run(_positional, _flags) {
45
47
  const projectRoot = process.cwd();
48
+ // At the workspace root (fourth audit, change 0235): the planner reaches
49
+ // for doctor when something looks wrong, and was told to `doctrina init`
50
+ // — a project inside the workspace. The root's diagnosis is the
51
+ // workspace's own integrity; each repository diagnoses itself.
52
+ const wsRoot = workspaceRootAt(projectRoot);
53
+ if (wsRoot) {
54
+ const snap = workspaceSnapshot(wsRoot);
55
+ const { errors, warnings } = workspaceFindings(snap);
56
+ console.log(c.bold("Doctrina doctor") + c.gray(` — workspace "${snap.ws.name}" (read-only):`));
57
+ console.log("");
58
+ for (const e of errors) console.log(` ${c.red("error")} ${e}`);
59
+ for (const w of warnings) console.log(` ${c.yellow("warn ")} ${w}`);
60
+ if (errors.length + warnings.length === 0) console.log(` ${c.green("ok ")} the workspace is consistent`);
61
+ console.log("");
62
+ console.log("Each repository diagnoses itself:");
63
+ for (const p of snap.projects.filter((x) => x.state !== "missing")) console.log(" " + c.cyan(cdThen(p.project.path, "doctrina doctor")));
64
+ return errors.length > 0 ? EXIT.GATE : EXIT.OK;
65
+ }
46
66
  if (!exists(path.join(projectRoot, ".doctrina"))) {
47
67
  throw notADoctrinaProject();
48
68
  }
@@ -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
 
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
+ import { attachCommand, shadowedCloneAt } from "../lib/workspace-view.js";
3
4
  import process from "node:process";
4
5
  import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
5
6
  import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
@@ -41,6 +42,15 @@ export async function run(_positional, flags) {
41
42
  const force = flagBool(flags, "force", false);
42
43
  const nonInteractive = flagBool(flags, "non-interactive", false);
43
44
  const projectRoot = process.cwd();
45
+ // A clone of a shadowed repository (third audit, change 0234): its context
46
+ // lives in the workspace, and a .doctrina/ planted here is one the
47
+ // repository may not carry — and one the workspace would never see.
48
+ const clone = force ? null : shadowedCloneAt(projectRoot);
49
+ if (clone && !path.resolve(projectRoot).startsWith(path.resolve(clone.wsDir) + path.sep)) {
50
+ console.error(c.red("error:") + ` this repository is the workspace's shadowed "${clone.name}" (registered at ${clone.path}) — its context lives in the workspace, not here`);
51
+ console.error(c.gray("hint: ") + `link it here instead: \`${attachCommand(clone, projectRoot)}\` — or pass --force to init a separate project anyway`);
52
+ return EXIT.PRECONDITION;
53
+ }
44
54
  const projectName = flagString(flags, "project-name") ?? path.basename(projectRoot);
45
55
  const date = flagString(flags, "date") ?? today();
46
56
 
@@ -454,6 +464,8 @@ Options:
454
464
  --from <path> Local conventions directory; if it contains AGENTS.md and/or
455
465
  .doctrina/product.md, the content is folded into the new project
456
466
  --date <YYYY-MM-DD> Override the system date
457
- --force Overwrite existing files
467
+ --force Overwrite existing files that are still untouched scaffolds;
468
+ also init a second clone of a repository a workspace shadows
469
+ --overwrite-content With --force, also overwrite an authored AGENTS.md or product.md
458
470
  --non-interactive Fail instead of prompting for missing values
459
471
  `;
@@ -43,7 +43,7 @@ export async function run(positional, flags) {
43
43
  // a bootstrap that was already done.
44
44
  if (flagBool(flags, "converted", false)) {
45
45
  ensureDoctrinaProject(projectRoot);
46
- // Converting is the claim that the bootstrap is done — step 6 of the
46
+ // Converting is the claim that the bootstrap is done — step 8 of the
47
47
  // playbook, "fix everything the gates report", made checkable (change
48
48
  // 0194). It used to convert over a tree `validate` failed, so the specs
49
49
  // became "the source of truth" while an error still said they were not
@@ -95,7 +95,12 @@ export async function run(positional, flags) {
95
95
  console.log(c.gray("hint: ") + "start implementation with `doctrina work \"<brief prompt>\"`");
96
96
  return 0;
97
97
  }
98
- printBootstrapPlaybook(projectRoot);
98
+ // An intake written by `--from-code` is a code inventory, and the
99
+ // playbook that reads it is the absorb (second audit, change 0233). The
100
+ // bootstrap one — "fill every product.md section from the intake" —
101
+ // handed a resuming agent the inventory as if it were the product's intent.
102
+ if (/code inventory/i.test(listHeader(read(intakePath), "Source") ?? "")) printAbsorbPlaybook(projectRoot);
103
+ else printBootstrapPlaybook(projectRoot);
99
104
  return 0;
100
105
  }
101
106
 
@@ -180,15 +185,21 @@ function intakeFromCode(projectRoot, force) {
180
185
  source: "code inventory (`doctrina intake --from-code`)",
181
186
  projectName: name, date: today(), force: true,
182
187
  });
183
- console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)} — ${inv.files} files read: ${inv.stack.join(", ") || "no manifest"}; ${inv.tests.files} test files; ${inv.areas.length} candidate areas`);
188
+ const plural = (/** @type {number} */ n, /** @type {string} */ word) => `${n} ${word}${n === 1 ? "" : "s"}`;
189
+ console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)} — ${plural(inv.files, "file")} read: ${inv.stack.join(", ") || "no manifest"}; ${plural(inv.tests.files, "test file")}; ${plural(inv.areas.length, "candidate area")}`);
184
190
  console.log("");
191
+ printAbsorbPlaybook(projectRoot);
192
+ return EXIT.OK;
193
+ }
194
+
195
+ /** The absorb playbook — printed by `--from-code`, and again by a bare `intake` while it is pending. */
196
+ function printAbsorbPlaybook(projectRoot) {
185
197
  const ws = findWorkspace(projectRoot);
186
198
  printPlaybookTemplate(projectRoot, "absorb", {
187
199
  WORKSPACE_STEP: ws
188
- ? `\n8. This project belongs to the workspace at ${ws.dir} — confirm it reads\n ready: ${c.cyan("doctrina workspace list")}, and commit the workspace.`
200
+ ? `\n10. This project belongs to the workspace at ${ws.dir} — confirm it reads\n ready: ${c.cyan("doctrina workspace list")}, and commit the workspace.`
189
201
  : "",
190
202
  });
191
- return EXIT.OK;
192
203
  }
193
204
 
194
205
  export const help = `
@@ -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
  }
@@ -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,10 +1,11 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { exists, read } from "../lib/fs-ops.js";
4
+ import { exists, isFile, read } from "../lib/fs-ops.js";
5
5
  import { findWorkspace, readWorkspace } from "../lib/workspace.js";
6
6
  import { listDeliveries } from "../lib/delivery.js";
7
7
  import { workspacePrimer } from "../lib/workspace-agents.js";
8
+ import { projectWorkspaceLine } from "../lib/workspace-view.js";
8
9
  import { flagBool, flagString } from "../lib/args.js";
9
10
  import { c } from "../lib/colors.js";
10
11
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
@@ -51,6 +52,14 @@ export async function run(_positional, flags) {
51
52
  // --pulse: one line for a per-prompt hook — the open changes only, read
52
53
  // without collecting the gates, because it runs before every prompt.
53
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
+ }
54
63
  for (const line of pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))) console.log(line);
55
64
  return 0;
56
65
  }
@@ -60,17 +69,55 @@ export async function run(_positional, flags) {
60
69
  console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
61
70
  return EXIT.USAGE;
62
71
  }
72
+ const as = hookFlavour(agent, projectRoot);
73
+ if (as === null) return 0;
63
74
  const text = ["Doctrina session primer (`doctrina prime`) — the project's gates, rules and open work:", "",
64
- ...renderView("prime", collectSnapshot(projectRoot)), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
75
+ ...renderView("prime", collectSnapshot(projectRoot)), ...workspaceLines(projectRoot), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
65
76
  .join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
66
- console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
77
+ console.log(as === "cursor" ? JSON.stringify({ additional_context: text }) : text);
67
78
  return 0;
68
79
  }
69
80
  const view = flagBool(flags, "rules", false) ? "rules" : "prime";
70
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);
71
83
  return 0;
72
84
  }
73
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
+
74
121
  function primeWorkspace(wsDir, flags) {
75
122
  const ws = readWorkspace(wsDir);
76
123
  const open = listDeliveries(wsDir).filter((d) => d.status !== "closed");
@@ -87,8 +134,10 @@ function primeWorkspace(wsDir, flags) {
87
134
  console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
88
135
  return EXIT.USAGE;
89
136
  }
137
+ const as = hookFlavour(agent, wsDir);
138
+ if (as === null) return 0;
90
139
  const text = [...lines, "", read(path.join(wsDir, "AGENTS.md"))].join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
91
- console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
140
+ console.log(as === "cursor" ? JSON.stringify({ additional_context: text }) : text);
92
141
  return 0;
93
142
  }
94
143
  for (const line of lines) console.log(line);