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
@@ -19,6 +19,8 @@ export const COMMAND_NAMES = [
19
19
  "prime", "context", "show", "search", "status", "next", "why", "handoff",
20
20
  // gates
21
21
  "analyze", "clarify", "validate", "coverage", "trace", "review", "verify", "close", "doctor",
22
+ // orchestrate
23
+ "workspace", "delivery",
22
24
  // maintenance
23
25
  "templates", "hooks", "index", "watch", "metrics", "report", "completion", "upgrade", "ci",
24
26
  ];
@@ -47,6 +49,8 @@ export const OPERATIONS = [
47
49
  ["change check", "Pre-close dry-run: everything close would refuse, listed first"],
48
50
  ["change tick", "List/tick the unchecked boxes (tasks + verification; --all)"],
49
51
  ["change abandon", "Discard an open change cleanly (recorded in the ledger)"],
52
+ ["change delta", "Add a requirement/criterion/bump to a change's delta (ops written for you)"],
53
+ ["change card", "One task as an executor receives it: the task, its why, the spec cut to it"],
50
54
  ["contract new", "Own the integration surface (ports, env, interfaces)"],
51
55
  ["contract list", "List contracts with status and last-updated date"],
52
56
  ["contract check", "Verify port collisions, env drift, referenced specs"],
@@ -81,6 +85,19 @@ export const OPERATIONS = [
81
85
  ["status", "One-glance project health dashboard"],
82
86
  ["why", "Explain provenance: a capability's chain, or an anchor's (SC1)"],
83
87
  ["handoff", "Markdown handoff note: open work, task state, resume command"],
88
+ ["workspace init", "Create a multi-repository workspace (a private git repo of context)"],
89
+ ["workspace add", "Register a repository: in-repo, or shadowed when it cannot carry .doctrina/"],
90
+ ["workspace attach", "Re-create a shadow project's links in its repository"],
91
+ ["workspace detach", "Remove a shadow project's links; the shadow stays"],
92
+ ["workspace list", "Projects with mode, state and open changes"],
93
+ ["workspace tool", "Declare a tool (MCP server) the work uses: role, read or write"],
94
+ ["workspace guard", "Pre-call hook: allow, deny or ask an MCP call from the declared access"],
95
+ ["delivery new", "Open a delivery across repositories; print the orchestration playbook"],
96
+ ["delivery route", "Choose the projects a delivery touches, dependencies first"],
97
+ ["delivery list", "Deliveries with status, ref and closed/total changes"],
98
+ ["delivery status", "Per project: the changes carrying a delivery and their tasks"],
99
+ ["delivery check", "What still keeps a delivery from closing (exit 1 while anything does)"],
100
+ ["delivery close", "Close a delivery once every project's change is closed"],
84
101
  ["templates list", "List the templates shipped by the installed CLI"],
85
102
  ["templates check", "Compare the project against the recommended template shape"],
86
103
  ["templates update", "Additive fixer for check findings (preview; --write applies)"],
@@ -126,8 +143,8 @@ export const COMMAND_META = {
126
143
  why: { moment: "Orient", when: "you need to justify or trace a capability's existence", purpose: "provenance: intent, proof, ADRs, and history" },
127
144
  handoff: { moment: "Orient", when: "BEFORE compaction or handing over to another session", purpose: "a resume note: open work, task state, next command" },
128
145
 
129
- triage: { moment: "Change", when: "a request arrives — BEFORE scaffolding, especially if it smells like an incident", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
130
- work: { moment: "Change", when: "a request arrives that changes behaviour (triage says PRODUCT)", purpose: "scaffold a change and print the playbook to execute" },
146
+ triage: { moment: "Change", when: "diagnosing an incident or a runtime symptom — `work` classifies every other request itself", purpose: "classify the lane (product/runtime/chore) and check the declared runtime surface" },
147
+ work: { moment: "Change", when: "a request changes behaviour — BEFORE editing code; it classifies the lane itself", purpose: "scaffold a change and print the playbook to execute" },
131
148
  spec: { moment: "Change", when: "a capability needs creating or its headers advancing", purpose: "create, list, and edit capability specs" },
132
149
  change: { moment: "Change", when: "driving a change through its lifecycle by hand", purpose: "drive a change by hand — check --verbose previews every delta" },
133
150
  decision: { moment: "Change", when: "the change decides something a later session must not relitigate", purpose: "record, accept, land, scope, and supersede ADRs" },
@@ -145,6 +162,9 @@ export const COMMAND_META = {
145
162
  close: { moment: "Gate", when: "a change is implemented and ready to finish", purpose: "the whole closing sequence in one attested pass" },
146
163
  doctor: { moment: "Gate", when: "something looks wrong and you do not know which gate to ask", purpose: "aggregate diagnostic with per-finding remedies" },
147
164
 
165
+ delivery: { moment: "Orchestrate", when: "a story or screen must land in more than one repository", purpose: "plan, route, track and close one item of work across repositories" },
166
+ workspace: { moment: "Orchestrate", when: "the work spans several repositories, or a repository cannot carry .doctrina/", purpose: "register repositories, in-repo or shadowed, in one versioned workspace" },
167
+
148
168
  templates: { moment: "Maintain", when: "customising a scaffold, to see which template file wins", purpose: "where each shipped template resolves from" },
149
169
  upgrade: { moment: "Maintain", when: "you just updated the doctrina-cli package", purpose: "bring this project up to the installed CLI" },
150
170
  index: { moment: "Maintain", when: "the index drifted from the tree", purpose: "regenerate index.json from the artifacts on disk" },
@@ -157,7 +177,7 @@ export const COMMAND_META = {
157
177
  };
158
178
 
159
179
  // The order moments appear in the generated block.
160
- export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Maintain"];
180
+ export const MOMENTS = ["Bootstrap", "Orient", "Change", "Gate", "Orchestrate", "Maintain"];
161
181
 
162
182
  // The Commands block of `doctrina --help`, generated from OPERATIONS so the
163
183
  // printed surface can never omit an operation the catalog knows about.
@@ -191,7 +211,10 @@ export function surfaceHelp() {
191
211
  const ops = live.filter(([op]) => op.split(" ")[0] === cmd);
192
212
  const subs = ops.map(([op]) => op.split(" ")[1]).filter(Boolean);
193
213
  const text = ops.length === 1 ? ops[0][1] : capitalise(COMMAND_META[cmd].purpose);
194
- out.push(line(subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd, text));
214
+ const op = subs.length > 0 ? `${cmd} ${subs.join("|")}` : cmd;
215
+ // A compact moment's long invocation stands alone rather than wrapping
216
+ // onto a second line (change 0220): `doctrina <cmd> --help` has the rest.
217
+ out.push(COMPACT_MOMENTS.has(moment) && op.length >= 20 ? ` ${op}` : line(op, text));
195
218
  }
196
219
  }
197
220
  const old = OPERATIONS.filter(([op]) => DEPRECATED[op]);
@@ -316,7 +339,10 @@ export const SURFACE_LINE_BUDGET = 40;
316
339
  // each its own line pushed the block past its budget. The budget is the
317
340
  // forcing function — a surface that cannot describe itself in 40 lines is
318
341
  // too large, and the answer is to cut commands (M8), not to raise it.
319
- const COMPACT_MOMENTS = new Set(["Maintain"]);
342
+ // "Orchestrate" is compact too (change 0220): an orchestrating session reads
343
+ // the workspace's own AGENTS.md, which carries the delivery playbook in full;
344
+ // inside one repository the commands only need to be discoverable.
345
+ const COMPACT_MOMENTS = new Set(["Orchestrate", "Maintain"]);
320
346
 
321
347
  // The full managed section, heading included, between the markers.
322
348
  //
@@ -516,6 +542,27 @@ export const AGENT_CHANGELOG = {
516
542
  // Each entry lists only ITS OWN delta. The series carries the rest
517
543
  // forward (see agentChangelogEntries), so a patch never has to restate
518
544
  // the minor that introduced the commands, and never silently erases it.
545
+ "0.20.0": [
546
+ "Work across repositories is a delivery: from the workspace, `doctrina delivery new \"<title>\" --ref <id>`, then follow its playbook — route, one change per repository with `doctrina work \"<title>\" --delivery <id>`, close each, then `doctrina delivery close <id>`.",
547
+ "Write tasks as self-contained cards (indented `Files:`, `Requirement:`, `Verify:` lines) and hand an executor `doctrina change card <id>`; one writer per repository, and only the planner closes (ADR 0030).",
548
+ "An existing codebase with no specs: `doctrina intake --from-code`, then the absorb playbook — describe what the code does today and send what it cannot tell to the human as open questions.",
549
+ "A repository that may not carry `.doctrina/` is a workspace shadow: its `.doctrina/` is a link, hidden from its git — never commit it there; commit the workspace.",
550
+ "MCP tools declared in the workspace are read-only unless declared `write`: a refused or held call means ask the human, never route around it.",
551
+ ],
552
+ "0.19.0": [
553
+ "A request that changes behaviour is a change opened BEFORE code, whether or not it names Doctrina; say its id in your first sentence to the human, and repeat the `Tell the human:` line `work` and `close` print.",
554
+ "`doctrina context <cap> --for \"<task>\" --concat` is now lean (no AGENTS.md, ADR digests, the spec sliced to the task) — read it before editing; `prime` is state, not the spec. `--full` gives the whole text.",
555
+ "Project skills are exported to `.agents/skills/` and `.claude/skills/`, where your agent lists skills — use them; edit `.doctrina/skills/`, never the exports.",
556
+ "`work` holds (exit 3) a request that repeats an open change or one archived in the last 14 days: resume it, or tell the human it is done and where; `--force` only for genuinely new work.",
557
+ "Hooks: `doctrina adapter add claude|cursor` merges session/prompt hooks (`prime --hook`, `prime --pulse`) into the agent's settings.",
558
+ ],
559
+ "0.18.0": [
560
+ "`doctrina change delta <id> <capability> --requirement \"<section>: <text>\" --criterion \"<text>\" --bump minor` writes the ops lines for you and dry-runs them against the spec — stop hand-writing ops blocks.",
561
+ "`close` runs `verify --reuse`: running `doctrina verify` yourself right before `doctrina close` is no longer paid twice, and a batch close runs the suite once. Use `verify --affected` while working when checks declare `\"paths\"`.",
562
+ "The review inside `close` counts the change's own deltas as spec updates and reports code under other capabilities in ONE line — read it: either this change forgot a delta or other work is lying in the tree.",
563
+ "`close` ends by listing other open changes that look finished or are a week old; close them with the `doctrina close <id>` it prints. `next` no longer suggests `change archive`.",
564
+ "A spec can shrink: `remove-requirement <section> <n>` / `remove-criterion <n>`, and `doctrina work --consolidate <cap>` when a spec has grown past what it needs to say.",
565
+ ],
519
566
  "0.17.0": [
520
567
  "Preview a close with `doctrina change check <id>` (it exits 1 while a Verification box is open), then run `doctrina close <id>` — do not run `analyze`, `change apply` or `change archive` by hand; a chore closes the same way.",
521
568
  "Deprecated names still run with a notice — use the replacement: `analyze` → `change check`, `report` → `status --view report`, `skill sync` → `index rebuild`, `templates check|update` → `upgrade [--write]`. `constitution` and `change diff` are removed (exit 2).",
@@ -0,0 +1,118 @@
1
+ // @ts-check
2
+ import { fold } from "./lexicon.js";
3
+ import { getSection } from "./doc-model.js";
4
+
5
+ // The lean read pack (change 0216).
6
+ //
7
+ // A field review: the agent ran `prime` and `work` and skipped `context`,
8
+ // although AGENTS.md calls it the read for ANY task. Measured on this
9
+ // repository, the reason is the price: `context gates --concat` was ~40k
10
+ // tokens — AGENTS.md again (the agent already carries it), the whole spec,
11
+ // and every accepted ADR in full — and `--for "<task>"` cut nothing, because
12
+ // retrieval only degraded the pack once the 50k budget overflowed. These
13
+ // cuts are the default now; `--full` restores the whole text.
14
+
15
+ // How much of an ADR's Decision a digest keeps.
16
+ const ADR_DIGEST_CHARS = 700;
17
+ // How many requirements and criteria a sliced spec keeps at most, each.
18
+ const SLICE_MAX_ITEMS = 25;
19
+
20
+ /**
21
+ * An ADR as a reader deciding whether it binds the task needs it: the
22
+ * title and the opening of its Decision section. `doctrina show NNNN`
23
+ * reads the rest.
24
+ *
25
+ * @param {string} text
26
+ * @param {string} title
27
+ * @param {string} id four-digit ADR number
28
+ */
29
+ export function adrDigest(text, title, id) {
30
+ const decision = (getSection(text.replace(/\r\n/g, "\n"), "Decision") ?? "")
31
+ .split("\n").filter((l) => !l.trim().startsWith("<!--")).join("\n").trim();
32
+ const clipped = decision.length > ADR_DIGEST_CHARS
33
+ ? `${decision.slice(0, ADR_DIGEST_CHARS).replace(/\s+\S*$/, "")} …`
34
+ : decision;
35
+ return [`# ${title}`, "", clipped || "(no Decision section)", "",
36
+ `(digest — full text: \`doctrina show ${id}\`, or \`context --full\`)`].join("\n");
37
+ }
38
+
39
+ /**
40
+ * A spec cut to what the task is about: its headers, its Purpose, and the
41
+ * requirements and criteria that match the query best — the rest named by
42
+ * count, never silently dropped. Deterministic: an item's score is how many
43
+ * distinct query terms it contains; the items scoring at least half the
44
+ * best score are kept, best first in document order, up to a cap per list.
45
+ *
46
+ * @param {string} text
47
+ * @param {string[]} queryTerms already folded, stop words removed
48
+ * @param {string} rel the spec's path, for the footer
49
+ * @returns {{ text: string, kept: number, total: number } | null} null when nothing matched
50
+ */
51
+ export function sliceSpec(text, queryTerms, rel) {
52
+ if (queryTerms.length === 0) return null;
53
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
54
+ // Items: a bullet or numbered entry with its continuation lines.
55
+ /** @type {Array<{ start: number, end: number, kind: "req"|"crit", score: number }>} */
56
+ const items = [];
57
+ let section = "";
58
+ for (let i = 0; i < lines.length; i++) {
59
+ const l = lines[i];
60
+ if (/^##\s/.test(l)) { section = l; continue; }
61
+ const req = /^##\s+Requirements/i.test(section) && /^\s*-\s+\S/.test(l);
62
+ const crit = /^##\s+Acceptance criteria/i.test(section) && /^\s*\d+\.\s+\S/.test(l);
63
+ if (!req && !crit) continue;
64
+ let end = i;
65
+ while (end + 1 < lines.length && /^\s+\S/.test(lines[end + 1]) && !/^\s*(-|\d+\.)\s/.test(lines[end + 1])) end += 1;
66
+ const body = fold(lines.slice(i, end + 1).join(" "));
67
+ const score = queryTerms.filter((t) => body.includes(t)).length;
68
+ items.push({ start: i, end, kind: req ? "req" : "crit", score });
69
+ i = end;
70
+ }
71
+ const best = Math.max(0, ...items.map((it) => it.score));
72
+ if (best === 0) return null;
73
+ const floor = Math.max(1, Math.ceil(best / 2));
74
+ const keep = new Set();
75
+ for (const kind of ["req", "crit"]) {
76
+ items.filter((it) => it.kind === kind && it.score >= floor)
77
+ .sort((a, b) => b.score - a.score || a.start - b.start)
78
+ .slice(0, SLICE_MAX_ITEMS)
79
+ .forEach((it) => keep.add(it.start));
80
+ }
81
+
82
+ const out = [];
83
+ section = "";
84
+ let omitted = 0;
85
+ const flushOmitted = () => {
86
+ if (omitted > 0) out.push(` … ${omitted} not matching the task`);
87
+ omitted = 0;
88
+ };
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const l = lines[i];
91
+ if (/^##\s/.test(l)) {
92
+ flushOmitted();
93
+ section = l;
94
+ out.push(l);
95
+ continue;
96
+ }
97
+ const item = items.find((it) => it.start === i);
98
+ if (item) {
99
+ if (keep.has(i)) { flushOmitted(); out.push(...lines.slice(i, item.end + 1)); } else omitted += 1;
100
+ i = item.end;
101
+ continue;
102
+ }
103
+ // Headers, Purpose and subsection headings are kept; prose in the other
104
+ // sections (Out of scope, Maturity, …) is the first thing a task can do
105
+ // without.
106
+ const inLists = /^##\s+(Requirements|Acceptance criteria)/i.test(section);
107
+ const prose = section !== "" && !/^##\s+Purpose/i.test(section) && !inLists;
108
+ if (/^###\s/.test(l)) { flushOmitted(); out.push(l); continue; }
109
+ if (section === "" || /^##\s+Purpose/i.test(section)) { out.push(l); continue; }
110
+ if (inLists && l.trim() !== "") continue;
111
+ if (!prose) out.push(l);
112
+ }
113
+ flushOmitted();
114
+ const kept = keep.size;
115
+ out.push("", `(sliced for "${queryTerms.join(" ")}" — ${kept} of ${items.length} requirements and criteria kept; ` +
116
+ `full text: ${rel}, or \`context --full\`)`);
117
+ return { text: out.join("\n").replace(/\n{3,}/g, "\n\n"), kept, total: items.length };
118
+ }
@@ -0,0 +1,208 @@
1
+ // @ts-check
2
+ import { readdirSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { isDir, isFile, read, write } from "./fs-ops.js";
5
+ import { listHeader } from "./scan.js";
6
+ import { checklistProgress, setHeader } from "./doc-model.js";
7
+ import { terms } from "./lexicon.js";
8
+ import { slugify, padNumber } from "./dates.js";
9
+ import { artifactRootOf } from "./workspace.js";
10
+
11
+ // A DELIVERY is one item of work that crosses repositories (ADR 0030): a
12
+ // story, a screen, a fix. It lives in the workspace's `deliveries/` and
13
+ // holds what no single repository owns — the story, the sources, the plan
14
+ // across projects, the release. Each project it touches gets ONE change,
15
+ // opened in that repository and carrying `- **Delivery:** <id>`, so the
16
+ // delivery's state is READ from the projects, never copied into it.
17
+
18
+ export const DELIVERY_FILE = "delivery.md";
19
+ export const DELIVERY_ID = /^\d{4}-[a-z0-9][a-z0-9-]*$/;
20
+
21
+ export function deliveriesDir(wsDir) {
22
+ return path.join(wsDir, "deliveries");
23
+ }
24
+
25
+ /** Every delivery on disk, oldest first. */
26
+ export function listDeliveries(wsDir) {
27
+ const dir = deliveriesDir(wsDir);
28
+ if (!isDir(dir)) return [];
29
+ return readdirSync(dir).filter((d) => DELIVERY_ID.test(d) && isFile(path.join(dir, d, DELIVERY_FILE))).sort()
30
+ .map((id) => readDelivery(wsDir, id));
31
+ }
32
+
33
+ export function deliveryPath(wsDir, id) {
34
+ return path.join(deliveriesDir(wsDir), id, DELIVERY_FILE);
35
+ }
36
+
37
+ /** A delivery by id — or by its number alone (`1`, `0001`). Null when absent. */
38
+ export function resolveDeliveryId(wsDir, ref) {
39
+ if (!ref) return null;
40
+ if (DELIVERY_ID.test(ref) && isFile(deliveryPath(wsDir, ref))) return ref;
41
+ if (/^\d+$/.test(ref)) {
42
+ const prefix = padNumber(Number(ref)) + "-";
43
+ return listDeliveries(wsDir).find((d) => d.id.startsWith(prefix))?.id ?? null;
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export function readDelivery(wsDir, id) {
49
+ // CRLF on a Windows checkout: a `$` would leave the \r in the title.
50
+ const text = read(deliveryPath(wsDir, id)).replace(/\r\n/g, "\n");
51
+ const title = text.match(/^#\s+Delivery\s+\S+\s+[—-]\s+(.+)$/m)?.[1]?.trim() ?? id;
52
+ const projects = (listHeader(text, "Projects") ?? "").split(",").map((s) => s.trim()).filter((s) => s && s !== "—");
53
+ return {
54
+ id, title, text,
55
+ status: (listHeader(text, "Status") ?? "open").toLowerCase(),
56
+ date: listHeader(text, "Date") ?? null,
57
+ closed: listHeader(text, "Closed") ?? null,
58
+ ref: listHeader(text, "Ref") ?? "—",
59
+ projects,
60
+ };
61
+ }
62
+
63
+ export function nextDeliveryId(wsDir, title) {
64
+ const numbers = listDeliveries(wsDir).map((d) => Number(d.id.slice(0, 4)));
65
+ const next = (numbers.length ? Math.max(...numbers) : 0) + 1;
66
+ return `${padNumber(next)}-${slugify(title).slice(0, 40).replace(/-+$/, "") || "delivery"}`;
67
+ }
68
+
69
+ /** Rewrite one list header of a delivery (adding it when absent). */
70
+ export function setDeliveryHeader(wsDir, id, name, value) {
71
+ const p = deliveryPath(wsDir, id);
72
+ let text = read(p);
73
+ const next = setHeader(text, name, value);
74
+ if (next !== null) text = next;
75
+ else text = text.replace(/^(- \*\*Projects:\*\*.*)$/m, `$1\n- **${name}:** ${value}`);
76
+ write(p, text, { force: true });
77
+ }
78
+
79
+ /**
80
+ * Projects ranked against a delivery's text — how many of its terms each
81
+ * project's name, role, description and capability names share. A hint for
82
+ * the orchestrator, never a decision (ADR 0005): `delivery route` is where
83
+ * the projects are chosen.
84
+ */
85
+ export function rankProjects(wsDir, ws, text) {
86
+ const want = new Set(terms(text));
87
+ const out = [];
88
+ for (const p of ws.projects) {
89
+ const caps = capabilityNames(wsDir, p);
90
+ const words = new Set(terms([p.name, p.role, p.about, ...caps].join(" ")));
91
+ const shared = [...want].filter((t) => words.has(t));
92
+ out.push({ project: p, score: shared.length, shared, capabilities: caps });
93
+ }
94
+ return out.sort((a, b) => b.score - a.score || a.project.name.localeCompare(b.project.name));
95
+ }
96
+
97
+ function capabilityNames(wsDir, project) {
98
+ const specs = path.join(artifactRootOf(wsDir, project), ".doctrina", "specs");
99
+ if (!isDir(specs)) return [];
100
+ return readdirSync(specs).filter((d) => isFile(path.join(specs, d, "spec.md")));
101
+ }
102
+
103
+ /** The projects in dependency order: a project after every project it depends on. */
104
+ export function dependencyOrder(ws, names) {
105
+ const byName = new Map(ws.projects.map((p) => [p.name, p]));
106
+ const out = [];
107
+ const seen = new Set();
108
+ const visit = (name, trail) => {
109
+ if (seen.has(name) || trail.has(name)) return;
110
+ trail.add(name);
111
+ for (const dep of byName.get(name)?.dependsOn ?? []) if (names.includes(dep)) visit(dep, trail);
112
+ seen.add(name);
113
+ out.push(name);
114
+ };
115
+ for (const n of names) visit(n, new Set());
116
+ return out;
117
+ }
118
+
119
+ /**
120
+ * The changes each routed project carries for this delivery — open ones in
121
+ * `changes/`, closed ones in `changes/archive/` — read from the projects.
122
+ */
123
+ export function deliveryChanges(wsDir, ws, delivery) {
124
+ const out = [];
125
+ for (const name of delivery.projects) {
126
+ const project = ws.projects.find((p) => p.name === name);
127
+ if (!project) {
128
+ out.push({ project: name, missing: "not registered in the workspace", changes: [] });
129
+ continue;
130
+ }
131
+ const changesDir = path.join(artifactRootOf(wsDir, project), ".doctrina", "changes");
132
+ const changes = [];
133
+ for (const { dir, archived } of [{ dir: changesDir, archived: false }, { dir: path.join(changesDir, "archive"), archived: true }]) {
134
+ if (!isDir(dir)) continue;
135
+ for (const entry of readdirSync(dir).sort()) {
136
+ const proposal = path.join(dir, entry, "proposal.md");
137
+ if (entry === "archive" || !isFile(proposal)) continue;
138
+ const text = read(proposal);
139
+ if ((listHeader(text, "Delivery") ?? "").trim() !== delivery.id) continue;
140
+ const tasks = path.join(dir, entry, "tasks.md");
141
+ const progress = isFile(tasks) ? checklistProgress(read(tasks)) : { total: 0, done: 0 };
142
+ changes.push({
143
+ id: archived ? entry.replace(/^\d{4}-\d{2}-\d{2}-/, "") : entry,
144
+ archived,
145
+ status: archived ? "archived" : (listHeader(text, "Status") ?? "proposed").toLowerCase(),
146
+ tasksDone: progress.done, tasksTotal: progress.total,
147
+ });
148
+ }
149
+ }
150
+ out.push({ project: name, path: project.path, changes });
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /** A section's own text: comments and blank lines removed. Empty string when absent. */
156
+ export function sectionText(text, heading) {
157
+ const lines = String(text).replace(/\r\n/g, "\n").replace(/<!--[\s\S]*?-->/g, "").split("\n");
158
+ const at = lines.findIndex((l) => l.trim() === `## ${heading}`);
159
+ if (at < 0) return "";
160
+ const out = [];
161
+ for (const l of lines.slice(at + 1)) {
162
+ if (/^## /.test(l)) break;
163
+ if (l.trim()) out.push(l.trim());
164
+ }
165
+ return out.join("\n");
166
+ }
167
+
168
+ /** Days between two YYYY-MM-DD dates, or null. */
169
+ export function daysBetween(from, to) {
170
+ const a = Date.parse(String(from ?? "") + "T00:00:00Z");
171
+ const b = Date.parse(String(to ?? "") + "T00:00:00Z");
172
+ return Number.isNaN(a) || Number.isNaN(b) ? null : Math.round((b - a) / 86400000);
173
+ }
174
+
175
+ /**
176
+ * The record a delivery keeps of its own work (change 0224): the story it
177
+ * came from, every Release claim ticked, and at least one line of what was
178
+ * watched after the release. The changes prove the code; these prove the
179
+ * delivery reached its users and was looked at once it had.
180
+ */
181
+ export function recordBlockers(delivery) {
182
+ const out = [];
183
+ if (sectionText(delivery.text, "Story") === "") out.push("## Story is empty — copy the tracker item's story and acceptance criteria, verbatim");
184
+ const release = sectionText(delivery.text, "Release");
185
+ const open = release.split("\n").filter((l) => /^[-*]\s*\[\s\]/.test(l));
186
+ for (const box of open) out.push(`## Release: unticked — ${box.replace(/^[-*]\s*\[\s\]\s*/, "")}`);
187
+ if (sectionText(delivery.text, "Observability") === "") out.push("## Observability is empty — record what was watched after the release (monitor, dashboard or query, the window, what it showed)");
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Everything that keeps a delivery from closing. Each routed project must
193
+ * carry at least one change for it, and every such change must be archived;
194
+ * the delivery's own record must be written (recordBlockers).
195
+ */
196
+ export function deliveryBlockers(wsDir, ws, delivery) {
197
+ const blockers = [];
198
+ if (delivery.projects.length === 0) blockers.push("no project routed — `doctrina delivery route <id> <a,b>`");
199
+ for (const row of deliveryChanges(wsDir, ws, delivery)) {
200
+ if (row.missing) blockers.push(`${row.project}: ${row.missing}`);
201
+ else if (row.changes.length === 0) blockers.push(`${row.project}: no change carries this delivery — open one there with \`doctrina work "<title>" --delivery ${delivery.id}\``);
202
+ else for (const ch of row.changes.filter((x) => !x.archived)) {
203
+ blockers.push(`${row.project}: change ${ch.id} is ${ch.status} (${ch.tasksDone}/${ch.tasksTotal} tasks) — close it there with \`doctrina close ${ch.id}\``);
204
+ }
205
+ }
206
+ blockers.push(...recordBlockers(delivery));
207
+ return blockers;
208
+ }
package/src/lib/git.js CHANGED
@@ -1,5 +1,10 @@
1
1
  // @ts-check
2
2
  import { spawnSync } from "node:child_process";
3
+ import { copyFileSync, existsSync, rmSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import process from "node:process";
3
8
 
4
9
  // One place that knows how to ask git a question and how to interpret not
5
10
  // getting an answer (audit item C8).
@@ -32,8 +37,13 @@ export const GIT_STATE = Object.freeze({
32
37
 
33
38
  // Run a git command. Never throws. Returns
34
39
  // { state, stdout, lines, stderr, code }.
35
- export function git(cwd, args) {
36
- const r = spawnSync("git", args, { cwd, encoding: "utf8" });
40
+ /**
41
+ * @param {string} cwd
42
+ * @param {string[]} args
43
+ * @param {{ env?: Record<string, string> }} [opts]
44
+ */
45
+ export function git(cwd, args, { env } = {}) {
46
+ const r = spawnSync("git", args, { cwd, encoding: "utf8", ...(env ? { env: { ...process.env, ...env } } : {}) });
37
47
  if (r.error) {
38
48
  // ENOENT here means the binary is missing, not that the command failed.
39
49
  return { state: GIT_STATE.ABSENT, stdout: "", lines: [], stderr: String(r.error.message ?? ""), code: null };
@@ -227,3 +237,50 @@ export function lastTagDate(cwd) {
227
237
  const date = tags[0] ?? "";
228
238
  return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
229
239
  }
240
+
241
+ /**
242
+ * A content fingerprint of the working tree under `cwd` — tracked AND
243
+ * untracked files, .gitignore respected, uncommitted edits included — with
244
+ * the top-level entries named in `exclude` left out (change 0203). Null when
245
+ * git cannot answer.
246
+ *
247
+ * Built from git's own object model rather than by reading files: the real
248
+ * index is copied to a scratch one, `git add -A` stages the whole tree into
249
+ * the copy (reusing the stat cache, so only changed files are hashed), and
250
+ * `git write-tree` names the result. The real index, HEAD and the working
251
+ * tree are never touched; the only side effect is loose objects for changed
252
+ * files, which `git gc` reclaims.
253
+ *
254
+ * @param {string} cwd
255
+ * @param {{ exclude?: string[] }} [opts]
256
+ * @returns {string | null}
257
+ */
258
+ export function worktreeFingerprint(cwd, { exclude = [] } = {}) {
259
+ const top = git(cwd, ["rev-parse", "--show-toplevel"]);
260
+ const dir = git(cwd, ["rev-parse", "--absolute-git-dir"]);
261
+ if (top.state !== GIT_STATE.OK || dir.state !== GIT_STATE.OK) return null;
262
+ const root = top.stdout.trim();
263
+ const scratch = path.join(os.tmpdir(), `doctrina-fingerprint-${process.pid}-${Date.now()}`);
264
+ try {
265
+ const real = path.join(dir.stdout.trim(), "index");
266
+ if (existsSync(real)) copyFileSync(real, scratch);
267
+ const env = { GIT_INDEX_FILE: scratch };
268
+ if (git(root, ["add", "-A", "--", "."], { env }).state !== GIT_STATE.OK) return null;
269
+ const tree = git(root, ["write-tree"], { env });
270
+ if (tree.state !== GIT_STATE.OK) return null;
271
+ // Asked of git, never computed with path.relative: on macOS the tmpdir
272
+ // is `/var/…`, a symlink to `/private/var/…`, and git answers with the
273
+ // resolved toplevel — the relative path between the two spellings is
274
+ // `../..`, which names nothing in the tree (the 0.18 macOS CI run).
275
+ const prefixed = git(cwd, ["rev-parse", "--show-prefix"]);
276
+ if (prefixed.state !== GIT_STATE.OK) return null;
277
+ const prefix = prefixed.stdout.trim().replace(/\/$/, "");
278
+ const listing = git(root, ["ls-tree", prefix ? `${tree.stdout.trim()}:${prefix}` : tree.stdout.trim()]);
279
+ if (listing.state !== GIT_STATE.OK) return null;
280
+ const skip = new Set(exclude);
281
+ const kept = listing.lines.filter((l) => !skip.has(l.split("\t")[1]));
282
+ return createHash("sha256").update(kept.join("\n")).digest("hex");
283
+ } finally {
284
+ rmSync(scratch, { force: true });
285
+ }
286
+ }
@@ -48,6 +48,16 @@ export function computeSnapshot(commits, since) {
48
48
  for (const f of cm.files) lastTouch.set(f, cm.ts);
49
49
  }
50
50
 
51
+ // Workflow adherence (change 0218): of the commits that changed code, how
52
+ // many carried a change — touched `.doctrina/changes/`, or named one in the
53
+ // subject. A field review found an agent that "knows the framework
54
+ // exists" and still went straight to code when the request did not name
55
+ // it; this is the number that says whether the hooks and the contract
56
+ // moved that. A proxy, like re-edit: a typo fix needs no change.
57
+ const code = commits.filter((cm) => cm.files.some(isCodePath));
58
+ const withChange = code.filter((cm) =>
59
+ cm.files.some((f) => f.startsWith(".doctrina/changes/")) || CHANGE_REF.test(cm.subject));
60
+
51
61
  return {
52
62
  generated: today(),
53
63
  window: since,
@@ -57,10 +67,22 @@ export function computeSnapshot(commits, since) {
57
67
  fix_share: round(fixes / total),
58
68
  reedit_window_days: REEDIT_WINDOW_DAYS,
59
69
  reedit_rate: round(reedits / total),
70
+ code_commits: code.length,
71
+ change_adherence: code.length > 0 ? round(withChange.length / code.length) : null,
60
72
  top_churn: topChurn,
61
73
  };
62
74
  }
63
75
 
76
+ // A subject that names a change: "Change 0212", or an id like "0212-x".
77
+ const CHANGE_REF = /\bchange\s+\d{4}\b|\b\d{4}-[a-z0-9][a-z0-9-]*\b/i;
78
+
79
+ // Code, for the adherence rate: not the Doctrina tree, not an agent's own
80
+ // config or generated skills, not prose.
81
+ function isCodePath(f) {
82
+ if (/^\.(doctrina|agents|claude|cursor)\//.test(f)) return false;
83
+ return !/\.(md|mdx|txt)$/i.test(f);
84
+ }
85
+
64
86
 
65
87
  export function parseLog(stdout) {
66
88
  const commits = [];
@@ -174,7 +196,7 @@ export function latestSnapshot(projectRoot, excludeBase = null) {
174
196
  * @param {ReturnType<typeof readSeries>} series
175
197
  */
176
198
  export function trend(series) {
177
- const rates = ["revert_rate", "fix_share", "reedit_rate"];
199
+ const rates = ["revert_rate", "fix_share", "reedit_rate", "change_adherence"];
178
200
  if (series.length < 2) return { span: series.length, rows: [] };
179
201
  const first = series[0];
180
202
  const last = series[series.length - 1];
@@ -94,4 +94,4 @@ export function printPlaybookTemplate(projectRoot, name, tokens = {}) {
94
94
  }
95
95
 
96
96
  /** The playbooks the CLI ships and `templates check` expects to resolve. */
97
- export const PLAYBOOKS = ["work", "chore", "bootstrap"];
97
+ export const PLAYBOOKS = ["work", "chore", "bootstrap", "consolidate", "absorb"];
@@ -631,6 +631,12 @@ export function expandBraces(glob) {
631
631
  // `{a,b}` alternates. Enough for the "where do my selectors live" and "which
632
632
  // code is mine" declarations, with no dependency and no surprises.
633
633
  export function globToRegExp(glob) {
634
+ // A pattern ending in `/` names a DIRECTORY, and a directory claims what
635
+ // is under it (change 0199). Compiled as written it matched only the
636
+ // literal path `src/health/` — which no file has — so the most natural way
637
+ // to declare a module's ownership claimed nothing, and `review` reported
638
+ // every file in it as ownerless.
639
+ if (/\/$/.test(String(glob))) glob = `${glob}**`;
634
640
  // Always compile the EXPANSION, never the original. Using it only when it
635
641
  // produced more than one alternative left `src/{a}.js` compiling with its
636
642
  // braces as literal characters, so a one-element group matched nothing
package/src/lib/scan.js CHANGED
@@ -599,3 +599,16 @@ function artifactPathOf(file) {
599
599
  if (m) return m[1];
600
600
  return null;
601
601
  }
602
+
603
+ // The capabilities a change's deltas target, sorted — the change's own scope.
604
+ // `close` gates coverage on it and `review --change` reads it as "these specs
605
+ // are being updated by this change", because the delta merges at apply.
606
+ export function deltaCapabilities(changeDir) {
607
+ const caps = new Set();
608
+ for (const deltaPath of walk(path.join(changeDir, "specs"))) {
609
+ if (!deltaPath.endsWith("delta.md")) continue;
610
+ const cap = parseCapabilityFromDelta(read(deltaPath), deltaPath);
611
+ if (cap) caps.add(cap);
612
+ }
613
+ return [...caps].sort();
614
+ }
@@ -96,6 +96,30 @@ export function changedSince(projectRoot, sha, paths) {
96
96
  return [...out].sort();
97
97
  }
98
98
 
99
+ /**
100
+ * Which of `paths` the uncommitted work touches — edits against HEAD plus
101
+ * untracked files, `.gitignore` respected (change 0204). The same pathspec
102
+ * reading a sign-off's "paths" gets, so "what this check covers" means one
103
+ * thing in verify.json. Null when git cannot answer: a caller then runs the
104
+ * check rather than reading "nothing changed" out of a question it never
105
+ * got to ask.
106
+ *
107
+ * @param {string} projectRoot
108
+ * @param {string[]} paths
109
+ * @returns {string[] | null}
110
+ */
111
+ export function workingChanges(projectRoot, paths) {
112
+ const tracked = git(projectRoot, ["diff", "--name-only", "HEAD", "--", ...paths]);
113
+ // No commit yet: every file is new, so what is staged counts as changed.
114
+ const staged = tracked.state === GIT_STATE.EMPTY
115
+ ? git(projectRoot, ["ls-files", "--cached", "--", ...paths])
116
+ : tracked;
117
+ if (staged.state !== GIT_STATE.OK) return null;
118
+ const untracked = git(projectRoot, ["ls-files", "--others", "--exclude-standard", "--", ...paths]);
119
+ if (untracked.state !== GIT_STATE.OK) return null;
120
+ return [...new Set([...staged.lines, ...untracked.lines].filter(Boolean))].sort();
121
+ }
122
+
99
123
  /** The commit a signature is anchored to, or null outside a repository. */
100
124
  export function currentSha(projectRoot) {
101
125
  const r = git(projectRoot, ["rev-parse", "HEAD"]);