agent-lyceum 0.4.9
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/LICENSE +21 -0
- package/README.md +132 -0
- package/README.zh-TW.md +133 -0
- package/dist/adapters/claude.d.ts +14 -0
- package/dist/adapters/claude.js +155 -0
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +109 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +20 -0
- package/dist/adapters/types.d.ts +61 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +471 -0
- package/dist/config.d.ts +115 -0
- package/dist/config.js +253 -0
- package/dist/dispatcher.d.ts +37 -0
- package/dist/dispatcher.js +467 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +151 -0
- package/dist/format.d.ts +33 -0
- package/dist/format.js +100 -0
- package/dist/fs-util.d.ts +2 -0
- package/dist/fs-util.js +9 -0
- package/dist/guard.d.ts +20 -0
- package/dist/guard.js +72 -0
- package/dist/mailbox.d.ts +97 -0
- package/dist/mailbox.js +259 -0
- package/dist/message-store.d.ts +91 -0
- package/dist/message-store.js +225 -0
- package/dist/paths.d.ts +30 -0
- package/dist/paths.js +62 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +143 -0
- package/dist/process-runner.d.ts +21 -0
- package/dist/process-runner.js +165 -0
- package/dist/project-lock.d.ts +30 -0
- package/dist/project-lock.js +186 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +125 -0
- package/dist/run-cleanup.d.ts +51 -0
- package/dist/run-cleanup.js +120 -0
- package/dist/run-store.d.ts +111 -0
- package/dist/run-store.js +119 -0
- package/dist/scaffold.d.ts +11 -0
- package/dist/scaffold.js +126 -0
- package/dist/schema.d.ts +303 -0
- package/dist/schema.js +84 -0
- package/dist/status.d.ts +142 -0
- package/dist/status.js +399 -0
- package/dist/task.d.ts +24 -0
- package/dist/task.js +71 -0
- package/dist/validate.d.ts +13 -0
- package/dist/validate.js +116 -0
- package/dist/worktree.d.ts +103 -0
- package/dist/worktree.js +395 -0
- package/package.json +58 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { realInvoker } from "./adapters/index.js";
|
|
4
|
+
import { briefOf, doneContract, parseSteps } from "./format.js";
|
|
5
|
+
import { ProtectedGuard } from "./guard.js";
|
|
6
|
+
import { deliver, deliverOnce, ensureProjectDirs, finishDone, listUnread, rejectDone, routeOutboxes } from "./mailbox.js";
|
|
7
|
+
import { abandonClaim, attemptLogDir, sourceIdOf, beginAttempt, claimMessages, commitClaim, finishAttempt, markOutputReady, parkOutbox, recoverRunMail, RouteJournal } from "./message-store.js";
|
|
8
|
+
import { isInside } from "./paths.js";
|
|
9
|
+
import { outboxDir, ownsDirs } from "./policy.js";
|
|
10
|
+
import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
11
|
+
import { atomicWrite } from "./fs-util.js";
|
|
12
|
+
import { assertWorktreeRunnable, collectAgentChanges, existingAgentWorkspace, integrateAgentChanges, prepareAgentWorkspace, resolveWorkspaceMode, snapshotBase } from "./worktree.js";
|
|
13
|
+
import { bindRunProject, newRunId, newRunState, saveRunState } from "./run-store.js";
|
|
14
|
+
import { taskMessageBody } from "./task.js";
|
|
15
|
+
export { newRunId };
|
|
16
|
+
export const RESULT_FILE = "result.md";
|
|
17
|
+
/** How many times a non-conforming `done` goes back to the lead before it is accepted as `partial`. */
|
|
18
|
+
export const MAX_DONE_REJECTIONS = 2;
|
|
19
|
+
/** Prompt for a repeat attempt: the earlier one failed and may have changed things already. */
|
|
20
|
+
function retryPrompt(prompt, attempt, error) {
|
|
21
|
+
return `${prompt}\n\n# Retry notice\nYour previous attempt at this wake-up failed${error ? ` (${error})` : ""} after possibly changing files already. Its mail was discarded. Check the current state of the repository before redoing the work, and send the mail again.`;
|
|
22
|
+
}
|
|
23
|
+
export async function runTeam(opts) {
|
|
24
|
+
const { task, resume, runDir } = opts;
|
|
25
|
+
if (!task && !resume)
|
|
26
|
+
throw new Error("runTeam needs a task or a run to resume");
|
|
27
|
+
const invoke = opts.invoker ?? realInvoker;
|
|
28
|
+
const say = opts.log ?? ((s) => console.log(s));
|
|
29
|
+
const cfg = opts.project.dispatcher;
|
|
30
|
+
const runId = path.basename(runDir);
|
|
31
|
+
const layout = resume ? resume.mail_layout : "run";
|
|
32
|
+
// Each run gets its own memory directory per agent and (new runs) its own mailboxes.
|
|
33
|
+
const project = bindRunProject(opts.project, runDir, layout);
|
|
34
|
+
// Parallel agents work in their own git worktrees; refuse early (before anything is written) when that cannot be done.
|
|
35
|
+
const wsMode = resolveWorkspaceMode(cfg);
|
|
36
|
+
if (wsMode === "worktree")
|
|
37
|
+
assertWorktreeRunnable(project, { fresh: !resume });
|
|
38
|
+
ensureProjectDirs(project);
|
|
39
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
40
|
+
const logFile = path.join(runDir, "log.jsonl");
|
|
41
|
+
const log = (event, data = {}) => fs.appendFileSync(logFile, JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + "\n");
|
|
42
|
+
let state;
|
|
43
|
+
if (resume) {
|
|
44
|
+
state = { ...resume, max_rounds: cfg.max_rounds, pid: process.pid, ended_at: undefined, end_reason: undefined, workspace_mode: wsMode, outcome: undefined, outcome_note: undefined, verification: undefined, active: {}, wakes: [...resume.wakes] };
|
|
45
|
+
// A wake that was running when the process died never finished; keep it in the history as interrupted.
|
|
46
|
+
for (const [agent, a] of Object.entries(resume.active ?? {})) {
|
|
47
|
+
state.wakes.push({
|
|
48
|
+
round: a.round,
|
|
49
|
+
agent,
|
|
50
|
+
at: new Date().toISOString(),
|
|
51
|
+
duration_ms: Date.now() - Date.parse(a.since),
|
|
52
|
+
ok: false,
|
|
53
|
+
handling: a.handling,
|
|
54
|
+
error: "interrupted",
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
const t = taskMessageBody(task);
|
|
60
|
+
state = newRunState({
|
|
61
|
+
run_id: runId,
|
|
62
|
+
project: project.name,
|
|
63
|
+
task_source: task.source,
|
|
64
|
+
task_path: task.sourcePath,
|
|
65
|
+
started_at: new Date().toISOString(),
|
|
66
|
+
rounds: 0,
|
|
67
|
+
max_rounds: cfg.max_rounds,
|
|
68
|
+
pid: process.pid,
|
|
69
|
+
task_summary: t.subject,
|
|
70
|
+
mail_layout: layout,
|
|
71
|
+
workspace_mode: wsMode,
|
|
72
|
+
});
|
|
73
|
+
const seed = parseSteps(task.content, false);
|
|
74
|
+
if (seed.length)
|
|
75
|
+
state.steps = seed;
|
|
76
|
+
}
|
|
77
|
+
const saveState = () => saveRunState(runDir, state);
|
|
78
|
+
const guard = new ProtectedGuard(project);
|
|
79
|
+
if (resume) {
|
|
80
|
+
log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const t = taskMessageBody(task);
|
|
84
|
+
guard.saveSnapshot(path.join(runDir, "snapshots"));
|
|
85
|
+
deliver(project, { from: "user", to: project.lead, type: "task", subject: t.subject, body: t.body });
|
|
86
|
+
log("start", { task_source: task.source, bytes: task.bytes, inline: task.inline });
|
|
87
|
+
}
|
|
88
|
+
saveState();
|
|
89
|
+
let endReason;
|
|
90
|
+
let doneMessage;
|
|
91
|
+
/** The lead's done file; it leaves the outbox only after the result and end state are stored. */
|
|
92
|
+
let pendingDoneFile;
|
|
93
|
+
let doneOutcome;
|
|
94
|
+
let doneNote;
|
|
95
|
+
let doneVerification;
|
|
96
|
+
/** Inbox mail each agent in the current batch is working on; it is marked read only after its output is routed. */
|
|
97
|
+
const claims = new Map();
|
|
98
|
+
const journal = () => new RouteJournal(runDir);
|
|
99
|
+
const noSessionNoted = new Set();
|
|
100
|
+
/** Git worktrees of the non-lead agents in the batch being woken (empty in shared mode and for the lead). */
|
|
101
|
+
let batchSpaces = {};
|
|
102
|
+
/** The attempt that produced each batch member's output; committed together with its claim. */
|
|
103
|
+
const attempts = new Map();
|
|
104
|
+
const note = (text) => {
|
|
105
|
+
(state.notes ??= []).push(text);
|
|
106
|
+
log("note", { text });
|
|
107
|
+
say(` ${text}`);
|
|
108
|
+
};
|
|
109
|
+
const pendingAgents = () => {
|
|
110
|
+
const rows = Object.values(project.agents)
|
|
111
|
+
.map((a) => ({ a, unread: listUnread(project, a.name) }))
|
|
112
|
+
.filter((r) => r.unread.length > 0)
|
|
113
|
+
.map((r) => ({ a: r.a, first: path.basename(r.unread[0].file) }));
|
|
114
|
+
rows.sort((x, y) => x.first.localeCompare(y.first));
|
|
115
|
+
return rows.map((r) => r.a);
|
|
116
|
+
};
|
|
117
|
+
const pickBatch = (pending) => {
|
|
118
|
+
const lead = pending.find((a) => a.name === project.lead);
|
|
119
|
+
if (lead || cfg.max_parallel <= 1)
|
|
120
|
+
return [lead ?? pending[0]];
|
|
121
|
+
const batch = [];
|
|
122
|
+
const taken = [];
|
|
123
|
+
for (const a of pending) {
|
|
124
|
+
if (batch.length >= cfg.max_parallel)
|
|
125
|
+
break;
|
|
126
|
+
const mine = ownsDirs(project, a);
|
|
127
|
+
const clash = mine.some((x) => taken.some((y) => isInside(x, y) || isInside(y, x)));
|
|
128
|
+
if (clash || mine.length === 0) {
|
|
129
|
+
if (batch.length === 0)
|
|
130
|
+
batch.push(a);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
batch.push(a);
|
|
134
|
+
taken.push(...mine);
|
|
135
|
+
}
|
|
136
|
+
return batch;
|
|
137
|
+
};
|
|
138
|
+
const wake = async (agent) => {
|
|
139
|
+
const queue = listUnread(project, agent.name);
|
|
140
|
+
if (queue.length === 0)
|
|
141
|
+
return { ok: true };
|
|
142
|
+
// Oldest message per wake-up (the lead takes a run of replies at once); the rest stay unread for later wake-ups.
|
|
143
|
+
const unread = pickMessages(project, agent, queue);
|
|
144
|
+
const claim = claimMessages(runDir, agent.name, unread);
|
|
145
|
+
claims.set(agent.name, claim);
|
|
146
|
+
const workDir = path.join(runDir, "agents", agent.name);
|
|
147
|
+
const aproject = Object.keys(batchSpaces).length ? { ...project, workspaces: batchSpaces } : project;
|
|
148
|
+
const base = {
|
|
149
|
+
project: aproject,
|
|
150
|
+
agent,
|
|
151
|
+
workDir,
|
|
152
|
+
workspace: batchSpaces[agent.name],
|
|
153
|
+
systemPrompt: buildSystemPrompt(aproject, agent),
|
|
154
|
+
userPrompt: buildUserPrompt(project, agent, queue, unread.length),
|
|
155
|
+
timeoutSec: cfg.wake_timeout_sec,
|
|
156
|
+
};
|
|
157
|
+
let result;
|
|
158
|
+
for (let attempt = 0; attempt <= cfg.retry; attempt++) {
|
|
159
|
+
if (state.rounds >= cfg.max_rounds) {
|
|
160
|
+
result = { ok: false, text: "", exitCode: null, timedOut: false, error: "round limit reached" };
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
const round = ++state.rounds;
|
|
164
|
+
const started = Date.now();
|
|
165
|
+
const att = beginAttempt(runDir, claim.id);
|
|
166
|
+
say(`[round ${state.rounds}/${cfg.max_rounds}] waking ${agent.name} (${agent.runtime})${attempt ? ` — retry ${attempt}` : ""}`);
|
|
167
|
+
const sessionId = agent.resume ? state.sessions[agent.name] : undefined;
|
|
168
|
+
const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body) }));
|
|
169
|
+
state.active[agent.name] = { round, since: new Date(started).toISOString(), handling };
|
|
170
|
+
saveState();
|
|
171
|
+
result = await invoke({ ...base, signal: opts.signal, logDir: attemptLogDir(runDir, att.id), userPrompt: attempt ? retryPrompt(base.userPrompt, attempt, result?.error) : base.userPrompt, sessionId }).catch((e) => ({
|
|
172
|
+
ok: false,
|
|
173
|
+
text: "",
|
|
174
|
+
exitCode: null,
|
|
175
|
+
timedOut: false,
|
|
176
|
+
error: e.message,
|
|
177
|
+
}));
|
|
178
|
+
if (result.sessionId && agent.resume)
|
|
179
|
+
state.sessions[agent.name] = result.sessionId;
|
|
180
|
+
if (result.outputTokens)
|
|
181
|
+
state.output_tokens += result.outputTokens;
|
|
182
|
+
delete state.active[agent.name];
|
|
183
|
+
state.wakes.push({
|
|
184
|
+
round,
|
|
185
|
+
agent: agent.name,
|
|
186
|
+
at: new Date().toISOString(),
|
|
187
|
+
duration_ms: Date.now() - started,
|
|
188
|
+
ok: result.ok,
|
|
189
|
+
output_tokens: result.outputTokens,
|
|
190
|
+
handling,
|
|
191
|
+
error: result.error,
|
|
192
|
+
});
|
|
193
|
+
state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error };
|
|
194
|
+
log("wake", {
|
|
195
|
+
agent: agent.name,
|
|
196
|
+
runtime: agent.runtime,
|
|
197
|
+
attempt,
|
|
198
|
+
ok: result.ok,
|
|
199
|
+
exit_code: result.exitCode,
|
|
200
|
+
timed_out: result.timedOut,
|
|
201
|
+
duration_ms: Date.now() - started,
|
|
202
|
+
messages: unread.map((m) => m.meta.id),
|
|
203
|
+
error: result.error,
|
|
204
|
+
});
|
|
205
|
+
saveState();
|
|
206
|
+
if (result.ok) {
|
|
207
|
+
attempts.set(agent.name, finishAttempt(runDir, att.id, "output_ready"));
|
|
208
|
+
if (agent.resume && !result.sessionId && !state.sessions[agent.name] && !noSessionNoted.has(agent.name)) {
|
|
209
|
+
noSessionNoted.add(agent.name);
|
|
210
|
+
note(`${agent.name} has resume: true but its ${agent.runtime} CLI reported no session id, so every wake-up starts a fresh session.`);
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
if (result.cancelled) {
|
|
215
|
+
// Stopped on purpose: not a failure to report. Its output is set aside and its input stays unread for the resume.
|
|
216
|
+
const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
|
|
217
|
+
finishAttempt(runDir, att.id, "failed", { error: "cancelled", parked });
|
|
218
|
+
abandonClaim(runDir, claim.id);
|
|
219
|
+
claims.delete(agent.name);
|
|
220
|
+
say(` ${agent.name} stopped (cancelled)`);
|
|
221
|
+
return { ok: false, cancelled: true };
|
|
222
|
+
}
|
|
223
|
+
// A failed attempt's output is never trusted: park it under the attempt, away from the next attempt's result.
|
|
224
|
+
const parked = parkOutbox(runDir, outboxDir(project, agent.name), att.id);
|
|
225
|
+
finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
|
|
226
|
+
say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
|
|
227
|
+
note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
|
|
228
|
+
`Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
|
|
229
|
+
}
|
|
230
|
+
if (result?.ok)
|
|
231
|
+
markOutputReady(runDir, claim.id);
|
|
232
|
+
if (!result?.ok) {
|
|
233
|
+
if (agent.name === project.lead)
|
|
234
|
+
return { ok: false };
|
|
235
|
+
deliverOnce(project, journal(), `wakefail:${claim.id}`, {
|
|
236
|
+
from: "dispatcher",
|
|
237
|
+
to: project.lead,
|
|
238
|
+
type: "failure",
|
|
239
|
+
subject: `${agent.name} failed`,
|
|
240
|
+
body: `Agent "${agent.name}" could not handle message(s) "${unread.map((m) => m.meta.subject).join('", "')}".\n\nError: ${result?.error ?? "unknown"}\n\nDecide whether to retry, reassign, or adjust the plan.`,
|
|
241
|
+
});
|
|
242
|
+
return { ok: false };
|
|
243
|
+
}
|
|
244
|
+
return { ok: true };
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
|
|
248
|
+
* sees them. When that cannot be done, nothing is applied, the member's work stays on its branch, and the lead is told.
|
|
249
|
+
*/
|
|
250
|
+
const integrateMember = (agent, ws, key) => {
|
|
251
|
+
const changes = collectAgentChanges(ws, agent.owns);
|
|
252
|
+
const r = integrateAgentChanges(project, changes);
|
|
253
|
+
log("integrate", { agent: agent.name, status: r.status, files: changes.files.map((f) => `${f.status} ${f.path}`), ...(r.status === "blocked" ? { reason: r.reason, report: r.report } : {}) });
|
|
254
|
+
const blocked = (state.blocked_integrations ?? []).filter((b) => b.agent !== agent.name);
|
|
255
|
+
if (r.status === "integrated") {
|
|
256
|
+
state.blocked_integrations = blocked;
|
|
257
|
+
if (r.files.length)
|
|
258
|
+
say(` ${agent.name}: ${r.files.length} changed file(s) brought into the repo`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
state.blocked_integrations = [...blocked, { agent: agent.name, branch: r.branch, reason: r.reason, report: r.report }];
|
|
262
|
+
say(` ${agent.name}: changes NOT brought into the repo (${r.reason.split("\n")[0]})`);
|
|
263
|
+
deliverOnce(project, journal(), `integration:${key}`, {
|
|
264
|
+
from: "dispatcher",
|
|
265
|
+
to: project.lead,
|
|
266
|
+
type: "failure",
|
|
267
|
+
subject: `${agent.name}'s changes could not be brought into the repo`,
|
|
268
|
+
body: `${agent.name} finished, but its changes could not be brought into the repo, and nothing was applied.\n\n${r.reason}\n\n` +
|
|
269
|
+
`Its work is kept: branch \`${r.branch}\`, worktree \`${r.workspace}\`, report \`${r.report}\`, patch \`${r.patch}\`.\n` +
|
|
270
|
+
`You can send ${agent.name} a task to redo or adjust it (it continues in the same worktree and the integration is retried after its next wake-up), or apply the patch yourself. ` +
|
|
271
|
+
`Do not report the job \`completed\` while this work is missing from the repo.`,
|
|
272
|
+
});
|
|
273
|
+
saveState();
|
|
274
|
+
};
|
|
275
|
+
/** After a batch (or, on resume, before the first one): revert guarded edits, route mail, detect done / lead failure. */
|
|
276
|
+
const settle = (batch, results) => {
|
|
277
|
+
const violations = guard.check(batch);
|
|
278
|
+
for (const v of violations) {
|
|
279
|
+
say(` protected file ${v.action}: ${v.file} (suspects: ${v.suspects.join(", ")})`);
|
|
280
|
+
log("violation", { ...v });
|
|
281
|
+
deliver(project, {
|
|
282
|
+
from: "dispatcher",
|
|
283
|
+
to: project.lead,
|
|
284
|
+
type: "failure",
|
|
285
|
+
subject: `Protected file ${v.action}`,
|
|
286
|
+
body: `\`${v.file}\` was changed without permission and has been ${v.action}. Agents active at the time: ${v.suspects.join(", ")}.`,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
batch.forEach((a, i) => {
|
|
290
|
+
const ws = batchSpaces[a.name];
|
|
291
|
+
if (ws && results[i]?.ok)
|
|
292
|
+
integrateMember(a, ws, claims.get(a.name)?.id ?? `${a.name}-${state.rounds}`);
|
|
293
|
+
});
|
|
294
|
+
const route = routeOutboxes(project);
|
|
295
|
+
// A done that breaks the completion contract goes back to the lead (a limited number of times).
|
|
296
|
+
let contract;
|
|
297
|
+
if (route.done) {
|
|
298
|
+
contract = doneContract(route.done.meta, route.done.body, state.steps);
|
|
299
|
+
if (contract.missing.length && (state.done_rejections ?? 0) < MAX_DONE_REJECTIONS) {
|
|
300
|
+
state.done_rejections = (state.done_rejections ?? 0) + 1;
|
|
301
|
+
const list = contract.missing.map((m) => `- ${m}`).join("\n");
|
|
302
|
+
deliverOnce(project, journal(), `done-reject:${sourceIdOf(route.done.from, route.done.file)}`, {
|
|
303
|
+
from: "dispatcher",
|
|
304
|
+
to: project.lead,
|
|
305
|
+
type: "failure",
|
|
306
|
+
subject: "Your done report was not accepted",
|
|
307
|
+
body: `The run was not ended: your \`done\` report does not meet the completion contract. Missing:\n${list}\n\nSend \`type: done\` again with the frontmatter line \`outcome: completed|partial|blocked|failed\` and the headings \`## Result\`, \`## Files\`, \`## Verification\`, \`## Not done\`. \`completed\` needs all four and every step ticked; if work remains, report \`partial\` or \`blocked\` (those need only \`## Result\` and \`## Not done\`) or \`failed\`, or send the task that continues the work. (Reminder ${state.done_rejections} of ${MAX_DONE_REJECTIONS}.)`,
|
|
308
|
+
});
|
|
309
|
+
rejectDone(route.done.file);
|
|
310
|
+
say(` done report sent back to ${project.lead}: missing ${contract.missing.join("; ")}`);
|
|
311
|
+
log("done-rejected", { missing: contract.missing, count: state.done_rejections });
|
|
312
|
+
route.done = undefined;
|
|
313
|
+
contract = undefined;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
for (const d of route.delivered)
|
|
317
|
+
log("route", d);
|
|
318
|
+
const handedOff = (from, t) => {
|
|
319
|
+
const w = [...state.wakes].reverse().find((x) => x.agent === from);
|
|
320
|
+
if (w)
|
|
321
|
+
(w.sent ??= []).push(t);
|
|
322
|
+
};
|
|
323
|
+
for (const d of route.delivered)
|
|
324
|
+
handedOff(d.from, { to: d.to, type: d.type, subject: d.subject });
|
|
325
|
+
if (route.done)
|
|
326
|
+
handedOff(route.done.from, { to: "(done)", type: "done", subject: route.done.subject });
|
|
327
|
+
if (route.steps)
|
|
328
|
+
state.steps = route.steps;
|
|
329
|
+
saveState();
|
|
330
|
+
// Output is routed: only now is the input these wake-ups handled consumed.
|
|
331
|
+
for (const a of batch) {
|
|
332
|
+
const c = claims.get(a.name);
|
|
333
|
+
if (!c)
|
|
334
|
+
continue;
|
|
335
|
+
commitClaim(runDir, c.id);
|
|
336
|
+
claims.delete(a.name);
|
|
337
|
+
const att = attempts.get(a.name);
|
|
338
|
+
if (att)
|
|
339
|
+
finishAttempt(runDir, att.id, "committed");
|
|
340
|
+
attempts.delete(a.name);
|
|
341
|
+
}
|
|
342
|
+
for (const w of route.warnings) {
|
|
343
|
+
say(` format warning: mail ${w.id} from ${w.from} lacks ${w.missing.join(", ")}`);
|
|
344
|
+
log("format-warning", w);
|
|
345
|
+
}
|
|
346
|
+
for (const r of route.rejected) {
|
|
347
|
+
say(` rejected mail from ${r.from}: ${r.reason}`);
|
|
348
|
+
log("rejected", r);
|
|
349
|
+
}
|
|
350
|
+
if (route.done) {
|
|
351
|
+
doneMessage = { subject: route.done.subject, body: route.done.body };
|
|
352
|
+
const ok = !contract || contract.missing.length === 0;
|
|
353
|
+
doneOutcome = ok ? contract.outcome : "partial";
|
|
354
|
+
doneVerification = contract?.verification;
|
|
355
|
+
doneNote = ok ? undefined : `Completion contract not met after ${state.done_rejections ?? 0} reminder(s): missing ${contract.missing.join("; ")}. Reported as partial; the lead's report is kept as written.`;
|
|
356
|
+
const unintegrated = state.blocked_integrations ?? [];
|
|
357
|
+
if (doneOutcome === "completed" && unintegrated.length) {
|
|
358
|
+
doneOutcome = "blocked";
|
|
359
|
+
doneNote = `Not completed: work of ${unintegrated.map((b) => `${b.agent} (branch ${b.branch})`).join(", ")} was never brought into the repo; see ${unintegrated.map((b) => b.report).join(", ")}.`;
|
|
360
|
+
}
|
|
361
|
+
atomicWrite(path.join(runDir, RESULT_FILE), `# ${route.done.subject}\n\n**Outcome:** ${doneOutcome}${doneNote ? ` (${doneNote})` : ""}\n\n${route.done.body.trimEnd()}\n`);
|
|
362
|
+
pendingDoneFile = route.done.file;
|
|
363
|
+
log("done", { subject: route.done.subject });
|
|
364
|
+
endReason = "done";
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const leadIdx = batch.findIndex((a) => a.name === project.lead);
|
|
368
|
+
if (leadIdx >= 0 && !results[leadIdx].ok && !results[leadIdx].cancelled) {
|
|
369
|
+
endReason = "lead_failed";
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
if (resume) {
|
|
374
|
+
const rec = recoverRunMail(runDir, { outboxOf: (agent) => outboxDir(project, agent) });
|
|
375
|
+
for (const i of rec.interrupted) {
|
|
376
|
+
note(`${i.attempt.agent} attempt ${i.attempt.n} was interrupted${i.parked.length ? `; ${i.parked.length} output file(s) set aside in ${path.join("mail", "attempts", i.attempt.id, "outbox")}` : ""}. ` +
|
|
377
|
+
`Anything it already did outside its mailbox (file edits, commands) may have happened and can happen again when the message is redone: side effects may have happened.`);
|
|
378
|
+
}
|
|
379
|
+
if (rec.abandoned.length || rec.finalized.length || rec.ready.length) {
|
|
380
|
+
log("recover", { abandoned: rec.abandoned.map((c) => c.id), finalized: rec.finalized, ready: rec.ready.map((c) => c.id) });
|
|
381
|
+
}
|
|
382
|
+
// A member whose wake-up finished before the crash: bring its files in (repeating is harmless) before its mail is routed.
|
|
383
|
+
if (wsMode === "worktree") {
|
|
384
|
+
for (const c of rec.ready) {
|
|
385
|
+
const agent = project.agents[c.agent];
|
|
386
|
+
const ws = agent && agent.name !== project.lead ? existingAgentWorkspace(project, runId, agent.name) : undefined;
|
|
387
|
+
if (agent && ws)
|
|
388
|
+
integrateMember(agent, ws, c.id);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const c of rec.abandoned)
|
|
392
|
+
say(` ${c.agent}'s unfinished wake-up is redone: its ${c.message_ids.length} message(s) stay unread`);
|
|
393
|
+
settle([], []);
|
|
394
|
+
// Wake-ups that had finished before the crash: their output is routed now, so their input is consumed.
|
|
395
|
+
for (const c of rec.ready)
|
|
396
|
+
commitClaim(runDir, c.id);
|
|
397
|
+
}
|
|
398
|
+
// When the lead was the last one woken and left every mailbox empty without a `done`, remind it once instead of ending silently.
|
|
399
|
+
let leadWasLast = false;
|
|
400
|
+
let nudged = false;
|
|
401
|
+
while (!endReason) {
|
|
402
|
+
if (opts.signal?.aborted) {
|
|
403
|
+
endReason = "cancelled";
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
let pending = pendingAgents();
|
|
407
|
+
if (pending.length === 0 && leadWasLast && !nudged) {
|
|
408
|
+
nudged = true;
|
|
409
|
+
say(" lead left no mail and no done: reminding it to send one");
|
|
410
|
+
log("nudge", { to: project.lead });
|
|
411
|
+
deliverOnce(project, journal(), `nudge:${state.rounds}`, {
|
|
412
|
+
from: "dispatcher",
|
|
413
|
+
to: project.lead,
|
|
414
|
+
type: "failure",
|
|
415
|
+
subject: "No done message sent",
|
|
416
|
+
body: `All mailboxes are empty and you have not sent a \`done\` message, so the run would end without a final report. If the job is finished and verified, send \`type: done\` now (with \`outcome: completed|partial|blocked|failed\` in the frontmatter and \`## Result\`, \`## Files\`, \`## Verification\`, \`## Not done\`${state.steps ? " and the full `## Steps` checklist" : ""}). If work remains, send the \`task\` that continues it. Writing a summary in your reply text does not count: only mail is delivered.`,
|
|
417
|
+
});
|
|
418
|
+
pending = pendingAgents();
|
|
419
|
+
}
|
|
420
|
+
if (pending.length === 0) {
|
|
421
|
+
endReason = "idle";
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
if (state.rounds >= cfg.max_rounds) {
|
|
425
|
+
endReason = "max_rounds";
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
const batch = pickBatch(pending);
|
|
429
|
+
batchSpaces = {};
|
|
430
|
+
const members = batch.filter((a) => a.name !== project.lead);
|
|
431
|
+
if (wsMode === "worktree" && members.length) {
|
|
432
|
+
state.snapshots = (state.snapshots ?? 0) + 1;
|
|
433
|
+
saveState();
|
|
434
|
+
const ref = snapshotBase(project, runId, state.snapshots);
|
|
435
|
+
for (const a of members)
|
|
436
|
+
batchSpaces[a.name] = prepareAgentWorkspace(project, runId, a.name, ref);
|
|
437
|
+
log("workspaces", { snapshot: ref, agents: Object.fromEntries(Object.entries(batchSpaces).map(([n, w]) => [n, w.dir])) });
|
|
438
|
+
}
|
|
439
|
+
const results = await Promise.all(batch.map((a) => wake(a)));
|
|
440
|
+
leadWasLast = batch.some((a) => a.name === project.lead);
|
|
441
|
+
if (batch.some((a) => a.name !== project.lead))
|
|
442
|
+
nudged = false;
|
|
443
|
+
settle(batch, results);
|
|
444
|
+
if (!endReason && opts.signal?.aborted)
|
|
445
|
+
endReason = "cancelled";
|
|
446
|
+
}
|
|
447
|
+
const outcome = endReason === "done" ? doneOutcome ?? "partial" : endReason === "lead_failed" ? "failed" : endReason === "cancelled" ? "cancelled" : "partial";
|
|
448
|
+
const outcomeNote = endReason === "done"
|
|
449
|
+
? doneNote
|
|
450
|
+
: endReason === "idle"
|
|
451
|
+
? "All mailboxes were empty but the lead never sent a done message."
|
|
452
|
+
: endReason === "max_rounds"
|
|
453
|
+
? "Stopped at the round limit before the lead sent done."
|
|
454
|
+
: endReason === "cancelled"
|
|
455
|
+
? "Cancelled before the lead finished. Unread mail was kept; continue with `agent-lyceum resume`."
|
|
456
|
+
: "The lead's wake-up failed.";
|
|
457
|
+
state.end_reason = endReason;
|
|
458
|
+
state.outcome = outcome;
|
|
459
|
+
state.outcome_note = outcomeNote;
|
|
460
|
+
state.verification = doneVerification;
|
|
461
|
+
state.ended_at = new Date().toISOString();
|
|
462
|
+
log("end", { reason: endReason, outcome, rounds: state.rounds });
|
|
463
|
+
saveState();
|
|
464
|
+
if (pendingDoneFile)
|
|
465
|
+
finishDone(pendingDoneFile);
|
|
466
|
+
return { runId, runDir, rounds: state.rounds, endReason, outcome, outcomeNote, verification: doneVerification, doneMessage };
|
|
467
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ResolvedProject } from "./config.js";
|
|
2
|
+
import type { Runtime } from "./schema.js";
|
|
3
|
+
export type Cap = "yes" | "no" | "unknown";
|
|
4
|
+
export interface RuntimeCapabilities {
|
|
5
|
+
runtime: Runtime;
|
|
6
|
+
binary: "found" | "missing";
|
|
7
|
+
/** First line of `<cli> --version`, when it could be read. */
|
|
8
|
+
version?: string;
|
|
9
|
+
/** Prints machine-readable (JSON) results. */
|
|
10
|
+
json: Cap;
|
|
11
|
+
/** Can continue an earlier session. */
|
|
12
|
+
resume: Cap;
|
|
13
|
+
/** Offers a sandbox / permission settings to scope writes. */
|
|
14
|
+
sandbox: Cap;
|
|
15
|
+
/** Accepts a reasoning-effort setting. */
|
|
16
|
+
effort: Cap;
|
|
17
|
+
/** What could not be determined, and why. */
|
|
18
|
+
notes: string[];
|
|
19
|
+
}
|
|
20
|
+
export interface ProbeOptions {
|
|
21
|
+
env?: NodeJS.ProcessEnv;
|
|
22
|
+
/** Per command; a CLI that does not answer in time leaves its capabilities `unknown`. Default 5000. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
/** What this machine's CLI for `runtime` can do, read from `--version` and `--help` only. Anything it does not say stays `unknown`. */
|
|
26
|
+
export declare function probeRuntime(runtime: Runtime, opts?: ProbeOptions): Promise<RuntimeCapabilities>;
|
|
27
|
+
export interface DoctorCheck {
|
|
28
|
+
level: "ok" | "info" | "warn" | "error";
|
|
29
|
+
subject: string;
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
export interface DoctorReport {
|
|
33
|
+
project: string;
|
|
34
|
+
ok: boolean;
|
|
35
|
+
checks: DoctorCheck[];
|
|
36
|
+
runtimes: RuntimeCapabilities[];
|
|
37
|
+
}
|
|
38
|
+
export declare function probeProjectRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<RuntimeCapabilities[]>;
|
|
39
|
+
/** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
|
|
40
|
+
export declare function preflightRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<{
|
|
41
|
+
errors: string[];
|
|
42
|
+
warnings: string[];
|
|
43
|
+
}>;
|
|
44
|
+
/** Everything `agent-lyceum doctor` reports. Reads configuration and asks the CLIs for `--version`/`--help`; never runs an agent. */
|
|
45
|
+
export declare function diagnoseProject(project: ResolvedProject, opts?: ProbeOptions): Promise<DoctorReport>;
|
|
46
|
+
export declare function formatDoctor(r: DoctorReport): string;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
|
|
5
|
+
import { runInvocation } from "./process-runner.js";
|
|
6
|
+
import { validateProject } from "./validate.js";
|
|
7
|
+
import { dirtyPaths, isGitRepo, resolveWorkspaceMode } from "./worktree.js";
|
|
8
|
+
const BINARY = { "claude-code": "claude", codex: "codex" };
|
|
9
|
+
/** Run a read-only CLI query (`--version`, `--help`): bounded in time and output, never an agent task. */
|
|
10
|
+
async function query(cmd, args, opts) {
|
|
11
|
+
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-lyceum-probe-"));
|
|
12
|
+
const inv = {
|
|
13
|
+
cmd,
|
|
14
|
+
args,
|
|
15
|
+
stdin: "",
|
|
16
|
+
cwd: os.tmpdir(),
|
|
17
|
+
env: opts.env ?? process.env,
|
|
18
|
+
parse: ({ stdout, stderr, code }) => ({ ok: code === 0, text: `${stdout}\n${stderr}`, error: code === 0 ? undefined : `exit ${code}` }),
|
|
19
|
+
};
|
|
20
|
+
try {
|
|
21
|
+
const r = await runInvocation(inv, { timeoutSec: (opts.timeoutMs ?? 5000) / 1000, logDir, killGraceMs: 200, settleWaitMs: 200 });
|
|
22
|
+
if (r.error?.includes("not found on PATH"))
|
|
23
|
+
return { state: "missing", out: "" };
|
|
24
|
+
if (r.timedOut)
|
|
25
|
+
return { state: "timeout", out: "" };
|
|
26
|
+
return { state: r.ok ? "ok" : "failed", out: r.text };
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
fs.rmSync(logDir, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const has = (help, re) => (re.test(help) ? "yes" : "no");
|
|
33
|
+
/** What this machine's CLI for `runtime` can do, read from `--version` and `--help` only. Anything it does not say stays `unknown`. */
|
|
34
|
+
export async function probeRuntime(runtime, opts = {}) {
|
|
35
|
+
const cmd = BINARY[runtime];
|
|
36
|
+
const caps = { runtime, binary: "found", json: "unknown", resume: "unknown", sandbox: "unknown", effort: "unknown", notes: [] };
|
|
37
|
+
const v = await query(cmd, ["--version"], opts);
|
|
38
|
+
if (v.state === "missing")
|
|
39
|
+
return { ...caps, binary: "missing", notes: [`${cmd} not found on PATH`] };
|
|
40
|
+
if (v.state === "ok")
|
|
41
|
+
caps.version = v.out.split("\n").map((l) => l.trim()).find(Boolean)?.slice(0, 100);
|
|
42
|
+
else
|
|
43
|
+
caps.notes.push(v.state === "timeout" ? `\`${cmd} --version\` timed out` : `\`${cmd} --version\` failed`);
|
|
44
|
+
const helpArgs = runtime === "codex" ? ["exec", "--help"] : ["--help"];
|
|
45
|
+
const h = await query(cmd, helpArgs, opts);
|
|
46
|
+
if (h.state !== "ok") {
|
|
47
|
+
caps.notes.push(h.state === "timeout" ? `\`${cmd} ${helpArgs.join(" ")}\` timed out` : `\`${cmd} ${helpArgs.join(" ")}\` failed`);
|
|
48
|
+
return caps;
|
|
49
|
+
}
|
|
50
|
+
if (runtime === "claude-code") {
|
|
51
|
+
caps.json = has(h.out, /--output-format\b/);
|
|
52
|
+
caps.resume = has(h.out, /--resume\b/);
|
|
53
|
+
caps.sandbox = has(h.out, /--settings\b/) === "yes" ? "yes" : "unknown";
|
|
54
|
+
caps.effort = has(h.out, /--effort\b/);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
caps.json = has(h.out, /--json\b/);
|
|
58
|
+
caps.sandbox = has(h.out, /--sandbox\b|\s-s,/);
|
|
59
|
+
caps.effort = has(h.out, /--config\b|\s-c,/) === "yes" ? "yes" : "unknown"; // effort is passed as `-c model_reasoning_effort=...`
|
|
60
|
+
const r = await query(cmd, ["exec", "resume", "--help"], opts);
|
|
61
|
+
if (r.state === "ok")
|
|
62
|
+
caps.resume = "yes";
|
|
63
|
+
else if (r.state === "failed")
|
|
64
|
+
caps.resume = "no";
|
|
65
|
+
else
|
|
66
|
+
caps.notes.push(`\`${cmd} exec resume --help\` timed out`);
|
|
67
|
+
}
|
|
68
|
+
return caps;
|
|
69
|
+
}
|
|
70
|
+
/** What the team needs from each runtime versus what its CLI offers: explicit "no" is an error, "unknown" a warning. */
|
|
71
|
+
function judgeRuntimes(project, caps) {
|
|
72
|
+
const checks = [];
|
|
73
|
+
for (const c of caps) {
|
|
74
|
+
const cmd = BINARY[c.runtime];
|
|
75
|
+
const users = Object.values(project.agents).filter((a) => a.runtime === c.runtime);
|
|
76
|
+
if (c.binary === "missing") {
|
|
77
|
+
checks.push({ level: "error", subject: c.runtime, message: `${cmd} not found on PATH (needed by ${users.map((a) => a.name).join(", ")}).` });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
checks.push({ level: "ok", subject: c.runtime, message: `${cmd} ${c.version ?? "(version unknown)"}` });
|
|
81
|
+
for (const n of c.notes)
|
|
82
|
+
checks.push({ level: "warn", subject: c.runtime, message: n });
|
|
83
|
+
const need = (cap, what, agents) => {
|
|
84
|
+
if (!agents.length)
|
|
85
|
+
return;
|
|
86
|
+
if (cap === "no")
|
|
87
|
+
checks.push({ level: "error", subject: c.runtime, message: `${agents.map((n) => `Agent "${n}"`).join(", ")}: the ${cmd} CLI does not support ${what}.` });
|
|
88
|
+
else if (cap === "unknown")
|
|
89
|
+
checks.push({ level: "warn", subject: c.runtime, message: `Whether the ${cmd} CLI supports ${what} is unknown (needed by ${agents.join(", ")}); agent-lyceum will try anyway.` });
|
|
90
|
+
};
|
|
91
|
+
need(c.json, "machine-readable (JSON) output, which every agent needs", users.map((a) => a.name));
|
|
92
|
+
need(c.resume, "resuming sessions (resume: true)", users.filter((a) => a.resume).map((a) => `${a.name}`));
|
|
93
|
+
need(c.effort, "a reasoning-effort setting (effort)", users.filter((a) => a.effort).map((a) => a.name));
|
|
94
|
+
}
|
|
95
|
+
return checks;
|
|
96
|
+
}
|
|
97
|
+
const runtimesUsed = (project) => [...new Set(Object.values(project.agents).map((a) => a.runtime).filter((r) => !!r))];
|
|
98
|
+
export async function probeProjectRuntimes(project, opts = {}) {
|
|
99
|
+
return Promise.all(runtimesUsed(project).map((r) => probeRuntime(r, opts)));
|
|
100
|
+
}
|
|
101
|
+
/** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
|
|
102
|
+
export async function preflightRuntimes(project, opts = {}) {
|
|
103
|
+
const checks = judgeRuntimes(project, await probeProjectRuntimes(project, opts));
|
|
104
|
+
return {
|
|
105
|
+
errors: checks.filter((c) => c.level === "error").map((c) => c.message),
|
|
106
|
+
warnings: checks.filter((c) => c.level === "warn").map((c) => c.message),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Everything `agent-lyceum doctor` reports. Reads configuration and asks the CLIs for `--version`/`--help`; never runs an agent. */
|
|
110
|
+
export async function diagnoseProject(project, opts = {}) {
|
|
111
|
+
const checks = [];
|
|
112
|
+
const v = validateProject(project);
|
|
113
|
+
for (const i of v.issues)
|
|
114
|
+
checks.push({ level: i.level === "error" ? "error" : "warn", subject: "config", message: i.message });
|
|
115
|
+
if (!v.issues.length)
|
|
116
|
+
checks.push({ level: "ok", subject: "config", message: "Configuration is valid." });
|
|
117
|
+
if (fs.existsSync(project.dir) && isGitRepo(project.dir)) {
|
|
118
|
+
if (resolveWorkspaceMode(project.dispatcher) === "worktree") {
|
|
119
|
+
const dirty = dirtyPaths(project.dir);
|
|
120
|
+
if (dirty.length)
|
|
121
|
+
checks.push({ level: "warn", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: a parallel run would refuse to start until they are committed or stashed.` });
|
|
122
|
+
else
|
|
123
|
+
checks.push({ level: "ok", subject: "git", message: "Repo is clean; parallel runs can start." });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const lock = inspectProjectLock(project.paths.root);
|
|
127
|
+
if (lock) {
|
|
128
|
+
const alive = lockHolderAlive(lock);
|
|
129
|
+
checks.push({
|
|
130
|
+
level: "warn",
|
|
131
|
+
subject: "lock",
|
|
132
|
+
message: alive ? `A run is active: ${lock.run_id} (pid ${lock.pid}).` : `A stale run lock is left by ${lock.run_id} (pid ${lock.pid} is gone); \`agent-lyceum unlock --force\` clears it.`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const runtimes = await probeProjectRuntimes(project, opts);
|
|
136
|
+
checks.push(...judgeRuntimes(project, runtimes));
|
|
137
|
+
checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
|
|
138
|
+
return { project: project.name, ok: !checks.some((c) => c.level === "error"), checks, runtimes };
|
|
139
|
+
}
|
|
140
|
+
export function formatDoctor(r) {
|
|
141
|
+
const mark = { ok: "ok ", info: "info ", warn: "warn ", error: "ERROR" };
|
|
142
|
+
const lines = [`Project: ${r.project}`, ""];
|
|
143
|
+
for (const c of r.checks)
|
|
144
|
+
lines.push(`${mark[c.level]} ${c.message}`);
|
|
145
|
+
lines.push("", "Runtime capabilities (yes / no / unknown):");
|
|
146
|
+
for (const c of r.runtimes) {
|
|
147
|
+
lines.push(` ${c.runtime}: ${c.binary === "missing" ? "not installed" : `${c.version ?? "version unknown"}; json ${c.json}, resume ${c.resume}, sandbox ${c.sandbox}, effort ${c.effort}`}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("", r.ok ? "No problems found." : "Problems found.");
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|