doctrina-cli 0.20.0 → 0.22.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 (102) hide show
  1. package/README.md +7 -7
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +10 -0
  4. package/src/commands/change.js +202 -27
  5. package/src/commands/clarify.js +3 -206
  6. package/src/commands/close.js +1 -1
  7. package/src/commands/context.js +33 -2
  8. package/src/commands/contract.js +1 -0
  9. package/src/commands/coverage.js +69 -6
  10. package/src/commands/delivery.js +135 -17
  11. package/src/commands/doctor.js +83 -5
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/hooks.js +56 -11
  14. package/src/commands/index-rebuild.js +47 -1
  15. package/src/commands/init.js +64 -8
  16. package/src/commands/intake.js +16 -5
  17. package/src/commands/metrics.js +16 -1
  18. package/src/commands/next.js +41 -6
  19. package/src/commands/onboard.js +113 -0
  20. package/src/commands/prime.js +53 -4
  21. package/src/commands/review.js +40 -4
  22. package/src/commands/search.js +2 -2
  23. package/src/commands/skill.js +2 -1
  24. package/src/commands/status.js +28 -0
  25. package/src/commands/templates.js +2 -1
  26. package/src/commands/triage.js +16 -1
  27. package/src/commands/ui.js +512 -0
  28. package/src/commands/upgrade.js +62 -4
  29. package/src/commands/validate.js +18 -0
  30. package/src/commands/verify.js +35 -7
  31. package/src/commands/why.js +1 -1
  32. package/src/commands/work.js +90 -42
  33. package/src/commands/workspace.js +89 -66
  34. package/src/index.js +107 -62
  35. package/src/lib/actions.js +62 -3
  36. package/src/lib/adapters.js +83 -1
  37. package/src/lib/agent-bridge.js +400 -0
  38. package/src/lib/applied-deltas.js +82 -0
  39. package/src/lib/args.js +34 -2
  40. package/src/lib/change-numbers.js +115 -0
  41. package/src/lib/change-ops.js +2 -2
  42. package/src/lib/clarify-scan.js +221 -0
  43. package/src/lib/code-inventory.js +594 -104
  44. package/src/lib/commands.js +58 -4
  45. package/src/lib/config.js +27 -8
  46. package/src/lib/conflict-markers.js +61 -0
  47. package/src/lib/console-model.js +668 -0
  48. package/src/lib/console-prefs.js +67 -0
  49. package/src/lib/console-room.js +201 -0
  50. package/src/lib/console-state.js +150 -0
  51. package/src/lib/console-view.js +557 -0
  52. package/src/lib/context-slice.js +34 -3
  53. package/src/lib/coverage-model.js +29 -6
  54. package/src/lib/delivery.js +90 -12
  55. package/src/lib/docs-impact.js +37 -2
  56. package/src/lib/fs-ops.js +103 -12
  57. package/src/lib/gates.js +7 -1
  58. package/src/lib/git.js +18 -0
  59. package/src/lib/index-json.js +10 -1
  60. package/src/lib/index-merge.js +90 -0
  61. package/src/lib/install.js +80 -0
  62. package/src/lib/intake-model.js +5 -2
  63. package/src/lib/json-out.js +9 -1
  64. package/src/lib/ledger.js +2 -3
  65. package/src/lib/lock.js +58 -0
  66. package/src/lib/merge-setup.js +55 -0
  67. package/src/lib/metrics-model.js +2 -2
  68. package/src/lib/onboard.js +197 -0
  69. package/src/lib/preset.js +177 -0
  70. package/src/lib/prompt.js +1 -1
  71. package/src/lib/properties.js +149 -0
  72. package/src/lib/runtime.js +19 -7
  73. package/src/lib/scan.js +13 -8
  74. package/src/lib/shell.js +45 -0
  75. package/src/lib/snapshot.js +5 -1
  76. package/src/lib/spec-ops.js +2 -2
  77. package/src/lib/task-card.js +101 -8
  78. package/src/lib/templates-model.js +3 -2
  79. package/src/lib/triage-model.js +75 -0
  80. package/src/lib/validation-model.js +23 -5
  81. package/src/lib/views.js +5 -4
  82. package/src/lib/work-model.js +8 -2
  83. package/src/lib/workspace-agents.js +67 -20
  84. package/src/lib/workspace-guard.js +65 -0
  85. package/src/lib/workspace-locate.js +141 -0
  86. package/src/lib/workspace-tools.js +104 -17
  87. package/src/lib/workspace-view.js +391 -0
  88. package/src/lib/workspace.js +83 -131
  89. package/templates/AGENTS.md.template +9 -5
  90. package/templates/README.md +4 -2
  91. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  92. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  93. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  94. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  95. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  96. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  97. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  98. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  99. package/templates/adapters/kiro/README.md +22 -0
  100. package/templates/playbooks/absorb.md.template +39 -16
  101. package/templates/playbooks/bootstrap.md.template +18 -6
  102. package/templates/playbooks/delivery.md.template +12 -6
