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
@@ -0,0 +1,67 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { isFile, mkdirp, read, write } from "./fs-ops.js";
4
+ import { doctrinaHome } from "./workspace-locate.js";
5
+
6
+ // WHAT A PERSON CHOSE IN THE CONSOLE (change 0258).
7
+ //
8
+ // Which agent answers and which model it runs are the person's choices, not
9
+ // the project's: two people on one repository use different agents. So they
10
+ // are kept beside the person's other Doctrina state, in
11
+ // `$DOCTRINA_HOME/console.json`, per directory the console was opened in —
12
+ // never in the repository, and never read by any command but the console.
13
+
14
+ export const PREFS_FILE = "console.json";
15
+
16
+ /** @typedef {{ agent: string | null, models: Record<string, string> }} ConsolePrefs */
17
+
18
+ const prefsPath = (/** @type {NodeJS.ProcessEnv} */ env) => path.join(doctrinaHome(env), PREFS_FILE);
19
+
20
+ function readAll(env) {
21
+ const file = prefsPath(env);
22
+ if (!isFile(file)) return {};
23
+ try {
24
+ const data = JSON.parse(read(file));
25
+ return data && typeof data.projects === "object" && data.projects !== null ? data.projects : {};
26
+ } catch {
27
+ // A file a person broke by hand is their choices lost, not a console that will not open.
28
+ return {};
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The choices kept for a directory; the defaults when there are none.
34
+ * @param {string} cwd
35
+ * @param {NodeJS.ProcessEnv} [env]
36
+ * @returns {ConsolePrefs}
37
+ */
38
+ export function consolePrefs(cwd, env = process.env) {
39
+ const entry = readAll(env)[path.resolve(cwd)];
40
+ /** @type {Record<string, string>} */
41
+ const models = {};
42
+ if (entry && typeof entry.models === "object" && entry.models !== null) {
43
+ for (const [id, name] of Object.entries(entry.models)) {
44
+ if (typeof name === "string" && name.trim() !== "") models[id] = name;
45
+ }
46
+ }
47
+ return { agent: typeof entry?.agent === "string" ? entry.agent : null, models };
48
+ }
49
+
50
+ /**
51
+ * Keep a directory's choices. Returns false when the file cannot be written:
52
+ * the choice then lasts for the session, which is what it did before.
53
+ * @param {string} cwd
54
+ * @param {ConsolePrefs} prefs
55
+ * @param {NodeJS.ProcessEnv} [env]
56
+ */
57
+ export function saveConsolePrefs(cwd, prefs, env = process.env) {
58
+ try {
59
+ const projects = readAll(env);
60
+ projects[path.resolve(cwd)] = { agent: prefs.agent, models: prefs.models };
61
+ mkdirp(doctrinaHome(env));
62
+ write(prefsPath(env), JSON.stringify({ version: 1, projects }, null, 2) + "\n", { force: true, atomic: true });
63
+ return true;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
@@ -0,0 +1,201 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { isDir, isFile, read } from "./fs-ops.js";
6
+ import { doctrinaHome, findWorkspace, projectAt, readWorkspace } from "./workspace-locate.js";
7
+
8
+ // CONSOLES THAT TALK TO EACH OTHER (change 0259, ADR 0033).
9
+ //
10
+ // A person with three repositories has three terminals. A console in each
11
+ // is three conversations that cannot hear one another — and the planner at
12
+ // the workspace root (ADR 0030) has nobody to hand a card to.
13
+ //
14
+ // A ROOM is what consoles opened for the same work share: the workspace
15
+ // when there is one, the directory otherwise. It is a folder under the
16
+ // person's `$DOCTRINA_HOME`, readable and writable by them alone. Each
17
+ // console keeps one small file there saying it is alive, and has an inbox;
18
+ // a message is a file moved into an inbox. No socket, no port, no daemon:
19
+ // the CLI still makes no network call, and a console that dies leaves a
20
+ // file that stops being fresh.
21
+ //
22
+ // Nothing here reads a terminal or starts a process.
23
+
24
+ /** @typedef {{ id: string, name: string, role: "planner" | "project" | "sender", cwd: string, pid: number, agent: string | null, model: string | null, busy: boolean, listening: boolean, seen: number }} Peer */
25
+ /** @typedef {{ dir: string, root: string, id: string, name: string, role: Peer["role"] }} Room */
26
+ /**
27
+ * @typedef {{ type: "request", id: string, from: { id: string, name: string }, text: string, at: number }
28
+ * | { type: "result", re: string, from: { id: string, name: string }, ok: boolean, summary: string, detail: string, at: number }
29
+ * | { type: "refused", re: string, from: { id: string, name: string }, reason: string, at: number }} Message
30
+ */
31
+
32
+ /** A peer that has not said it is alive for this long is gone. */
33
+ export const PEER_STALE_MS = 10_000;
34
+
35
+ const key = (/** @type {string} */ root) => createHash("sha256").update(root).digest("hex").slice(0, 12);
36
+ const token = () => randomBytes(3).toString("hex");
37
+
38
+ /**
39
+ * Where the consoles of this directory meet, and what this one is called there.
40
+ * @param {string} cwd
41
+ * @param {NodeJS.ProcessEnv} [env]
42
+ */
43
+ export function roomFor(cwd, env = process.env) {
44
+ const here = path.resolve(cwd);
45
+ const found = findWorkspace(here, env);
46
+ let root = here;
47
+ let name = path.basename(here) || "console";
48
+ /** @type {Peer["role"]} */
49
+ let role = "project";
50
+ if (found) {
51
+ try {
52
+ const ws = readWorkspace(found.dir);
53
+ const member = projectAt(ws, here);
54
+ if (path.resolve(found.dir) === here) {
55
+ root = here;
56
+ name = ws.name || name;
57
+ role = "planner";
58
+ } else if (member) {
59
+ root = path.resolve(found.dir);
60
+ name = member.name;
61
+ }
62
+ } catch { /* an unreadable workspace is no room: the directory is */ }
63
+ }
64
+ return { dir: path.join(doctrinaHome(env), "rooms", key(root)), root, name, role };
65
+ }
66
+
67
+ function writeJson(file, data) {
68
+ const tmp = `${file}.${token()}.tmp`;
69
+ writeFileSync(tmp, JSON.stringify(data));
70
+ renameSync(tmp, file);
71
+ }
72
+
73
+ function readJson(file) {
74
+ try {
75
+ return JSON.parse(read(file));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /** Every peer file in the room that is still fresh. */
82
+ function alive(dir, now) {
83
+ const peers = path.join(dir, "peers");
84
+ /** @type {Peer[]} */
85
+ const out = [];
86
+ if (!isDir(peers)) return out;
87
+ for (const f of readdirSync(peers).filter((n) => n.endsWith(".json")).sort()) {
88
+ const p = readJson(path.join(peers, f));
89
+ if (!p || typeof p.id !== "string" || typeof p.seen !== "number") continue;
90
+ if (now - p.seen > PEER_STALE_MS) {
91
+ // What a console that died left behind: its card and its unread mail.
92
+ rmSync(path.join(peers, f), { force: true });
93
+ rmSync(path.join(dir, "inbox", p.id), { recursive: true, force: true });
94
+ continue;
95
+ }
96
+ out.push(p);
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Enter the room: a card of this console's own, and an inbox.
103
+ * @param {string} cwd
104
+ * @param {{ env?: NodeJS.ProcessEnv, role?: Peer["role"], name?: string, now?: number }} [opts]
105
+ * @returns {Room}
106
+ */
107
+ export function joinRoom(cwd, { env = process.env, role, name, now = Date.now() } = {}) {
108
+ const at = roomFor(cwd, env);
109
+ // The person's alone: a request found here was put here by them.
110
+ mkdirSync(path.join(at.dir, "peers"), { recursive: true, mode: 0o700 });
111
+ mkdirSync(path.join(at.dir, "inbox"), { recursive: true, mode: 0o700 });
112
+ if (!isFile(path.join(at.dir, "room.json"))) writeJson(path.join(at.dir, "room.json"), { root: at.root });
113
+ const id = token();
114
+ // Two consoles in one directory are told apart by a number.
115
+ const taken = new Set(alive(at.dir, now).filter((p) => p.role !== "sender").map((p) => p.name));
116
+ let unique = name ?? at.name;
117
+ // A `--send` speaks as the console whose agent ran it: it keeps that name.
118
+ for (let n = 2; role !== "sender" && taken.has(unique); n++) unique = `${name ?? at.name}-${n}`;
119
+ mkdirSync(path.join(at.dir, "inbox", id), { recursive: true, mode: 0o700 });
120
+ return { dir: at.dir, root: at.root, id, name: unique, role: role ?? at.role };
121
+ }
122
+
123
+ /**
124
+ * Say this console is alive, and what it is doing.
125
+ * @param {Room} room
126
+ * @param {{ cwd: string, agent: string | null, model: string | null, busy: boolean, listening: boolean }} info
127
+ * @param {number} [now]
128
+ */
129
+ export function beat(room, info, now = Date.now()) {
130
+ /** @type {Peer} */
131
+ const card = { id: room.id, name: room.name, role: room.role, pid: process.pid, seen: now, ...info };
132
+ try {
133
+ writeJson(path.join(room.dir, "peers", `${room.id}.json`), card);
134
+ } catch { /* a home that cannot be written is a console alone, not a console that stops */ }
135
+ }
136
+
137
+ /** Leave: the card and the inbox go. */
138
+ export function leaveRoom(room) {
139
+ rmSync(path.join(room.dir, "peers", `${room.id}.json`), { force: true });
140
+ rmSync(path.join(room.dir, "inbox", room.id), { recursive: true, force: true });
141
+ }
142
+
143
+ /**
144
+ * The other consoles in the room, by name. A `--send` in flight is not one.
145
+ * @param {Room} room
146
+ * @param {number} [now]
147
+ * @returns {Peer[]}
148
+ */
149
+ export function listPeers(room, now = Date.now()) {
150
+ return alive(room.dir, now).filter((p) => p.id !== room.id && p.role !== "sender").sort((a, b) => a.name.localeCompare(b.name));
151
+ }
152
+
153
+ /**
154
+ * The consoles open for a directory, without entering the room.
155
+ * @param {string} cwd
156
+ * @param {NodeJS.ProcessEnv} [env]
157
+ * @param {number} [now]
158
+ * @returns {Peer[]}
159
+ */
160
+ export function roomPeers(cwd, env = process.env, now = Date.now()) {
161
+ return alive(roomFor(cwd, env).dir, now).filter((p) => p.role !== "sender").sort((a, b) => a.name.localeCompare(b.name));
162
+ }
163
+
164
+ /**
165
+ * Put a message in a console's inbox. False when that console is gone.
166
+ * @param {Room} room
167
+ * @param {string} toId
168
+ * @param {Message} message
169
+ */
170
+ export function sendMessage(room, toId, message) {
171
+ const inbox = path.join(room.dir, "inbox", toId);
172
+ if (!/^[0-9a-f]+$/.test(toId) || !isDir(inbox)) return false;
173
+ try {
174
+ writeJson(path.join(inbox, `${String(message.at).padStart(15, "0")}-${token()}.json`), message);
175
+ return true;
176
+ } catch {
177
+ return false;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Take what arrived, oldest first. Each message is read once.
183
+ * @param {Room} room
184
+ * @returns {Message[]}
185
+ */
186
+ export function takeInbox(room) {
187
+ const inbox = path.join(room.dir, "inbox", room.id);
188
+ /** @type {Message[]} */
189
+ const out = [];
190
+ if (!isDir(inbox)) return out;
191
+ for (const f of readdirSync(inbox).filter((n) => n.endsWith(".json")).sort()) {
192
+ const file = path.join(inbox, f);
193
+ const m = readJson(file);
194
+ rmSync(file, { force: true });
195
+ if (m && typeof m.type === "string" && m.from && typeof m.from.id === "string" && typeof m.from.name === "string") out.push(m);
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /** A new message id. */
201
+ export const messageId = () => `${Date.now().toString(36)}${token()}`;
@@ -0,0 +1,150 @@
1
+ // @ts-check
2
+ import path from "node:path";
3
+ import { isDir } from "./fs-ops.js";
4
+ import * as idx from "./index-json.js";
5
+ import { collectSnapshot } from "./snapshot.js";
6
+ import { invocation } from "./actions.js";
7
+ import { cliVersion } from "./version.js";
8
+ import { workspaceActions, workspaceRootAt, workspaceSnapshot } from "./workspace-view.js";
9
+
10
+ // WHAT THE CONSOLE'S PANEL SHOWS (change 0257).
11
+ //
12
+ // The console is a face for a human; the panel beside the conversation is
13
+ // the project's state — the gates, the open work, the next step — and it is
14
+ // read from the tree after every command, never kept by the console. It is
15
+ // the snapshot `prime`, `status` and `next` already render, reduced to what
16
+ // fits a side panel, so the panel cannot disagree with them.
17
+ //
18
+ // Nothing here prints and nothing here writes. `doctrina ui --state` emits
19
+ // it as JSON from a process of its own: the console is long-lived and the
20
+ // CLI's collectors were written for one run.
21
+
22
+ /** @typedef {"ok" | "warn" | "fail" | "off"} Light */
23
+ /** @typedef {{ id: string, state: Light, text: string }} Gate */
24
+ /** @typedef {{ id: string, title: string, status: string, done: number, total: number, stage: "plan" | "build" | "close", delivery: string | null }} OpenChange */
25
+ /** @typedef {{ kind: string, id: string, label: string, path: string }} Mention */
26
+ /**
27
+ * @typedef {{
28
+ * kind: "project" | "workspace" | "none",
29
+ * project: string | null,
30
+ * cli: string,
31
+ * gates: Gate[],
32
+ * counts: { specs: number, decisions: number, skills: number, closed: number },
33
+ * open: OpenChange[],
34
+ * next: { text: string, command: string | null }[],
35
+ * recent: { id: string, date: string }[],
36
+ * projects: { name: string, mode: string, state: string, reason: string }[],
37
+ * mentions: Mention[],
38
+ * }} ConsoleState
39
+ */
40
+
41
+ const NEXT_SHOWN = 5;
42
+
43
+ /** A change's stage, from its tasks: written yet, ticked yet. */
44
+ function stageOf(ch) {
45
+ const placeholders = ch.unchecked.filter((/** @type {string} */ t) => t.startsWith("(unwritten task")).length;
46
+ if (ch.tasksTotal === 0 || placeholders > 0) return /** @type {const} */ ("plan");
47
+ return ch.tasksDone >= ch.tasksTotal ? /** @type {const} */ ("close") : /** @type {const} */ ("build");
48
+ }
49
+
50
+ /** @returns {ConsoleState} */
51
+ function blank(kind) {
52
+ return {
53
+ kind, project: null, cli: cliVersion(), gates: [],
54
+ counts: { specs: 0, decisions: 0, skills: 0, closed: 0 },
55
+ open: [], next: [], recent: [], projects: [], mentions: [],
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The panel's state for the directory the console runs in.
61
+ * @param {string} cwd
62
+ * @returns {ConsoleState}
63
+ */
64
+ export function consoleState(cwd) {
65
+ const wsRoot = workspaceRootAt(cwd);
66
+ if (wsRoot) return workspaceState(wsRoot);
67
+ if (!isDir(path.join(cwd, ".doctrina"))) {
68
+ const out = blank("none");
69
+ out.next = [
70
+ { text: "this directory is not a Doctrina project yet", command: "doctrina init" },
71
+ { text: "the whole cycle once, guided", command: "doctrina onboard" },
72
+ ];
73
+ return out;
74
+ }
75
+ const s = collectSnapshot(cwd);
76
+ const out = blank("project");
77
+ out.project = s.project;
78
+ const cov = s.coverage;
79
+ out.gates = [
80
+ { id: "index", state: s.indexState === "in-sync" ? "ok" : "fail", text: s.indexState },
81
+ cov.pct === null || cov.pct === undefined
82
+ ? { id: "coverage", state: "off", text: "no criteria" }
83
+ : { id: "coverage", state: cov.pct === 100 ? "ok" : "warn", text: `${cov.pct}% (${cov.totalCovered}/${cov.totalCriteria})` },
84
+ s.trace.anchors === 0
85
+ ? { id: "trace", state: "off", text: "no anchors" }
86
+ : { id: "trace", state: s.trace.realized === s.trace.anchors ? "ok" : "warn", text: `${s.trace.realized}/${s.trace.anchors}` },
87
+ !s.verify.configured
88
+ ? { id: "verify", state: "off", text: "not set" }
89
+ : (s.verify.invalid
90
+ ? { id: "verify", state: "fail", text: "invalid" }
91
+ : { id: "verify", state: "ok", text: `${s.verify.checks} check${s.verify.checks === 1 ? "" : "s"}` }),
92
+ ];
93
+ out.counts = { specs: s.specs.total, decisions: s.decisions.total, skills: s.skills, closed: s.archive.length };
94
+ out.open = s.openChanges.map((ch) => ({
95
+ id: ch.id, title: ch.title ?? "", status: ch.status, done: ch.tasksDone, total: ch.tasksTotal,
96
+ stage: stageOf(ch), delivery: ch.delivery,
97
+ }));
98
+ out.next = s.actions.slice(0, NEXT_SHOWN).map((a) => ({ text: a.why, command: invocation(a) }));
99
+ out.recent = s.recentlyClosed.map((r) => ({ id: r.id, date: r.date }));
100
+ out.mentions = mentionsOf(cwd);
101
+ return out;
102
+ }
103
+
104
+ /** What `@` completes to: the artifacts the index names. */
105
+ function mentionsOf(projectRoot) {
106
+ /** @type {Mention[]} */
107
+ const out = [];
108
+ let index;
109
+ try {
110
+ index = idx.load(projectRoot);
111
+ } catch {
112
+ return out;
113
+ }
114
+ const a = index?.artifacts ?? {};
115
+ for (const ch of a.changes ?? []) out.push({ kind: "change", id: ch.id, label: ch.title ?? "", path: `${ch.path}/proposal.md` });
116
+ for (const s of a.specs ?? []) out.push({ kind: "spec", id: s.id, label: `v${s.version} · ${s.implementation ?? s.status ?? ""}`, path: s.path });
117
+ for (const s of a.skills ?? []) out.push({ kind: "skill", id: s.id, label: s.description ?? "", path: s.path });
118
+ for (const d of a.decisions ?? []) out.push({ kind: "adr", id: d.id, label: d.title ?? "", path: d.path });
119
+ for (const ct of a.contracts ?? []) out.push({ kind: "contract", id: ct.id, label: "", path: ct.path });
120
+ return out;
121
+ }
122
+
123
+ /** @returns {ConsoleState} */
124
+ function workspaceState(wsRoot) {
125
+ const snap = workspaceSnapshot(wsRoot);
126
+ const out = blank("workspace");
127
+ out.project = snap.ws.name ?? path.basename(wsRoot);
128
+ out.projects = snap.projects.map((p) => ({
129
+ name: p.project.name, mode: p.project.mode ?? "", state: p.state, reason: p.reason ?? "",
130
+ }));
131
+ const ready = out.projects.filter((p) => p.state === "ready").length;
132
+ out.gates = [
133
+ { id: "projects", state: ready === out.projects.length ? "ok" : "warn", text: `${ready}/${out.projects.length} ready` },
134
+ { id: "deliveries", state: snap.open.length === 0 ? "off" : "ok", text: `${snap.open.length} open` },
135
+ ];
136
+ out.counts.closed = snap.deliveries.length - snap.open.length;
137
+ out.open = snap.open.map(({ delivery: d, rows }) => {
138
+ const changes = rows.flatMap((r) => r.changes ?? []);
139
+ const closed = changes.filter((/** @type {any} */ ch) => ch.archived || ch.status === "archived").length;
140
+ return {
141
+ id: d.id, title: d.title, status: d.status, done: closed, total: Math.max(d.projects.length, changes.length),
142
+ stage: d.projects.length === 0 ? /** @type {const} */ ("plan") : /** @type {const} */ ("build"),
143
+ delivery: null,
144
+ };
145
+ });
146
+ out.next = workspaceActions(snap).slice(0, NEXT_SHOWN).map((a) => ({ text: a.text, command: a.command ?? null }));
147
+ for (const p of out.projects) out.mentions.push({ kind: "project", id: p.name, label: `${p.mode} · ${p.state}`, path: p.name });
148
+ for (const { delivery: d } of snap.open) out.mentions.push({ kind: "delivery", id: d.id, label: d.title, path: `deliveries/${d.id}/delivery.md` });
149
+ return out;
150
+ }