doctrina-cli 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +92 -14
  5. package/src/commands/clarify.js +1 -204
  6. package/src/commands/context.js +33 -2
  7. package/src/commands/contract.js +1 -0
  8. package/src/commands/delivery.js +135 -17
  9. package/src/commands/doctor.js +21 -1
  10. package/src/commands/handoff.js +11 -2
  11. package/src/commands/init.js +13 -1
  12. package/src/commands/intake.js +16 -5
  13. package/src/commands/metrics.js +15 -0
  14. package/src/commands/next.js +41 -6
  15. package/src/commands/prime.js +53 -4
  16. package/src/commands/review.js +39 -3
  17. package/src/commands/skill.js +2 -1
  18. package/src/commands/status.js +28 -0
  19. package/src/commands/upgrade.js +18 -0
  20. package/src/commands/validate.js +18 -0
  21. package/src/commands/verify.js +32 -4
  22. package/src/commands/work.js +43 -8
  23. package/src/commands/workspace.js +73 -63
  24. package/src/index.js +98 -62
  25. package/src/lib/actions.js +43 -3
  26. package/src/lib/adapters.js +63 -1
  27. package/src/lib/args.js +33 -1
  28. package/src/lib/clarify-scan.js +221 -0
  29. package/src/lib/code-inventory.js +576 -103
  30. package/src/lib/commands.js +8 -0
  31. package/src/lib/context-slice.js +34 -3
  32. package/src/lib/delivery.js +88 -10
  33. package/src/lib/docs-impact.js +36 -1
  34. package/src/lib/fs-ops.js +23 -0
  35. package/src/lib/intake-model.js +1 -1
  36. package/src/lib/json-out.js +9 -1
  37. package/src/lib/shell.js +45 -0
  38. package/src/lib/snapshot.js +5 -1
  39. package/src/lib/task-card.js +100 -7
  40. package/src/lib/views.js +3 -3
  41. package/src/lib/work-model.js +8 -2
  42. package/src/lib/workspace-agents.js +65 -18
  43. package/src/lib/workspace-guard.js +65 -0
  44. package/src/lib/workspace-locate.js +141 -0
  45. package/src/lib/workspace-tools.js +63 -16
  46. package/src/lib/workspace-view.js +391 -0
  47. package/src/lib/workspace.js +82 -130
  48. package/templates/AGENTS.md.template +1 -1
  49. package/templates/README.md +2 -1
  50. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  51. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  52. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  53. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  54. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  55. package/templates/playbooks/absorb.md.template +39 -16
  56. package/templates/playbooks/bootstrap.md.template +18 -6
  57. package/templates/playbooks/delivery.md.template +12 -6
