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
package/dist/status.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RESULT_FILE } from "./dispatcher.js";
|
|
4
|
+
import { bindRunProject, loadRunState, outcomeOf } from "./run-store.js";
|
|
5
|
+
import { listUnread } from "./mailbox.js";
|
|
6
|
+
import { inspectProjectLock, lockHolderAlive } from "./project-lock.js";
|
|
7
|
+
export function latestRun(project) {
|
|
8
|
+
const root = project.paths.runs;
|
|
9
|
+
if (!fs.existsSync(root))
|
|
10
|
+
return undefined;
|
|
11
|
+
const dirs = fs
|
|
12
|
+
.readdirSync(root, { withFileTypes: true })
|
|
13
|
+
.filter((e) => e.isDirectory())
|
|
14
|
+
.map((e) => e.name)
|
|
15
|
+
.sort()
|
|
16
|
+
.reverse();
|
|
17
|
+
for (const d of dirs) {
|
|
18
|
+
if (!fs.existsSync(path.join(root, d, "state.json")))
|
|
19
|
+
continue;
|
|
20
|
+
try {
|
|
21
|
+
return { dir: path.join(root, d), state: loadRunState(path.join(root, d)) };
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* skip unreadable */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
/** Newest run that can still be resumed: not done and not currently running. */
|
|
30
|
+
export function latestUnfinishedRun(project) {
|
|
31
|
+
return listRuns(project, Infinity).find((r) => r.state.end_reason !== "done" && (r.state.end_reason || !runIsAlive(r.state, project)));
|
|
32
|
+
}
|
|
33
|
+
export function listRuns(project, limit) {
|
|
34
|
+
const root = project.paths.runs;
|
|
35
|
+
if (!fs.existsSync(root))
|
|
36
|
+
return [];
|
|
37
|
+
const out = [];
|
|
38
|
+
const dirs = fs
|
|
39
|
+
.readdirSync(root, { withFileTypes: true })
|
|
40
|
+
.filter((e) => e.isDirectory())
|
|
41
|
+
.map((e) => e.name)
|
|
42
|
+
.sort()
|
|
43
|
+
.reverse();
|
|
44
|
+
for (const d of dirs) {
|
|
45
|
+
if (out.length >= limit)
|
|
46
|
+
break;
|
|
47
|
+
try {
|
|
48
|
+
out.push({ dir: path.join(root, d), state: loadRunState(path.join(root, d)) });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* skip unreadable */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Is this run's dispatcher running now? Runs with a project lock (schema 2) are judged by the lock holder;
|
|
58
|
+
* the recorded pid is only the fallback for older runs and runs started without a lock.
|
|
59
|
+
*/
|
|
60
|
+
export function runIsAlive(s, project) {
|
|
61
|
+
if (project && s.schema_version >= 2) {
|
|
62
|
+
const l = inspectProjectLock(project.paths.root);
|
|
63
|
+
if (l)
|
|
64
|
+
return l.run_id === s.run_id && lockHolderAlive(l);
|
|
65
|
+
}
|
|
66
|
+
return alive(s.pid);
|
|
67
|
+
}
|
|
68
|
+
function alive(pid) {
|
|
69
|
+
if (!pid)
|
|
70
|
+
return false;
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
return e.code === "EPERM";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const OUTCOME_ZH = { completed: "完成", partial: "部分完成", blocked: "受阻", failed: "失敗", cancelled: "已取消" };
|
|
80
|
+
export function runStateLabel(s, live = alive(s.pid)) {
|
|
81
|
+
if (s.end_reason) {
|
|
82
|
+
const o = outcomeOf(s);
|
|
83
|
+
// Runs from before outcomes existed: keep what they recorded, but never present it as a checked success.
|
|
84
|
+
if (!o.verified)
|
|
85
|
+
return s.end_reason === "done" ? "已結束:完成(未驗證)" : `已結束:${s.end_reason}`;
|
|
86
|
+
return o.outcome === "completed" ? "已結束:完成" : `已結束:${OUTCOME_ZH[o.outcome]}${s.end_reason === "done" ? "" : `(${s.end_reason})`}`;
|
|
87
|
+
}
|
|
88
|
+
return live ? "執行中" : "已中斷";
|
|
89
|
+
}
|
|
90
|
+
export function paint(on) {
|
|
91
|
+
const wrap = (code) => (t) => (on ? `\x1b[${code}m${t}\x1b[0m` : t);
|
|
92
|
+
return { bold: wrap("1"), dim: wrap("2"), red: wrap("31"), green: wrap("32"), yellow: wrap("33"), cyan: wrap("36") };
|
|
93
|
+
}
|
|
94
|
+
/** Color a run-state label: running green, interrupted yellow, ended done green / otherwise red. */
|
|
95
|
+
function stateColor(c, label) {
|
|
96
|
+
if (label === "執行中" || label === "已結束:完成")
|
|
97
|
+
return c.green(label);
|
|
98
|
+
if (label === "已中斷" || label.startsWith("已結束:完成(未驗證") || label.startsWith("已結束:部分完成") || label.startsWith("已結束:受阻"))
|
|
99
|
+
return c.yellow(label);
|
|
100
|
+
return c.red(label);
|
|
101
|
+
}
|
|
102
|
+
const okText = (c, ok) => (ok ? c.green("成功") : c.red("失敗"));
|
|
103
|
+
/** Terminal columns: CJK characters take two. */
|
|
104
|
+
const cols = (t) => [...t].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) >= 0x2e80 ? 2 : 1), 0);
|
|
105
|
+
const padCols = (t, n) => t + " ".repeat(Math.max(0, n - cols(t)));
|
|
106
|
+
const clip = (t, n) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
|
|
107
|
+
const tokens = (n) => (n ? n.toLocaleString("en-US") : "0");
|
|
108
|
+
function since(iso, now) {
|
|
109
|
+
const sec = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
|
|
110
|
+
return sec < 60 ? `${sec}s` : `${Math.floor(sec / 60)}m${String(sec % 60).padStart(2, "0")}s`;
|
|
111
|
+
}
|
|
112
|
+
function handlingText(h) {
|
|
113
|
+
if (!h.length)
|
|
114
|
+
return "(無訊息)";
|
|
115
|
+
return h
|
|
116
|
+
.map((m) => `來自 ${m.from} 的 ${m.type}:「${clip(m.subject, 60)}」${m.brief ? ` — ${clip(m.brief, 80)}` : ""}`)
|
|
117
|
+
.join("; ");
|
|
118
|
+
}
|
|
119
|
+
const sentText = (sent) => sent?.length
|
|
120
|
+
? sent.map((m) => (m.to === "(done)" ? `完成:「${clip(m.subject, 50)}」` : `${m.to} ← ${m.type}「${clip(m.subject, 50)}」`)).join("; ")
|
|
121
|
+
: "未寄出信件";
|
|
122
|
+
export function progressBar(done, total, width = 10, c = paint(false)) {
|
|
123
|
+
const n = total ? Math.round((done / total) * width) : 0;
|
|
124
|
+
return c.green("█".repeat(n)) + c.dim("░".repeat(width - n));
|
|
125
|
+
}
|
|
126
|
+
function stepsLine(steps, c, finished = false) {
|
|
127
|
+
if (!steps?.length)
|
|
128
|
+
return undefined;
|
|
129
|
+
const cur = steps.find((x) => !x.done);
|
|
130
|
+
if (!cur)
|
|
131
|
+
return "全部完成";
|
|
132
|
+
if (finished)
|
|
133
|
+
return c.yellow(`⚠ lead 宣告完成,但步驟「${clip(cur.text, 60)}」尚未勾選`);
|
|
134
|
+
return `目前:${clip(cur.text, 60)}`;
|
|
135
|
+
}
|
|
136
|
+
/** Order the dispatcher will wake agents: the lead first, otherwise the oldest mail. */
|
|
137
|
+
function queueOf(project) {
|
|
138
|
+
const rows = Object.values(project.agents)
|
|
139
|
+
.map((a) => ({ agent: a.name, msg: listUnread(project, a.name)[0] }))
|
|
140
|
+
.filter((r) => !!r.msg);
|
|
141
|
+
rows.sort((x, y) => {
|
|
142
|
+
if ((x.agent === project.lead) !== (y.agent === project.lead))
|
|
143
|
+
return x.agent === project.lead ? -1 : 1;
|
|
144
|
+
return path.basename(x.msg.file).localeCompare(path.basename(y.msg.file));
|
|
145
|
+
});
|
|
146
|
+
return rows.map((r) => ({ agent: r.agent, from: r.msg.meta.from, type: r.msg.meta.type, subject: r.msg.meta.subject }));
|
|
147
|
+
}
|
|
148
|
+
/** One run as `status` sees it. Without `project`, liveness falls back to the recorded pid and there is no queue. */
|
|
149
|
+
export function buildRunReport(run, project, opts = {}) {
|
|
150
|
+
const s = run.state;
|
|
151
|
+
const detail = opts.detail ?? true;
|
|
152
|
+
const live = runIsAlive(s, project) && !s.end_reason;
|
|
153
|
+
const o = outcomeOf(s);
|
|
154
|
+
const bound = project ? bindRunProject(project, run.dir, s.mail_layout) : undefined;
|
|
155
|
+
const result = path.join(run.dir, RESULT_FILE);
|
|
156
|
+
const hasResult = detail && s.end_reason === "done" && fs.existsSync(result);
|
|
157
|
+
const cur = s.steps?.find((x) => !x.done);
|
|
158
|
+
return {
|
|
159
|
+
run_id: s.run_id,
|
|
160
|
+
dir: run.dir,
|
|
161
|
+
state: s.end_reason ? "ended" : live ? "running" : "interrupted",
|
|
162
|
+
live,
|
|
163
|
+
end_reason: s.end_reason,
|
|
164
|
+
outcome: o?.outcome,
|
|
165
|
+
outcome_verified: o?.verified ?? false,
|
|
166
|
+
outcome_note: s.outcome_note,
|
|
167
|
+
verification: s.verification,
|
|
168
|
+
started_at: s.started_at,
|
|
169
|
+
ended_at: s.ended_at,
|
|
170
|
+
rounds: s.rounds,
|
|
171
|
+
max_rounds: s.max_rounds,
|
|
172
|
+
output_tokens: s.output_tokens,
|
|
173
|
+
task_source: s.task_source,
|
|
174
|
+
task_path: s.task_path,
|
|
175
|
+
task_summary: s.task_summary,
|
|
176
|
+
steps: s.steps,
|
|
177
|
+
current_step: cur?.text ?? null,
|
|
178
|
+
active: s.active,
|
|
179
|
+
// mail still waiting is what `resume` will pick up, so it is shown for every run that did not finish with a done
|
|
180
|
+
queue: detail && bound && s.end_reason !== "done" ? queueOf(bound) : [],
|
|
181
|
+
wakes: s.wakes,
|
|
182
|
+
notes: s.notes ?? [],
|
|
183
|
+
blocked_integrations: s.blocked_integrations ?? [],
|
|
184
|
+
result_path: fs.existsSync(result) ? result : undefined,
|
|
185
|
+
result_head: hasResult ? fs.readFileSync(result, "utf8").split("\n").filter((l) => l.trim()).slice(0, 4) : [],
|
|
186
|
+
mail_layout: s.mail_layout,
|
|
187
|
+
workspace_mode: s.workspace_mode,
|
|
188
|
+
last_wake: s.last_wake,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
|
|
192
|
+
/** The status of a project: its agents and one run (the newest, or the one named by `runId`). */
|
|
193
|
+
export function buildStatusReport(base, runId) {
|
|
194
|
+
let run;
|
|
195
|
+
if (runId) {
|
|
196
|
+
const dir = path.join(base.paths.runs, runId);
|
|
197
|
+
if (!fs.existsSync(path.join(dir, "state.json")))
|
|
198
|
+
throw new Error(`Run "${runId}" not found in ${base.paths.runs}. List ids with: agent-lyceum status --task-list`);
|
|
199
|
+
run = { dir, state: loadRunState(dir) };
|
|
200
|
+
}
|
|
201
|
+
else
|
|
202
|
+
run = latestRun(base);
|
|
203
|
+
// Mail is per run: count the unread mail of the run being shown.
|
|
204
|
+
const project = run ? bindRunProject(base, run.dir, run.state.mail_layout) : base;
|
|
205
|
+
const report = run ? buildRunReport(run, base) : undefined;
|
|
206
|
+
return {
|
|
207
|
+
schema_version: 1,
|
|
208
|
+
project: projectInfo(base),
|
|
209
|
+
agents: Object.values(project.agents).map((a) => ({
|
|
210
|
+
name: a.name,
|
|
211
|
+
runtime: a.runtime,
|
|
212
|
+
lead: a.name === project.lead,
|
|
213
|
+
unread: listUnread(project, a.name).length,
|
|
214
|
+
last_wake: run?.state.last_wake[a.name],
|
|
215
|
+
})),
|
|
216
|
+
run: report,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/** Every run of the project, newest first. */
|
|
220
|
+
export function buildTaskListReport(project) {
|
|
221
|
+
return { schema_version: 1, project: projectInfo(project), runs: listRuns(project, Infinity).map((r) => buildRunReport(r, project, { detail: false })) };
|
|
222
|
+
}
|
|
223
|
+
// ---- text pages ----
|
|
224
|
+
/** Agents in wake order, newest last; the ones working now are bracketed. */
|
|
225
|
+
function flowLine(r, c) {
|
|
226
|
+
const names = (r.wakes ?? []).map((w) => w.agent);
|
|
227
|
+
const shown = names.slice(-7);
|
|
228
|
+
const parts = shown.map((n) => c.cyan(n));
|
|
229
|
+
const activeNames = r.live ? Object.keys(r.active ?? {}) : [];
|
|
230
|
+
const out = (names.length > shown.length ? "… → " : "") + parts.join(" → ");
|
|
231
|
+
if (!activeNames.length)
|
|
232
|
+
return out || "(尚無)";
|
|
233
|
+
return `${out}${out ? " → " : ""}${c.green(`[${activeNames.join(", ")}]`)}`;
|
|
234
|
+
}
|
|
235
|
+
/** The state label of a report: an outcome that was only inferred (old runs) is not shown as a checked result. */
|
|
236
|
+
const reportLabel = (r) => runStateLabel({ end_reason: r.end_reason, outcome: r.outcome_verified ? r.outcome : undefined }, r.live);
|
|
237
|
+
const mailText = (m) => `來自 ${m.from} 的 ${m.type}:「${clip(m.subject, 60)}」`;
|
|
238
|
+
const taskText = (s) => s.task_summary ?? (s.task_source === "file" ? `檔案 ${s.task_path}` : "文字");
|
|
239
|
+
/** The readable top block: what is happening, what is next, how far along the task is. */
|
|
240
|
+
function summaryBlock(run, now, c) {
|
|
241
|
+
const L = (label) => c.bold(c.cyan(label)) + " ".repeat(Math.max(1, 10 - cols(label)));
|
|
242
|
+
if (!run)
|
|
243
|
+
return [`${L("現在:")}${c.dim("閒置(尚無執行紀錄)")}`];
|
|
244
|
+
const s = run;
|
|
245
|
+
const live = s.live;
|
|
246
|
+
const lines = [
|
|
247
|
+
`${c.bold("執行 " + s.run_id)} [${stateColor(c, reportLabel(s))}] 第 ${s.rounds}/${s.max_rounds} 輪`,
|
|
248
|
+
`${L("任務:")}${taskText(s)}`,
|
|
249
|
+
];
|
|
250
|
+
const steps = stepsLine(s.steps, c, s.end_reason === "done");
|
|
251
|
+
lines.push(`${L("進度:")}${steps ?? `無清單;已用 ${s.rounds}/${s.max_rounds} 輪(lead 可在信中加入 "## Steps" 清單)`}`);
|
|
252
|
+
const active = Object.entries(s.active ?? {});
|
|
253
|
+
if (active.length) {
|
|
254
|
+
active.forEach(([agent, a], i) => lines.push(`${i === 0 ? L("現在:") : " "}${c.cyan(agent)} ${live ? c.green(`工作中 ${since(a.since, now)}`) : c.yellow("曾在工作(已中斷)")},處理 ${handlingText(a.handling)}`));
|
|
255
|
+
}
|
|
256
|
+
else
|
|
257
|
+
lines.push(`${L("現在:")}${c.dim(`閒置${s.end_reason ? `(執行已結束:${s.end_reason})` : live ? "" : "(執行未在運作)"}`)}`);
|
|
258
|
+
const queue = s.queue;
|
|
259
|
+
if (queue.length) {
|
|
260
|
+
queue.slice(0, 3).forEach((q, i) => lines.push(`${i === 0 ? L("下一個:") : " "}${c.cyan(q.agent)} ← ${mailText(q)}`));
|
|
261
|
+
if (queue.length > 3)
|
|
262
|
+
lines.push(c.dim(` … 還有 ${queue.length - 3} 筆`));
|
|
263
|
+
}
|
|
264
|
+
else if (active.length && live)
|
|
265
|
+
lines.push(`${L("下一個:")}${c.dim("等工作中的 agent 完成後,再轉送其信件")}`);
|
|
266
|
+
else
|
|
267
|
+
lines.push(`${L("下一個:")}${c.dim(s.end_reason === "done" ? "無 — lead 已完成任務" : "無待處理")}`);
|
|
268
|
+
if (s.end_reason === "done" && s.result_path && s.result_head.length) {
|
|
269
|
+
lines.push(`${L("結果:")}${s.result_path}`, ...s.result_head.map((l) => c.dim(" " + clip(l, 80))));
|
|
270
|
+
}
|
|
271
|
+
const lastWake = (s.wakes ?? []).at(-1);
|
|
272
|
+
if (lastWake)
|
|
273
|
+
lines.push(`${L("上一次:")}#${lastWake.round} ${c.cyan(lastWake.agent)} ${okText(c, lastWake.ok)} → ${sentText(lastWake.sent)}`);
|
|
274
|
+
lines.push(`${L("流程:")}${flowLine(s, c)}`);
|
|
275
|
+
return lines;
|
|
276
|
+
}
|
|
277
|
+
/** What a run is doing / did, one line per wake. */
|
|
278
|
+
function describeRun(s, now, c, limit = Infinity) {
|
|
279
|
+
const lines = [
|
|
280
|
+
`${c.bold("執行 " + s.run_id)} [${stateColor(c, reportLabel(s))}] 輪次 ${s.rounds}/${s.max_rounds} 輸出 tokens ${tokens(s.output_tokens)}`,
|
|
281
|
+
` 任務:${taskText(s)}`,
|
|
282
|
+
];
|
|
283
|
+
const steps = stepsLine(s.steps, c);
|
|
284
|
+
if (steps)
|
|
285
|
+
lines.push(` 進度:${steps}`);
|
|
286
|
+
if (s.outcome_note)
|
|
287
|
+
lines.push(` 結果說明:${clip(s.outcome_note, 200)}`);
|
|
288
|
+
if (s.verification)
|
|
289
|
+
lines.push(` 驗證:${clip(s.verification, 200)}`);
|
|
290
|
+
for (const n of s.notes ?? [])
|
|
291
|
+
lines.push(` ${c.yellow("⚠ " + clip(n, 200))}`);
|
|
292
|
+
const wakes = s.wakes ?? [];
|
|
293
|
+
if (wakes.length > limit)
|
|
294
|
+
lines.push(c.dim(` … 省略較早的 ${wakes.length - limit} 筆(不帶 --monitor 可看完整紀錄)`));
|
|
295
|
+
for (const w of limit < wakes.length ? wakes.slice(-limit) : wakes) {
|
|
296
|
+
lines.push(` #${w.round} ${c.cyan(w.agent)} ${okText(c, w.ok)} (${Math.round(w.duration_ms / 1000)}s, ${tokens(w.output_tokens)} tok) — ${handlingText(w.handling)}`, ` → ${sentText(w.sent)}`);
|
|
297
|
+
}
|
|
298
|
+
for (const [agent, a] of Object.entries(s.active ?? {})) {
|
|
299
|
+
lines.push(` #${a.round} ${c.cyan(agent)} ${c.green(`工作中 ${since(a.since, now)}`)} — ${handlingText(a.handling)}`);
|
|
300
|
+
}
|
|
301
|
+
return lines;
|
|
302
|
+
}
|
|
303
|
+
const MONITOR_WAKES = 3;
|
|
304
|
+
/** Live view: summary and agent table, plus the latest run and any run still going, wake by wake. */
|
|
305
|
+
export function formatMonitor(project, now = Date.now(), color = false) {
|
|
306
|
+
const c = paint(color);
|
|
307
|
+
const runs = listRuns(project, 50).filter((r, i) => i === 0 || (!r.state.end_reason && runIsAlive(r.state, project)));
|
|
308
|
+
const out = [formatStatus(project, now, color), "", c.bold("執行紀錄(最新/執行中):")];
|
|
309
|
+
if (runs.length === 0)
|
|
310
|
+
out.push(" 無");
|
|
311
|
+
for (const r of runs)
|
|
312
|
+
out.push("", ...describeRun(buildRunReport(r, project, { detail: false }), now, c, MONITOR_WAKES));
|
|
313
|
+
return out.join("\n");
|
|
314
|
+
}
|
|
315
|
+
/** Plain `status`: the status page followed by the full wake-by-wake record of the latest run. */
|
|
316
|
+
export function formatStatusWithLog(project, now = Date.now(), color = false) {
|
|
317
|
+
const c = paint(color);
|
|
318
|
+
const report = buildStatusReport(project);
|
|
319
|
+
const out = [formatStatusReport(report, now, color)];
|
|
320
|
+
if (report.run)
|
|
321
|
+
out.push("", c.bold("執行紀錄:"), ...describeRun(report.run, now, c, Infinity));
|
|
322
|
+
return out.join("\n");
|
|
323
|
+
}
|
|
324
|
+
/** One run in full: header, every wake, the final result, and where its logs live. */
|
|
325
|
+
export function formatRunDetail(run, now = Date.now(), color = false, project) {
|
|
326
|
+
return formatRunReport(buildRunReport(run, project, { detail: false }), now, color);
|
|
327
|
+
}
|
|
328
|
+
export function formatRunReport(r, now = Date.now(), color = false) {
|
|
329
|
+
const c = paint(color);
|
|
330
|
+
const out = [...describeRun(r, now, c, Infinity)];
|
|
331
|
+
if (r.result_path)
|
|
332
|
+
out.push("", c.bold("結果(" + r.result_path + "):"), fs.readFileSync(r.result_path, "utf8").trimEnd());
|
|
333
|
+
out.push("", c.dim(`目錄:${r.dir}`));
|
|
334
|
+
return out.join("\n");
|
|
335
|
+
}
|
|
336
|
+
/** Every run of the project, newest first: id, state, rounds, progress and task. */
|
|
337
|
+
export function formatTaskList(project, color = false) {
|
|
338
|
+
return formatTaskListReport(buildTaskListReport(project), color);
|
|
339
|
+
}
|
|
340
|
+
export function formatTaskListReport(report, color = false) {
|
|
341
|
+
const c = paint(color);
|
|
342
|
+
const runs = report.runs;
|
|
343
|
+
const head = `${c.bold("專案:")}${report.project.name} ${c.dim(`(共 ${runs.length} 個任務)`)}`;
|
|
344
|
+
if (!runs.length)
|
|
345
|
+
return `${head}\n\n無執行紀錄。`;
|
|
346
|
+
const rows = [["ID", "狀態", "輪次", "目前步驟", "任務"]];
|
|
347
|
+
for (const s of runs) {
|
|
348
|
+
rows.push([
|
|
349
|
+
s.run_id,
|
|
350
|
+
reportLabel(s),
|
|
351
|
+
`${s.rounds}/${s.max_rounds}`,
|
|
352
|
+
s.steps?.length ? (s.current_step ? clip(s.current_step, 30) : "全部完成") : "-",
|
|
353
|
+
clip(taskText(s), 60),
|
|
354
|
+
]);
|
|
355
|
+
}
|
|
356
|
+
const w = rows[0].map((_, i) => Math.max(...rows.map((r) => cols(r[i]))));
|
|
357
|
+
const line = (r, ri) => r
|
|
358
|
+
.map((t, i) => {
|
|
359
|
+
const p = i === r.length - 1 ? t : padCols(t, w[i]);
|
|
360
|
+
return ri === 0 ? c.bold(p) : i === 1 ? stateColor(c, t) + " ".repeat(Math.max(0, w[i] - cols(t))) : p;
|
|
361
|
+
})
|
|
362
|
+
.join(" ")
|
|
363
|
+
.trimEnd();
|
|
364
|
+
const table = rows.map(line);
|
|
365
|
+
table.splice(1, 0, c.dim(w.map((n) => "-".repeat(n)).join(" ")));
|
|
366
|
+
return [head, "", ...table, "", c.dim("接續中斷的任務:agent-lyceum resume <ID> -p " + report.project.name)].join("\n");
|
|
367
|
+
}
|
|
368
|
+
export function formatStatus(base, now = Date.now(), color = false) {
|
|
369
|
+
return formatStatusReport(buildStatusReport(base), now, color);
|
|
370
|
+
}
|
|
371
|
+
export function formatStatusReport(report, now = Date.now(), color = false) {
|
|
372
|
+
const c = paint(color);
|
|
373
|
+
const run = report.run;
|
|
374
|
+
const rows = [["成員", "執行環境", "未讀", "上次喚醒"]];
|
|
375
|
+
for (const a of report.agents) {
|
|
376
|
+
const lw = a.last_wake;
|
|
377
|
+
rows.push([a.name + (a.lead ? " (lead)" : ""), a.runtime ?? "?", String(a.unread), lw ? `${lw.ok ? "成功" : "失敗"} ${lw.at}` : "-"]);
|
|
378
|
+
}
|
|
379
|
+
const w = rows[0].map((_, i) => Math.max(...rows.map((r) => cols(r[i]))));
|
|
380
|
+
// Pad on the plain text first so ANSI codes never skew the column widths.
|
|
381
|
+
const cell = (r, ri, i) => {
|
|
382
|
+
const t = i === r.length - 1 ? r[i] : padCols(r[i], w[i]); // no trailing padding, as in plain output
|
|
383
|
+
if (ri === 0)
|
|
384
|
+
return c.bold(t);
|
|
385
|
+
if (i === 0)
|
|
386
|
+
return r[0].endsWith("(lead)") ? c.cyan(t) : t;
|
|
387
|
+
if (i === 2)
|
|
388
|
+
return r[2] === "0" ? c.dim(t) : c.yellow(t);
|
|
389
|
+
if (i === 3)
|
|
390
|
+
return r[3].startsWith("失敗") ? c.red(t) : r[3].startsWith("成功") ? c.green(t) : c.dim(t);
|
|
391
|
+
return t;
|
|
392
|
+
};
|
|
393
|
+
const table = rows.map((r, ri) => r.map((_, i) => cell(r, ri, i)).join(" ").trimEnd());
|
|
394
|
+
table.splice(1, 0, c.dim(w.map((n) => "-".repeat(n)).join(" ")));
|
|
395
|
+
const lines = [`${c.bold("專案:")}${report.project.name} ${c.dim(`(repo:${report.project.dir})`)}`, "", ...summaryBlock(run, now, c), "", ...table];
|
|
396
|
+
if (run)
|
|
397
|
+
lines.push("", `輸出 tokens:${tokens(run.output_tokens)}`);
|
|
398
|
+
return lines.join("\n");
|
|
399
|
+
}
|
package/dist/task.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const TASK_FILE_MAX: number;
|
|
2
|
+
export declare const TASK_INLINE_MAX: number;
|
|
3
|
+
export interface PreparedTask {
|
|
4
|
+
source: "text" | "file";
|
|
5
|
+
/** Original file path when source is "file". */
|
|
6
|
+
sourcePath?: string;
|
|
7
|
+
/** Read-only copy kept in the run dir. */
|
|
8
|
+
copyPath: string;
|
|
9
|
+
content: string;
|
|
10
|
+
inline: boolean;
|
|
11
|
+
bytes: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function readTaskFile(file: string): string;
|
|
14
|
+
/** Resolve CLI input into a task; never modifies the source file. */
|
|
15
|
+
export declare function prepareTask(opts: {
|
|
16
|
+
text?: string;
|
|
17
|
+
file?: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
runDir: string;
|
|
20
|
+
}): PreparedTask;
|
|
21
|
+
export declare function taskMessageBody(task: PreparedTask): {
|
|
22
|
+
subject: string;
|
|
23
|
+
body: string;
|
|
24
|
+
};
|
package/dist/task.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { absPath } from "./paths.js";
|
|
4
|
+
export const TASK_FILE_MAX = 1024 * 1024;
|
|
5
|
+
export const TASK_INLINE_MAX = 16 * 1024;
|
|
6
|
+
export function readTaskFile(file) {
|
|
7
|
+
let st;
|
|
8
|
+
try {
|
|
9
|
+
st = fs.statSync(file);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error(`Task file not found: ${file}`);
|
|
13
|
+
}
|
|
14
|
+
if (!st.isFile())
|
|
15
|
+
throw new Error(`Task file is not a regular file: ${file}`);
|
|
16
|
+
if (st.size === 0)
|
|
17
|
+
throw new Error(`Task file is empty: ${file}`);
|
|
18
|
+
if (st.size > TASK_FILE_MAX) {
|
|
19
|
+
throw new Error(`Task file is ${st.size} bytes; the limit is ${TASK_FILE_MAX} (1 MB). Shorten it or split the work.`);
|
|
20
|
+
}
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = fs.readFileSync(file, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
throw new Error(`Task file is not readable: ${file} (${e.message})`);
|
|
27
|
+
}
|
|
28
|
+
if (!text.trim())
|
|
29
|
+
throw new Error(`Task file is empty: ${file}`);
|
|
30
|
+
return text;
|
|
31
|
+
}
|
|
32
|
+
/** Resolve CLI input into a task; never modifies the source file. */
|
|
33
|
+
export function prepareTask(opts) {
|
|
34
|
+
const hasText = opts.text !== undefined && opts.text.trim() !== "";
|
|
35
|
+
if (hasText && opts.file)
|
|
36
|
+
throw new Error('Give either a task text or --task-file, not both.');
|
|
37
|
+
if (!hasText && !opts.file)
|
|
38
|
+
throw new Error('No task given. Use `run "<task>"` or `run --task-file <path>`.');
|
|
39
|
+
let content;
|
|
40
|
+
let sourcePath;
|
|
41
|
+
if (opts.file) {
|
|
42
|
+
sourcePath = absPath(opts.file, opts.cwd);
|
|
43
|
+
content = readTaskFile(sourcePath);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
content = opts.text.trim();
|
|
47
|
+
}
|
|
48
|
+
const bytes = Buffer.byteLength(content);
|
|
49
|
+
const copyPath = path.join(opts.runDir, "task.md");
|
|
50
|
+
fs.mkdirSync(opts.runDir, { recursive: true });
|
|
51
|
+
fs.writeFileSync(copyPath, content.endsWith("\n") ? content : content + "\n", { mode: 0o444 });
|
|
52
|
+
return {
|
|
53
|
+
source: opts.file ? "file" : "text",
|
|
54
|
+
sourcePath,
|
|
55
|
+
copyPath,
|
|
56
|
+
content,
|
|
57
|
+
inline: bytes <= TASK_INLINE_MAX,
|
|
58
|
+
bytes,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export function taskMessageBody(task) {
|
|
62
|
+
const first = task.content.split("\n").find((l) => l.trim()) ?? "Task";
|
|
63
|
+
const subject = first.replace(/^#+\s*/, "").slice(0, 80);
|
|
64
|
+
if (task.inline)
|
|
65
|
+
return { subject, body: task.content };
|
|
66
|
+
return {
|
|
67
|
+
subject,
|
|
68
|
+
body: `The task is large (${task.bytes} bytes), so it is not inlined here. Read it from \`${task.copyPath}\` before doing anything else.\n\n` +
|
|
69
|
+
`First lines:\n\n${task.content.split("\n").slice(0, 10).join("\n")}\n`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ResolvedProject } from "./config.js";
|
|
2
|
+
import { type AgentEnforcement } from "./policy.js";
|
|
3
|
+
export interface Issue {
|
|
4
|
+
level: "error" | "warn";
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ValidationResult {
|
|
8
|
+
issues: Issue[];
|
|
9
|
+
enforcement: AgentEnforcement[];
|
|
10
|
+
ok: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function validateProject(project: ResolvedProject): ValidationResult;
|
|
13
|
+
export declare function formatEnforcement(list: AgentEnforcement[]): string;
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { enforcementFor, memoryDirs, ownsDirs } from "./policy.js";
|
|
3
|
+
import { isInside } from "./paths.js";
|
|
4
|
+
import { inferRuntime, RUNTIME_EFFORTS } from "./schema.js";
|
|
5
|
+
import { isGitRepo, resolveWorkspaceMode } from "./worktree.js";
|
|
6
|
+
export function validateProject(project) {
|
|
7
|
+
const issues = [];
|
|
8
|
+
const err = (message) => issues.push({ level: "error", message });
|
|
9
|
+
const warn = (message) => issues.push({ level: "warn", message });
|
|
10
|
+
const names = Object.keys(project.agents);
|
|
11
|
+
if (names.length < 2)
|
|
12
|
+
err(`A team needs at least 2 agents: the lead and one member (found ${names.length}).`);
|
|
13
|
+
if (!project.agents[project.lead])
|
|
14
|
+
err(`Lead "${project.lead}" is not listed under agents.`);
|
|
15
|
+
if (!fs.existsSync(project.dir) || !fs.statSync(project.dir).isDirectory()) {
|
|
16
|
+
err(`Project dir does not exist: ${project.dir}`);
|
|
17
|
+
}
|
|
18
|
+
for (const a of Object.values(project.agents)) {
|
|
19
|
+
if (!a.runtime)
|
|
20
|
+
err(`Agent "${a.name}" has no runtime (set runtime, or a recognizable model such as opus/sonnet/haiku/claude-* or gpt-*/codex-*).`);
|
|
21
|
+
const modelRuntime = inferRuntime(a.model);
|
|
22
|
+
if (a.runtime && modelRuntime && modelRuntime !== a.runtime) {
|
|
23
|
+
warn(`Agent "${a.name}": model "${a.model}" looks like a ${modelRuntime} model but runtime is ${a.runtime}.`);
|
|
24
|
+
}
|
|
25
|
+
if (a.effort && a.runtime && !RUNTIME_EFFORTS[a.runtime].includes(a.effort)) {
|
|
26
|
+
err(`Agent "${a.name}": effort "${a.effort}" is not supported by ${a.runtime} (use ${RUNTIME_EFFORTS[a.runtime].join(", ")}).`);
|
|
27
|
+
}
|
|
28
|
+
if (!fs.existsSync(a.agentMd))
|
|
29
|
+
err(`Agent "${a.name}": AGENT.md not found at ${a.agentMd}`);
|
|
30
|
+
if (!a.memory.global && !a.memory.project)
|
|
31
|
+
warn(`Agent "${a.name}" has no long-term memory configured.`);
|
|
32
|
+
if (a.canMessage !== "all") {
|
|
33
|
+
for (const t of a.canMessage) {
|
|
34
|
+
if (!project.agents[t])
|
|
35
|
+
err(`Agent "${a.name}": can_message target "${t}" is not in the team.`);
|
|
36
|
+
if (t === a.name)
|
|
37
|
+
warn(`Agent "${a.name}" lists itself in can_message.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Memory directories must not overlap between agents (or equal each other).
|
|
42
|
+
const mem = [];
|
|
43
|
+
for (const a of Object.values(project.agents))
|
|
44
|
+
for (const d of memoryDirs(a))
|
|
45
|
+
mem.push({ agent: a.name, dir: d });
|
|
46
|
+
for (let i = 0; i < mem.length; i++) {
|
|
47
|
+
for (let j = i + 1; j < mem.length; j++) {
|
|
48
|
+
const x = mem[i];
|
|
49
|
+
const y = mem[j];
|
|
50
|
+
if (x.agent === y.agent && x.dir !== y.dir)
|
|
51
|
+
continue;
|
|
52
|
+
if (isInside(x.dir, y.dir) || isInside(y.dir, x.dir)) {
|
|
53
|
+
err(`Memory dirs overlap: "${x.agent}" ${x.dir} vs "${y.agent}" ${y.dir}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Parallelism requires disjoint `owns`.
|
|
58
|
+
if (project.dispatcher.max_parallel > 1) {
|
|
59
|
+
const withOwns = Object.values(project.agents).filter((a) => a.name !== project.lead);
|
|
60
|
+
for (const a of withOwns) {
|
|
61
|
+
if (a.owns.length === 0)
|
|
62
|
+
err(`max_parallel > 1 requires "owns" for agent "${a.name}".`);
|
|
63
|
+
}
|
|
64
|
+
for (let i = 0; i < withOwns.length; i++) {
|
|
65
|
+
for (let j = i + 1; j < withOwns.length; j++) {
|
|
66
|
+
for (const x of ownsDirs(project, withOwns[i])) {
|
|
67
|
+
for (const y of ownsDirs(project, withOwns[j])) {
|
|
68
|
+
if (isInside(x, y) || isInside(y, x)) {
|
|
69
|
+
err(`"owns" overlap: ${withOwns[i].name} (${x}) vs ${withOwns[j].name} (${y})`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Parallel agents each need their own checkout, which needs git.
|
|
77
|
+
if (project.dispatcher.workspace_mode === "shared" && project.dispatcher.max_parallel > 1) {
|
|
78
|
+
err(`dispatcher.workspace_mode "shared" cannot be combined with max_parallel > 1: parallel agents need separate git worktrees (use "auto" or "worktree").`);
|
|
79
|
+
}
|
|
80
|
+
else if (resolveWorkspaceMode(project.dispatcher) === "worktree" && fs.existsSync(project.dir) && !isGitRepo(project.dir)) {
|
|
81
|
+
err(`Parallel runs (worktree workspaces) need ${project.dir} to be a git repository. Run sequentially instead (max_parallel: 1, workspace_mode: auto or shared).`);
|
|
82
|
+
}
|
|
83
|
+
// Context paths should live outside the repo (the tool never writes into it).
|
|
84
|
+
for (const a of Object.values(project.agents)) {
|
|
85
|
+
for (const d of memoryDirs(a)) {
|
|
86
|
+
if (isInside(project.dir, d))
|
|
87
|
+
warn(`Agent "${a.name}": memory dir ${d} is inside the repo.`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const enforcement = Object.values(project.agents).map((a) => enforcementFor(project, a));
|
|
91
|
+
if (project.dispatcher.strict) {
|
|
92
|
+
for (const e of enforcement) {
|
|
93
|
+
for (const k of ["memory", "agentMd", "otherContext"]) {
|
|
94
|
+
if (e[k] !== "os")
|
|
95
|
+
err(`strict: agent "${e.agent}" ${k} protection is "${e[k]}", not OS-enforced.`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { issues, enforcement, ok: !issues.some((i) => i.level === "error") };
|
|
100
|
+
}
|
|
101
|
+
export function formatEnforcement(list) {
|
|
102
|
+
const rows = [["agent", "runtime", "memory", "AGENT.md", "others' ctx", "repo CLAUDE/AGENTS", "owns"]];
|
|
103
|
+
for (const e of list)
|
|
104
|
+
rows.push([e.agent, e.runtime, e.memory, e.agentMd, e.otherContext, e.repoInstructions, e.owns]);
|
|
105
|
+
const w = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
|
|
106
|
+
const lines = rows.map((r) => r.map((c, i) => c.padEnd(w[i])).join(" ").trimEnd());
|
|
107
|
+
lines.splice(1, 0, w.map((n) => "-".repeat(n)).join(" "));
|
|
108
|
+
const extra = [];
|
|
109
|
+
for (const e of list) {
|
|
110
|
+
for (const n of e.notes)
|
|
111
|
+
extra.push(` [${e.agent}] ${n}`);
|
|
112
|
+
if (e.writablePaths.length)
|
|
113
|
+
extra.push(` [${e.agent}] writes outside repo: ${e.writablePaths.map((p) => p.replace(process.env.HOME ?? "", "~")).join(", ")}`);
|
|
114
|
+
}
|
|
115
|
+
return [...lines, ...(extra.length ? ["", ...extra] : [])].join("\n");
|
|
116
|
+
}
|