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.
- package/README.md +6 -6
- package/package.json +1 -1
- package/src/commands/change.js +111 -14
- package/src/commands/clarify.js +2 -2
- package/src/commands/close.js +1 -1
- package/src/commands/coverage.js +69 -6
- package/src/commands/doctor.js +62 -4
- package/src/commands/hooks.js +56 -11
- package/src/commands/index-rebuild.js +47 -1
- package/src/commands/init.js +51 -7
- package/src/commands/metrics.js +1 -1
- package/src/commands/onboard.js +113 -0
- package/src/commands/review.js +1 -1
- package/src/commands/search.js +2 -2
- package/src/commands/templates.js +2 -1
- package/src/commands/triage.js +16 -1
- package/src/commands/ui.js +512 -0
- package/src/commands/upgrade.js +46 -6
- package/src/commands/verify.js +3 -3
- package/src/commands/why.js +1 -1
- package/src/commands/work.js +53 -40
- package/src/commands/workspace.js +16 -3
- package/src/index.js +9 -0
- package/src/lib/actions.js +19 -0
- package/src/lib/adapters.js +23 -3
- package/src/lib/agent-bridge.js +400 -0
- package/src/lib/applied-deltas.js +82 -0
- package/src/lib/args.js +1 -1
- package/src/lib/change-numbers.js +115 -0
- package/src/lib/change-ops.js +2 -2
- package/src/lib/clarify-scan.js +3 -3
- package/src/lib/code-inventory.js +22 -5
- package/src/lib/commands.js +50 -4
- package/src/lib/config.js +27 -8
- package/src/lib/conflict-markers.js +61 -0
- package/src/lib/console-model.js +668 -0
- package/src/lib/console-prefs.js +67 -0
- package/src/lib/console-room.js +201 -0
- package/src/lib/console-state.js +150 -0
- package/src/lib/console-view.js +557 -0
- package/src/lib/coverage-model.js +29 -6
- package/src/lib/delivery.js +2 -2
- package/src/lib/docs-impact.js +1 -1
- package/src/lib/fs-ops.js +80 -12
- package/src/lib/gates.js +7 -1
- package/src/lib/git.js +18 -0
- package/src/lib/index-json.js +10 -1
- package/src/lib/index-merge.js +90 -0
- package/src/lib/install.js +80 -0
- package/src/lib/intake-model.js +4 -1
- package/src/lib/ledger.js +2 -3
- package/src/lib/lock.js +58 -0
- package/src/lib/merge-setup.js +55 -0
- package/src/lib/metrics-model.js +2 -2
- package/src/lib/onboard.js +197 -0
- package/src/lib/preset.js +177 -0
- package/src/lib/prompt.js +1 -1
- package/src/lib/properties.js +149 -0
- package/src/lib/runtime.js +19 -7
- package/src/lib/scan.js +13 -8
- package/src/lib/spec-ops.js +2 -2
- package/src/lib/task-card.js +1 -1
- package/src/lib/templates-model.js +3 -2
- package/src/lib/triage-model.js +75 -0
- package/src/lib/validation-model.js +23 -5
- package/src/lib/views.js +2 -1
- package/src/lib/workspace-agents.js +2 -2
- package/src/lib/workspace-tools.js +41 -1
- package/src/lib/workspace.js +1 -1
- package/templates/AGENTS.md.template +8 -4
- package/templates/README.md +2 -1
- package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
- package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
- 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
|
+
`;
|
package/src/commands/upgrade.js
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
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.
|
package/src/commands/verify.js
CHANGED
|
@@ -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
|
|
package/src/commands/why.js
CHANGED
|
@@ -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
|
|