@@ -0,0 +1,391 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { existsSync, realpathSync } from "node:fs";
5
+ import { git, GIT_STATE } from "./git.js";
6
+ import { isDir, isFile, read } from "./fs-ops.js";
7
+ import { listHeader } from "./scan.js";
8
+ import { absorbCommand, findWorkspace, pointerPath, projectAt, projectState, readWorkspace, linkPlan, shadowDirOf } from "./workspace.js";
9
+ import { deliveryBlockers, deliveryChanges, listDeliveries, rankProjects, readDelivery, releaseBoxes } from "./delivery.js";
10
+ import { cdThen, qarg, qpath } from "./shell.js";
11
+
12
+ // THE WORKSPACE ROOT IS WHERE THE PLANNER WORKS (change 0230).
13
+ //
14
+ // `prime` knew it: at a workspace root it printed the orchestrator's primer.
15
+ // Every other orientation command answered as if the directory were an empty
16
+ // project — `next` told the planner to run `doctrina init` there, which
17
+ // would have created a project inside the workspace, and `handoff`, `status`,
18
+ // `validate` and `context` refused with "not a Doctrina project". The planner
19
+ // lost exactly the commands AGENTS.md says to reach for before compaction and
20
+ // when unsure what comes next. This module decides what each of them says at
21
+ // the root; the commands render it (ADR 0025).
22
+
23
+ /**
24
+ * The workspace this directory is the ROOT of — reached by the directory or
25
+ * $DOCTRINA_WORKSPACE, with no `.doctrina/` of its own. A project inside a
26
+ * workspace keeps its own answers; only the pointer route is never a root.
27
+ * @returns {string | null}
28
+ */
29
+ export function workspaceRootAt(cwd) {
30
+ if (existsSync(path.join(cwd, ".doctrina"))) return null;
31
+ const found = findWorkspace(cwd);
32
+ return found && found.via !== "pointer" ? found.dir : null;
33
+ }
34
+
35
+ /**
36
+ * Where the workspace stands: every project with its state, every open
37
+ * delivery with its changes and what keeps it open.
38
+ */
39
+ export function workspaceSnapshot(wsDir) {
40
+ const ws = readWorkspace(wsDir);
41
+ const projects = ws.projects.map((p) => ({ project: p, ...projectState(wsDir, p) }));
42
+ const deliveries = listDeliveries(wsDir).map((d) => readDelivery(wsDir, d.id));
43
+ const open = deliveries.filter((d) => d.status !== "closed").map((d) => ({
44
+ delivery: d,
45
+ rows: deliveryChanges(wsDir, ws, d),
46
+ blockers: deliveryBlockers(wsDir, ws, d),
47
+ release: releaseBoxes(d.text),
48
+ }));
49
+ return { ws, wsDir, projects, deliveries, open };
50
+ }
51
+
52
+ /**
53
+ * The next actions at the workspace root, in the order they unblock work:
54
+ * a missing repository first, then a project that cannot be routed to, then
55
+ * each open delivery's next step, then — with nothing open — a new delivery.
56
+ * @returns {Array<{ text: string, command: string }>}
57
+ */
58
+ export function workspaceActions(snap) {
59
+ const out = [];
60
+ for (const p of snap.projects.filter((x) => x.state === "missing")) {
61
+ out.push({ text: `${p.project.name}'s repository is not at its path — re-link it`, command: `doctrina workspace attach ${p.project.name} --path <dir>` });
62
+ }
63
+ for (const p of snap.projects.filter((x) => x.state === "pending")) {
64
+ if (p.reason === "its shadow folder in the workspace is gone") {
65
+ out.push({ text: `${p.project.name}'s shadow projects/${p.project.name}/ is gone — restore it from the workspace's git, or scaffold it again`, command: absorbCommand(p.project, p.reason) });
66
+ continue;
67
+ }
68
+ out.push(p.reason === "intake not converted"
69
+ ? { text: `${p.project.name}'s absorb is under way (${p.reason}) — finish its playbook, then \`doctrina intake --converted\` there`, command: absorbCommand(p.project, p.reason) }
70
+ : { text: `${p.project.name} has no specs yet (${p.reason}) — absorb it before routing work to it`, command: absorbCommand(p.project, p.reason) });
71
+ }
72
+ for (const { delivery: d, rows, blockers } of snap.open) {
73
+ if (d.projects.length === 0) {
74
+ out.push({ text: `delivery ${d.id} is routed nowhere yet`, command: `doctrina delivery route ${d.id} <a,b>` });
75
+ continue;
76
+ }
77
+ let moved = false;
78
+ for (const row of rows) {
79
+ if (row.missing || row.absent) continue;
80
+ if (row.changes.length === 0 && row.abandoned?.length) {
81
+ const a = row.abandoned[row.abandoned.length - 1];
82
+ out.push({ text: `${row.project}'s change ${a.id} for ${d.id} was abandoned${a.reason ? ` (${a.reason})` : ""} — re-route the delivery without ${row.project}, or open a new change there`, command: `doctrina delivery route ${d.id} ${d.projects.filter((n) => n !== row.project).join(",") || "<a,b>"}` });
83
+ moved = true;
84
+ continue;
85
+ }
86
+ if (row.changes.length === 0) {
87
+ out.push({ text: `${row.project} carries no change for ${d.id}`, command: cdThen(row.path, `doctrina work ${qarg(d.title)} --delivery ${d.id}`) });
88
+ moved = true;
89
+ continue;
90
+ }
91
+ for (const ch of row.changes.filter((x) => !x.archived)) {
92
+ if (ch.placeholders > 0) {
93
+ out.push({ text: `${row.project}: ${ch.id} still holds the scaffold's placeholder tasks — write its delta and its tasks as cards before handing one out`, command: cdThen(row.path, `doctrina work --resume ${ch.id}`) });
94
+ moved = true;
95
+ continue;
96
+ }
97
+ out.push(ch.tasksDone < ch.tasksTotal
98
+ ? { text: `${row.project}: ${ch.id} has ${ch.tasksTotal - ch.tasksDone} task${ch.tasksTotal - ch.tasksDone === 1 ? "" : "s"} left — hand the next card to an executor`, command: cdThen(row.path, `doctrina change card ${ch.id}`) }
99
+ : { text: `${row.project}: ${ch.id} has every task ticked — review and close it`, command: cdThen(row.path, `doctrina close ${ch.id}`) });
100
+ moved = true;
101
+ }
102
+ }
103
+ if (moved) continue;
104
+ out.push(blockers.length === 0
105
+ ? { text: `delivery ${d.id} is ready to close`, command: `doctrina delivery close ${d.id}` }
106
+ : recordAction(d, blockers[0]));
107
+ }
108
+ if (snap.open.length === 0 && snap.projects.length > 0 && out.length === 0) {
109
+ out.push({ text: "no delivery is open — an item of work across repositories opens one", command: 'doctrina delivery new "<title>" --ref <id>' });
110
+ }
111
+ if (snap.projects.length === 0) {
112
+ out.push({ text: "the workspace registers no repository yet", command: "doctrina workspace add <path> --role <r> --about \"<what it is>\"" });
113
+ }
114
+ return out;
115
+ }
116
+
117
+ /** The step that clears a delivery's first record gap — the command for it, not the check. */
118
+ function recordAction(d, blocker) {
119
+ const file = `deliveries/${d.id}/delivery.md`;
120
+ if (/^## Story/.test(blocker)) return { text: `delivery ${d.id}: copy the item's story and acceptance criteria, verbatim, under ## Story in ${file}`, command: `doctrina delivery check ${d.id}` };
121
+ if (/^## Release/.test(blocker)) return { text: `delivery ${d.id}: record the release evidence under ## Release and tick each claim`, command: `doctrina delivery tick ${d.id}` };
122
+ if (/^## Observability/.test(blocker)) return { text: `delivery ${d.id}: record what was watched after the release under ## Observability in ${file}`, command: `doctrina delivery check ${d.id}` };
123
+ return { text: `delivery ${d.id}: ${blocker}`, command: `doctrina delivery check ${d.id}` };
124
+ }
125
+
126
+ /**
127
+ * Structural findings about the workspace itself — the integrity
128
+ * `doctrina validate` checks at its root. Errors break routing or a close;
129
+ * warnings are worth knowing.
130
+ * @returns {{ errors: string[], warnings: string[] }}
131
+ */
132
+ export function workspaceFindings(snap) {
133
+ const errors = [];
134
+ const warnings = [];
135
+ const names = new Set(snap.ws.projects.map((p) => p.name));
136
+ for (const p of snap.ws.projects) {
137
+ for (const dep of p.dependsOn ?? []) if (!names.has(dep)) errors.push(`project ${p.name} depends on "${dep}", which the workspace does not register`);
138
+ }
139
+ for (const p of snap.projects) {
140
+ if (p.state === "missing") errors.push(`project ${p.project.name}: ${p.reason} — \`doctrina workspace attach ${p.project.name} --path <dir>\``);
141
+ if (p.project.mode !== "shadow" || p.state === "missing") continue;
142
+ const shadow = shadowDirOf(snap.wsDir, p.project.name);
143
+ if (!isDir(shadow)) {
144
+ errors.push(`shadow ${p.project.name}: projects/${p.project.name}/ is gone — restore it from the workspace's git, or \`doctrina workspace attach ${p.project.name}\` scaffolds it again`);
145
+ continue;
146
+ }
147
+ const plan = linkPlan(shadow, p.project.path);
148
+ const unlinked = plan.filter((l) => l.action === "link");
149
+ const conflicts = plan.filter((l) => l.action === "conflict");
150
+ if (unlinked.length > 0) errors.push(`shadow ${p.project.name}: ${unlinked.length} link${unlinked.length === 1 ? "" : "s"} missing from the repository — \`doctrina workspace attach ${p.project.name}\``);
151
+ if (conflicts.length > 0) warnings.push(`shadow ${p.project.name}: the repository has its own ${conflicts.map((l) => l.rel).slice(0, 3).join(", ")} where a link would go`);
152
+ }
153
+ // An in-repo project owns its hook files, so the workspace does not write
154
+ // them (third audit, change 0234): say when they leave the declared tools
155
+ // unguarded for a session opened in that repository.
156
+ if (snap.ws.tools.length > 0) {
157
+ for (const p of snap.projects) {
158
+ if (p.project.mode !== "in-repo" || p.state === "missing") continue;
159
+ for (const [dir, file] of [[".cursor", "hooks.json"], [".claude", "settings.json"]]) {
160
+ if (!isDir(path.join(p.project.path, dir))) continue;
161
+ const f = path.join(p.project.path, dir, file);
162
+ if (!isFile(f) || !read(f).includes("doctrina workspace guard")) {
163
+ warnings.push(`project ${p.project.name} is in-repo and its ${dir}/${file} runs no \`doctrina workspace guard\` — the tools the workspace declares are unguarded in a session opened there; copy the guard entry from the workspace root's ${dir}/${file}`);
164
+ }
165
+ }
166
+ }
167
+ }
168
+ // A declared tool whose server no MCP configuration names (fourth audit,
169
+ // change 0235). The guard matches on the server's KEY in the agent's
170
+ // config; the preset guesses "atlassian", and a server the operator named
171
+ // "jira-cloud" made every call through it an undeclared one — allowed.
172
+ const servers = mcpServerNames([snap.wsDir, ...snap.ws.projects.map((p) => p.path)]);
173
+ if (servers.found) {
174
+ for (const tool of snap.ws.tools.filter((x) => x.enabled !== false)) {
175
+ if (servers.names.has(tool.server)) continue;
176
+ const near = [...servers.names].find((n) => n.toLowerCase() === String(tool.server).toLowerCase());
177
+ warnings.push(near
178
+ ? `tool ${tool.name} declares server "${tool.server}", but the MCP configuration names it "${near}" — the guard matches the exact key: \`doctrina workspace tool ${tool.name} --server ${near}\``
179
+ : `tool ${tool.name} declares server "${tool.server}", which no MCP configuration here names (${[...servers.names].sort().join(", ") || "none"}) — the guard never sees its calls: \`doctrina workspace tool ${tool.name} --server <the key in mcp.json>\``);
180
+ }
181
+ }
182
+ // A dependency the manifests declare and the registry does not (second
183
+ // audit, change 0233): `delivery route` orders projects by --depends-on, so
184
+ // a front end that imports the shared library's package but was registered
185
+ // without the edge could be changed before the library it builds on.
186
+ const pkgs = new Map(snap.ws.projects.map((p) => [p.name, packageFacts(p.path)]));
187
+ for (const p of snap.ws.projects) {
188
+ const mine = pkgs.get(p.name);
189
+ if (!mine) continue;
190
+ for (const other of snap.ws.projects) {
191
+ const theirs = pkgs.get(other.name);
192
+ if (other === p || !theirs?.name || !mine.deps.has(theirs.name) || (p.dependsOn ?? []).includes(other.name)) continue;
193
+ warnings.push(`project ${p.name}'s package.json depends on ${theirs.name}, which is ${other.name} — declare it so routing orders them: \`doctrina workspace attach ${p.name} --depends-on ${[...(p.dependsOn ?? []), other.name].join(",")}\``);
194
+ }
195
+ }
196
+ for (const d of snap.deliveries) {
197
+ const status = (listHeader(d.text, "Status") ?? "").trim().toLowerCase();
198
+ if (!["open", "closed"].includes(status)) errors.push(`delivery ${d.id}: Status must be open or closed (got "${status}")`);
199
+ for (const name of d.projects) if (!names.has(name)) errors.push(`delivery ${d.id} routes "${name}", which the workspace does not register`);
200
+ if (status === "closed" && !listHeader(d.text, "Closed")) warnings.push(`delivery ${d.id} is closed with no Closed date`);
201
+ // A change that names this delivery from a project it does not route
202
+ // (change 0234): no status, check or close of the delivery counts it.
203
+ const outside = snap.ws.projects.map((p) => p.name).filter((n) => !d.projects.includes(n));
204
+ for (const row of deliveryChanges(snap.wsDir, snap.ws, { ...d, projects: outside })) {
205
+ for (const ch of row.changes.filter((x) => !x.archived)) {
206
+ warnings.push(`${row.project}'s change ${ch.id} names delivery ${d.id}, which does not route ${row.project} — route it back (\`doctrina delivery route ${d.id} ${[...d.projects, row.project].join(",")}\`) or close the change outside it`);
207
+ }
208
+ }
209
+ }
210
+ if (!isFile(path.join(snap.wsDir, "AGENTS.md"))) warnings.push("the workspace has no AGENTS.md hub — any workspace write regenerates it");
211
+ return { errors, warnings };
212
+ }
213
+
214
+ /** The resume note for the next planner session — `handoff` at the root. */
215
+ export function workspaceHandoff(snap, date) {
216
+ const L = [`# Doctrina workspace handoff — ${snap.ws.name} (${date})`, ""];
217
+ L.push("## Projects", "");
218
+ for (const p of snap.projects) {
219
+ const open = p.open.length ? ` · open: ${p.open.map((o) => o.id ?? o).join(", ")}` : "";
220
+ L.push(`- **${p.project.name}** (${p.project.mode}, ${p.state}${p.project.role ? `, ${p.project.role}` : ""}) — ${p.reason}${open} · \`${p.project.path}\``);
221
+ }
222
+ if (snap.projects.length === 0) L.push("- none registered");
223
+ L.push("", "## Open deliveries", "");
224
+ for (const { delivery: d, rows, blockers, release } of snap.open) {
225
+ L.push(`### ${d.id} — ${d.title}${d.ref && d.ref !== "—" ? ` (${d.ref})` : ""}`, "");
226
+ for (const row of rows) {
227
+ if (row.missing) { L.push(`- ${row.project}: ${row.missing}`); continue; }
228
+ if (row.changes.length === 0 && row.abandoned?.length) { L.push(`- ${row.project}: ${row.abandoned.map((a) => `${a.id} abandoned${a.reason ? ` (${a.reason})` : ""}`).join(", ")} — no change left`); continue; }
229
+ if (row.changes.length === 0) { L.push(`- ${row.project}: no change yet`); continue; }
230
+ for (const ch of row.changes) L.push(`- ${row.project}: ${ch.id} — ${ch.status}, tasks ${ch.tasksDone}/${ch.tasksTotal}`);
231
+ }
232
+ if (release.length) L.push(`- Release: ${release.filter((b) => b.checked).length}/${release.length} claims ticked`);
233
+ if (blockers.length) L.push(`- Before the close: ${blockers.length} — \`doctrina delivery check ${d.id}\``);
234
+ L.push("");
235
+ }
236
+ if (snap.open.length === 0) L.push("- none", "");
237
+ L.push("## Next actions", "");
238
+ workspaceActions(snap).slice(0, 8).forEach((a, i) => L.push(`${i + 1}. ${a.text} — \`${a.command}\``));
239
+ L.push("", "*Commit the workspace: it is the version history of every shadow's context. Regenerate with `doctrina handoff`.*");
240
+ return L;
241
+ }
242
+
243
+ /**
244
+ * The projects a task is most likely about — `context --for` at the root,
245
+ * which cannot read one repository's specs for all of them.
246
+ */
247
+ export function contextRouting(snap, task) {
248
+ return rankProjects(snap.wsDir, snap.ws, task).map((r) => ({
249
+ name: r.project.name, path: r.project.path, score: r.score, shared: r.shared,
250
+ state: snap.projects.find((p) => p.project.name === r.project.name)?.state ?? "missing",
251
+ }));
252
+ }
253
+
254
+ /** One-glance status at the root — `status`. */
255
+ export function workspaceStatus(snap) {
256
+ const ready = snap.projects.filter((p) => p.state === "ready").length;
257
+ const openChanges = snap.projects.reduce((n, p) => n + p.open.length, 0);
258
+ return {
259
+ name: snap.ws.name,
260
+ projects: snap.projects.length, ready,
261
+ pending: snap.projects.filter((p) => p.state === "pending").map((p) => p.project.name),
262
+ missing: snap.projects.filter((p) => p.state === "missing").map((p) => p.project.name),
263
+ openChanges,
264
+ deliveries: { open: snap.open.length, closed: snap.deliveries.length - snap.open.length },
265
+ tools: snap.ws.tools.length,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * For `prime` inside a project of a workspace: the workspace it belongs to
271
+ * and the open deliveries routed to it — an executor or a session opened in
272
+ * the repository otherwise never learns a delivery is waiting on it.
273
+ * @returns {{ name: string, dir: string, project: string, deliveries: Array<{ id: string, title: string, changes: string[], open: number }> } | null}
274
+ */
275
+ export function projectWorkspaceLine(projectRoot) {
276
+ const found = findWorkspace(projectRoot);
277
+ if (!found) return null;
278
+ let ws;
279
+ try {
280
+ ws = readWorkspace(found.dir);
281
+ } catch {
282
+ return null;
283
+ }
284
+ const project = projectAt(ws, projectRoot);
285
+ if (!project) return null;
286
+ const deliveries = [];
287
+ for (const d of listDeliveries(found.dir)) {
288
+ const full = readDelivery(found.dir, d.id);
289
+ if (full.status === "closed" || !full.projects.includes(project.name)) continue;
290
+ const row = deliveryChanges(found.dir, ws, full).find((r) => r.project === project.name);
291
+ const changes = row?.changes ?? [];
292
+ deliveries.push({ id: full.id, title: full.title, changes: changes.map((ch) => `${ch.id} ${ch.status}`), open: changes.filter((ch) => !ch.archived).length });
293
+ }
294
+ return { name: ws.name, dir: found.dir, project: project.name, deliveries };
295
+ }
296
+
297
+ /** The package name a repository publishes and the names it depends on — from its root package.json. */
298
+ function packageFacts(dir) {
299
+ const file = path.join(dir, "package.json");
300
+ if (!isFile(file)) return null;
301
+ try {
302
+ const pkg = JSON.parse(read(file));
303
+ const deps = new Set(["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]
304
+ .flatMap((k) => Object.keys(pkg?.[k] ?? {})));
305
+ return { name: typeof pkg?.name === "string" ? pkg.name : null, deps };
306
+ } catch {
307
+ return null;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * A clone of a SHADOWED repository at another path (third audit, change
313
+ * 0234): it has no `.doctrina/` — the links live in the registered clone —
314
+ * so every command answered "run `doctrina init`", which would plant a
315
+ * `.doctrina/` in a repository that may not carry one. Matched by the git
316
+ * remote: the clone's origin is the registered clone, or both share an origin.
317
+ * The registered repository itself, with its links gone, is returned with
318
+ * `same: true`: `attach` alone links it again.
319
+ * @returns {{ wsDir: string, name: string, path: string, same?: boolean } | null}
320
+ */
321
+ export function shadowedCloneAt(cwd) {
322
+ if (existsSync(path.join(cwd, ".doctrina"))) return null;
323
+ // The pointer route finds a workspace only for a REGISTERED path; a clone
324
+ // elsewhere is exactly what it cannot match, so the pointer is read here.
325
+ const pointed = isFile(pointerPath()) ? read(pointerPath()).trim() : "";
326
+ const found = findWorkspace(cwd) ?? (pointed && isFile(path.join(pointed, "workspace.json")) ? { dir: pointed } : null);
327
+ if (!found) return null;
328
+ const origin = (/** @type {string} */ dir) => {
329
+ const r = git(dir, ["remote", "get-url", "origin"]);
330
+ return r.state === GIT_STATE.OK ? r.stdout.trim().replace(/\.git$/, "").replace(/\/+$/, "") : null;
331
+ };
332
+ const real = (/** @type {string} */ p) => {
333
+ try {
334
+ return realpathSync(p);
335
+ } catch {
336
+ return path.resolve(p);
337
+ }
338
+ };
339
+ let ws;
340
+ try {
341
+ ws = readWorkspace(found.dir);
342
+ } catch {
343
+ return null;
344
+ }
345
+ // The registered repository itself, its links gone (change 0235): attach.
346
+ const same = ws.projects.find((x) => x.mode === "shadow" && real(x.path) === real(cwd));
347
+ if (same) return { wsDir: found.dir, name: same.name, path: same.path, same: true };
348
+ const mine = origin(cwd);
349
+ if (!mine) return null;
350
+ for (const p of ws.projects.filter((x) => x.mode === "shadow")) {
351
+ const theirs = isDir(p.path) ? origin(p.path) : null;
352
+ const local = !/^[a-z][a-z0-9+.-]*:\/\/|^[^/\\]+@[^:]+:/i.test(mine) && isDir(path.resolve(cwd, mine)) && real(path.resolve(cwd, mine)) === real(p.path);
353
+ if (local || (theirs && theirs === mine)) return { wsDir: found.dir, name: p.name, path: p.path };
354
+ }
355
+ return null;
356
+ }
357
+
358
+ /**
359
+ * The MCP server keys the agents' configurations declare: each folder's
360
+ * `.cursor/mcp.json` and `.mcp.json`, the user's `~/.cursor/mcp.json`, and
361
+ * `~/.claude.json` (its own servers and each project's). `found` is false
362
+ * when no configuration could be read — then nothing can be said.
363
+ * @param {string[]} dirs
364
+ */
365
+ export function mcpServerNames(dirs, home = os.homedir()) {
366
+ const names = new Set();
367
+ let found = false;
368
+ const take = (/** @type {unknown} */ servers) => {
369
+ if (servers && typeof servers === "object") for (const k of Object.keys(servers)) names.add(k);
370
+ };
371
+ const files = [...dirs.flatMap((d) => [path.join(d, ".cursor", "mcp.json"), path.join(d, ".mcp.json")]),
372
+ path.join(home, ".cursor", "mcp.json"), path.join(home, ".claude.json")];
373
+ for (const file of files) {
374
+ if (!isFile(file)) continue;
375
+ let json;
376
+ try {
377
+ json = JSON.parse(read(file));
378
+ } catch {
379
+ continue;
380
+ }
381
+ found = true;
382
+ take(json?.mcpServers);
383
+ if (json?.projects && typeof json.projects === "object") for (const p of Object.values(json.projects)) take(p?.mcpServers);
384
+ }
385
+ return { found, names };
386
+ }
387
+
388
+ /** The command that links a shadowed repository's context into `cwd`. */
389
+ export function attachCommand(clone, cwd) {
390
+ return cdThen(clone.wsDir, clone.same ? `doctrina workspace attach ${clone.name}` : `doctrina workspace attach ${clone.name} --path ${qpath(cwd)}`);
391
+ }
@@ -4,11 +4,11 @@ import {
4
4
  realpathSync, rmdirSync, statSync, symlinkSync, unlinkSync, copyFileSync,
5
5
  } from "node:fs";
6
6
  import path from "node:path";
7
- import os from "node:os";
8
7
  import { isDir, isFile, read, toPosix, write } from "./fs-ops.js";
9
8
  import { listHeader } from "./scan.js";
10
9
  import { openChanges } from "./snapshot.js";
11
10
  import { git as runGit, GIT_STATE } from "./git.js";
11
+ import { cdThen } from "./shell.js";
12
12
 
13
13
  // A WORKSPACE holds the context of several repositories in one place (ADR
14
14
  // 0029): which projects exist, what each one is, how they depend on each
@@ -24,148 +24,49 @@ import { git as runGit, GIT_STATE } from "./git.js";
24
24
  // the repository, because from its working directory `.doctrina/` is simply
25
25
  // there.
26
26
 
27
- export const WORKSPACE_FILE = "workspace.json";
28
- export const WORKSPACE_SCHEMA = 1;
29
- export const PROJECT_MODES = ["in-repo", "shadow"];
30
- export const PROJECT_NAME = /^[a-z0-9][a-z0-9._-]*$/;
31
- export const EXCLUDE_BEGIN = "# doctrina:workspace:begin — managed by `doctrina workspace attach`; edits inside are overwritten";
32
- export const EXCLUDE_END = "# doctrina:workspace:end";
33
-
34
- /** A fresh workspace document. */
35
- export function blankWorkspace(name) {
36
- return {
37
- doctrina_workspace: WORKSPACE_SCHEMA,
38
- name,
39
- projects: [],
40
- tools: [],
41
- agents: { planner: { model: "inherit" }, executor: { model: "inherit" } },
42
- };
43
- }
44
-
45
- export function isWorkspaceDir(dir) {
46
- const p = path.join(dir, WORKSPACE_FILE);
47
- if (!isFile(p)) return false;
48
- try {
49
- return JSON.parse(read(p)).doctrina_workspace !== undefined;
50
- } catch {
51
- return false;
52
- }
53
- }
54
-
55
- /** The workspace document, with every optional list present. */
56
- export function readWorkspace(dir) {
57
- const ws = JSON.parse(read(path.join(dir, WORKSPACE_FILE)));
58
- ws.projects = Array.isArray(ws.projects) ? ws.projects : [];
59
- ws.tools = Array.isArray(ws.tools) ? ws.tools : [];
60
- ws.agents = ws.agents && typeof ws.agents === "object" ? ws.agents : {};
61
- return ws;
62
- }
63
-
64
- export function writeWorkspace(dir, ws) {
65
- write(path.join(dir, WORKSPACE_FILE), JSON.stringify(ws, null, 2) + "\n", { force: true });
66
- }
67
-
68
- /** Where the CLI keeps its per-user state: `$DOCTRINA_HOME`, else `~/.doctrina`. */
69
- export function doctrinaHome(env = process.env) {
70
- return env.DOCTRINA_HOME && env.DOCTRINA_HOME.trim() !== ""
71
- ? path.resolve(env.DOCTRINA_HOME)
72
- : path.join(os.homedir(), ".doctrina");
73
- }
74
-
75
- /** The pointer `workspace init` leaves, so an in-repo project can find its workspace. */
76
- export function pointerPath(env = process.env) {
77
- return path.join(doctrinaHome(env), "workspace");
78
- }
79
-
80
- export function writePointer(dir, env = process.env) {
81
- write(pointerPath(env), path.resolve(dir) + "\n", { force: true });
82
- }
83
-
84
- function ancestors(start) {
85
- const out = [];
86
- let cur = path.resolve(start);
87
- for (;;) {
88
- out.push(cur);
89
- const up = path.dirname(cur);
90
- if (up === cur) return out;
91
- cur = up;
92
- }
93
- }
94
-
95
- /**
96
- * The workspace this directory belongs to, or null. In order:
97
- * 1. `$DOCTRINA_WORKSPACE`;
98
- * 2. the directory or an ancestor holding `workspace.json` (the orchestrator
99
- * runs at the workspace root);
100
- * 3. the real path of `./.doctrina` — a shadow project's link points into
101
- * its workspace;
102
- * 4. the pointer `workspace init` wrote, when the workspace lists this
103
- * directory as a project.
104
- * @returns {{ dir: string, via: string } | null}
105
- */
106
- export function findWorkspace(cwd, env = process.env) {
107
- if (env.DOCTRINA_WORKSPACE && env.DOCTRINA_WORKSPACE.trim() !== "") {
108
- const dir = path.resolve(env.DOCTRINA_WORKSPACE);
109
- return isWorkspaceDir(dir) ? { dir, via: "DOCTRINA_WORKSPACE" } : null;
110
- }
111
- for (const dir of ancestors(cwd)) {
112
- if (isWorkspaceDir(dir)) return { dir, via: "directory" };
113
- }
114
- const local = path.join(cwd, ".doctrina");
115
- if (existsSync(local)) {
116
- try {
117
- const real = realpathSync(local);
118
- if (real !== path.resolve(local)) {
119
- for (const dir of ancestors(path.dirname(real))) {
120
- if (isWorkspaceDir(dir)) return { dir, via: "shadow link" };
121
- }
122
- }
123
- } catch { /* an unreadable link is no workspace */ }
124
- }
125
- const pointer = pointerPath(env);
126
- if (isFile(pointer)) {
127
- const dir = read(pointer).trim();
128
- if (dir && isWorkspaceDir(dir) && projectAt(readWorkspace(dir), cwd)) return { dir, via: "pointer" };
129
- }
130
- return null;
131
- }
132
-
133
- /** The registered project whose path is `dir`, or null. */
134
- export function projectAt(ws, dir) {
135
- const want = safeReal(dir);
136
- return ws.projects.find((p) => safeReal(p.path) === want) ?? null;
137
- }
138
-
139
- function safeReal(p) {
140
- try {
141
- return realpathSync(p);
142
- } catch {
143
- return path.resolve(p);
144
- }
145
- }
146
-
147
- export function shadowDirOf(wsDir, name) {
148
- return path.join(wsDir, "projects", name);
149
- }
150
-
151
- /** The directory whose `.doctrina/` holds this project's artifacts. */
152
- export function artifactRootOf(wsDir, project) {
153
- return project.mode === "shadow" ? shadowDirOf(wsDir, project.name) : project.path;
154
- }
27
+ export {
28
+ WORKSPACE_FILE, WORKSPACE_SCHEMA, PROJECT_MODES, PROJECT_NAME, EXCLUDE_BEGIN, EXCLUDE_END,
29
+ blankWorkspace, isWorkspaceDir, readWorkspace, writeWorkspace, doctrinaHome, pointerPath, writePointer,
30
+ findWorkspace, projectAt, shadowDirOf, artifactRootOf,
31
+ } from "./workspace-locate.js";
32
+ import {
33
+ EXCLUDE_BEGIN, EXCLUDE_END, isWorkspaceDir, readWorkspace, writeWorkspace, findWorkspace, projectAt,
34
+ shadowDirOf, artifactRootOf, WORKSPACE_FILE, doctrinaHome, pointerPath, blankWorkspace, writePointer, PROJECT_NAME, PROJECT_MODES, WORKSPACE_SCHEMA, safeReal,
35
+ } from "./workspace-locate.js";
155
36
 
156
37
  /**
157
38
  * Where a project stands. `missing` when its repository path is gone;
158
39
  * `pending` while it has no spec or its intake is unconverted — the
159
40
  * project still has to be absorbed; `ready` otherwise.
160
41
  */
42
+ const INTAKE_PENDING = "intake not converted";
43
+ // A shadow's folder in the workspace is gone (change 0235): `attach` restores it.
44
+ const SHADOW_GONE = "its shadow folder in the workspace is gone";
45
+
46
+ /**
47
+ * The command that moves a pending project on (second audit, change 0233).
48
+ * `--from-code` starts an absorb and refuses once the inventory is stored —
49
+ * every surface still named it for a project whose absorb was under way. A
50
+ * stored, pending intake reprints its playbook with a bare `doctrina intake`.
51
+ * @param {{ path: string, name?: string }} project
52
+ * @param {string} reason projectState's reason
53
+ */
54
+ export function absorbCommand(project, reason) {
55
+ if (reason === SHADOW_GONE) return `doctrina workspace attach ${project.name ?? "<name>"}`;
56
+ return reason === INTAKE_PENDING
57
+ ? cdThen(project.path, "doctrina intake")
58
+ : cdThen(project.path, "doctrina intake --from-code");
59
+ }
60
+
161
61
  export function projectState(wsDir, project) {
162
62
  if (!isDir(project.path)) return { state: "missing", reason: `path not found: ${project.path}`, open: [] };
163
63
  const root = artifactRootOf(wsDir, project);
164
64
  const doctrina = path.join(root, ".doctrina");
65
+ if (project.mode === "shadow" && !isDir(doctrina)) return { state: "pending", reason: SHADOW_GONE, open: [] };
165
66
  if (!isDir(doctrina)) return { state: "pending", reason: "no .doctrina/ yet", open: [] };
166
67
  const intake = path.join(doctrina, "intake.md");
167
68
  if (isFile(intake) && /pending/i.test(listHeader(read(intake), "Status") ?? "")) {
168
- return { state: "pending", reason: "intake not converted", open: openChanges(root) };
69
+ return { state: "pending", reason: INTAKE_PENDING, open: openChanges(root) };
169
70
  }
170
71
  const specs = path.join(doctrina, "specs");
171
72
  const count = isDir(specs) ? readdirSync(specs).filter((d) => isFile(path.join(specs, d, "spec.md"))).length : 0;
@@ -274,6 +175,57 @@ export function applyLinks(plan, shadowDir, repoDir) {
274
175
  return done;
275
176
  }
276
177
 
178
+ /**
179
+ * The shadow folder a repository's `.doctrina/` links to, or null when the
180
+ * repository owns its `.doctrina/` (change 0238).
181
+ * @param {string} projectRoot
182
+ */
183
+ export function shadowRootOf(projectRoot) {
184
+ const link = path.join(projectRoot, ".doctrina");
185
+ try {
186
+ if (!lstatSync(link).isSymbolicLink()) return null;
187
+ return path.dirname(realpathSync(link));
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Re-link a shadowed repository after its shadow gained or lost files
195
+ * (change 0238): a link for each new file, the links to removed files taken
196
+ * out, and the exclude block rewritten. A command that writes an adapter
197
+ * file for a shadowed repository writes it in the shadow and calls this —
198
+ * written in the repository it was a stray, untracked file.
199
+ * @param {string} projectRoot
200
+ * @param {Array<{ rel: string, content: string }>} gone the files the shadow lost, with what they held
201
+ * @returns {{ linked: string[], unlinked: string[] } | null} null for a repository that is not a shadow
202
+ */
203
+ export function relinkShadow(projectRoot, gone = []) {
204
+ const shadow = shadowRootOf(projectRoot);
205
+ if (!shadow) return null;
206
+ const unlinked = [];
207
+ for (const { rel, content } of gone) {
208
+ const at = path.join(projectRoot, rel);
209
+ let st;
210
+ try {
211
+ st = lstatSync(at);
212
+ } catch {
213
+ continue;
214
+ }
215
+ // A symlink to the removed file, or the hard link or copy a platform
216
+ // without symlinks got instead — still holding what the shadow held.
217
+ if (st.isSymbolicLink() || (st.isFile() && readFileSync(at, "utf8").replace(/\r\n/g, "\n").trim() === content.replace(/\r\n/g, "\n").trim())) {
218
+ unlinkSync(at);
219
+ unlinked.push(toPosix(rel));
220
+ pruneEmpty(path.dirname(at), projectRoot);
221
+ }
222
+ }
223
+ const plan = linkPlan(shadow, projectRoot);
224
+ const linked = applyLinks(plan, shadow, projectRoot).map((d) => d.rel);
225
+ writeExclude(projectRoot, excludeEntries(plan, projectRoot));
226
+ return { linked, unlinked };
227
+ }
228
+
277
229
  /**
278
230
  * Remove every link the shadow put in the repository — the link, never what
279
231
  * it points at: `fs-ops.remove` follows a directory link and would empty
@@ -64,7 +64,7 @@ canonical templates and syncs `index.json`. Flags: `doctrina <cmd> --help`.
64
64
  - `doctrina verify` — the project's declared typecheck/test/build checks. *When:* the real build gate must run.
65
65
  - `doctrina close <id...>` — the whole closing sequence in one attested pass. *When:* a change is implemented and ready to finish.
66
66
  - `doctrina doctor` — aggregate diagnostic with per-finding remedies. *When:* something looks wrong and you do not know which gate to ask.
67
- **Orchestrate** (triggers: `doctrina <command> --help`) — `doctrina workspace init|add|attach|detach|list|tool|guard` · `doctrina delivery new|route|list|status|check|close`
67
+ **Orchestrate** (triggers: `doctrina <command> --help`) — `doctrina workspace init|add|attach|detach|list|tool|guard` · `doctrina delivery new|route|list|status|tick|check|close`
68
68
  **Maintain** (triggers: `doctrina <command> --help`) — `doctrina templates list` · `doctrina hooks install` · `doctrina index rebuild` · `doctrina watch` · `doctrina metrics` · `doctrina completion` · `doctrina upgrade --write` · `doctrina ci --emit github`
69
69
  <!-- doctrina:surface:end -->
70
70
 
@@ -27,6 +27,7 @@ replace. No conditionals, loops, or includes.
27
27
  | `{{AGENTS_MD_PATH}}` | Path to root `AGENTS.md` from the adapter location | per adapter |
28
28
  | `{{DELIVERY_ID}}` | Delivery folder identifier (`NNNN-slug`) | required in `delivery` |
29
29
  | `{{DELIVERY_TITLE}}` | The delivery's title | required in `delivery` |
30
+ | `{{DELIVERY_TITLE_ARG}}` | The delivery's title quoted as one shell argument, for a command line | optional in the `delivery` playbook |
30
31
  | `{{DELIVERY_REF}}` | The tracker item it comes from | `—` |
31
32
  | `{{DELIVERY_PROJECTS}}` | The routed projects, dependencies first | `—` |
32
33
 
@@ -107,7 +108,7 @@ adapters/
107
108
  claude/.claude/commands/doctrina-*.md.template native slash commands (core loop)
108
109
  codex/README.md
109
110
  cursor/.cursor/rules/00-doctrina.mdc.template
110
- cursor/.cursor/commands/doctrina-*.md.template native slash commands (core loop)
111
+ cursor/.cursor/skills/doctrina-*/SKILL.md.template the core loop as skills (explicit `/` invocation)
111
112
  copilot/.github/copilot-instructions.md.template
112
113
  gemini/GEMINI.md.template
113
114
  aider/CONVENTIONS.md.template