doctrina-cli 0.21.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 (75) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/change.js +111 -14
  4. package/src/commands/clarify.js +2 -2
  5. package/src/commands/close.js +1 -1
  6. package/src/commands/coverage.js +69 -6
  7. package/src/commands/doctor.js +62 -4
  8. package/src/commands/hooks.js +56 -11
  9. package/src/commands/index-rebuild.js +47 -1
  10. package/src/commands/init.js +51 -7
  11. package/src/commands/metrics.js +1 -1
  12. package/src/commands/onboard.js +113 -0
  13. package/src/commands/review.js +1 -1
  14. package/src/commands/search.js +2 -2
  15. package/src/commands/templates.js +2 -1
  16. package/src/commands/triage.js +16 -1
  17. package/src/commands/ui.js +512 -0
  18. package/src/commands/upgrade.js +46 -6
  19. package/src/commands/verify.js +3 -3
  20. package/src/commands/why.js +1 -1
  21. package/src/commands/work.js +53 -40
  22. package/src/commands/workspace.js +16 -3
  23. package/src/index.js +9 -0
  24. package/src/lib/actions.js +19 -0
  25. package/src/lib/adapters.js +23 -3
  26. package/src/lib/agent-bridge.js +400 -0
  27. package/src/lib/applied-deltas.js +82 -0
  28. package/src/lib/args.js +1 -1
  29. package/src/lib/change-numbers.js +115 -0
  30. package/src/lib/change-ops.js +2 -2
  31. package/src/lib/clarify-scan.js +3 -3
  32. package/src/lib/code-inventory.js +22 -5
  33. package/src/lib/commands.js +50 -4
  34. package/src/lib/config.js +27 -8
  35. package/src/lib/conflict-markers.js +61 -0
  36. package/src/lib/console-model.js +668 -0
  37. package/src/lib/console-prefs.js +67 -0
  38. package/src/lib/console-room.js +201 -0
  39. package/src/lib/console-state.js +150 -0
  40. package/src/lib/console-view.js +557 -0
  41. package/src/lib/coverage-model.js +29 -6
  42. package/src/lib/delivery.js +2 -2
  43. package/src/lib/docs-impact.js +1 -1
  44. package/src/lib/fs-ops.js +80 -12
  45. package/src/lib/gates.js +7 -1
  46. package/src/lib/git.js +18 -0
  47. package/src/lib/index-json.js +10 -1
  48. package/src/lib/index-merge.js +90 -0
  49. package/src/lib/install.js +80 -0
  50. package/src/lib/intake-model.js +4 -1
  51. package/src/lib/ledger.js +2 -3
  52. package/src/lib/lock.js +58 -0
  53. package/src/lib/merge-setup.js +55 -0
  54. package/src/lib/metrics-model.js +2 -2
  55. package/src/lib/onboard.js +197 -0
  56. package/src/lib/preset.js +177 -0
  57. package/src/lib/prompt.js +1 -1
  58. package/src/lib/properties.js +149 -0
  59. package/src/lib/runtime.js +19 -7
  60. package/src/lib/scan.js +13 -8
  61. package/src/lib/spec-ops.js +2 -2
  62. package/src/lib/task-card.js +1 -1
  63. package/src/lib/templates-model.js +3 -2
  64. package/src/lib/triage-model.js +75 -0
  65. package/src/lib/validation-model.js +23 -5
  66. package/src/lib/views.js +2 -1
  67. package/src/lib/workspace-agents.js +2 -2
  68. package/src/lib/workspace-tools.js +41 -1
  69. package/src/lib/workspace.js +1 -1
  70. package/templates/AGENTS.md.template +8 -4
  71. package/templates/README.md +2 -1
  72. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  73. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  74. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  75. package/templates/adapters/kiro/README.md +22 -0
