doctrina-cli 0.18.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 (42) hide show
  1. package/README.md +3 -2
  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 +73 -4
  6. package/src/commands/close.js +18 -1
  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/prime.js +76 -7
  14. package/src/commands/upgrade.js +15 -0
  15. package/src/commands/work.js +86 -2
  16. package/src/commands/workspace.js +409 -0
  17. package/src/index.js +5 -7
  18. package/src/lib/actions.js +1 -1
  19. package/src/lib/adapters.js +80 -0
  20. package/src/lib/code-inventory.js +297 -0
  21. package/src/lib/commands.js +44 -5
  22. package/src/lib/context-slice.js +118 -0
  23. package/src/lib/delivery.js +208 -0
  24. package/src/lib/metrics-model.js +23 -1
  25. package/src/lib/playbook.js +1 -1
  26. package/src/lib/skill-export.js +95 -0
  27. package/src/lib/snapshot.js +39 -2
  28. package/src/lib/task-card.js +102 -0
  29. package/src/lib/views.js +51 -2
  30. package/src/lib/work-model.js +58 -0
  31. package/src/lib/workspace-agents.js +259 -0
  32. package/src/lib/workspace-tools.js +113 -0
  33. package/src/lib/workspace.js +371 -0
  34. package/templates/AGENTS.md.template +17 -14
  35. package/templates/README.md +9 -0
  36. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  37. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  38. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  39. package/templates/delivery.md.template +39 -0
  40. package/templates/playbooks/absorb.md.template +40 -0
  41. package/templates/playbooks/delivery.md.template +38 -0
  42. package/templates/playbooks/work.md.template +1 -1
@@ -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
@@ -1,11 +1,15 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { exists } from "../lib/fs-ops.js";
5
- import { flagBool } from "../lib/args.js";
6
- import { notADoctrinaProject } from "../lib/exit-codes.js";
7
- import { collectSnapshot } from "../lib/snapshot.js";
8
- import { renderView } from "../lib/views.js";
4
+ import { exists, read } from "../lib/fs-ops.js";
5
+ import { findWorkspace, readWorkspace } from "../lib/workspace.js";
6
+ import { listDeliveries } from "../lib/delivery.js";
7
+ import { workspacePrimer } from "../lib/workspace-agents.js";
8
+ import { flagBool, flagString } from "../lib/args.js";
9
+ import { c } from "../lib/colors.js";
10
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
11
+ import { collectSnapshot, openChanges, recentlyClosed } from "../lib/snapshot.js";
12
+ import { renderView, pulse } from "../lib/views.js";
9
13
 
10
14
  // Session primer: the ~40-line read that orients an agent at the start of a
11
15
  // session — where things stand, what the standing rules are, what work is
@@ -22,23 +26,77 @@ import { renderView } from "../lib/views.js";
22
26
  // Flags this command accepts. Declared HERE, with the command, so
23
27
  // adding a command never requires editing the entrypoint — the gap that
24
28
  // let six flags ship undeclared and silently swallow a positional (C3).
25
- export const flags = { boolean: ["json", "rules"], string: [] };
29
+ export const flags = { boolean: ["json", "rules", "pulse"], string: ["hook"] };
30
+
31
+ // What an agent hook accepts on stdout (change 0211). Claude Code adds the
32
+ // plain text of a SessionStart hook to the conversation; Cursor's
33
+ // sessionStart reads `additional_context` from a JSON object. Both cap what
34
+ // they keep — Claude at 10,000 characters — so the primer is cut short of
35
+ // that rather than replaced by a file path the agent is never asked to read.
36
+ const HOOK_AGENTS = ["claude", "cursor"];
37
+ const HOOK_MAX_CHARS = 9000;
26
38
 
27
39
  export async function run(_positional, flags) {
28
40
  const projectRoot = process.cwd();
29
41
  if (!exists(path.join(projectRoot, ".doctrina"))) {
42
+ // At a workspace root the session belongs to the orchestrator (change
43
+ // 0223): the primer is the workspace's.
44
+ const found = findWorkspace(projectRoot);
45
+ if (found && found.via !== "pointer") return primeWorkspace(found.dir, flags);
30
46
  throw notADoctrinaProject();
31
47
  }
32
48
  // --rules: the standing rules in full, which is what the removed
33
49
  // `constitution` printed (changes 0049, 0175). The primer names the ADRs and counts the non-goals; this
34
50
  // prints both lists, from the same collection, so the two cannot disagree.
51
+ // --pulse: one line for a per-prompt hook — the open changes only, read
52
+ // without collecting the gates, because it runs before every prompt.
53
+ if (flagBool(flags, "pulse", false)) {
54
+ for (const line of pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))) console.log(line);
55
+ return 0;
56
+ }
57
+ if (flags.has("hook")) {
58
+ const agent = flagString(flags, "hook");
59
+ if (!agent || !HOOK_AGENTS.includes(agent)) {
60
+ console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
61
+ return EXIT.USAGE;
62
+ }
63
+ const text = ["Doctrina session primer (`doctrina prime`) — the project's gates, rules and open work:", "",
64
+ ...renderView("prime", collectSnapshot(projectRoot)), "", ...pulse(openChanges(projectRoot), recentlyClosed(projectRoot, { limit: 1 }))]
65
+ .join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
66
+ console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
67
+ return 0;
68
+ }
35
69
  const view = flagBool(flags, "rules", false) ? "rules" : "prime";
