doctrina-cli 0.17.0 → 0.20.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 +8 -4
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +74 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +131 -7
  6. package/src/commands/close.js +92 -28
  7. package/src/commands/context.js +49 -8
  8. package/src/commands/delivery.js +238 -0
  9. package/src/commands/index-rebuild.js +23 -0
  10. package/src/commands/init.js +15 -0
  11. package/src/commands/intake.js +47 -1
  12. package/src/commands/metrics.js +28 -3
  13. package/src/commands/next.js +1 -0
  14. package/src/commands/prime.js +76 -7
  15. package/src/commands/review.js +47 -8
  16. package/src/commands/skill.js +3 -127
  17. package/src/commands/upgrade.js +15 -0
  18. package/src/commands/verify.js +59 -6
  19. package/src/commands/work.js +139 -5
  20. package/src/commands/workspace.js +409 -0
  21. package/src/index.js +5 -7
  22. package/src/lib/actions.js +9 -5
  23. package/src/lib/adapters.js +80 -0
  24. package/src/lib/change-ops.js +92 -0
  25. package/src/lib/code-inventory.js +297 -0
  26. package/src/lib/commands.js +52 -5
  27. package/src/lib/context-slice.js +118 -0
  28. package/src/lib/delivery.js +208 -0
  29. package/src/lib/git.js +59 -2
  30. package/src/lib/metrics-model.js +23 -1
  31. package/src/lib/playbook.js +1 -1
  32. package/src/lib/runtime.js +6 -0
  33. package/src/lib/scan.js +13 -0
  34. package/src/lib/signoff.js +24 -0
  35. package/src/lib/skill-candidates.js +162 -0
  36. package/src/lib/skill-export.js +95 -0
  37. package/src/lib/snapshot.js +76 -2
  38. package/src/lib/spec-ops.js +117 -1
  39. package/src/lib/task-card.js +102 -0
  40. package/src/lib/validation-model.js +1 -1
  41. package/src/lib/verify-cache.js +76 -0
  42. package/src/lib/views.js +56 -2
  43. package/src/lib/work-model.js +90 -12
  44. package/src/lib/workspace-agents.js +259 -0
  45. package/src/lib/workspace-tools.js +113 -0
  46. package/src/lib/workspace.js +371 -0
  47. package/templates/AGENTS.md.template +17 -14
  48. package/templates/README.md +11 -0
  49. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  50. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  51. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  52. package/templates/change/spec-delta.md.template +2 -0
  53. package/templates/delivery.md.template +39 -0
  54. package/templates/playbooks/absorb.md.template +40 -0
  55. package/templates/playbooks/consolidate.md.template +28 -0
  56. package/templates/playbooks/delivery.md.template +38 -0
  57. package/templates/playbooks/work.md.template +4 -1
@@ -14,6 +14,7 @@ import { GIT_STATE, historyState, changedFiles } from "../lib/git.js";
14
14
  import { terms as queryTerms, relevance } from "../lib/lexicon.js";
15
15
  import { notADoctrinaProject, EXIT } from "../lib/exit-codes.js";
16
16
  import * as idx from "../lib/index-json.js";
17
+ import { adrDigest, sliceSpec } from "../lib/context-slice.js";
17
18
 
18
19
  // Materialise the AGENTS.md read order as a command: print the exact
19
20
  // context pack for a task, in the order an agent should read it. This is
@@ -72,11 +73,14 @@ export const TIER = { CORE: 0, SPEC: 1, CHANGE: 2, DEPENDENCY: 3, DECISION: 4 };
72
73
  // Flags this command accepts. Declared HERE, with the command, so
73
74
  // adding a command never requires editing the entrypoint — the gap that
74
75
  // let six flags ship undeclared and silently swallow a positional (C3).
75
- export const flags = { boolean: ["json", "concat"], string: ["budget", "diff", "for"] };
76
+ export const flags = { boolean: ["json", "concat", "full"], string: ["budget", "diff", "for"] };
76
77
 
