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
@@ -0,0 +1,356 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { eolOf, read, relPath, withEol, write } from "../lib/fs-ops.js";
4
+ import { flagBool, flagString } from "../lib/args.js";
5
+ import { c } from "../lib/colors.js";
6
+ import { emitJson } from "../lib/json-out.js";
7
+ import { suggest } from "../lib/suggest.js";
8
+ import { today } from "../lib/dates.js";
9
+ import { EXIT, PreconditionError } from "../lib/exit-codes.js";
10
+ import { readTemplate, substitute } from "../lib/templates.js";
11
+ import { renderPlaybook } from "../lib/playbook.js";
12
+ import { absorbCommand, findWorkspace, readWorkspace } from "../lib/workspace.js";
13
+ import {
14
+ deliveryBlockers, deliveryChanges, deliveryPath, dependencyOrder, listDeliveries, nextDeliveryId,
15
+ rankProjects, readDelivery, resolveDeliveryId, setDeliveryHeader, daysBetween, recordBlockers,
16
+ releaseBoxes, tickRelease, unroutable, otherWriters,
17
+ } from "../lib/delivery.js";
18
+ import { cdThen, qarg } from "../lib/shell.js";
19
+
20
+ // `doctrina delivery` — one item of work across repositories (ADR 0030).
21
+ // Decisions in lib/delivery.js; this module renders them.
22
+
23
+ const SUBCOMMANDS = ["new", "route", "list", "status", "tick", "check", "close"];
24
+
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";
28
+
29
+ export async function run(positional, flags) {
30
+ const sub = positional[0];
31
+ if (!SUBCOMMANDS.includes(sub)) {
32
+ console.error(c.red("error:") + ` unknown delivery subcommand "${sub ?? ""}"`);
33
+ const guess = suggest(sub, SUBCOMMANDS);
34
+ console.error(c.gray("hint: ") + (guess ? `did you mean \`doctrina delivery ${guess}\`?` : `available: ${SUBCOMMANDS.join(", ")}`));
35
+ return EXIT.USAGE;
36
+ }
37
+ const found = findWorkspace(process.cwd());
38
+ if (!found) {
39
+ throw new PreconditionError("a delivery lives in a workspace, and none was found here", "doctrina workspace init <dir>");
40
+ }
41
+ const wsDir = found.dir;
42
+ const ws = readWorkspace(wsDir);
43
+ if (sub === "new") return deliveryNew(wsDir, ws, positional.slice(1).join(" ").trim(), flags);
44
+ if (sub === "list") return deliveryList(wsDir, ws);
45
+ const id = resolveDeliveryId(wsDir, positional[1]);
46
+ if (!id) {
47
+ console.error(c.red("error:") + ` no delivery "${positional[1] ?? ""}" in this workspace`);
48
+ console.error(c.gray("hint: ") + "`doctrina delivery list` shows them");
49
+ return EXIT.USAGE;
50
+ }
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));
54
+ if (sub === "check") return deliveryCheck(wsDir, ws, id);
55
+ return deliveryClose(wsDir, ws, id, flags);
56
+ }
57
+
58
+ function parseProjects(ws, raw) {
59
+ const names = String(raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
60
+ const unknown = names.filter((n) => !ws.projects.some((p) => p.name === n));
61
+ return { names, unknown };
62
+ }
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
+
78
+ function deliveryNew(wsDir, ws, title, flags) {
79
+ if (!title) {
80
+ console.error(c.red("error:") + " delivery new needs a title");
81
+ console.error(c.gray("hint: ") + 'doctrina delivery new "Login screen" --ref SHOP-123');
82
+ return EXIT.USAGE;
83
+ }
84
+ if (ws.projects.length === 0) {
85
+ throw new PreconditionError("the workspace has no project to deliver into", "doctrina workspace add <path>");
86
+ }
87
+ const { names, unknown } = parseProjects(ws, flagString(flags, "projects"));
88
+ if (unknown.length > 0) {
89
+ console.error(c.red("error:") + ` --projects names no registered project: ${unknown.join(", ")}`);
90
+ return EXIT.USAGE;
91
+ }
92
+ if (refuseUnroutable(wsDir, ws, names, flags)) return EXIT.PRECONDITION;
93
+ const id = nextDeliveryId(wsDir, title);
94
+ const ref = flagString(flags, "ref") ?? "—";
95
+ const ordered = dependencyOrder(ws, names);
96
+ const tpl = readTemplate(wsDir, "delivery.md.template");
97
+ const file = deliveryPath(wsDir, id);
98
+ write(file, substitute(tpl.body, {
99
+ DELIVERY_ID: id, DELIVERY_TITLE: title, DATE: flagString(flags, "date") ?? today(),
100
+ DELIVERY_REF: ref, DELIVERY_PROJECTS: ordered.length ? ordered.join(", ") : "—",
101
+ }));
102
+ console.log(c.green("created") + ` ${relPath(wsDir, file)}`);
103
+ console.log("");
104
+ console.log(renderPlaybook(wsDir, "delivery", playbookTokens(wsDir, ws, readDelivery(wsDir, id))));
105
+ return EXIT.OK;
106
+ }
107
+
108
+ function playbookTokens(wsDir, ws, delivery) {
109
+ const ranked = rankProjects(wsDir, ws, `${delivery.title} ${delivery.ref}`);
110
+ const routing = delivery.projects.length > 0
111
+ ? ` routed: ${dependencyOrder(ws, delivery.projects).join(" → ")} — route again to change it`
112
+ : ranked.map(({ project, score, shared }) =>
113
+ ` ${c.cyan(project.name.padEnd(16))} ${score > 0 ? `shares ${shared.join(", ")}` : c.gray("no shared term")}`
114
+ + c.gray(` · ${project.role || "no role"}${project.dependsOn?.length ? ` · depends on ${project.dependsOn.join(", ")}` : ""}`)).join("\n");
115
+ const tools = ws.tools.filter((t) => t.enabled !== false);
116
+ return {
117
+ DELIVERY_ID: delivery.id,
118
+ DELIVERY_TITLE: delivery.title,
119
+ DELIVERY_TITLE_ARG: qarg(delivery.title),
120
+ DELIVERY_REF: delivery.ref === "—" ? "(no --ref given: ask the human which item this is)" : delivery.ref,
121
+ DELIVERY_FILE: relPath(wsDir, deliveryPath(wsDir, delivery.id)),
122
+ ROUTING: routing,
123
+ TOOLS: tools.length === 0 ? "" : " Tools this workspace declares:\n" + tools.map((t) =>
124
+ ` ${c.cyan(t.name.padEnd(12))} ${t.access === "write" ? "read/write (writes need the human)" : "read-only"} — ${t.purpose || t.server}`).join("\n"),
125
+ };
126
+ }
127
+
128
+ function deliveryRoute(wsDir, ws, id, raw, flags) {
129
+ const { names, unknown } = parseProjects(ws, raw);
130
+ if (names.length === 0) {
131
+ console.error(c.red("error:") + " delivery route needs the projects, comma-separated");
132
+ console.error(c.gray("hint: ") + `doctrina delivery route ${id} back-end,front-core`);
133
+ return EXIT.USAGE;
134
+ }
135
+ if (unknown.length > 0) {
136
+ console.error(c.red("error:") + ` no registered project named ${unknown.join(", ")}`);
137
+ return EXIT.USAGE;
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
+ }
155
+ const ordered = dependencyOrder(ws, names);
156
+ setDeliveryHeader(wsDir, id, "Projects", ordered.join(", "));
157
+ const d = readDelivery(wsDir, id);
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`);
160
+ console.log("");
161
+ const rows = deliveryChanges(wsDir, ws, d);
162
+ console.log("Open one change in each, in this order:");
163
+ for (const name of ordered) {
164
+ const p = ws.projects.find((x) => x.name === name);
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}`)));
168
+ }
169
+ return EXIT.OK;
170
+ }
171
+
172
+ function deliveryList(wsDir, ws) {
173
+ const all = listDeliveries(wsDir);
174
+ if (all.length === 0) {
175
+ console.log(c.gray("no delivery yet — `doctrina delivery new \"<title>\" --ref <tracker-id>`"));
176
+ return EXIT.OK;
177
+ }
178
+ for (const d of all) {
179
+ const rows = deliveryChanges(wsDir, ws, d);
180
+ const changes = rows.flatMap((r) => r.changes);
181
+ const closed = changes.filter((ch) => ch.archived).length;
182
+ const colour = d.status === "closed" ? c.green : c.yellow;
183
+ const days = daysBetween(d.date, d.status === "closed" ? d.closed : today());
184
+ const age = days === null ? "" : d.status === "closed" ? `, lead time ${days}d` : `, open ${days}d`;
185
+ console.log(` ${c.cyan(d.id.padEnd(30))} ${colour(d.status.padEnd(7))} ${d.ref.padEnd(12)} ${d.projects.join(", ") || "—"}` +
186
+ c.gray(` ${closed}/${changes.length} changes closed${age}`));
187
+ }
188
+ const leads = all.filter((d) => d.status === "closed").map((d) => daysBetween(d.date, d.closed)).filter((n) => n !== null).sort((a, b) => a - b);
189
+ if (leads.length > 0) {
190
+ console.log("");
191
+ console.log(c.gray(`${leads.length} closed · median lead time ${leads[Math.floor((leads.length - 1) / 2)]}d · longest ${leads[leads.length - 1]}d`));
192
+ }
193
+ return EXIT.OK;
194
+ }
195
+
196
+ function deliveryStatus(wsDir, ws, id, json = false) {
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
+ }
213
+ console.log(c.bold(`Delivery ${d.id}`) + ` — ${d.title}` + c.gray(` (${d.status}, ref ${d.ref}, opened ${d.date ?? "?"})`));
214
+ console.log("");
215
+ for (const row of deliveryChanges(wsDir, ws, d)) {
216
+ console.log(` ${c.cyan(row.project)}${row.path ? c.gray(" " + row.path) : ""}`);
217
+ if (row.missing) console.log(c.red(` ${row.missing}`));
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}`)}`));
222
+ for (const ch of row.changes) {
223
+ const colour = ch.archived ? c.green : c.yellow;
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}`)}`));
229
+ }
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
+ }
237
+ const record = recordBlockers(d);
238
+ if (record.length > 0) {
239
+ console.log(` ${c.cyan("record")}`);
240
+ for (const r of record) console.log(c.yellow(" ") + r);
241
+ }
242
+ const blockers = deliveryBlockers(wsDir, ws, d);
243
+ console.log("");
244
+ console.log(blockers.length === 0
245
+ ? c.green("ready to close") + ` — doctrina delivery close ${d.id}`
246
+ : c.gray(`${blockers.length} thing${blockers.length === 1 ? "" : "s"} before the close — doctrina delivery check ${d.id}`));
247
+ return EXIT.OK;
248
+ }
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
+
287
+ function deliveryCheck(wsDir, ws, id) {
288
+ const d = readDelivery(wsDir, id);
289
+ const blockers = deliveryBlockers(wsDir, ws, d);
290
+ if (blockers.length === 0) {
291
+ console.log(c.green("ok") + ` ${id} is ready to close: doctrina delivery close ${id}`);
292
+ return EXIT.OK;
293
+ }
294
+ for (const b of blockers) console.log(c.red("✗ ") + b);
295
+ console.log("");
296
+ console.log(c.red("fail") + ` ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}`);
297
+ return EXIT.GATE;
298
+ }
299
+
300
+ function deliveryClose(wsDir, ws, id, flags) {
301
+ const d = readDelivery(wsDir, id);
302
+ if (d.status === "closed") {
303
+ console.log(c.gray(`${id} is already closed (${d.closed ?? "?"})`));
304
+ return EXIT.OK;
305
+ }
306
+ if (deliveryCheck(wsDir, ws, id) !== EXIT.OK) return EXIT.GATE;
307
+ const date = flagString(flags, "date") ?? today();
308
+ setDeliveryHeader(wsDir, id, "Status", "closed");
309
+ setDeliveryHeader(wsDir, id, "Closed", date);
310
+ console.log(c.green("closed") + ` ${id}`);
311
+ console.log(`Tell the human: delivery ${id} is closed — ${d.projects.length} project${d.projects.length === 1 ? "" : "s"} (${d.projects.join(", ")}); record at ${relPath(wsDir, deliveryPath(wsDir, id))}. Commit the workspace.`);
312
+ return EXIT.OK;
313
+ }
314
+
315
+ export const help = `
316
+ Usage: doctrina delivery <new|route|list|status|tick|check|close> [options]
317
+
318
+ One item of work across repositories (ADR 0030) — a story, a screen, a fix.
319
+ It lives in the workspace's deliveries/ and holds what no single repository
320
+ owns. Each project it touches gets ONE change, opened there with
321
+ \`doctrina work "<title>" --delivery <id>\`; the delivery's state is read
322
+ from those changes, never copied into it.
323
+
324
+ new "<title>" [--ref <tracker-id>] [--projects <a,b>] [--force]
325
+ Create deliveries/<NNNN-slug>/delivery.md and print the orchestration
326
+ playbook: read the story, route, open one change per repository,
327
+ delegate the task cards, close each change, release, watch, close.
328
+ Without --projects it ranks the workspace's projects against the title.
329
+ route <id> <a,b> [--force]
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.
335
+ list
336
+ Every delivery with its status, ref, closed/total changes and age —
337
+ lead time once closed, with the median over the closed ones.
338
+ status <id>
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.
346
+ check <id>
347
+ Exit 1 while a routed project has no change for it or one still open,
348
+ while ## Story is empty, a ## Release box is unticked, or
349
+ ## Observability records nothing.
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).
353
+
354
+ <id> is the folder name or its number (\`1\`, \`0001\`).
355
+ `;
356
+
@@ -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
 
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { suggest } from "../lib/suggest.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
12
13
 