36
70
  for (const line of renderView(view, collectSnapshot(projectRoot))) console.log(line);
37
71
  return 0;
38
72
  }
39
73
 
74
+ function primeWorkspace(wsDir, flags) {
75
+ const ws = readWorkspace(wsDir);
76
+ const open = listDeliveries(wsDir).filter((d) => d.status !== "closed");
77
+ if (flagBool(flags, "pulse", false)) {
78
+ console.log(open.length === 0
79
+ ? "Doctrina workspace: no delivery is open — an item of work across repositories opens one with `doctrina delivery new \"<title>\" --ref <id>`."
80
+ : open.map((d) => `open delivery: ${d.id} — "${d.title}" (${d.projects.join(", ") || "not routed"}). Name it in your first sentence to the human; \`doctrina delivery status ${d.id}\`.`).join(" "));
81
+ return 0;
82
+ }
83
+ const lines = workspacePrimer(ws, wsDir, open);
84
+ if (flags.has("hook")) {
85
+ const agent = flagString(flags, "hook");
86
+ if (!agent || !HOOK_AGENTS.includes(agent)) {
87
+ console.error(c.red("error:") + ` --hook needs one of ${HOOK_AGENTS.join(", ")} (got "${agent ?? ""}")`);
88
+ return EXIT.USAGE;
89
+ }
90
+ const text = [...lines, "", read(path.join(wsDir, "AGENTS.md"))].join("\n").replace(/\x1b\[[0-9;]*m/g, "").slice(0, HOOK_MAX_CHARS);
91
+ console.log(agent === "cursor" ? JSON.stringify({ additional_context: text }) : text);
92
+ return 0;
93
+ }
94
+ for (const line of lines) console.log(line);
95
+ return 0;
96
+ }
97
+
40
98
  export const help = `
41
- Usage: doctrina prime
99
+ Usage: doctrina prime [--rules | --pulse | --hook claude|cursor]
42
100
 
43
101
  Print the session primer: gate digest, standing rules (accepted ADR
44
102
  titles + non-goal count), open work with task progress, and the top
@@ -51,6 +109,17 @@ collection of the tree, so they cannot disagree.
51
109
  --rules Print the standing rules in full instead of the primer: every
52
110
  accepted ADR and every declared non-goal — what the removed
53
111
  \`doctrina constitution\` printed, from the same collection.
112
+ --pulse One line for a per-prompt agent hook: the open change(s), or
113
+ that a request changing behaviour opens one with \`work\`.
114
+ --hook <agent>
115
+ The primer plus the pulse, as the agent's session-start hook
116
+ reads it: plain text for claude, {"additional_context"} JSON
117
+ for cursor; cut at 9,000 characters. \`doctrina adapter add
118
+ claude|cursor\` installs the hooks that call these.
119
+
120
+ At a workspace root (no .doctrina/, a workspace.json) it is the
121
+ orchestrator's primer: the projects and their state, the open deliveries,
122
+ the tools — and with --hook, the workspace's AGENTS.md after it.
54
123
 
55
124
  Read-only; always exits 0. Deeper reads: \`doctrina context\`,
56
125
  \`doctrina why <cap>\`, \`doctrina show <ref>\`.
@@ -9,6 +9,7 @@ import { flagBool } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { today } from "../lib/dates.js";
11
11
  import { notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
12
13
  import { collectFindings } from "../lib/templates-model.js";
13
14
  import * as templates from "./templates.js";
14
15
  import * as validate from "./validate.js";
@@ -74,6 +75,11 @@ export async function run(_positional, flags) {
74
75
  console.log(c.gray(" fix: ") + (f.remedy ? c.cyan(f.remedy) : c.gray("manual repair — no command can fix this")));
75
76
  }
76
77
 
78
+ // The skill exports (change 0212) follow the index: regenerated by the
79
+ // write, reported by the preview.
80
+ const exportPlan = planSkillExport(projectRoot);
81
+ const exportChanges = exportPlan.writes.length + exportPlan.removes.length;
82
+
77
83
  // 3. Index: rebuild from the tree, which also migrates the framework stamp.
78
84
  console.log("");
79
85
  console.log(c.gray("──── 2/3 index + framework stamp"));
@@ -81,6 +87,15 @@ export async function run(_positional, flags) {
81
87
  const current = idx.load(projectRoot);
82
88
  const drifted = !indexesMatch(deriveIndex(projectRoot, current), current);
83
89
  const staleStamp = (current.framework_version ?? null) !== running;
90
+ if (exportChanges > 0) {
91
+ if (writeMode) {
92
+ applySkillExport(projectRoot, exportPlan);
93
+ console.log(c.green("export ") + ` ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
94
+ } else {
95
+ console.log(c.yellow("would ") + `export ${exportChanges} skill file${exportChanges === 1 ? "" : "s"} where the agents discover skills`);
96
+ pending += 1;
97
+ }
98
+ }
84
99
  if (!drifted && !staleStamp) {
85
100
  console.log(c.green("ok") + " index.json matches the tree and the stamp is current");
86
101
  } else if (writeMode) {