77
78
  export async function run(positional, cmdFlags) {
78
79
  const capability = positional[0] ?? null;
79
80
  const concat = flagBool(cmdFlags, "concat", false);
81
+ // --full: the whole text of everything, as before change 0216 — AGENTS.md
82
+ // repeated, every spec and ADR whole. The lean pack is the default.
83
+ const full = flagBool(cmdFlags, "full", false);
80
84
  const budgetRaw = flagString(cmdFlags, "budget");
81
85
  const diffRef = flagString(cmdFlags, "diff");
82
86
  const query = flagString(cmdFlags, "for");
@@ -162,6 +166,8 @@ export async function run(positional, cmdFlags) {
162
166
  tier: TIER.CORE,
163
167
  rank: [],
164
168
  degraded: false,
169
+ /** @type {string | null} */ slice: null,
170
+ /** @type {string | null} */ digest: null,
165
171
  ...extra,
166
172
  };
167
173
  pack.push(item);
@@ -170,7 +176,10 @@ export async function run(positional, cmdFlags) {
170
176
 
171
177
  // 1-2. Root rules and product truth — always first. Displayed paths are
172
178
  // POSIX strings (path.join would emit backslashes on Windows).
173
- if (wanted("AGENTS.md")) pushFile("AGENTS.md", "root rules");
179
+ // AGENTS.md is not repeated by default (change 0216): every agent this
180
+ // pack is for loads it already — natively, or through its adapter — and
181
+ // it cost ~2.5k tokens per pack to say it twice.
182
+ if (wanted("AGENTS.md") && full) pushFile("AGENTS.md", "root rules");
174
183
  if (wanted(".doctrina/product.md")) pushFile(".doctrina/product.md", "product truth");
175
184
 
176
185
  // 3. The capability spec(s) — the current truth. With a named capability,
@@ -241,6 +250,21 @@ export async function run(positional, cmdFlags) {
241
250
  }
242
251
  }
243
252
 
253
+ // A spec read for a task — the task's own, and the ones it depends on — is
254
+ // cut to what the task is about (change
255
+ // 0216): headers, Purpose, and the requirements and criteria the query
256
+ // matches, the rest named by count. No query, or nothing matched: whole.
257
+ if (!full && terms.length > 0) {
258
+ for (const item of pack) {
259
+ if ((item.tier !== TIER.CORE && item.tier !== TIER.DEPENDENCY) || !item.rel.startsWith(".doctrina/specs/")) continue;
260
+ const sliced = sliceSpec(read(path.join(projectRoot, item.rel)), terms, item.rel);
261
+ if (!sliced) continue;
262
+ item.slice = sliced.text;
263
+ item.tokens = estimateTokens(sliced.text);
264
+ item.note = `${item.note} — sliced: ${sliced.kept} of ${sliced.total} items match`;
265
+ }
266
+ }
267
+
244
268
  // 4. Open changes (their proposal, tasks, and deltas) — never
245
269
  // diff-filtered: work in flight is the one thing a resuming agent
246
270
  // cannot reconstruct from anywhere else, so it is always present.
@@ -347,7 +371,7 @@ export async function run(positional, cmdFlags) {
347
371
 
348
372
  const id = path.basename(f).match(/^(\d{4})-/)?.[1] ?? "0000";
349
373
  const title = getTitle(text) ?? path.basename(f, ".md");
350
- pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
374
+ const adrItem = pushFile(rel, global ? "accepted ADR" : `accepted ADR (${scope.join(", ")})`, {
351
375
  tier: TIER.DECISION,
352
376
  // Best-first, deterministic: an ADR that NAMES this capability outranks
353
377
  // an unscoped one — which is DECLARED to belong in every pack — which in
@@ -360,6 +384,13 @@ export async function run(positional, cmdFlags) {
360
384
  title,
361
385
  summary: adrSummary(text),
362
386
  });
387
+ // A decision as a digest by default (change 0216): its title and the
388
+ // opening of its Decision — what tells a reader whether it binds the
389
+ // task. Sixteen whole ADRs were ~22k tokens of the `gates` pack.
390
+ if (adrItem && !full) {
391
+ adrItem.digest = adrDigest(text, title, id);
392
+ adrItem.tokens = estimateTokens(adrItem.digest);
393
+ }
363
394
  }
364
395
 
365
396
  // 6. Skills are on-demand: list name + description + trigger, never the
@@ -406,7 +437,8 @@ export async function run(positional, cmdFlags) {
406
437
  for (const item of kept) {
407
438
  console.log(`===== ${item.rel} (${item.note}) =====`);
408
439
  console.log("");
409
- console.log(item.degraded ? degradedBody(item) : read(path.join(projectRoot, item.rel)).trimEnd());
440
+ console.log(item.degraded ? degradedBody(item)
441
+ : item.slice ?? item.digest ?? read(path.join(projectRoot, item.rel)).trimEnd());
410
442
  console.log("");
411
443
  }
412
444
  // Keep the pack pipeable: the budget verdict goes to stderr.
@@ -417,7 +449,7 @@ export async function run(positional, cmdFlags) {
417
449
  console.log(c.bold("Context pack") + (capability ? c.gray(` (capability: ${capability})`) : "") + forNote + scope + c.gray(" — read in this order:"));
418
450
  console.log("");
419
451
  kept.forEach((item, i) => {
420
- const mark = item.degraded ? c.yellow(" summary") : " ";
452
+ const mark = item.degraded ? c.yellow(" summary") : item.slice ? c.yellow(" sliced") : item.digest ? c.yellow(" digest") : " ";
421
453
  console.log(`${String(i + 1).padStart(2)}. ${c.cyan(item.rel.padEnd(56))} ${String(item.lines).padStart(4)} lines ${c.gray(`~${String(item.tokens).padStart(5)} tok`)}${mark} ${c.gray(item.note)}`);
422
454
  });
423
455
  console.log("");
@@ -709,14 +741,21 @@ function changedPaths(projectRoot, ref) {
709
741
 
710
742
  export const help = `
711
743
  Usage: doctrina context [<capability>] [--for "<task>"] [--concat]
712
- [--budget <tokens>] [--diff <ref>]
744
+ [--budget <tokens>] [--diff <ref>] [--full]
713
745
 
714
746
  Print the exact context pack for a task, in the documented read
715
- order: AGENTS.md, product.md, the capability spec (when given —
716
- otherwise every active spec), open changes, accepted ADRs. Skills are
747
+ order: product.md, the capability spec (when given — otherwise every
748
+ active spec), open changes, accepted ADRs. Skills are
717
749
  listed name + description + when-trigger only — they are on-demand by
718
750
  design. The change archive is excluded.
719
751
 
752
+ The pack is LEAN by default: AGENTS.md is not repeated (your agent
753
+ loads it every turn), an accepted ADR is a digest — its title and the
754
+ opening of its Decision; \`doctrina show NNNN\` reads the rest — and
755
+ with --for the task's spec is sliced to its headers, Purpose and the
756
+ requirements and criteria the task matches, the others named by count.
757
+ --full restores every text whole.
758
+
720
759
  Assembly is retrieval, not a dump. A token budget ALWAYS applies
721
760
  (default ${DEFAULT_BUDGET}; set "context_budget": <n> in
722
761
  .doctrina/config.json, or pass --budget). Over budget, accepted ADRs
@@ -731,6 +770,8 @@ Flags:
731
770
  the list — ready to pipe into an agent. The budget
732
771
  verdict goes to stderr, keeping stdout pure.
733
772
  --budget <n> Token ceiling for this call, overriding the project's.
773
+ --full Every artifact whole: AGENTS.md included, specs not
774
+ sliced, ADRs not digested (the pre-0.19 pack).
734
775
  --diff <ref> Scope the stable artifacts (AGENTS.md, product.md,
735
776
  specs, ADRs) to those changed since the git ref; open
736
777
  changes are always included. The resume-session read.
@@ -0,0 +1,238 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { relPath, write } from "../lib/fs-ops.js";
4
+ import { flagString } from "../lib/args.js";
5
+ import { c } from "../lib/colors.js";
6
+ import { suggest } from "../lib/suggest.js";
7
+ import { today } from "../lib/dates.js";
8
+ import { EXIT, PreconditionError } from "../lib/exit-codes.js";
9
+ import { readTemplate, substitute } from "../lib/templates.js";
10
+ import { renderPlaybook } from "../lib/playbook.js";
11
+ import { findWorkspace, readWorkspace } from "../lib/workspace.js";
12
+ import {
13
+ deliveryBlockers, deliveryChanges, deliveryPath, dependencyOrder, listDeliveries, nextDeliveryId,
14
+ rankProjects, readDelivery, resolveDeliveryId, setDeliveryHeader, daysBetween, recordBlockers,
15
+ } from "../lib/delivery.js";
16
+
17
+ // `doctrina delivery` — one item of work across repositories (ADR 0030).
18
+ // Decisions in lib/delivery.js; this module renders them.
19
+
20
+ const SUBCOMMANDS = ["new", "route", "list", "status", "check", "close"];
21
+
22
+ export const flags = { boolean: ["json"], string: ["ref", "projects", "date"] };
23
+
24
+ export async function run(positional, flags) {
25
+ const sub = positional[0];
26
+ if (!SUBCOMMANDS.includes(sub)) {
27
+ console.error(c.red("error:") + ` unknown delivery subcommand "${sub ?? ""}"`);
28
+ const guess = suggest(sub, SUBCOMMANDS);
29
+ console.error(c.gray("hint: ") + (guess ? `did you mean \`doctrina delivery ${guess}\`?` : `available: ${SUBCOMMANDS.join(", ")}`));
30
+ return EXIT.USAGE;
31
+ }
32
+ const found = findWorkspace(process.cwd());
33
+ if (!found) {
34
+ throw new PreconditionError("a delivery lives in a workspace, and none was found here", "doctrina workspace init <dir>");
35
+ }
36
+ const wsDir = found.dir;
37
+ const ws = readWorkspace(wsDir);
38
+ if (sub === "new") return deliveryNew(wsDir, ws, positional.slice(1).join(" ").trim(), flags);
39
+ if (sub === "list") return deliveryList(wsDir, ws);
40
+ const id = resolveDeliveryId(wsDir, positional[1]);
41
+ if (!id) {
42
+ console.error(c.red("error:") + ` no delivery "${positional[1] ?? ""}" in this workspace`);
43
+ console.error(c.gray("hint: ") + "`doctrina delivery list` shows them");
44
+ return EXIT.USAGE;
45
+ }
46
+ if (sub === "route") return deliveryRoute(wsDir, ws, id, positional[2] ?? flagString(flags, "projects"));
47
+ if (sub === "status") return deliveryStatus(wsDir, ws, id);
48
+ if (sub === "check") return deliveryCheck(wsDir, ws, id);
49
+ return deliveryClose(wsDir, ws, id, flags);
50
+ }
51
+
52
+ function parseProjects(ws, raw) {
53
+ const names = String(raw ?? "").split(",").map((s) => s.trim()).filter(Boolean);
54
+ const unknown = names.filter((n) => !ws.projects.some((p) => p.name === n));
55
+ return { names, unknown };
56
+ }
57
+
58
+ function deliveryNew(wsDir, ws, title, flags) {
59
+ if (!title) {
60
+ console.error(c.red("error:") + " delivery new needs a title");
61
+ console.error(c.gray("hint: ") + 'doctrina delivery new "Login screen" --ref SHOP-123');
62
+ return EXIT.USAGE;
63
+ }
64
+ if (ws.projects.length === 0) {
65
+ throw new PreconditionError("the workspace has no project to deliver into", "doctrina workspace add <path>");
66
+ }
67
+ const { names, unknown } = parseProjects(ws, flagString(flags, "projects"));
68
+ if (unknown.length > 0) {
69
+ console.error(c.red("error:") + ` --projects names no registered project: ${unknown.join(", ")}`);
70
+ return EXIT.USAGE;
71
+ }
72
+ const id = nextDeliveryId(wsDir, title);
73
+ const ref = flagString(flags, "ref") ?? "—";
74
+ const ordered = dependencyOrder(ws, names);
75
+ const tpl = readTemplate(wsDir, "delivery.md.template");
76
+ const file = deliveryPath(wsDir, id);
77
+ write(file, substitute(tpl.body, {
78
+ DELIVERY_ID: id, DELIVERY_TITLE: title, DATE: flagString(flags, "date") ?? today(),
79
+ DELIVERY_REF: ref, DELIVERY_PROJECTS: ordered.length ? ordered.join(", ") : "—",
80
+ }));
81
+ console.log(c.green("created") + ` ${relPath(wsDir, file)}`);
82
+ console.log("");
83
+ console.log(renderPlaybook(wsDir, "delivery", playbookTokens(wsDir, ws, readDelivery(wsDir, id))));
84
+ return EXIT.OK;
85
+ }
86
+
87
+ function playbookTokens(wsDir, ws, delivery) {
88
+ const ranked = rankProjects(wsDir, ws, `${delivery.title} ${delivery.ref}`);
89
+ const routing = delivery.projects.length > 0
90
+ ? ` routed: ${dependencyOrder(ws, delivery.projects).join(" → ")} — route again to change it`
91
+ : ranked.map(({ project, score, shared }) =>
92
+ ` ${c.cyan(project.name.padEnd(16))} ${score > 0 ? `shares ${shared.join(", ")}` : c.gray("no shared term")}`
93
+ + c.gray(` · ${project.role || "no role"}${project.dependsOn?.length ? ` · depends on ${project.dependsOn.join(", ")}` : ""}`)).join("\n");
94
+ const tools = ws.tools.filter((t) => t.enabled !== false);
95
+ return {
96
+ DELIVERY_ID: delivery.id,
97
+ DELIVERY_TITLE: delivery.title,
98
+ DELIVERY_REF: delivery.ref === "—" ? "(no --ref given: ask the human which item this is)" : delivery.ref,
99
+ DELIVERY_FILE: relPath(wsDir, deliveryPath(wsDir, delivery.id)),
100
+ ROUTING: routing,
101
+ TOOLS: tools.length === 0 ? "" : " Tools this workspace declares:\n" + tools.map((t) =>
102
+ ` ${c.cyan(t.name.padEnd(12))} ${t.access === "write" ? "read/write (writes need the human)" : "read-only"} — ${t.purpose || t.server}`).join("\n"),
103
+ };
104
+ }
105
+
106
+ function deliveryRoute(wsDir, ws, id, raw) {
107
+ const { names, unknown } = parseProjects(ws, raw);
108
+ if (names.length === 0) {
109
+ console.error(c.red("error:") + " delivery route needs the projects, comma-separated");
110
+ console.error(c.gray("hint: ") + `doctrina delivery route ${id} back-end,front-core`);
111
+ return EXIT.USAGE;
112
+ }
113
+ if (unknown.length > 0) {
114
+ console.error(c.red("error:") + ` no registered project named ${unknown.join(", ")}`);
115
+ return EXIT.USAGE;
116
+ }
117
+ const ordered = dependencyOrder(ws, names);
118
+ setDeliveryHeader(wsDir, id, "Projects", ordered.join(", "));
119
+ const d = readDelivery(wsDir, id);
120
+ console.log(c.green("routed") + ` ${id} → ${ordered.join(" → ")}` + c.gray(" (dependencies first)"));
121
+ console.log("");
122
+ console.log("Open one change in each, in this order:");
123
+ for (const name of ordered) {
124
+ const p = ws.projects.find((x) => x.name === name);
125
+ console.log(" " + c.cyan(`cd ${p?.path} && doctrina work "${d.title}" --delivery ${id}`));
126
+ }
127
+ return EXIT.OK;
128
+ }
129
+
130
+ function deliveryList(wsDir, ws) {
131
+ const all = listDeliveries(wsDir);
132
+ if (all.length === 0) {
133
+ console.log(c.gray("no delivery yet — `doctrina delivery new \"<title>\" --ref <tracker-id>`"));
134
+ return EXIT.OK;
135
+ }
136
+ for (const d of all) {
137
+ const rows = deliveryChanges(wsDir, ws, d);
138
+ const changes = rows.flatMap((r) => r.changes);
139
+ const closed = changes.filter((ch) => ch.archived).length;
140
+ const colour = d.status === "closed" ? c.green : c.yellow;
141
+ const days = daysBetween(d.date, d.status === "closed" ? d.closed : today());
142
+ const age = days === null ? "" : d.status === "closed" ? `, lead time ${days}d` : `, open ${days}d`;
143
+ console.log(` ${c.cyan(d.id.padEnd(30))} ${colour(d.status.padEnd(7))} ${d.ref.padEnd(12)} ${d.projects.join(", ") || "—"}` +
144
+ c.gray(` ${closed}/${changes.length} changes closed${age}`));
145
+ }
146
+ const leads = all.filter((d) => d.status === "closed").map((d) => daysBetween(d.date, d.closed)).filter((n) => n !== null).sort((a, b) => a - b);
147
+ if (leads.length > 0) {
148
+ console.log("");
149
+ console.log(c.gray(`${leads.length} closed · median lead time ${leads[Math.floor((leads.length - 1) / 2)]}d · longest ${leads[leads.length - 1]}d`));
150
+ }
151
+ return EXIT.OK;
152
+ }
153
+
154
+ function deliveryStatus(wsDir, ws, id) {
155
+ const d = readDelivery(wsDir, id);
156
+ console.log(c.bold(`Delivery ${d.id}`) + ` — ${d.title}` + c.gray(` (${d.status}, ref ${d.ref}, opened ${d.date ?? "?"})`));
157
+ console.log("");
158
+ for (const row of deliveryChanges(wsDir, ws, d)) {
159
+ console.log(` ${c.cyan(row.project)}${row.path ? c.gray(" " + row.path) : ""}`);
160
+ 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}`));
162
+ for (const ch of row.changes) {
163
+ const colour = ch.archived ? c.green : c.yellow;
164
+ console.log(` ${colour(ch.status.padEnd(9))} ${ch.id}` + c.gray(` tasks ${ch.tasksDone}/${ch.tasksTotal}`));
165
+ }
166
+ }
167
+ const record = recordBlockers(d);
168
+ if (record.length > 0) {
169
+ console.log(` ${c.cyan("record")}`);
170
+ for (const r of record) console.log(c.yellow(" ") + r);
171
+ }
172
+ const blockers = deliveryBlockers(wsDir, ws, d);
173
+ console.log("");
174
+ console.log(blockers.length === 0
175
+ ? c.green("ready to close") + ` — doctrina delivery close ${d.id}`
176
+ : c.gray(`${blockers.length} thing${blockers.length === 1 ? "" : "s"} before the close — doctrina delivery check ${d.id}`));
177
+ return EXIT.OK;
178
+ }
179
+
180
+ function deliveryCheck(wsDir, ws, id) {
181
+ const d = readDelivery(wsDir, id);
182
+ const blockers = deliveryBlockers(wsDir, ws, d);
183
+ if (blockers.length === 0) {
184
+ console.log(c.green("ok") + ` ${id} is ready to close: doctrina delivery close ${id}`);
185
+ return EXIT.OK;
186
+ }
187
+ for (const b of blockers) console.log(c.red("✗ ") + b);
188
+ console.log("");
189
+ console.log(c.red("fail") + ` ${blockers.length} blocker${blockers.length === 1 ? "" : "s"}`);
190
+ return EXIT.GATE;
191
+ }
192
+
193
+ function deliveryClose(wsDir, ws, id, flags) {
194
+ const d = readDelivery(wsDir, id);
195
+ if (d.status === "closed") {
196
+ console.log(c.gray(`${id} is already closed (${d.closed ?? "?"})`));
197
+ return EXIT.OK;
198
+ }
199
+ if (deliveryCheck(wsDir, ws, id) !== EXIT.OK) return EXIT.GATE;
200
+ const date = flagString(flags, "date") ?? today();
201
+ setDeliveryHeader(wsDir, id, "Status", "closed");
202
+ setDeliveryHeader(wsDir, id, "Closed", date);
203
+ console.log(c.green("closed") + ` ${id}`);
204
+ 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.`);
205
+ return EXIT.OK;
206
+ }
207
+
208
+ export const help = `
209
+ Usage: doctrina delivery <new|route|list|status|check|close> [options]
210
+
211
+ One item of work across repositories (ADR 0030) — a story, a screen, a fix.
212
+ It lives in the workspace's deliveries/ and holds what no single repository
213
+ owns. Each project it touches gets ONE change, opened there with
214
+ \`doctrina work "<title>" --delivery <id>\`; the delivery's state is read
215
+ from those changes, never copied into it.
216
+
217
+ new "<title>" [--ref <tracker-id>] [--projects <a,b>]
218
+ Create deliveries/<NNNN-slug>/delivery.md and print the orchestration
219
+ playbook: read the story, route, open one change per repository,
220
+ delegate the task cards, close each change, release, watch, close.
221
+ Without --projects it ranks the workspace's projects against the title.
222
+ route <id> <a,b>
223
+ Set the projects (dependencies first) and print the \`work\` line for each.
224
+ list
225
+ Every delivery with its status, ref, closed/total changes and age —
226
+ lead time once closed, with the median over the closed ones.
227
+ status <id>
228
+ Per project: the changes carrying this delivery, their state and tasks.
229
+ check <id>
230
+ Exit 1 while a routed project has no change for it or one still open,
231
+ while ## Story is empty, a ## Release box is unticked, or
232
+ ## Observability records nothing.
233
+ close <id>
234
+ The check, then Status: closed and the Closed date.
235
+
236
+ <id> is the folder name or its number (\`1\`, \`0001\`).
237
+ `;
238
+
@@ -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
  `;
@@ -14,6 +14,7 @@ import { flagBool, flagString, flagGivenWithoutValue } from "../lib/args.js";
14
14
  import { c } from "../lib/colors.js";
15
15
  import { EXIT } from "../lib/exit-codes.js";
16
16
  import { ask, isInteractive } from "../lib/prompt.js";
17
+ import { mergeJson } from "../lib/adapters.js";
17
18
  import { looksLikePath, writeIntakeFile, printBootstrapPlaybook, warnIfThinIntake, nextStepAfterInit } from "../lib/intake-model.js";
18
19
 
19
20
  const SUPPORTED_AGENTS = [
@@ -409,6 +410,20 @@ function installAdapter(templatesDir, projectRoot, agent, tokens, opts) {
409
410
  ...tokens,
410
411
  AGENTS_MD_PATH: adapterAgentsMdRelativePath(entry.relativePath),
411
412
  };
413
+ // A shared settings file the project already has (`.claude/settings.json`)
414
+ // is merged, never overwritten (change 0211) — the same rule `adapter add`
415
+ // follows, so `init` in an existing repository keeps the user's keys.
416
+ const dest = path.join(projectRoot, entry.relativePath.replace(/\.template$/, ""));
417
+ if (dest.endsWith(".json") && isFile(dest)) {
418
+ try {
419
+ const merged = mergeJson(JSON.parse(read(dest)), JSON.parse(substitute(entry.body, adapterTokens)));
420
+ write(dest, JSON.stringify(merged, null, 2) + "\n", { force: true });
421
+ console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} (merged)`);
422
+ } catch {
423
+ console.log(c.yellow(`adapter[${agent}]`) + ` ${relPath(projectRoot, dest)} left alone (not valid JSON)`);
424
+ }
425
+ continue;
426
+ }
412
427
  const written = materialiseEntry(entry, projectRoot, adapterTokens, opts);