13
14
  const SUBCOMMANDS = ["rebuild"];
14
15
 
@@ -56,6 +57,23 @@ function rebuild(flags) {
56
57
  console.log(c.yellow("warn:") + ` ${state.unreadable} — rebuilding from scratch`);
57
58
  }
58
59
 
60
+ // The skills, where the agents look for them (change 0212). Exported on
61
+ // every rebuild; under --check a stale export is a NOTE, never a failure —
62
+ // the index gate is about the index, and a project upgrading Doctrina must
63
+ // not go red in CI before its first rebuild.
64
+ const exportPlan = planSkillExport(projectRoot);
65
+ const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
66
+ if (exportChanges > 0) {
67
+ if (check) {
68
+ console.log(c.gray("note: ") + `${exportChanges} skill export${exportChanges === 1 ? " is" : "s are"} out of date — ` +
69
+ "`doctrina index rebuild` regenerates them");
70
+ } else {
71
+ applySkillExport(projectRoot, exportPlan);
72
+ for (const w of exportPlan.writes) console.log(c.green("export ") + ` ${w.rel}`);
73
+ for (const r of exportPlan.removes) console.log(c.green("removed") + ` ${r} (its skill is gone)`);
74
+ }
75
+ }
76
+
59
77
  if (state.ok) {
60
78
  console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
61
79
  return 0;
@@ -131,6 +149,11 @@ Subcommands:
131
149
  both, because the index is derived from the whole tree.
132
150
  Exits 0 when nothing relevant is staged.
133
151
 
152
+ Every rebuild also exports .doctrina/skills/ as SKILL.md folders where the
153
+ agents discover skills on their own: .agents/skills/ (Cursor, Codex) and,
154
+ with the claude adapter installed, .claude/skills/. The exports are marked
155
+ as generated; edit the source. Under --check a stale export is a note only.
156
+
134
157
  Fields with no on-disk source (project name, framework_version, product
135
158
  metadata) are carried over from the existing index.
136
159
  `;
@@ -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";
@@ -14,6 +15,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
14
15
  import { c } from "../lib/colors.js";
15
16
  import { EXIT } from "../lib/exit-codes.js";
16
17
  import { ask, isInteractive } from "../lib/prompt.js";
18
+ import { mergeJson } from "../lib/adapters.js";
17
19
  import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
18
20
 
19
21
  const SUPPORTED_AGENTS = [
@@ -40,6 +42,15 @@ export async function run(_positional, flags) {
40
42
  const force = flagBool(flags, "force", false);
41
43
  const nonInteractive = flagBool(flags, "non-interactive", false);
42
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
+ }
43
54
  const projectName = flagString(flags, "project-name") ?? path.basename(projectRoot);
44
55
  const date = flagString(flags, "date") ?? today();
45
56
 
@@ -409,6 +420,20 @@ function installAdapter(templatesDir, projectRoot, agent, tokens, opts) {
409
420
  ...tokens,
410
421
  AGENTS_MD_PATH: adapterAgentsMdRelativePath(entry.relativePath),
411
422
  };
423
+ // A shared settings file the project already has (`.claude/settings.json`)
424
+ // is merged, never overwritten (change 0211) — the same rule `adapter add`
425
+ // follows, so `init` in an existing repository keeps the user's keys.
426
+ const dest = path.join(projectRoot, entry.relativePath.replace(/\.template$/, ""));
427
+ if (dest.endsWith(".json") && isFile(dest)) {
428
+ try {
429
+ const merged = mergeJson(JSON.parse(read(dest)), JSON.parse(substitute(entry.body, adapterTokens)));
430
+ write(dest, JSON.stringify(merged, null, 2) + "\n", { force: true });
431
+ console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} (merged)`);
432
+ } catch {
433
+ console.log(c.yellow(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} left alone (not valid JSON)`);
434
+ }
435
+ continue;
436
+ }
412
437
  const written = materialiseEntry(entry, projectRoot, adapterTokens, opts);
413
438
  console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, written)}`);
414
439
  }
