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,668 @@
1
+ // @ts-check
2
+ import { CORE_SURFACE, OPERATIONS, DEPRECATED } from "./commands.js";
3
+ import { splitArgs } from "./agent-bridge.js";
4
+
5
+ // THE CONSOLE, AS STATE (change 0257).
6
+ //
7
+ // Everything the console knows is one plain object and everything that
8
+ // happens to it — a key, a line a command printed, an event from the agent —
9
+ // goes through `reduce`, which returns the next state and the EFFECTS it
10
+ // asks for: run a command, ask the agent, interrupt, refresh, quit. The
11
+ // command module performs them; nothing here reads a terminal, starts a
12
+ // process or touches the tree, so the whole interaction is tested as data.
13
+
14
+ /** @typedef {import("./console-state.js").ConsoleState} Panel */
15
+ /** @typedef {import("./agent-bridge.js").AgentEvent} AgentEvent */
16
+ /** @typedef {{ kind: "banner" | "user" | "out" | "err" | "agent" | "tool" | "note" | "ok" | "fail" | "blank" | "peer" | "quote", text: string }} Entry */
17
+ /** @typedef {import("./console-room.js").Message} Message */
18
+ /** @typedef {{ id: string, name: string, role: string, agent: string | null, model: string | null, busy: boolean, listening: boolean }} PeerView */
19
+ /** @typedef {{ id: string, from: { id: string, name: string }, text: string }} PeerRequest */
20
+ /** @typedef {{ name: string, summary: string, local: boolean, immediate: boolean }} PaletteCommand */
21
+ /** @typedef {{ id: string, label: string, file: string | null, bins: string[], exercised: boolean, about: string, models: string[], listing: string, takesModel: boolean, custom: boolean }} CatalogAgent */
22
+ /** @typedef {{ kind: "slash" | "mention", items: { value: string, label: string, immediate: boolean }[], from: number }} Palette */
23
+ /**
24
+ * @typedef {{ type: "run", argv: string[], label: string }
25
+ * | { type: "ask", prompt: string, origin?: string }
26
+ * | { type: "send", to: { id: string, name: string }[], text: string }
27
+ * | { type: "reply", to: string, re: string, ok: boolean, summary: string, detail: string, refused: string }
28
+ * | { type: "beat" }
29
+ * | { type: "save" }
30
+ * | { type: "interrupt" } | { type: "refresh" } | { type: "quit" }} Effect
31
+ */
32
+ /**
33
+ * @typedef {{ name: "char" | "paste", text: string }
34
+ * | { name: "enter" | "tab" | "shift-tab" | "backspace" | "delete" | "left" | "right" | "up" | "down" | "home" | "end"
35
+ * | "pageup" | "pagedown" | "escape" | "ctrl-c" | "ctrl-d" | "ctrl-u" | "ctrl-w" | "ctrl-k" | "ctrl-l" | "ctrl-r" }} Key
36
+ */
37
+ /**
38
+ * @typedef {{
39
+ * input: string[], cursor: number,
40
+ * history: string[], historyAt: number | null, draft: string,
41
+ * transcript: Entry[], scroll: number,
42
+ * paletteAt: number, paletteOff: boolean,
43
+ * panel: Panel | null, showPanel: boolean,
44
+ * agents: { id: string, label: string, file: string, exercised: boolean }[], agent: string | null, session: string | null,
45
+ * catalog: CatalogAgent[], models: Record<string, string>, reported: Record<string, string>,
46
+ * room: { name: string, role: string, root: string } | null, peers: PeerView[], listening: boolean,
47
+ * queue: PeerRequest[], serving: PeerRequest | null, lastText: string, lastTurn: { ok: boolean, detail: string } | null,
48
+ * busy: { kind: "command" | "agent", label: string, since: number } | null,
49
+ * notice: string, armed: boolean, said: boolean,
50
+ * }} State
51
+ */
52
+
53
+ // The commands that only exist inside the console.
54
+ const LOCAL = [
55
+ ["help", "What the console understands"],
56
+ ["agent", "Which agent answers: every one known, installed or not · /agent <name>"],
57
+ ["model", "The model the agent runs: /model <name> · /model default"],
58
+ ["new", "Start a new conversation with the agent"],
59
+ ["peers", "The other consoles open in this room"],
60
+ ["send", "Hand a request to another console: /send <name> <request> · /send all …"],
61
+ ["listen", "Take requests from other consoles, or stop: /listen off"],
62
+ ["clear", "Clear the conversation"],
63
+ ["panel", "Show or hide the project panel"],
64
+ ["refresh", "Read the project's state again"],
65
+ ["quit", "Leave the console"],
66
+ ];
67
+
68
+ // The operations that mean something with no argument: Enter on one runs it.
69
+ const IMMEDIATE = new Set(["prime", "next", "status", "validate", "doctor", "handoff", "onboard", "coverage", "trace",
70
+ "verify", "metrics", "spec list", "decision list", "skill list", "contract list", "intent list", "adapter list",
71
+ "templates list", "workspace list", "delivery list", "help", "agent", "model", "new", "peers", "listen", "clear", "panel", "refresh", "quit"]);
72
+
73
+ /**
74
+ * Every command `/` offers, in the order a person reaches for them: the
75
+ * session loop, then the console's own, then the rest of the catalog.
76
+ */
77
+ export function paletteCommands() {
78
+ /** @type {PaletteCommand[]} */
79
+ const cli = [];
80
+ for (const [name, summary] of OPERATIONS) {
81
+ if (DEPRECATED[name] || name === "ui" || name.startsWith("workspace guard")) continue;
82
+ cli.push({ name, summary, local: false, immediate: IMMEDIATE.has(name) });
83
+ }
84
+ const loop = ["work", "next", "close", "status", ...CORE_SURFACE];
85
+ const first = [...new Set(loop)].map((n) => cli.find((c) => c.name === n)).filter((c) => c !== undefined);
86
+ return [
87
+ ...first,
88
+ ...LOCAL.map(([name, summary]) => ({ name, summary, local: true, immediate: IMMEDIATE.has(name) })),
89
+ ...cli.filter((c) => !first.includes(c)),
90
+ ];
91
+ }
92
+
93
+ const COMMANDS = paletteCommands();
94
+
95
+ /**
96
+ * @param {{ agents?: State["agents"], panel?: Panel | null, catalog?: CatalogAgent[], prefs?: { agent: string | null, models: Record<string, string> } }} [opts]
97
+ * @returns {State}
98
+ */
99
+ export function initialState({ agents = [], panel = null, catalog = [], prefs = { agent: null, models: {} } } = {}) {
100
+ // The agent a person chose here before, while it is still installed;
101
+ // otherwise the one this terminal and this project point at.
102
+ const kept = agents.find((a) => a.id === prefs.agent);
103
+ return {
104
+ input: [], cursor: 0, history: [], historyAt: null, draft: "",
105
+ transcript: [{ kind: "banner", text: "" }], scroll: 0,
106
+ paletteAt: 0, paletteOff: false,
107
+ panel, showPanel: true,
108
+ agents, agent: (kept ?? agents[0])?.id ?? null, session: null,
109
+ catalog, models: { ...prefs.models }, reported: {},
110
+ room: null, peers: [], listening: true, queue: [], serving: null, lastText: "", lastTurn: null,
111
+ busy: null, notice: "", armed: false, said: false,
112
+ };
113
+ }
114
+
115
+ const text = (/** @type {State} */ s) => s.input.join("");
116
+
117
+ /**
118
+ * What the palette offers for the input as it stands, or null.
119
+ * @param {State} s
120
+ * @returns {Palette | null}
121
+ */
122
+ export function paletteOf(s) {
123
+ if (s.paletteOff) return null;
124
+ const line = text(s);
125
+ // `@` completes the word under the cursor, wherever it is.
126
+ let from = s.cursor;
127
+ while (from > 0 && !/\s/.test(s.input[from - 1])) from--;
128
+ const word = s.input.slice(from, s.cursor).join("");
129
+ if (/^@[\w:.-]*$/.test(word)) {
130
+ const q = word.slice(1).toLowerCase();
131
+ const all = (s.panel?.mentions ?? []).map((m) => ({ value: `@${m.kind}:${m.id}`, label: m.label, key: `${m.kind}:${m.id}`.toLowerCase(), id: m.id.toLowerCase() }));
132
+ const hits = [
133
+ ...all.filter((m) => m.key.startsWith(q) || m.id.startsWith(q)),
134
+ ...all.filter((m) => !(m.key.startsWith(q) || m.id.startsWith(q)) && m.key.includes(q)),
135
+ ];
136
+ // A mention that is already whole needs no list: enter sends the line.
137
+ if (hits.length === 0 || (hits.length === 1 && hits[0].value === word)) return null;
138
+ return { kind: "mention", from, items: hits.map((m) => ({ value: m.value, label: m.label, immediate: false })) };
139
+ }
140
+ if (!line.startsWith("/") || s.cursor !== s.input.length) return null;
141
+ // The argument of /agent, /model and /send is chosen from a list too.
142
+ const arg = /^\/(agent|model|send) (\S*)$/.exec(line);
143
+ if (arg) {
144
+ const typed = arg[2].toLowerCase();
145
+ let options;
146
+ if (arg[1] === "agent") {
147
+ options = [...s.catalog.map((a) => ({ value: a.id, label: `${a.label} — ${a.file ? (a.id === s.agent ? "in use" : "installed") : "not installed"}` })),
148
+ { value: "off", label: "send requests nowhere" }];
149
+ } else if (arg[1] === "model") {
150
+ options = [...(s.catalog.find((a) => a.id === s.agent)?.models ?? []).map((m) => ({ value: m, label: "" })),
151
+ { value: "default", label: "the agent's own default" }];
152
+ } else {
153
+ options = [...s.peers.map((p) => ({ value: p.name, label: peerLine(p) })),
154
+ ...(s.peers.length > 1 ? [{ value: "all", label: "every console that is listening" }] : [])];
155
+ }
156
+ const hits = options.filter((o) => o.value.toLowerCase().startsWith(typed));
157
+ if (hits.length === 0 || (hits.length === 1 && hits[0].value.toLowerCase() === typed)) return null;
158
+ // A request still has to be typed after the name of a console.
159
+ return { kind: "slash", from: 0, items: hits.map((o) => ({ value: `/${arg[1]} ${o.value}`, label: o.label, immediate: arg[1] !== "send" })) };
160
+ }
161
+ const q = line.slice(1).toLowerCase();
162
+ // A command that is already whole, followed by its arguments, needs no list.
163
+ // What the name starts with first; then a later word of it (`/new` finds `spec new`).
164
+ const hits = [
165
+ ...COMMANDS.filter((c) => c.name.startsWith(q)),
166
+ ...COMMANDS.filter((c) => !c.name.startsWith(q) && q !== "" && !q.includes(" ") && c.name.split(" ").some((w) => w.startsWith(q))),
167
+ ];
168
+ if (hits.length === 0 || (hits.length === 1 && hits[0].name === q)) return null;
169
+ return { kind: "slash", from: 0, items: hits.map((c) => ({ value: `/${c.name}`, label: c.summary, immediate: c.immediate })) };
170
+ }
171
+
172
+ const say = (/** @type {State} */ s, /** @type {Entry["kind"]} */ kind, /** @type {string} */ t) => ({ ...s, transcript: [...s.transcript, { kind, text: t }], scroll: 0 });
173
+ const setInput = (/** @type {State} */ s, /** @type {string} */ t) => ({ ...s, input: Array.from(t), cursor: Array.from(t).length, paletteAt: 0, paletteOff: false });
174
+
175
+ const WHILE_BUSY = /^(\?|\/(help|peers|listen|send|panel|clear|refresh|quit|exit)(\s|$))/;
176
+
177
+ const HELP = [
178
+ "Type a request in your own words and it goes to the agent, which works in this repository.",
179
+ "",
180
+ " / a Doctrina command — /next, /work \"<request>\", /close <id>, /status … (tab completes)",
181
+ " @ mention an artifact — @spec:auth, @change:0001-login, @skill:…, @adr:0005",
182
+ " ↑ ↓ earlier inputs · pgup pgdn scroll the conversation",
183
+ " esc interrupt what is running, or clear the line",
184
+ " /peers the consoles open in other terminals · /send <name> <request> hands one a request, and its report comes back",
185
+ " /agent which agent answers — Claude Code, Cursor, GitHub Copilot, Codex, Gemini, opencode, or your own",
186
+ " /model the model that agent runs · /new starts a new conversation with it",
187
+ " ctrl+c twice to leave (or /quit)",
188
+ ];
189
+
190
+ /** The command line of a `/` input: mentions become the ids they name. */
191
+ export function commandArgv(line) {
192
+ return splitArgs(line.replace(/^\//, "")).map((a) => a.replace(/^@[a-z]+:([A-Za-z0-9._-]+)$/, "$1"));
193
+ }
194
+
195
+ /**
196
+ * @param {State} s
197
+ * @param {string} line
198
+ * @returns {{ state: State, effects: Effect[] }}
199
+ */
200
+ function submit(s, line) {
201
+ const trimmed = line.trim();
202
+ if (trimmed === "") return { state: s, effects: [] };
203
+ // While something runs, only what starts nothing of its own goes through:
204
+ // the console's own commands, and a request handed to another console.
205
+ if (s.busy && !WHILE_BUSY.test(trimmed)) return { state: { ...s, notice: `${s.busy.label} is still running — esc interrupts it` }, effects: [] };
206
+ /** @type {State} */
207
+ let next = { ...setInput(s, ""), history: s.history[s.history.length - 1] === trimmed ? s.history : [...s.history, trimmed], historyAt: null, draft: "", said: true };
208
+ next = say(say(next, "blank", ""), "user", trimmed);
209
+ if (trimmed === "?") return { state: HELP.reduce((acc, l) => say(acc, "note", l), next), effects: [] };
210
+ if (!trimmed.startsWith("/")) {
211
+ const agent = next.agents.find((a) => a.id === next.agent);
212
+ if (!agent) {
213
+ next = say(next, "note", next.agents.length > 0
214
+ ? "No agent is selected — /agent <name> picks one."
215
+ : "No agent command-line tool was found on PATH (claude, cursor-agent, codex, gemini), so a request has nowhere to go.");
216
+ next = say(next, "note", "Open the change yourself with /work \"<request>\", or install your agent's CLI and run /agent.");
217
+ return { state: next, effects: [] };
218
+ }
219
+ return { state: { ...next, busy: { kind: "agent", label: agent.label, since: Date.now() }, lastText: "", lastTurn: null }, effects: [{ type: "ask", prompt: trimmed }] };
220
+ }
221
+ // The raw line: a request handed to another console keeps its quotes.
222
+ const sending = /^\/send\s+(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed);
223
+ if (sending) return sendTo(next, sending[1], (sending[2] ?? "").trim());
224
+ const argv = commandArgv(trimmed);
225
+ const [name, ...rest] = argv;
226
+ switch (name) {
227
+ case undefined:
228
+ return { state: next, effects: [] };
229
+ case "help":
230
+ return { state: HELP.reduce((acc, l) => say(acc, "note", l), next), effects: [] };
231
+ case "quit":
232
+ case "exit":
233
+ return { state: next, effects: [{ type: "quit" }] };
234
+ case "clear":
235
+ return { state: { ...next, transcript: [], scroll: 0 }, effects: [] };
236
+ case "panel":
237
+ return { state: { ...next, showPanel: !next.showPanel }, effects: [] };
238
+ case "refresh":
239
+ return { state: say(next, "note", "reading the project's state again"), effects: [{ type: "refresh" }] };
240
+ case "new":
241
+ return { state: say({ ...next, session: null }, "note", "the next request starts a new conversation with the agent"), effects: [] };
242
+ case "agent": {
243
+ const chosen = chooseAgent(next, rest[0] ?? "");
244
+ return { state: chosen, effects: chosen.agent !== next.agent ? [{ type: "save" }] : [] };
245
+ }
246
+ case "model": {
247
+ const chosen = chooseModel(next, rest.join(" "));
248
+ return { state: chosen, effects: chosen.models !== next.models ? [{ type: "save" }] : [] };
249
+ }
250
+ case "ui":
251
+ return { state: say(next, "note", "this is the console already"), effects: [] };
252
+ case "send":
253
+ return { state: say(next, "note", "/send <name> <request> — /peers lists the consoles open in this room"), effects: [] };
254
+ case "peers":
255
+ return { state: listPeers(next), effects: [] };
256
+ case "listen": {
257
+ const on = rest[0] === undefined ? !next.listening : rest[0] !== "off";
258
+ const said = say({ ...next, listening: on }, "note", on
259
+ ? "listening: a request another console sends here goes to this console's agent"
260
+ : "not listening: requests from other consoles are refused until /listen on");
261
+ // Stopping sends back what was waiting, instead of leaving its sender to wait.
262
+ const refusals = on ? [] : next.queue.map((q) => refusal(q, `${next.room?.name ?? "that console"} stopped listening`));
263
+ return { state: { ...said, queue: on ? said.queue : [] }, effects: [{ type: "beat" }, ...refusals] };
264
+ }
265
+ default:
266
+ return { state: { ...next, busy: { kind: "command", label: `doctrina ${name}`, since: Date.now() } }, effects: [{ type: "run", argv, label: `doctrina ${argv.join(" ")}` }] };
267
+ }
268
+ }
269
+
270
+ const peerLine = (/** @type {PeerView} */ p) => `${p.role === "planner" ? "planner" : "console"} · ${p.agent ?? "no agent"}${p.model ? ` · ${p.model}` : ""} · ${p.busy ? "busy" : "idle"}${p.listening ? "" : " · not listening"}`;
271
+
272
+ /** @returns {Effect} */
273
+ const refusal = (/** @type {PeerRequest} */ req, /** @type {string} */ reason) => ({ type: "reply", to: req.from.id, re: req.id, ok: false, summary: "", detail: "", refused: reason });
274
+
275
+ /** `/peers`: who else is in the room. */
276
+ function listPeers(s) {
277
+ if (!s.room) return say(s, "note", "this console is alone: it could not join a room under $DOCTRINA_HOME");
278
+ if (s.peers.length === 0) {
279
+ const out = say(s, "note", `No other console is open in this room (${s.room.root}).`);
280
+ return say(out, "note", "Open one in another terminal — `doctrina` in a repository of the same workspace, or in this directory — and it appears here.");
281
+ }
282
+ let out = say(s, "note", `This console is ${s.room.name}${s.room.role === "planner" ? " (the planner)" : ""}${s.listening ? "" : ", not listening"}. Also open:`);
283
+ for (const p of s.peers) out = say(out, "note", `↔ ${p.name.padEnd(14)} ${peerLine(p)}`);
284
+ return say(out, "note", "Hand one a request with /send <name> <request>; its agent works there and the report comes back here.");
285
+ }
286
+
287
+ /**
288
+ * `/send`: a request for another console's agent.
289
+ * @param {State} s
290
+ * @param {string} name
291
+ * @param {string} request
292
+ * @returns {{ state: State, effects: Effect[] }}
293
+ */
294
+ function sendTo(s, name, request) {
295
+ if (request === "") return { state: say(s, "note", `/send ${name} <request> — say what that console's agent should do`), effects: [] };
296
+ const targets = name === "all" ? s.peers.filter((p) => p.listening) : s.peers.filter((p) => p.name === name || p.id === name);
297
+ if (targets.length === 0) {
298
+ const known = s.peers.map((p) => p.name).join(", ");
299
+ return { state: say(s, "note", name === "all" ? "no console is listening — /peers" : `no console named "${name}" is open in this room${known ? ` — open: ${known}` : " — /peers"}`), effects: [] };
300
+ }
301
+ const deaf = targets.filter((p) => !p.listening);
302
+ if (deaf.length > 0) return { state: say(s, "note", `${deaf.map((p) => p.name).join(", ")} is not listening — it takes requests again after /listen on there`), effects: [] };
303
+ const out = say(s, "peer", `to ${targets.map((p) => p.name).join(", ")} ${request}`);
304
+ return { state: out, effects: [{ type: "send", to: targets.map((p) => ({ id: p.id, name: p.name })), text: request }] };
305
+ }
306
+
307
+ /** Start the agent on a request another console sent. */
308
+ function serve(s, req) {
309
+ const agent = s.agents.find((a) => a.id === s.agent);
310
+ if (!agent) return { state: s, effects: [refusal(req, `${s.room?.name ?? "that console"} has no agent connected`)] };
311
+ const said = say(say(s, "blank", ""), "peer", `from ${req.from.name} ${req.text}`);
312
+ return {
313
+ state: { ...said, said: true, serving: req, lastText: "", lastTurn: null, busy: { kind: /** @type {const} */ ("agent"), label: agent.label, since: Date.now() } },
314
+ effects: [/** @type {Effect} */ ({ type: "ask", prompt: req.text, origin: req.from.name })],
315
+ };
316
+ }
317
+
318
+ /**
319
+ * The consoles seen in the room, as the last look found them.
320
+ * @param {State} s
321
+ * @param {{ name: string, role: string, root: string } | null} room
322
+ * @param {PeerView[]} peers
323
+ * @returns {State}
324
+ */
325
+ export function peersSeen(s, room, peers) {
326
+ const same = JSON.stringify(peers) === JSON.stringify(s.peers) && JSON.stringify(room) === JSON.stringify(s.room);
327
+ return same ? s : { ...s, room, peers };
328
+ }
329
+
330
+ /**
331
+ * A message from another console arrived.
332
+ * @param {State} s
333
+ * @param {Message} m
334
+ * @returns {{ state: State, effects: Effect[] }}
335
+ */
336
+ export function messageReceived(s, m) {
337
+ if (m.type === "request") {
338
+ const req = { id: m.id, from: m.from, text: String(m.text ?? "") };
339
+ if (!s.listening) return { state: s, effects: [refusal(req, `${s.room?.name ?? "that console"} is not listening`)] };
340
+ if (s.busy || s.queue.length > 0) {
341
+ return { state: say({ ...s, queue: [...s.queue, req] }, "note", `↔ ${m.from.name} sent a request — it runs when what is running here ends (${s.queue.length + 1} waiting)`), effects: [] };
342
+ }
343
+ return serve(s, req);
344
+ }
345
+ if (m.type === "result") {
346
+ let out = say(say(s, "blank", ""), "peer", `${m.from.name} ${m.ok ? "finished" : "failed"}${m.detail ? ` ${m.detail}` : ""}`);
347
+ if (m.summary) out = say(out, "quote", m.summary);
348
+ return { state: { ...out, said: true }, effects: [] };
349
+ }
350
+ if (m.type === "refused") return { state: say(s, "note", `↔ ${m.from.name} did not take the request: ${m.reason}`), effects: [] };
351
+ return { state: s, effects: [] };
352
+ }
353
+
354
+ /**
355
+ * Nothing is running any more: report to the console that asked, and take
356
+ * the next request that was waiting.
357
+ * @param {State} s
358
+ * @returns {{ state: State, effects: Effect[] }}
359
+ */
360
+ export function settle(s) {
361
+ if (s.busy) return { state: s, effects: [] };
362
+ /** @type {Effect[]} */
363
+ const effects = [];
364
+ let out = s;
365
+ if (s.serving) {
366
+ const turn = s.lastTurn ?? { ok: false, detail: "" };
367
+ effects.push({ type: "reply", to: s.serving.from.id, re: s.serving.id, ok: turn.ok, summary: s.lastText.length > 1500 ? `${s.lastText.slice(0, 1499)}…` : s.lastText, detail: turn.detail, refused: "" });
368
+ out = { ...out, serving: null, lastTurn: null };
369
+ }
370
+ if (out.queue.length > 0) {
371
+ const [next, ...rest] = out.queue;
372
+ const started = serve({ ...out, queue: rest }, next);
373
+ return { state: started.state, effects: [...effects, ...started.effects] };
374
+ }
375
+ return { state: out, effects };
376
+ }
377
+
378
+ /** `/agent`: every agent known, and the switch to one that is installed. */
379
+ function chooseAgent(s, name) {
380
+ if (name === "") {
381
+ let out = s;
382
+ // One line each: in use ●, installed ○, known and not installed ·.
383
+ for (const a of s.catalog) {
384
+ const mark = a.id === s.agent ? "●" : (a.file ? "○" : "·");
385
+ const where = a.file ? a.file : `not installed (${a.bins.map((b) => `\`${b}\``).join(" or ")} on PATH)`;
386
+ out = say(out, "note", `${mark} ${a.id.padEnd(9)} ${a.label}${a.about ? `, ${a.about}` : ""} — ${where}`);
387
+ }
388
+ out = say(out, "note", "Switch with /agent <name> · /agent off. One of your own goes in $DOCTRINA_HOME/agents.json. DOCTRINA_AGENT pins one; DOCTRINA_AGENT_ARGS adds flags.");
389
+ return say(out, "note", "Claude Code is exercised against its real binary by Doctrina's tests; the others are started as their documentation says.");
390
+ }
391
+ if (name === "off" || name === "none") return say({ ...s, agent: null, session: null }, "note", "no agent: requests are not sent anywhere until /agent <name>");
392
+ const known = s.catalog.find((a) => a.id === name);
393
+ const hit = s.agents.find((a) => a.id === name);
394
+ if (!hit) {
395
+ return say(s, "note", known
396
+ ? `${known.label} is not installed here — the console looked for ${known.bins.map((b) => `\`${b}\``).join(", ")} on PATH. Install its command-line tool and open the console again.`
397
+ : `no agent "${name}" — known: ${s.catalog.map((a) => a.id).join(", ") || "none"}`);
398
+ }
399
+ if (hit.id === s.agent) return say(s, "note", `${hit.label} already answers`);
400
+ const model = s.models[hit.id];
401
+ return say({ ...s, agent: hit.id, session: null }, "note", `requests now go to ${hit.label}${model ? ` · ${model}` : ""} — a new conversation`);
402
+ }
403
+
404
+ /** `/model`: what the agent in use runs, and the change of it. */
405
+ function chooseModel(s, name) {
406
+ const agent = s.catalog.find((a) => a.id === s.agent);
407
+ if (!agent) return say(s, "note", "no agent is selected — /agent <name> first, then /model");
408
+ const current = s.models[agent.id];
409
+ if (name === "") {
410
+ let out = say(s, "note", current
411
+ ? `${agent.label} runs ${current} (chosen here)`
412
+ : `${agent.label} runs its own default${s.reported[agent.id] ? ` — it reported ${s.reported[agent.id]}` : ""}`);
413
+ if (!agent.takesModel) return say(out, "note", `${agent.label} takes no model on its command line here — name it in the agent's own settings, or in DOCTRINA_AGENT_ARGS`);
414
+ if (agent.models.length > 0) out = say(out, "note", `Names it accepts include: ${agent.models.join(", ")}`);
415
+ if (agent.listing) out = say(out, "note", `The models your account has: ${agent.listing}`);
416
+ return say(out, "note", "Choose with /model <name> — any name the agent accepts — or /model default.");
417
+ }
418
+ if (!agent.takesModel) return say(s, "note", `${agent.label} takes no model on its command line here — name it in the agent's own settings, or in DOCTRINA_AGENT_ARGS`);
419
+ if (name === "default") {
420
+ if (!current) return say(s, "note", `${agent.label} already runs its own default`);
421
+ const { [agent.id]: _dropped, ...rest } = s.models;
422
+ return say({ ...s, models: rest }, "note", `${agent.label} goes back to its own default from the next request`);
423
+ }
424
+ if (name === current) return say(s, "note", `${agent.label} already runs ${name}`);
425
+ return say({ ...s, models: { ...s.models, [agent.id]: name } }, "note", `${agent.label} runs ${name} from the next request — the agent refuses a name it does not know`);
426
+ }
427
+
428
+ /**
429
+ * One key.
430
+ * @param {State} s0
431
+ * @param {Key} key
432
+ * @returns {{ state: State, effects: Effect[] }}
433
+ */
434
+ export function pressKey(s0, key) {
435
+ // Any key but a second ctrl+c disarms the exit, and clears the notice.
436
+ const s = key.name === "ctrl-c" ? s0 : { ...s0, armed: false, notice: "" };
437
+ const palette = paletteOf(s);
438
+ const edit = (/** @type {string[]} */ input, /** @type {number} */ cursor) => ({ state: { ...s, input, cursor, paletteAt: 0, paletteOff: false, historyAt: null }, effects: [] });
439
+ const accept = () => {
440
+ if (!palette) return s;
441
+ const item = palette.items[Math.min(s.paletteAt, palette.items.length - 1)];
442
+ const before = s.input.slice(0, palette.from);
443
+ const after = s.input.slice(s.cursor);
444
+ const insert = Array.from(`${item.value} `);
445
+ return { ...s, input: [...before, ...insert, ...after], cursor: before.length + insert.length, paletteAt: 0, paletteOff: false };
446
+ };
447
+ switch (key.name) {
448
+ case "char":
449
+ case "paste": {
450
+ const chars = Array.from(key.text.replace(/\r?\n|\r|\t/g, " ")).filter((ch) => ch >= " " && ch !== "\x7f");
451
+ if (chars.length === 0) return { state: s, effects: [] };
452
+ return edit([...s.input.slice(0, s.cursor), ...chars, ...s.input.slice(s.cursor)], s.cursor + chars.length);
453
+ }
454
+ case "backspace":
455
+ return s.cursor === 0 ? { state: s, effects: [] } : edit([...s.input.slice(0, s.cursor - 1), ...s.input.slice(s.cursor)], s.cursor - 1);
456
+ case "delete":
457
+ return edit([...s.input.slice(0, s.cursor), ...s.input.slice(s.cursor + 1)], s.cursor);
458
+ case "ctrl-u":
459
+ return edit(s.input.slice(s.cursor), 0);
460
+ case "ctrl-k":
461
+ return edit(s.input.slice(0, s.cursor), s.cursor);
462
+ case "ctrl-w": {
463
+ let at = s.cursor;
464
+ while (at > 0 && /\s/.test(s.input[at - 1])) at--;
465
+ while (at > 0 && !/\s/.test(s.input[at - 1])) at--;
466
+ return edit([...s.input.slice(0, at), ...s.input.slice(s.cursor)], at);
467
+ }
468
+ case "left":
469
+ return { state: { ...s, cursor: Math.max(0, s.cursor - 1), paletteAt: 0 }, effects: [] };
470
+ case "right":
471
+ return { state: { ...s, cursor: Math.min(s.input.length, s.cursor + 1), paletteAt: 0 }, effects: [] };
472
+ case "home":
473
+ return { state: { ...s, cursor: 0 }, effects: [] };
474
+ case "end":
475
+ return { state: { ...s, cursor: s.input.length }, effects: [] };
476
+ case "up":
477
+ case "down": {
478
+ const step = key.name === "up" ? -1 : 1;
479
+ if (palette) {
480
+ const n = palette.items.length;
481
+ return { state: { ...s, paletteAt: (Math.min(s.paletteAt, n - 1) + step + n) % n }, effects: [] };
482
+ }
483
+ if (s.history.length === 0) return { state: s, effects: [] };
484
+ const at = s.historyAt === null ? s.history.length : s.historyAt;
485
+ const to = at + step;
486
+ if (to < 0) return { state: s, effects: [] };
487
+ if (to >= s.history.length) return { state: { ...setInput(s, s.draft), historyAt: null, paletteOff: true }, effects: [] };
488
+ return { state: { ...setInput(s, s.history[to]), historyAt: to, draft: s.historyAt === null ? text(s) : s.draft, paletteOff: true }, effects: [] };
489
+ }
490
+ case "pageup":
491
+ return { state: { ...s, scroll: s.scroll + 8 }, effects: [] };
492
+ case "pagedown":
493
+ return { state: { ...s, scroll: Math.max(0, s.scroll - 8) }, effects: [] };
494
+ case "tab":
495
+ case "shift-tab":
496
+ return { state: accept(), effects: [] };
497
+ case "enter": {
498
+ if (palette) {
499
+ const item = palette.items[Math.min(s.paletteAt, palette.items.length - 1)];
500
+ const typed = text(s).trim();
501
+ // A whole command is run as typed; a partial one is completed first,
502
+ // and run at once only when it means something without arguments.
503
+ if (palette.kind === "mention" || typed !== item.value) {
504
+ const done = accept();
505
+ return palette.kind === "slash" && item.immediate ? submit(done, text(done)) : { state: done, effects: [] };
506
+ }
507
+ }
508
+ return submit(s, text(s));
509
+ }
510
+ case "escape":
511
+ if (palette) return { state: { ...s, paletteOff: true }, effects: [] };
512
+ if (s.busy) return { state: { ...s, notice: `interrupting ${s.busy.label}…` }, effects: [{ type: "interrupt" }] };
513
+ return { state: { ...setInput(s, ""), scroll: 0 }, effects: [] };
514
+ case "ctrl-l":
515
+ case "ctrl-r":
516
+ return { state: s, effects: [{ type: "refresh" }] };
517
+ case "ctrl-d":
518
+ return s.input.length === 0 ? { state: s, effects: [{ type: "quit" }] } : { state: s, effects: [] };
519
+ case "ctrl-c":
520
+ if (s.busy) return { state: { ...s, notice: `interrupting ${s.busy.label}…`, armed: false }, effects: [{ type: "interrupt" }] };
521
+ if (s.input.length > 0) return { state: { ...setInput(s, ""), armed: false }, effects: [] };
522
+ if (s.armed) return { state: s, effects: [{ type: "quit" }] };
523
+ return { state: { ...s, armed: true, notice: "press ctrl+c again to leave" }, effects: [] };
524
+ default:
525
+ return { state: s, effects: [] };
526
+ }
527
+ }
528
+
529
+ /** A line a running command printed. */
530
+ export function commandLine(s, stream, line) {
531
+ return { ...s, transcript: [...s.transcript, { kind: stream === "stderr" ? "err" : "out", text: line }] };
532
+ }
533
+
534
+ /** A command ended. */
535
+ export function commandDone(s, code, signal) {
536
+ const secs = s.busy ? ((Date.now() - s.busy.since) / 1000).toFixed(1) : "0.0";
537
+ const entry = signal
538
+ ? { kind: /** @type {const} */ ("fail"), text: `interrupted · ${secs}s` }
539
+ : (code === 0 ? { kind: /** @type {const} */ ("ok"), text: `${secs}s` } : { kind: /** @type {const} */ ("fail"), text: `exit ${code} · ${secs}s` });
540
+ return { ...s, busy: null, notice: "", transcript: [...s.transcript, entry] };
541
+ }
542
+
543
+ /**
544
+ * Something the agent said or did.
545
+ * @param {State} s
546
+ * @param {AgentEvent} ev
547
+ * @returns {State}
548
+ */
549
+ export function agentEvent(s, ev) {
550
+ const add = (/** @type {Entry["kind"]} */ kind, /** @type {string} */ t) => ({ ...s, transcript: [...s.transcript, { kind, text: t }] });
551
+ switch (ev.type) {
552
+ case "session":
553
+ return { ...s, session: ev.id };
554
+ case "model":
555
+ return s.agent ? { ...s, reported: { ...s.reported, [s.agent]: ev.name } } : s;
556
+ case "text":
557
+ return { ...add("agent", ev.text), lastText: ev.text };
558
+ case "tool":
559
+ return add("tool", ev.detail ? `${ev.name} ${ev.detail}` : ev.name);
560
+ case "raw":
561
+ return add("out", ev.text);
562
+ case "result": {
563
+ const secs = ev.ms !== null ? `${(ev.ms / 1000).toFixed(1)}s` : (s.busy ? `${((Date.now() - s.busy.since) / 1000).toFixed(1)}s` : "");
564
+ const turns = ev.turns !== null ? `${ev.turns} turn${ev.turns === 1 ? "" : "s"}` : "";
565
+ let out = { ...s, busy: null, notice: "" };
566
+ // What the agent was refused, each on one line, and whose rule it is.
567
+ for (const d of ev.denials.slice(0, 4)) {
568
+ out = { ...out, transcript: [...out.transcript, { kind: "note", text: `refused ${d.length > 72 ? `${d.slice(0, 71)}…` : d}` }] };
569
+ }
570
+ if (ev.denials.length > 0) {
571
+ const more = ev.denials.length > 4 ? ` (and ${ev.denials.length - 4} more)` : "";
572
+ out = { ...out, transcript: [...out.transcript, { kind: "note", text: `The agent runs only what its own settings allow${more} — add a rule there, or flags in DOCTRINA_AGENT_ARGS.` }] };
573
+ }
574
+ const summary = [turns, secs].filter(Boolean).join(" · ");
575
+ return { ...out, lastTurn: { ok: ev.ok, detail: summary }, transcript: [...out.transcript, ev.ok ? { kind: "ok", text: summary } : { kind: "fail", text: [ev.error, summary].filter(Boolean).join(" · ") }] };
576
+ }
577
+ default:
578
+ return s;
579
+ }
580
+ }
581
+
582
+ /** The agent's process ended; a turn that reported no result is closed here. */
583
+ export function agentExit(s, code, signal, stderr) {
584
+ if (!s.busy) return s;
585
+ const secs = `${((Date.now() - s.busy.since) / 1000).toFixed(1)}s`;
586
+ /** @type {State} */
587
+ let out = { ...s, busy: null, notice: "", lastTurn: { ok: code === 0 && !signal, detail: signal ? `interrupted · ${secs}` : (code === 0 ? secs : `exited ${code} · ${secs}`) } };
588
+ if (signal) return { ...out, transcript: [...out.transcript, { kind: "fail", text: `interrupted · ${secs}` }] };
589
+ if (code !== 0) {
590
+ for (const l of String(stderr).split("\n").map((x) => x.trim()).filter(Boolean).slice(-4)) {
591
+ out = { ...out, transcript: [...out.transcript, { kind: "err", text: l }] };
592
+ }
593
+ return { ...out, transcript: [...out.transcript, { kind: "fail", text: `${s.busy.label} exited ${code} · ${secs}` }] };
594
+ }
595
+ return { ...out, transcript: [...out.transcript, { kind: "ok", text: secs }] };
596
+ }
597
+
598
+ // ---------------------------------------------------------------------------
599
+ // Keys
600
+ // ---------------------------------------------------------------------------
601
+
602
+ const CTRL = /** @type {Record<string, Key["name"]>} */ ({
603
+ "\x01": "home", "\x03": "ctrl-c", "\x04": "ctrl-d", "\x05": "end", "\x0b": "ctrl-k", "\x0c": "ctrl-l",
604
+ "\x12": "ctrl-r", "\x15": "ctrl-u", "\x17": "ctrl-w", "\x7f": "backspace", "\b": "backspace", "\t": "tab", "\r": "enter", "\n": "enter",
605
+ });
606
+ const CSI = /** @type {Record<string, Key["name"]>} */ ({
607
+ A: "up", B: "down", C: "right", D: "left", H: "home", F: "end", Z: "shift-tab",
608
+ "1~": "home", "7~": "home", "4~": "end", "8~": "end", "3~": "delete", "5~": "pageup", "6~": "pagedown",
609
+ });
610
+ const PASTE_END = "\x1b[201~";
611
+
612
+ /**
613
+ * What a chunk of terminal input means. `rest` is an unfinished sequence to
614
+ * prepend to the next chunk.
615
+ * @param {string} data
616
+ * @returns {{ keys: Key[], rest: string }}
617
+ */
618
+ export function parseKeys(data) {
619
+ /** @type {Key[]} */
620
+ const keys = [];
621
+ let i = 0;
622
+ let run = "";
623
+ const flush = () => {
624
+ if (run !== "") keys.push({ name: "char", text: run });
625
+ run = "";
626
+ };
627
+ while (i < data.length) {
628
+ const ch = data[i];
629
+ if (ch === "\x1b") {
630
+ flush();
631
+ if (data.startsWith("\x1b[200~", i)) {
632
+ const end = data.indexOf(PASTE_END, i);
633
+ if (end < 0) return { keys, rest: data.slice(i) };
634
+ keys.push({ name: "paste", text: data.slice(i + 6, end) });
635
+ i = end + PASTE_END.length;
636
+ continue;
637
+ }
638
+ const next = data[i + 1];
639
+ if (next === "[" || next === "O") {
640
+ // CSI: parameters, then one final byte.
641
+ let j = i + 2;
642
+ while (j < data.length && !/[@-~]/.test(data[j])) j++;
643
+ if (j >= data.length) return { keys, rest: data.slice(i) };
644
+ const body = data.slice(i + 2, j + 1).replace(/^1;\d/, "");
645
+ const name = CSI[body];
646
+ if (name) keys.push(/** @type {Key} */ ({ name }));
647
+ i = j + 1;
648
+ continue;
649
+ }
650
+ if (next === "\x7f" || next === "\b") {
651
+ keys.push({ name: "ctrl-w" });
652
+ i += 2;
653
+ continue;
654
+ }
655
+ keys.push({ name: "escape" });
656
+ i += next === undefined || next === "\x1b" ? 1 : 2;
657
+ continue;
658
+ }
659
+ const ctrl = CTRL[ch];
660
+ if (ctrl) {
661
+ flush();
662
+ keys.push(/** @type {Key} */ ({ name: ctrl }));
663
+ } else if (ch >= " ") run += ch;
664
+ i++;
665
+ }
666
+ flush();
667
+ return { keys, rest: "" };
668
+ }