413
428
  console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, written)}`);
414
429
  }
@@ -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();
@@ -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");
@@ -151,6 +155,42 @@ export async function run(positional, flags) {
151
155
  }
152
156
 
153
157
 
158
+ // An existing codebase becomes specs (change 0221). The intake is the code
159
+ // INVENTORY — facts read from manifests and file counts — and the playbook
160
+ // asks the agent to describe what the system does today, leaving what the
161
+ // code cannot tell to the human as open questions.
162
+ function intakeFromCode(projectRoot, force) {
163
+ const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
164
+ if (exists(intakePath)) {
165
+ const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
166
+ if (status === "converted") {
167
+ console.error(c.red("error:") + " the intake is already converted — the specs are the source of truth now");
168
+ console.error(c.gray("hint: ") + "a change of behaviour: `doctrina work \"<prompt>\"`");
169
+ return EXIT.PRECONDITION;
170
+ }
171
+ if (!force) {
172
+ console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to replace it with the code inventory)`);
173
+ return EXIT.PRECONDITION;
174
+ }
175
+ }
176
+ const name = projectName(projectRoot);
177
+ const inv = inventory(projectRoot);
178
+ writeIntakeFile(projectRoot, {
179
+ body: inventoryMarkdown(inv, name),
180
+ source: "code inventory (`doctrina intake --from-code`)",
181
+ projectName: name, date: today(), force: true,
182
+ });
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`);
184
+ console.log("");
185
+ const ws = findWorkspace(projectRoot);
186
+ printPlaybookTemplate(projectRoot, "absorb", {
187
+ 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.`
189
+ : "",
190
+ });
191
+ return EXIT.OK;
192
+ }
193
+
154
194
  export const help = `
155
195
  Usage: doctrina intake [<file>] [--text "<description>"] [--force]
156
196
 
@@ -168,11 +208,17 @@ Forms:
168
208
  existing pending intake
169
209
  doctrina intake --converted Close the bootstrap: flip the stored
170
210
  intake's Status to converted
211
+ doctrina intake --from-code An EXISTING codebase: store its code
212
+ inventory as the intake and print the
213
+ absorb playbook
171
214
 
172
215
  Options:
173
216
  --text "<description>" Inline description instead of a file
174
217
  --force Overwrite an existing .doctrina/intake.md; with
175
218
  --converted, convert although validate fails
219
+ --from-code Inventory the code (stack, tests, CI, env names,
220
+ candidate areas, build commands) instead of a
221
+ description; --force replaces a pending intake
176
222
  --converted Mark the stored intake converted (ends the
177
223
  bootstrap; nothing else writes that header).
178
224
  Refuses (exit 1) while validate reports an error
@@ -98,6 +98,9 @@ export async function run(_positional, flags) {
98
98
  printDelta("revert rate", previous.revert_rate, snapshot.revert_rate);
99
99
  printDelta("fix share", previous.fix_share, snapshot.fix_share);
100
100
  printDelta(`${REEDIT_WINDOW_DAYS}-day re-edit rate`, previous.reedit_rate, snapshot.reedit_rate);
101
+ if (typeof previous.change_adherence === "number" && typeof snapshot.change_adherence === "number") {
102
+ printDelta("commits with a change", previous.change_adherence, snapshot.change_adherence);
103
+ }
101
104
  }
102
105
  }
103
106
  return 0;
@@ -140,6 +143,7 @@ const LABELS = {
140
143
  revert_rate: "revert rate",
141
144
  fix_share: "fix share",
142
145
  reedit_rate: `${REEDIT_WINDOW_DAYS}-day re-edit rate`,
146
+ change_adherence: "commits with a change",
143
147
  };
144
148
 
145
149
  function printReport(s) {
@@ -148,6 +152,10 @@ function printReport(s) {
148
152
  console.log(`fix-typed commits ${pct(s.fix_share)} of commits (Conventional Commits "fix")`);
149
153
  console.log(`${s.reedit_window_days}-day re-edit rate ${pct(s.reedit_rate)} of commits touch a file edited in the prior ${s.reedit_window_days} days`);
150
154
  console.log(c.gray(" (proxy for rework — iterative work also counts)"));
155
+ if (s.change_adherence !== null && s.change_adherence !== undefined) {
156
+ console.log(`commits with a change ${pct(s.change_adherence)} of ${s.code_commits} code commits touched .doctrina/changes/ or named a change`);
157
+ console.log(c.gray(" (workflow adherence — a one-line fix may rightly need none)"));
158
+ }
151
159
  if (s.top_churn.length > 0) {
152
160
  console.log("");
153
161
  console.log("top churn:");
@@ -200,6 +208,20 @@ function commandUsage() {
200
208
  for (const [op, n] of used) {
201
209
  console.log(` ${String(n).padStart(5)} ${c.cyan(op)}`);
202
210
  }
211
+ // The cycle, read off the sample (change 0218): did a change get its read
212
+ // and its close? A field review found `prime` and `work` used, `context`
213
+ // and `triage` skipped. Ratios, not verdicts — one context can serve two
214
+ // works, and a close can be retried.
215
+ const count = (op) => used.find(([o]) => o === op)?.[1] ?? 0;
216
+ const works = count("work");
217
+ if (works > 0) {
218
+ console.log("");
219
+ console.log(c.bold("Workflow") + c.gray(` — per \`work\` (${works})`));
220
+ console.log(` context ${(count("context") / works).toFixed(1)} close ${(count("close") / works).toFixed(1)} prime ${(count("prime") / works).toFixed(1)}`);
221
+ if (count("context") < works) {
222
+ console.log(c.gray(" fewer reads than changes: the pack is the spec, `prime` is only the state"));
223
+ }
224
+ }
203
225
  console.log("");
204
226
  console.log(c.gray(`${unused.length} of ${catalog.length} operations never invoked in this sample:`));
205
227
  for (let i = 0; i < unused.length; i += 4) {
@@ -216,12 +238,15 @@ export const help = `
216
238
  Usage: doctrina metrics [--since <days|date>] [--save] [--commands]
217
239
 
218
240
  Derive adoption metrics from LOCAL git history: commit count, revert
219
- count and rate, Conventional-Commit fix share, top-churn files, and a
220
- ${REEDIT_WINDOW_DAYS}-day re-edit proxy rate. No network calls; nothing leaves the repo.
241
+ count and rate, Conventional-Commit fix share, top-churn files, a
242
+ ${REEDIT_WINDOW_DAYS}-day re-edit proxy rate, and workflow adherence — the share of
243
+ code commits that carried a change (touched .doctrina/changes/ or named
244
+ one). No network calls; nothing leaves the repo.
221
245
 
222
246
  Flags:
223
247
  --commands Report which operations were actually invoked, from the
224
- opt-in usage log. Recording is OFF unless DOCTRINA_USAGE_LOG
248
+ opt-in usage log, and how many context, close and prime
249
+ runs there were per work. Recording is OFF unless DOCTRINA_USAGE_LOG
225
250
  names a file; only the operation is recorded, never
226
251
  arguments, and nothing leaves the machine.
227
252
  --since <n|date> Window: a day count (default 90), a calendar date
@@ -82,6 +82,7 @@ export const RUNNERS = {
82
82
  "change check": () => import("./change.js"),
83
83
  "change apply": () => import("./change.js"),
84
84
  "change archive": () => import("./change.js"),
85
+ close: () => import("./close.js"),
85
86
  "index rebuild": () => import("./index-rebuild.js"),
86
87
  };
87
88