@@ -439,6 +464,8 @@ Options:
439
464
  --from <path> Local conventions directory; if it contains AGENTS.md and/or
440
465
  .doctrina/product.md, the content is folded into the new project
441
466
  --date <YYYY-MM-DD> Override the system date
442
- --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
443
470
  --non-interactive Fail instead of prompting for missing values
444
471
  `;
@@ -10,6 +10,9 @@ import { today } from "../lib/dates.js";
10
10
  import { flagBool, flagString } from "../lib/args.js";
11
11
  import { c } from "../lib/colors.js";
12
12
  import { looksLikePath, markIntakeConverted, printBootstrapPlaybook, warnIfThinIntake, writeIntakeFile } from "../lib/intake-model.js";
13
+ import { inventory, inventoryMarkdown } from "../lib/code-inventory.js";
14
+ import { printPlaybookTemplate } from "../lib/playbook.js";
15
+ import { findWorkspace } from "../lib/workspace.js";
13
16
 
14
17
  // `init --intake` performs the same operations; they live in
15
18
  // lib/intake-model.js so neither command imports out of the other (F7).
@@ -26,7 +29,7 @@ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
26
29
  // Flags this command accepts. Declared HERE, with the command, so
27
30
  // adding a command never requires editing the entrypoint — the gap that
28
31
  // let six flags ship undeclared and silently swallow a positional (C3).
29
- export const flags = { boolean: ["json", "force", "converted"], string: ["text"] };
32
+ export const flags = { boolean: ["json", "force", "converted", "from-code"], string: ["text"] };
30
33
 
31
34
  export async function run(positional, flags) {
32
35
  const projectRoot = process.cwd();
@@ -40,7 +43,7 @@ export async function run(positional, flags) {
40
43
  // a bootstrap that was already done.
41
44
  if (flagBool(flags, "converted", false)) {
42
45
  ensureDoctrinaProject(projectRoot);
43
- // 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
44
47
  // playbook, "fix everything the gates report", made checkable (change
45
48
  // 0194). It used to convert over a tree `validate` failed, so the specs
46
49
  // became "the source of truth" while an error still said they were not
@@ -72,6 +75,7 @@ export async function run(positional, flags) {
72
75
  }
73
76
 
74
77
  const force = flagBool(flags, "force", false);
78
+ if (flagBool(flags, "from-code", false)) return intakeFromCode(projectRoot, force);
75
79
  const inlineText = flagString(flags, "text");
76
80
  const sourceFile = positional[0];
77
81
  const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
@@ -91,7 +95,12 @@ export async function run(positional, flags) {
91
95
  console.log(c.gray("hint: ") + "start implementation with `doctrina work \"<brief prompt>\"`");