@@ -0,0 +1,512 @@
1
+ // @ts-check
2
+ import process from "node:process";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { flagBool, flagString } from "../lib/args.js";
6
+ import { c } from "../lib/colors.js";
7
+ import { emitJson } from "../lib/json-out.js";
8
+ import { EXIT } from "../lib/exit-codes.js";
9
+ import { consoleState } from "../lib/console-state.js";
10
+ import { agentById, agentCatalog, agentInvocation, agentPrompt, detectAgents } from "../lib/agent-bridge.js";
11
+ import { consolePrefs, saveConsolePrefs } from "../lib/console-prefs.js";
12
+ import { agentEvent, agentExit, commandDone, commandLine, initialState, messageReceived, parseKeys, peersSeen, pressKey, settle } from "../lib/console-model.js";
13
+ import { beat, joinRoom, leaveRoom, listPeers, messageId, roomPeers, sendMessage, takeInbox } from "../lib/console-room.js";
14
+ import { renderFrame } from "../lib/console-view.js";
15
+
16
+ // `doctrina ui` — the console (change 0257, ADR 0032).
17
+ //
18
+ // A face for the human. Everything it shows is decided in lib/: the state
19
+ // (console-model), the frame (console-view), the panel (console-state) and
20
+ // how an agent is started (agent-bridge). This module is the terminal: it
21
+ // reads keys, paints frames, and runs what the model asks for — a Doctrina
22
+ // command or the agent — each as a process of its own, so the console shows
23
+ // exactly what that command prints anywhere else.
24
+
25
+ export const jsonNative = true;
26
+
27
+ export const flags = { boolean: ["json", "frame", "peers"], string: ["cols", "rows", "send", "timeout"] };
28
+
29
+ const CLI = fileURLToPath(new URL("../index.js", import.meta.url));
30
+ const ENTER = "\x1b[?1049h\x1b[?2004h";
31
+ const LEAVE = "\x1b[?2004l\x1b[?25h\x1b[?1049l";
32
+ const SIGNALS = /** @type {const} */ (["SIGTERM", "SIGHUP"]);
33
+
34
+ const peerView = (/** @type {import("../lib/console-room.js").Peer} */ p) => ({ id: p.id, name: p.name, role: p.role, agent: p.agent, model: p.model, busy: p.busy, listening: p.listening });
35
+
36
+ export async function run(positional, cmdFlags) {
37
+ const cwd = process.cwd();
38
+ if (flagBool(cmdFlags, "json", false)) {
39
+ const prefs = consolePrefs(cwd);
40
+ emitJson("ui", {
41
+ ...consoleState(cwd),
42
+ agents: agentCatalog({ cwd }).map((a) => ({ id: a.id, label: a.label, file: a.file, model: prefs.models[a.id] ?? null })),
43
+ peers: roomPeers(cwd).map(peerView),
44
+ });
45
+ return EXIT.OK;
46
+ }
47
+ if (flagBool(cmdFlags, "peers", false)) {
48
+ const peers = roomPeers(cwd);
49
+ if (peers.length === 0) console.log("no console is open in this room — open one in a terminal: doctrina ui");
50
+ for (const p of peers) {
51
+ console.log(`${p.name.padEnd(16)} ${p.role === "planner" ? "planner" : "console"} ${(p.agent ?? "no agent").padEnd(9)} ${p.busy ? "busy" : "idle"}${p.listening ? "" : " not listening"} ${p.cwd}`);
52
+ }
53
+ return EXIT.OK;
54
+ }
55
+ const to = flagString(cmdFlags, "send");
56
+ if (to !== undefined) return sendFromCli(cwd, to, positional.join(" ").trim(), Number(flagString(cmdFlags, "timeout")));
57
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY) && !flagBool(cmdFlags, "frame", false);
58
+ if (!interactive) {
59
+ const size = (/** @type {string} */ name, /** @type {number} */ fallback) => {
60
+ const n = Number(flagString(cmdFlags, name));
61
+ return Number.isInteger(n) && n > 0 ? n : fallback;
62
+ };
63
+ const state = initialState({ agents: detectAgents({ cwd }), catalog: agentCatalog({ cwd }), prefs: consolePrefs(cwd), panel: consoleState(cwd) });
64
+ const frame = renderFrame(state, { cols: size("cols", process.stdout.columns || 100), rows: size("rows", process.stdout.rows || 30), color: c.enabled });
65
+ console.log(frame.lines.join("\n"));
66
+ if (!flagBool(cmdFlags, "frame", false)) {
67
+ console.error(c.gray("note: ") + "the console is interactive — run `doctrina ui` in a terminal; this is one frame of it");
68
+ }
69
+ return EXIT.OK;
70
+ }
71
+ return new Promise((resolve) => { session(cwd, resolve); });
72
+ }
73
+
74
+ /**
75
+ * `doctrina ui --send <name> "<request>"`: hand a console a request from
76
+ * outside one — what a planner's agent runs — and wait for its report.
77
+ * @param {string} cwd
78
+ * @param {string} to
79
+ * @param {string} text
80
+ * @param {number} seconds
81
+ */
82
+ async function sendFromCli(cwd, to, text, seconds) {
83
+ const env = process.env;
84
+ if (to.trim() === "" || text === "") {
85
+ console.error(c.red("error:") + " ui --send takes the console's name and the request: doctrina ui --send <name> \"<request>\"");
86
+ return EXIT.USAGE;
87
+ }
88
+ // A request that was handed over is not handed on: two consoles waiting
89
+ // on each other would wait until both gave up.
90
+ if (env.DOCTRINA_CONSOLE_SERVING) {
91
+ console.error(c.red("error:") + ` this console is answering "${env.DOCTRINA_CONSOLE_SERVING}" — a request that was handed over is done here, not handed on`);
92
+ return EXIT.GATE;
93
+ }
94
+ const room = joinRoom(cwd, { env, role: "sender", name: env.DOCTRINA_CONSOLE_NAME || undefined });
95
+ try {
96
+ const peers = listPeers(room).filter((p) => p.name !== env.DOCTRINA_CONSOLE_NAME);
97
+ const target = peers.find((p) => p.name === to || p.id === to);
98
+ if (!target) {
99
+ console.error(c.red("error:") + ` no console named "${to}" is open in this room`);
100
+ console.error(c.gray("hint: ") + (peers.length ? `open: ${peers.map((p) => p.name).join(", ")}` : "open one in that repository's terminal: doctrina ui"));
101
+ return EXIT.PRECONDITION;
102
+ }
103
+ if (!target.listening) {
104
+ console.error(c.red("error:") + ` ${target.name} is not listening — /listen on in that console`);
105
+ return EXIT.PRECONDITION;
106
+ }
107
+ const id = messageId();
108
+ const card = { cwd, agent: null, model: null, busy: false, listening: false };
109
+ beat(room, card);
110
+ if (!sendMessage(room, target.id, { type: "request", id, from: { id: room.id, name: room.name }, text, at: Date.now() })) {
111
+ console.error(c.red("error:") + ` ${target.name} closed before the request reached it`);
112
+ return EXIT.PRECONDITION;
113
+ }
114
+ const limit = Number.isFinite(seconds) && seconds > 0 ? seconds : 600;
115
+ const end = Date.now() + limit * 1000;
116
+ while (Date.now() < end) {
117
+ await new Promise((r) => setTimeout(r, 300));
118
+ beat(room, card);
119
+ for (const m of takeInbox(room)) {
120
+ if (m.type === "result" && m.re === id) {
121
+ console.log(`${m.from.name} ${m.ok ? "finished" : "failed"}${m.detail ? ` — ${m.detail}` : ""}`);
122
+ if (m.summary) console.log(`\n${m.summary}`);
123
+ return m.ok ? EXIT.OK : EXIT.GATE;
124
+ }
125
+ if (m.type === "refused" && m.re === id) {
126
+ console.error(c.red("error:") + ` ${m.from.name} did not take the request: ${m.reason}`);
127
+ return EXIT.GATE;
128
+ }
129
+ }
130
+ if (!listPeers(room).some((p) => p.id === target.id)) {
131
+ console.error(c.red("error:") + ` ${target.name} closed before it reported`);
132
+ return EXIT.GATE;
133
+ }
134
+ }
135
+ console.error(c.red("error:") + ` no report from ${target.name} after ${limit}s — it may still be working; its console shows it`);
136
+ return EXIT.GATE;
137
+ } finally {
138
+ leaveRoom(room);
139
+ }
140
+ }
141
+
142
+ /** Lines out of a stream, whole. */
143
+ function byLine(stream, onLine) {
144
+ let pending = "";
145
+ stream.setEncoding("utf8");
146
+ stream.on("data", (/** @type {string} */ chunk) => {
147
+ const parts = (pending + chunk).split(/\r?\n/);
148
+ pending = parts.pop() ?? "";
149
+ for (const line of parts) onLine(line);
150
+ });
151
+ stream.on("end", () => {
152
+ if (pending !== "") onLine(pending);
153
+ pending = "";
154
+ });
155
+ }
156
+
157
+ /**
158
+ * The interactive session. The terminal is a parameter, so a test drives it
159
+ * with streams of its own and reads the frames it paints.
160
+ * @param {string} cwd
161
+ * @param {(code: number) => void} done
162
+ * @param {{ inp?: any, out?: any, env?: NodeJS.ProcessEnv, color?: boolean, poll?: number }} [io]
163
+ */
164
+ export function session(cwd, done, { inp = process.stdin, out = process.stdout, env = process.env, color = c.enabled, poll = 700 } = {}) {
165
+ let state = initialState({ agents: detectAgents({ cwd, env }), catalog: agentCatalog({ cwd, env }), prefs: consolePrefs(cwd, env) });
166
+ // The room: the other consoles opened for the same work. A home that
167
+ // cannot be written leaves this console alone, and working.
168
+ /** @type {import("../lib/console-room.js").Room | null} */
169
+ let room = null;
170
+ try {
171
+ room = joinRoom(cwd, { env });
172
+ } catch {
173
+ room = null;
174
+ }
175
+ /** @type {NodeJS.Timeout | null} */
176
+ let pulseTimer = null;
177
+ /** @type {{ proc: import("node:child_process").ChildProcess, kind: "command" | "agent", finished: boolean } | null} */
178
+ let running = null;
179
+ let refreshing = false;
180
+ let stale = false;
181
+ let queued = false;
182
+ let pendingKeys = "";
183
+ let closed = false;
184
+ /** @type {NodeJS.Timeout | null} */
185
+ let ticker = null;
186
+
187
+ const paint = () => {
188
+ queued = false;
189
+ if (closed) return;
190
+ const { lines, cursor } = renderFrame(state, { cols: out.columns || 80, rows: out.rows || 24, color });
191
+ out.write(`\x1b[?25l\x1b[H${lines.join("\r\n")}\x1b[${cursor.row + 1};${cursor.col + 1}H\x1b[?25h`);
192
+ // The spinner and the clock move while something runs.
193
+ if (state.busy && !ticker) ticker = setInterval(schedule, 120);
194
+ if (!state.busy && ticker) {
195
+ clearInterval(ticker);
196
+ ticker = null;
197
+ }
198
+ };
199
+ const schedule = () => {
200
+ if (queued || closed) return;
201
+ queued = true;
202
+ setImmediate(paint);
203
+ };
204
+
205
+ const refresh = () => {
206
+ if (refreshing) {
207
+ stale = true;
208
+ return;
209
+ }
210
+ refreshing = true;
211
+ let text = "";
212
+ const proc = spawn(process.execPath, [CLI, "ui", "--json"], { cwd, env, stdio: ["ignore", "pipe", "ignore"] });
213
+ proc.stdout.setEncoding("utf8");
214
+ proc.stdout.on("data", (/** @type {string} */ d) => { text += d; });
215
+ const settle = () => {
216
+ refreshing = false;
217
+ try {
218
+ const panel = JSON.parse(text);
219
+ if (panel && typeof panel === "object" && Array.isArray(panel.gates)) state = { ...state, panel };
220
+ } catch { /* the panel keeps what it showed */ }
221
+ if (stale) {
222
+ stale = false;
223
+ refresh();
224
+ }
225
+ schedule();
226
+ };
227
+ proc.on("error", settle);
228
+ proc.on("close", settle);
229
+ };
230
+
231
+ const card = () => ({ cwd, agent: state.agent, model: (state.agent ? state.models[state.agent] : null) ?? null, busy: Boolean(state.busy), listening: state.listening });
232
+
233
+ // Nothing runs any more: the console that asked gets its report, and the
234
+ // next request that was waiting starts.
235
+ const ended = () => {
236
+ const after = settle(state);
237
+ state = after.state;
238
+ perform(after.effects);
239
+ if (room) beat(room, card());
240
+ refresh();
241
+ schedule();
242
+ };
243
+
244
+ const finish = (/** @type {{ proc: import("node:child_process").ChildProcess }} */ job) => {
245
+ if (running === job) running = null;
246
+ ended();
247
+ };
248
+
249
+ // One look at the room: say this console is alive, see who else is, and
250
+ // take what arrived.
251
+ const pulse = () => {
252
+ if (!room || closed) return;
253
+ beat(room, card());
254
+ state = peersSeen(state, { name: room.name, role: room.role, root: room.root }, listPeers(room).map(peerView));
255
+ for (const m of takeInbox(room)) {
256
+ const got = messageReceived(state, m);
257
+ state = got.state;
258
+ perform(got.effects);
259
+ }
260
+ schedule();
261
+ };
262
+
263
+ const runCommand = (/** @type {string[]} */ argv) => {
264
+ /** @type {NodeJS.ProcessEnv} */
265
+ const plain = { ...env, NO_COLOR: "1" };
266
+ delete plain.FORCE_COLOR;
267
+ const proc = spawn(process.execPath, [CLI, ...argv], { cwd, env: plain, stdio: ["ignore", "pipe", "pipe"] });
268
+ const job = { proc, kind: /** @type {const} */ ("command"), finished: false };
269
+ running = job;
270
+ byLine(proc.stdout, (line) => { state = commandLine(state, "stdout", line); schedule(); });
271
+ byLine(proc.stderr, (line) => { state = commandLine(state, "stderr", line); schedule(); });
272
+ proc.on("error", (err) => {
273
+ state = commandDone(commandLine(state, "stderr", `could not start the command: ${err.message}`), 1, null);
274
+ finish(job);
275
+ });
276
+ proc.on("close", (code, signal) => {
277
+ if (!job.finished) state = commandDone(state, code ?? 1, signal);
278
+ job.finished = true;
279
+ finish(job);
280
+ });
281
+ };
282
+
283
+ const askAgent = (/** @type {string} */ prompt, /** @type {string | null} */ origin) => {
284
+ const agent = state.agents.find((a) => a.id === state.agent);
285
+ const spec = agent ? agentById(agent.id, env) : null;
286
+ if (!agent || !spec) {
287
+ state = agentExit(state, 1, null, "no agent is selected");
288
+ ended();
289
+ return;
290
+ }
291
+ const full = agentPrompt(prompt, state.panel?.mentions ?? [], { peers: state.peers, origin });
292
+ const call = agentInvocation(agent, full, { session: state.session, model: state.models[agent.id] ?? null, env });
293
+ if (call.refused) {
294
+ state = agentExit(state, 1, null, call.refused);
295
+ ended();
296
+ return;
297
+ }
298
+ // The agent's own `doctrina ui --send` speaks as this console, and one
299
+ // answering a request another console sent may not hand it on.
300
+ const agentEnv = { ...env, DOCTRINA_CONSOLE_NAME: room?.name ?? "", DOCTRINA_CONSOLE_SERVING: origin ?? "" };
301
+ const proc = spawn(call.file, call.args, { cwd, env: agentEnv, stdio: [call.stdin === null ? "ignore" : "pipe", "pipe", "pipe"], shell: call.shell });
302
+ const job = { proc, kind: /** @type {const} */ ("agent"), finished: false };
303
+ running = job;
304
+ let stderr = "";
305
+ if (call.stdin !== null && proc.stdin) {
306
+ proc.stdin.on("error", () => { /* the agent closed its input early */ });
307
+ proc.stdin.end(call.stdin);
308
+ }
309
+ if (proc.stdout) {
310
+ byLine(proc.stdout, (line) => {
311
+ for (const ev of spec.parse(line)) {
312
+ if (ev.type === "result") job.finished = true;
313
+ state = agentEvent(state, ev);
314
+ }
315
+ schedule();
316
+ });
317
+ }
318
+ if (proc.stderr) {
319
+ proc.stderr.setEncoding("utf8");
320
+ proc.stderr.on("data", (/** @type {string} */ d) => { stderr = (stderr + d).slice(-2000); });
321
+ }
322
+ proc.on("error", (err) => {
323
+ if (!job.finished) state = agentExit(state, 1, null, `could not start ${agent.label}: ${err.message}`);
324
+ job.finished = true;
325
+ finish(job);
326
+ });
327
+ proc.on("close", (code, signal) => {
328
+ if (!job.finished) state = agentExit(state, code ?? 1, signal, stderr);
329
+ job.finished = true;
330
+ finish(job);
331
+ });
332
+ };
333
+
334
+ const interrupt = () => {
335
+ const job = running;
336
+ if (!job) return;
337
+ job.proc.kill("SIGINT");
338
+ setTimeout(() => {
339
+ if (running === job && !job.finished) job.proc.kill("SIGTERM");
340
+ }, 2000).unref();
341
+ };
342
+
343
+ const leave = () => {
344
+ if (closed) return;
345
+ closed = true;
346
+ if (ticker) clearInterval(ticker);
347
+ if (pulseTimer) clearInterval(pulseTimer);
348
+ if (room) leaveRoom(room);
349
+ if (running) running.proc.kill("SIGTERM");
350
+ inp.off("data", onData);
351
+ out.off("resize", onResize);
352
+ process.off("exit", restore);
353
+ for (const sig of SIGNALS) process.off(sig, leave);
354
+ if (inp.isTTY) inp.setRawMode(false);
355
+ inp.pause();
356
+ out.write(LEAVE);
357
+ done(EXIT.OK);
358
+ };
359
+
360
+ /** @param {import("../lib/console-model.js").Effect[]} effects */
361
+ const perform = (effects) => {
362
+ for (const effect of effects) {
363
+ if (effect.type === "run") runCommand(effect.argv);
364
+ else if (effect.type === "ask") askAgent(effect.prompt, effect.origin ?? null);
365
+ else if (effect.type === "beat") {
366
+ if (room) beat(room, card());
367
+ } else if (effect.type === "send" && room) {
368
+ const from = { id: room.id, name: room.name };
369
+ for (const target of effect.to) {
370
+ if (!sendMessage(room, target.id, { type: "request", id: messageId(), from, text: effect.text, at: Date.now() })) {
371
+ state = commandLine(state, "stderr", `${target.name} closed before the request reached it`);
372
+ }
373
+ }
374
+ } else if (effect.type === "reply" && room) {
375
+ const from = { id: room.id, name: room.name };
376
+ sendMessage(room, effect.to, effect.refused
377
+ ? { type: "refused", re: effect.re, from, reason: effect.refused, at: Date.now() }
378
+ : { type: "result", re: effect.re, from, ok: effect.ok, summary: effect.summary, detail: effect.detail, at: Date.now() });
379
+ }
380
+ else if (effect.type === "interrupt") interrupt();
381
+ else if (effect.type === "refresh") refresh();
382
+ else if (effect.type === "save") saveConsolePrefs(cwd, { agent: state.agent, models: state.models }, env);
383
+ else if (effect.type === "quit") leave();
384
+ }
385
+ };
386
+
387
+ function onData(/** @type {string} */ data) {
388
+ const { keys, rest } = parseKeys(pendingKeys + data);
389
+ pendingKeys = rest;
390
+ for (const key of keys) {
391
+ const next = pressKey(state, key);
392
+ state = next.state;
393
+ perform(next.effects);
394
+ if (closed) return;
395
+ }
396
+ schedule();
397
+ }
398
+ function onResize() {
399
+ out.write("\x1b[2J");
400
+ schedule();
401
+ }
402
+
403
+ // The terminal is given back whatever ends the process.
404
+ function restore() {
405
+ if (!closed) out.write(LEAVE);
406
+ }
407
+ process.once("exit", restore);
408
+ for (const sig of SIGNALS) process.once(sig, leave);
409
+
410
+ out.write(ENTER);
411
+ inp.setRawMode(true);
412
+ inp.setEncoding("utf8");
413
+ inp.resume();
414
+ inp.on("data", onData);
415
+ out.on("resize", onResize);
416
+ paint();
417
+ refresh();
418
+ pulse();
419
+ pulseTimer = setInterval(pulse, poll);
420
+ }
421
+
422
+ export const help = `
423
+ Usage: doctrina ui [--frame] [--cols <n>] [--rows <n>] [--json]
424
+ doctrina ui --peers
425
+ doctrina ui --send <name> "<request>" [--timeout <seconds>]
426
+
427
+ The console: Doctrina with a face, for the human at the terminal. Running
428
+ \`doctrina\` with no arguments in a terminal opens it too.
429
+
430
+ ◆ Doctrina │ <project> ● <agent> v<version>
431
+ index ✓ coverage ✓ 100% trace ✓ 5/5 verify ✓ 8 checks
432
+ ───────────────────────────────────────────┬───────────────────────
433
+ the conversation │ OPEN WORK
434
+ what you asked, what each command │ <change> ● plan ─ ○ build ─ ○ close
435
+ printed, what the agent said and did │ NEXT · RECENTLY CLOSED
436
+ ╭──────────────────────────────────────────┴──────────────────────╮
437
+ │ › the line you type │
438
+ ╰─────────────────────────────────────────────────────────────────╯
439
+
440
+ What you type:
441
+
442
+ a request In your own words. It goes to the AI agent you already use,
443
+ through that agent's non-interactive mode, and everything it
444
+ says and does is shown as it happens. The next request
445
+ continues the same conversation; /new starts another.
446
+ /command A Doctrina command, as you would type it after \`doctrina\`:
447
+ /next, /status, /work "<request>", /close <id>. A list
448
+ completes it as you type (tab), and enter runs it.
449
+ @artifact Mention a spec, an open change, a skill or a decision —
450
+ @spec:auth, @change:0001-login, @skill:cut-a-release,
451
+ @adr:0005. In a command it becomes the id; in a request the
452
+ agent is told the file it names.
453
+ ? What the console understands.
454
+
455
+ The panel and the gates are read from the tree when the console opens and
456
+ after every command and every answer; the console keeps nothing about the
457
+ project.
458
+
459
+ The agent is found on PATH: claude (Claude Code), cursor-agent (Cursor),
460
+ copilot (GitHub Copilot, VS Code), codex, gemini, opencode — or one of
461
+ your own, declared in $DOCTRINA_HOME/agents.json. /agent lists them all,
462
+ installed or not, and switches; /model names the model the agent in use
463
+ runs (/model default goes back). The one used first is the one you chose
464
+ here before, then the IDE whose terminal this is, then the one the
465
+ project has an adapter for. DOCTRINA_AGENT=<name> pins one (none disables
466
+ it) and DOCTRINA_AGENT_ARGS adds flags of your own to its command line.
467
+ The choice is kept per directory in $DOCTRINA_HOME/console.json.
468
+
469
+ Doctrina passes no flag that skips an agent's approvals: what the agent
470
+ may edit or run is its own settings' to say, and a call it refuses is
471
+ reported in the conversation. It is the same agent, account and rules as
472
+ in your IDE, in a session of its own — it does not type into a chat pane
473
+ that is already open.
474
+
475
+ Several consoles (ADR 0033). Consoles opened for the same work — the
476
+ repositories of one workspace and its root, or the same directory — share
477
+ a room: each sees the others (the header counts them, the panel lists
478
+ them, /peers says what each is doing) and can hand one a request:
479
+
480
+ /send <name> <request> that console's agent does it there, in its own
481
+ repository, and its last message comes back
482
+ here as the report. /send all … asks every one.
483
+ /listen off this console stops taking requests (on again
484
+ with /listen on). It takes them by default.
485
+
486
+ A request that arrives while something runs waits its turn, and is shown
487
+ with the console it came from. The room is files under $DOCTRINA_HOME,
488
+ yours alone to read and write: no port is opened and nothing leaves the
489
+ machine. The agent of a console is told which consoles are open, and can
490
+ hand one a task itself with \`doctrina ui --send <name> "<request>"\`,
491
+ which waits for the report — so a planner's agent at the workspace root
492
+ can give each repository its card. A request that was handed over is done
493
+ where it lands: it is not handed on.
494
+
495
+ Keys: enter send · tab complete · ↑↓ history (or the list) · pgup/pgdn
496
+ scroll · esc interrupt or clear · ctrl+c twice (or /quit) to leave.
497
+
498
+ Options:
499
+ --frame Print one frame and exit instead of opening the console
500
+ (what a pipe or a script gets anyway)
501
+ --cols <n> The frame's width, with --frame (default: the terminal's, or 100)
502
+ --rows <n> The frame's height, with --frame (default: the terminal's, or 30)
503
+ --json The panel's state — gates, open work, next steps, the
504
+ artifacts @ completes, every agent known and the model
505
+ chosen for it, the consoles open in the room — as JSON
506
+ --peers List the consoles open in this room and exit
507
+ --send <name> Hand the console <name> a request and wait for its report:
508
+ exit 0 when its agent finished, 1 when it failed, was
509
+ refused or did not report in time, 3 when no such console
510
+ is open
511
+ --timeout <s> How long --send waits for the report (default 600)
512
+ `;
@@ -5,10 +5,11 @@ import { exists } from "../lib/fs-ops.js";
5
5
  import * as idx from "../lib/index-json.js";