@@ -25,10 +25,11 @@ export const TOOL_PRESETS = {
25
25
  };
26
26
 
27
27
  /** A tool entry from flags and an optional preset. Throws a message on bad input. */
28
- export function toolEntry(name, opts, existing = null) {
28
+ export function toolEntry(name, opts, existing = /** @type {any} */ (null)) {
29
29
  if (!TOOL_NAME.test(name)) throw new Error(`tool name "${name}" must match ${TOOL_NAME}`);
30
30
  const preset = TOOL_PRESETS[name] ?? {};
31
31
  const base = existing ?? { name, server: preset.server ?? null, access: "read", for: preset.for ?? [], purpose: preset.purpose ?? "" };
32
+ /** @type {any} */
32
33
  const entry = { ...base };
33
34
  if (opts.server) entry.server = opts.server;
34
35
  if (opts.access) {
@@ -42,10 +43,40 @@ export function toolEntry(name, opts, existing = null) {
42
43
  entry.for = roles;
43
44
  }
44
45
  if (opts.purpose) entry.purpose = opts.purpose;
46
+ // The calls this tool may make, by name (change 0244). `none` takes the
47
+ // list away; a pattern is a tool name, `*` standing for any run of it.
48
+ if (opts.only !== undefined && opts.only !== null) {
49
+ const patterns = String(opts.only).split(",").map((s) => s.trim()).filter(Boolean);
50
+ if (patterns.length === 0 || (patterns.length === 1 && patterns[0].toLowerCase() === "none")) {
51
+ delete entry.only;
52
+ } else {
53
+ const bad = patterns.filter((p) => !CALL_PATTERN.test(p));
54
+ if (bad.length) throw new Error(`--only takes MCP tool names, \`*\` for any run of characters (got ${bad.map((b) => `"${b}"`).join(", ")})`);
55
+ entry.only = patterns;
56
+ }
57
+ }
45
58
  if (!entry.server) throw new Error(`tool "${name}" has no preset — pass --server <the MCP server's name in your agent's config>`);
46
59
  return entry;
47
60
  }
48
61
 
62
+ // A LIST OF THE CALLS A TOOL MAY MAKE (change 0244).
63
+ //
64
+ // "Does this call write?" is answered from the call's name and input, which
65
+ // is a guess: a verb the list below has never seen reads as a read, and the
66
+ // call goes through. A tool that declares `only` turns the question around —
67
+ // the calls named pass on to that judgment, and every other call through
68
+ // its server is denied, whatever its name suggests.
69
+ const CALL_PATTERN = /^[A-Za-z0-9_.*:-]+$/;
70
+
71
+ /** Does `tool` match one of the patterns (case-insensitive, `*` = any run)? */
72
+ export function matchesCall(patterns, tool) {
73
+ const name = String(tool).toLowerCase();
74
+ return patterns.some((p) => {
75
+ const re = new RegExp("^" + String(p).toLowerCase().split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
76
+ return re.test(name);
77
+ });
78
+ }
79
+
49
80
  // Verbs that change state in the system a tool talks to. Matched on the
50
81
  // tool's own name, word by word.
51
82
  const WRITE_VERBS = new Set([
@@ -53,35 +84,82 @@ const WRITE_VERBS = new Set([
53
84
  "transition", "assign", "comment", "publish", "upload", "deploy", "trigger", "set", "move",
54
85
  "merge", "close", "archive", "insert", "drop", "alter", "truncate", "mutate", "rollback", "restart",
55
86
  "approve", "reject", "resolve", "mute", "unmute", "schedule", "cancel", "import",
87
+ // The fourth audit (change 0235): verbs real MCP servers use that passed as reads.
88
+ "upsert", "link", "unlink", "reopen", "rename", "replace", "save", "submit", "sync", "release",
89
+ "rotate", "grant", "revoke", "reset", "clear", "purge", "destroy", "terminate", "kill", "pause",
90
+ "enable", "disable", "snooze", "escalate", "notify", "reply", "react", "vote", "fork", "push",
91
+ "commit", "lock", "unlock", "ack", "acknowledge", "install", "uninstall", "provision", "scale",
92
+ "rerun", "retry", "dispatch", "copy", "duplicate", "invite", "share", "subscribe", "unsubscribe",
93
+ "block", "unblock", "revert", "restore", "empty",
56
94
  ]);
57
- const MUTATING_SQL = /\b(insert|update|delete|merge|drop|alter|create|truncate|grant|revoke|replace)\b/i;
95
+ // Statements that change the database, a procedure that may, and a SELECT
96
+ // that writes a table (`SELECT … INTO`). A keyword in a comment counts: the
97
+ // guard errs on the side of asking.
98
+ const MUTATING_SQL = /\b(insert|update|delete|merge|drop|alter|create|truncate|grant|revoke|replace|call|exec|execute|copy|do|vacuum|reindex|cluster|refresh|upsert|load|unload|optimize|restore|rename|comment|lock|into)\b/i;
99
+ const SQL_START = /^\s*(\(|\/\*|--)?\s*(select|with|insert|update|delete|merge|drop|alter|create|truncate|show|describe|explain|call|exec|execute|copy|grant|revoke|vacuum|refresh|optimize|do)\b/i;
100
+ // An argument that names the operation a generic tool performs.
101
+ const ACTION_KEYS = new Set(["action", "operation", "op", "method", "verb", "mutation"]);
58
102
 
59
103
  /**
60
104
  * Whether a tool call would change state. A SQL statement in the input
61
105
  * decides for itself; otherwise the tool's name does.
62
106
  */
63
107
  export function isWriteCall(toolName, toolInput) {
64
- const sql = sqlIn(toolInput);
108
+ const input = parsed(toolInput);
109
+ // An argument that says what a generic tool does: `{ action: "delete" }`,
110
+ // `{ method: "POST" }`, a GraphQL `mutation` (change 0235).
111
+ if (actionWrites(input)) return true;
112
+ const sql = sqlIn(input);
65
113
  if (sql !== null) return MUTATING_SQL.test(sql.replace(/'[^']*'/g, "''"));
66
114
  // camelCase and snake_case alike: `transitionJiraIssue`, `transition_issue`.
67
- const words = String(toolName).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z]+/).filter(Boolean);
68
- return words.some((w) => WRITE_VERBS.has(w));
115
+ const words = nameWords(toolName);
116
+ if (words.some((w) => WRITE_VERBS.has(w))) return true;
117
+ // A name with no separator at all — `createissue`, `CREATEISSUE`,
118
+ // `getandremove` — hides its verbs inside one word (change 0235).
119
+ if (words.length === 1 && words[0].length > 6) {
120
+ return [...WRITE_VERBS].some((v) => v.length >= 5 && words[0].includes(v));
121
+ }
122
+ return false;
69
123
  }
70
124
 
71
- function sqlIn(input) {
72
- let obj = input;
73
- if (typeof obj === "string") {
74
- try {
75
- obj = JSON.parse(obj);
76
- } catch {
77
- return /^\s*(select|with|insert|update|delete|merge|drop|alter|create|truncate|show|describe)\b/i.test(obj) ? obj : null;
78
- }
125
+ function nameWords(name) {
126
+ return String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z]+/).filter(Boolean);
127
+ }
128
+
129
+ function parsed(input) {
130
+ if (typeof input !== "string") return input;
131
+ try {
132
+ return JSON.parse(input);
133
+ } catch {
134
+ return input;
79
135
  }
80
- if (!obj || typeof obj !== "object") return null;
81
- for (const key of ["sql", "query", "statement", "sql_query"]) {
82
- if (typeof obj[key] === "string") return obj[key];
136
+ }
137
+
138
+ /** Every string inside an input, to a bounded depth. */
139
+ function stringsIn(value, depth = 0, out = /** @type {Array<{ key: string, value: string }>} */ ([]), key = "") {
140
+ if (depth > 5 || out.length > 200) return out;
141
+ if (typeof value === "string") out.push({ key, value });
142
+ else if (Array.isArray(value)) for (const v of value) stringsIn(v, depth + 1, out, key);
143
+ else if (value && typeof value === "object") for (const [k, v] of Object.entries(value)) stringsIn(v, depth + 1, out, k);
144
+ return out;
145
+ }
146
+
147
+ function actionWrites(input) {
148
+ for (const { key, value } of stringsIn(input)) {
149
+ if (/^\s*mutation\b/i.test(value)) return true;
150
+ if (!ACTION_KEYS.has(key.toLowerCase())) continue;
151
+ const words = nameWords(value);
152
+ if (words.some((w) => WRITE_VERBS.has(w) || ["post", "put", "patch", "delete"].includes(w))) return true;
83
153
  }
84
- return null;
154
+ return false;
155
+ }
156
+
157
+ // SQL wherever the input carries it — `sql`, `query`, `q`, a `statements`
158
+ // list, a nested `{ input: { sql } }` (change 0235): only four top-level
159
+ // keys were read, and a DELETE under any other passed as no SQL at all.
160
+ function sqlIn(input) {
161
+ const found = stringsIn(input).map((s) => s.value).filter((s) => SQL_START.test(s));
162
+ return found.length ? found.join(";\n") : null;
85
163
  }
86
164
 
87
165
  /**
@@ -93,6 +171,15 @@ function sqlIn(input) {
93
171
  export function guardDecision(tools, server, tool, input) {
94
172
  const declared = tools.filter((t) => t.server === server && t.enabled !== false);
95
173
  if (declared.length === 0) return { decision: "none", reason: `server "${server}" is not declared in the workspace` };
174
+ // A list on any tool of this server is the server's list: a call outside
175
+ // it is denied before its name is read for a verb.
176
+ const listed = declared.filter((t) => Array.isArray(t.only) && t.only.length > 0);
177
+ if (listed.length > 0 && !listed.some((t) => matchesCall(t.only, tool))) {
178
+ return {
179
+ decision: "deny",
180
+ reason: `${tool} is not among the calls the workspace allows through "${server}" (${listed.map((t) => `${t.name}: ${t.only.join(", ")}`).join("; ")}). Ask the human; to allow it: doctrina workspace tool ${listed[0].name} --only "${[...listed[0].only, tool].join(",")}"`,
181
+ };
182
+ }
96
183
  const write = isWriteCall(tool, input);
97
184
  if (!write) return { decision: "allow", reason: `${tool} reads` };
98
185
  const writable = declared.some((t) => t.access === "write");
@@ -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
+ }