92
96
  return 0;
93
97
  }
94
- 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);
95
104
  return 0;
96
105
  }
97
106
 
@@ -151,6 +160,48 @@ export async function run(positional, flags) {
151
160
  }
152
161
 
153
162
 
163
+ // An existing codebase becomes specs (change 0221). The intake is the code
164
+ // INVENTORY — facts read from manifests and file counts — and the playbook
165
+ // asks the agent to describe what the system does today, leaving what the
166
+ // code cannot tell to the human as open questions.
167
+ function intakeFromCode(projectRoot, force) {
168
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
169
+ if (exists(intakePath)) {
170
+ const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
171
+ if (status === "converted") {
172
+ console.error(c.red("error:") + " the intake is already converted — the specs are the source of truth now");
173
+ console.error(c.gray("hint: ") + "a change of behaviour: `doctrina work \"<prompt>\"`");
174
+ return EXIT.PRECONDITION;
175
+ }
176
+ if (!force) {
177
+ console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to replace it with the code inventory)`);
178
+ return EXIT.PRECONDITION;
179
+ }
180
+ }
181
+ const name = projectName(projectRoot);
182
+ const inv = inventory(projectRoot);
183
+ writeIntakeFile(projectRoot, {
184
+ body: inventoryMarkdown(inv, name),
185
+ source: "code inventory (`doctrina intake --from-code`)",
186
+ projectName: name, date: today(), force: true,
187
+ });
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")}`);
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) {
197
+ const ws = findWorkspace(projectRoot);
198
+ printPlaybookTemplate(projectRoot, "absorb", {
199
+ WORKSPACE_STEP: ws
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.`
201
+ : "",
202
+ });
203
+ }
204
+
154
205
  export const help = `
155
206
  Usage: doctrina intake [<file>] [--text "<description>"] [--force]
156
207
 
@@ -168,11 +219,17 @@ Forms:
168
219
  existing pending intake
169
220
  doctrina intake --converted Close the bootstrap: flip the stored
170
221
  intake's Status to converted
222
+ doctrina intake --from-code An EXISTING codebase: store its code
223
+ inventory as the intake and print the
224
+ absorb playbook
171
225
 
172
226
  Options:
173
227
  --text "<description>" Inline description instead of a file
174
228
  --force Overwrite an existing .doctrina/intake.md; with
175
229
  --converted, convert although validate fails
230
+ --from-code Inventory the code (stack, tests, CI, env names,
231
+ candidate areas, build commands) instead of a
232
+ description; --force replaces a pending intake
176
233
  --converted Mark the stored intake converted (ends the
177
234
  bootstrap; nothing else writes that header).
178
235
  Refuses (exit 1) while validate reports an error