6
6
  import { deriveIndex, indexesMatch } from "../lib/scan.js";
7
7
  import { cliVersion } from "../lib/version.js";
8
- import { flagBool } from "../lib/args.js";
8
+ import { flagBool, flagString } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { today } from "../lib/dates.js";
11
- import { notADoctrinaProject } from "../lib/exit-codes.js";
11
+ import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
12
+ import { applyPreset, describePreset, planPreset, presetPending, readPreset } from "../lib/preset.js";
12
13
  import { planSkillExport, applySkillExport } from "../lib/skill-export.js";
13
14
  import { applyAdapterMigration, planAdapterMigration } from "../lib/adapters.js";
14
15
  import { projectName } from "../lib/project.js";
@@ -30,7 +31,7 @@ import * as validate from "./validate.js";
30
31
  // Flags this command accepts. Declared HERE, with the command, so
31
32
  // adding a command never requires editing the entrypoint — the gap that
32
33
  // let six flags ship undeclared and silently swallow a positional (C3).
33
- export const flags = { boolean: ["json", "write"], string: [] };
34
+ export const flags = { boolean: ["json", "write"], string: ["preset"] };
34
35
 
35
36
  export async function run(_positional, flags) {
36
37
  const projectRoot = process.cwd();
@@ -58,6 +59,27 @@ export async function run(_positional, flags) {
58
59
 
59
60
  let pending = 0;
60
61
 
62
+ // 0. The organisation's preset, when one is named (change 0253): laid
63
+ // over the project before the scaffold is measured, so the steps below
64
+ // check the project as it will be.
65
+ const presetDir = flagString(flags, "preset");
66
+ if (flags.has("preset")) {
67
+ let preset;
68
+ try {
69
+ if (!presetDir) throw new Error("--preset needs the preset's folder");
70
+ preset = readPreset(presetDir);
71
+ } catch (err) {
72
+ console.error(c.red("error:") + ` ${/** @type {Error} */ (err).message}`);
73
+ return EXIT.USAGE;
74
+ }
75
+ const plan = planPreset(projectRoot, preset);
76
+ if (writeMode) applyPreset(projectRoot, preset, plan);
77
+ else if (presetPending(plan) > 0) pending += 1;
78
+ console.log(c.gray("──── preset"));
79
+ for (const line of describePreset(preset, plan, writeMode)) console.log(line);
80
+ console.log("");
81
+ }
82
+
61
83
  // 2. Additive template updates (missing recommended sections in AGENTS.md /
62
84
  // product.md, missing index.json fields). Reuses `templates update`
63
85
  // verbatim: preview without --write, apply with it.
@@ -102,8 +124,18 @@ export async function run(_positional, flags) {
102
124
  }
103
125
  }
104
126
  const moves = movePlan.writes.length + movePlan.removes.length;
105
- if (moves > 0) {
106
- if (writeMode) {
127
+ // What the installed adapters gained since this project added them.
128
+ if (movePlan.added.length > 0) {
129
+ const names = movePlan.added.map((a) => a.relativePath).join(", ");
130
+ if (writeMode) console.log(c.green("added ") + ` ${movePlan.added.length} adapter file${movePlan.added.length === 1 ? "" : "s"} this CLI ships now: ${names}`);
131
+ else {
132
+ console.log(c.yellow("would ") + `add ${movePlan.added.length} adapter file${movePlan.added.length === 1 ? "" : "s"} this CLI ships now: ${names}`);
133
+ pending += 1;
134
+ }
135
+ }
136
+ if (moves > 0 || (writeMode && movePlan.added.length > 0)) {
137
+ if (writeMode && moves === 0) applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
138
+ else if (writeMode) {
107
139
  const gone = applyAdapterMigration(shadowRootOf(projectRoot) ?? projectRoot, movePlan);
108
140
  relinkShadow(projectRoot, gone);
109
141
  console.log(c.green("moved ") + ` ${movePlan.writes.length} adapter file${movePlan.writes.length === 1 ? "" : "s"} to the skills the agent reads now` +
@@ -173,7 +205,7 @@ export async function run(_positional, flags) {
173
205
  }
174
206
 
175
207
  export const help = `
176
- Usage: doctrina upgrade [--write]
208
+ Usage: doctrina upgrade [--write] [--preset <dir>]
177
209
 
178
210
  Bring an existing project up to the installed CLI after an npm update.
179
211
  The project keeps the scaffold of the version that init-ed it; this is
@@ -192,6 +224,14 @@ the one command that closes the gap, orchestrating the existing pieces:
192
224
  3. validate (--fix) — surface anything the upgrade cannot fix
193
225
  (hand-authored drift, new validate checks)
194
226
 
227
+ --preset <dir> first lays an organisation's preset over the project: a
228
+ folder with preset.json ({ "name", "description", "config" }) and optional
229
+ templates/, decisions/, skills/ and verify.json. Additive — a file the
230
+ project has is kept, a config key it declares is kept, an ADR number it
231
+ uses is not overwritten — and the plan names each one. A preset's
232
+ verify.json is copied only when the project has none, and the commands it
233
+ would run are printed.
234
+
195
235
  Preview by default (exits 1 when steps are pending or a manual repair is
196
236
  left); --write applies. The preview is what \`templates check\` reported
197
237
  and --write what \`templates update --write\` did — both deprecated.
@@ -249,8 +249,8 @@ export async function run(_positional, flags) {
249
249
  results.push({ name, ok: true, kind: "manual", signoff: "fresh" });
250
250
  } else if (verdict.state === "expired") {
251
251
  console.log(c.yellow(`○ ${name} — sign-off expired`) +
252
- c.gray(` (signed ${so.date} at ${short(so.sha)}; changed since: ${verdict.changed.slice(0, 3).join(", ")}` +
253
- `${verdict.changed.length > 3 ? `, +${verdict.changed.length - 3} more` : ""})`) + resign);
252
+ c.gray(` (signed ${so.date} at ${short(so.sha)}; changed since: ${(verdict.changed ?? []).slice(0, 3).join(", ")}` +
253
+ `${(verdict.changed ?? []).length > 3 ? `, +${(verdict.changed ?? []).length - 3} more` : ""})`) + resign);
254
254
  results.push({ name, ok: false, kind: "manual", pending: true, signoff: "expired" });
255
255
  } else if (verdict.state === "unverifiable") {
256
256
  console.log(c.yellow(`○ ${name} — sign-off cannot be verified`) +
@@ -320,7 +320,7 @@ export async function run(_positional, flags) {
320
320
  const verdict = judgeOutput(expectation, output);
321
321
  if (!verdict.ok) {
322
322
  ok = false;
323
- expectationNote = verdict.reason;
323
+ expectationNote = verdict.reason ?? null;
324
324
  }
325
325
  }
326
326
 
@@ -174,7 +174,7 @@ function whyAnchor(projectRoot, id, specsDir, known) {
174
174
  for (const capName of known.sort()) {
175
175
  const text = read(path.join(specsDir, capName, "spec.md"));
176
176
  const realizesRaw = specHeader(text, "Realizes");
177
- const ids = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? []) : [];
177
+ const ids = realizesRaw ? (realizesRaw.match(/[A-Z]+\d+/g) ?? /** @type {string[]} */ ([])) : /** @type {string[]} */ ([]);
178
178
  if (ids.includes(id)) realizing.push({ cap: capName, text });
179
179
  }
180
180