agent-lyceum 0.25.1 → 0.27.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 -2
- package/README.zh-TW.md +6 -2
- package/dist/call.js +2 -0
- package/dist/config.d.ts +29 -1
- package/dist/config.js +34 -3
- package/dist/doctor-instances.d.ts +4 -0
- package/dist/doctor-instances.js +63 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +17 -1
- package/dist/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +4 -2
- package/dist/fallback.js +11 -4
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- package/dist/instance-containment.d.ts +15 -0
- package/dist/instance-containment.js +36 -0
- package/dist/instances.d.ts +183 -0
- package/dist/instances.js +268 -0
- package/dist/mailbox.d.ts +46 -2
- package/dist/mailbox.js +121 -36
- package/dist/message-store.d.ts +47 -1
- package/dist/message-store.js +75 -7
- package/dist/paths.d.ts +7 -0
- package/dist/paths.js +27 -2
- package/dist/policy.d.ts +1 -0
- package/dist/policy.js +2 -2
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +66 -10
- package/dist/run-session.d.ts +35 -2
- package/dist/run-session.js +337 -20
- package/dist/run-store.d.ts +17 -3
- package/dist/run-store.js +17 -7
- package/dist/scaffold.d.ts +3 -0
- package/dist/scaffold.js +10 -1
- package/dist/schema.d.ts +19 -0
- package/dist/schema.js +12 -0
- package/dist/status.d.ts +7 -0
- package/dist/status.js +74 -15
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +28 -1
- package/dist/worktree.d.ts +6 -0
- package/dist/worktree.js +16 -2
- package/package.json +1 -1
package/dist/scaffold.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
|
|
2
|
+
export declare const LEAD_PARALLEL_HINT = "Mark a task `parallel: true` only when it is independent and touches different files from the other open tasks; list each task's files in `## Scope` first; shared components, types, routes and tokens go first, alone.";
|
|
3
|
+
export declare const GLOBAL_TEAM_YAML = "# Global agent library. Projects pick agents from here by name and may override any field.\n# Relative paths here are resolved against this folder. \"~\" is allowed.\nagents:\n lead:\n runtime: claude-code # claude-code | codex\n agent_md: agents/lead/AGENT.md\n memory: { global: agents/lead/memory }\n # model: opus # most capable model: keep it for the lead\n fe-member:\n runtime: claude-code\n agent_md: agents/fe-member/AGENT.md\n memory: { global: agents/fe-member/memory }\n # model: sonnet # cheaper model for routine work\n qa-member:\n runtime: claude-code\n agent_md: agents/qa-member/AGENT.md\n memory: { global: agents/qa-member/memory }\n # model: sonnet # cheaper model for routine work\n # Model hints (comments only):\n # - a read-only search/explorer role suits \"model: haiku\", the cheapest tier (the alias resolves per provider; on the Anthropic API it is claude-haiku-5-5).\n # - a reviewer can run on the other runtime (\"runtime: codex\"); leave the codex model unset to use the CLI default.\n";
|
|
1
4
|
export interface InitResult {
|
|
2
5
|
home: string;
|
|
3
6
|
created: string[];
|
package/dist/scaffold.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { assertName, homePaths, projectPaths } from "./paths.js";
|
|
4
|
+
/** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
|
|
5
|
+
export const LEAD_PARALLEL_HINT = "Mark a task `parallel: true` only when it is independent and touches different files from the other open tasks; list each task's files in `## Scope` first; shared components, types, routes and tokens go first, alone.";
|
|
4
6
|
const PERSONAS = {
|
|
5
7
|
lead: `# Lead
|
|
6
8
|
|
|
@@ -11,6 +13,7 @@ You coordinate the team. You receive the user's task, break it into concrete pie
|
|
|
11
13
|
- Write every task with the standard task headings, and cite the mail it derives from under Upstream so teammates can trace it.
|
|
12
14
|
- When a teammate reports back, decide the next step: assign follow-up work, ask another teammate to verify, or accept the result.
|
|
13
15
|
- Teammates cannot message each other by default; route everything through yourself.
|
|
16
|
+
- ${LEAD_PARALLEL_HINT}
|
|
14
17
|
- When the whole job is verified and finished, send a \`done\` message summarising what was delivered.
|
|
15
18
|
`,
|
|
16
19
|
"fe-member": `# Frontend member
|
|
@@ -30,21 +33,27 @@ You verify work: run and write tests, reproduce bugs, and review changes against
|
|
|
30
33
|
- Besides the standard reply headings, add a \`## Verdict\` section containing \`pass\` or \`fail\`, and put your prioritised list of issues under Open items.
|
|
31
34
|
`,
|
|
32
35
|
};
|
|
33
|
-
const GLOBAL_TEAM_YAML = `# Global agent library. Projects pick agents from here by name and may override any field.
|
|
36
|
+
export const GLOBAL_TEAM_YAML = `# Global agent library. Projects pick agents from here by name and may override any field.
|
|
34
37
|
# Relative paths here are resolved against this folder. "~" is allowed.
|
|
35
38
|
agents:
|
|
36
39
|
lead:
|
|
37
40
|
runtime: claude-code # claude-code | codex
|
|
38
41
|
agent_md: agents/lead/AGENT.md
|
|
39
42
|
memory: { global: agents/lead/memory }
|
|
43
|
+
# model: opus # most capable model: keep it for the lead
|
|
40
44
|
fe-member:
|
|
41
45
|
runtime: claude-code
|
|
42
46
|
agent_md: agents/fe-member/AGENT.md
|
|
43
47
|
memory: { global: agents/fe-member/memory }
|
|
48
|
+
# model: sonnet # cheaper model for routine work
|
|
44
49
|
qa-member:
|
|
45
50
|
runtime: claude-code
|
|
46
51
|
agent_md: agents/qa-member/AGENT.md
|
|
47
52
|
memory: { global: agents/qa-member/memory }
|
|
53
|
+
# model: sonnet # cheaper model for routine work
|
|
54
|
+
# Model hints (comments only):
|
|
55
|
+
# - a read-only search/explorer role suits "model: haiku", the cheapest tier (the alias resolves per provider; on the Anthropic API it is claude-haiku-5-5).
|
|
56
|
+
# - a reviewer can run on the other runtime ("runtime: codex"); leave the codex model unset to use the CLI default.
|
|
48
57
|
`;
|
|
49
58
|
export function initHome(home) {
|
|
50
59
|
const hp = homePaths(home);
|
package/dist/schema.d.ts
CHANGED
|
@@ -108,11 +108,23 @@ export declare const DispatcherPartial: z.ZodObject<{
|
|
|
108
108
|
}>>;
|
|
109
109
|
}, z.core.$strict>>>;
|
|
110
110
|
fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
|
|
111
|
+
max_instances: z.ZodOptional<z.ZodNumber>;
|
|
111
112
|
}, z.core.$strict>;
|
|
112
113
|
/** Settings that no longer exist; files that still carry them load, and `validate` warns. */
|
|
113
114
|
export declare const REMOVED_DISPATCHER_KEYS: readonly ["max_parallel", "max_concurrent_runs", "workspace_mode"];
|
|
114
115
|
export declare const REMOVED_AGENT_KEYS: readonly ["owns"];
|
|
116
|
+
/** One runtime's model ladder, lowest first; `[]` = no ladder for that runtime (overrides team.yaml). */
|
|
117
|
+
export declare const ModelTiersPartial: z.ZodObject<{
|
|
118
|
+
"claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
119
|
+
codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
120
|
+
}, z.core.$strict>;
|
|
121
|
+
export declare const TIERS: readonly ["low", "mid", "high"];
|
|
122
|
+
export type Tier = (typeof TIERS)[number];
|
|
115
123
|
export declare const GlobalConfig: z.ZodObject<{
|
|
124
|
+
model_tiers: z.ZodOptional<z.ZodObject<{
|
|
125
|
+
"claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
126
|
+
codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
127
|
+
}, z.core.$strict>>;
|
|
116
128
|
agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
117
129
|
runtime: z.ZodOptional<z.ZodEnum<{
|
|
118
130
|
"claude-code": "claude-code";
|
|
@@ -164,6 +176,10 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
164
176
|
team: z.ZodObject<{
|
|
165
177
|
lead: z.ZodString;
|
|
166
178
|
}, z.core.$strict>;
|
|
179
|
+
model_tiers: z.ZodOptional<z.ZodObject<{
|
|
180
|
+
"claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
181
|
+
codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
182
|
+
}, z.core.$strict>>;
|
|
167
183
|
dispatcher: z.ZodDefault<z.ZodObject<{
|
|
168
184
|
max_rounds: z.ZodOptional<z.ZodNumber>;
|
|
169
185
|
max_parallel: z.ZodOptional<z.ZodUnknown>;
|
|
@@ -189,6 +205,7 @@ export declare const ProjectConfig: z.ZodObject<{
|
|
|
189
205
|
}>>;
|
|
190
206
|
}, z.core.$strict>>>;
|
|
191
207
|
fallback_cooldown_sec: z.ZodOptional<z.ZodNumber>;
|
|
208
|
+
max_instances: z.ZodOptional<z.ZodNumber>;
|
|
192
209
|
}, z.core.$strict>>;
|
|
193
210
|
agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
194
211
|
runtime: z.ZodOptional<z.ZodEnum<{
|
|
@@ -245,6 +262,8 @@ export interface DispatcherSettings {
|
|
|
245
262
|
log_max_bytes: number;
|
|
246
263
|
/** How long a primary that hit rate_limit/quota is skipped within a run, unless the CLI says when to come back. */
|
|
247
264
|
fallback_cooldown_sec: number;
|
|
265
|
+
/** Most instances of one role at a time. Not in `DISPATCHER_DEFAULTS`, so `doctor --resolved` lists it only when written; the default lives in `instances.ts`. */
|
|
266
|
+
max_instances?: number;
|
|
248
267
|
}
|
|
249
268
|
export declare const DISPATCHER_DEFAULTS: DispatcherSettings;
|
|
250
269
|
/** How a run turned out, independent of why it stopped (`EndReason`). Only "completed" means success. */
|
package/dist/schema.js
CHANGED
|
@@ -65,13 +65,24 @@ export const DispatcherPartial = z
|
|
|
65
65
|
log_max_bytes: z.number().int().min(0).optional(),
|
|
66
66
|
fallback: z.array(FallbackEntryPartial).optional(),
|
|
67
67
|
fallback_cooldown_sec: z.number().int().min(0).optional(),
|
|
68
|
+
/** Most instances of one role at a time (spec 23); 1 turns parallel tasks off. Unset = `DEFAULT_MAX_INSTANCES`. */
|
|
69
|
+
max_instances: z.number().int().min(1).max(8).optional(),
|
|
68
70
|
})
|
|
69
71
|
.strict();
|
|
70
72
|
/** Settings that no longer exist; files that still carry them load, and `validate` warns. */
|
|
71
73
|
export const REMOVED_DISPATCHER_KEYS = ["max_parallel", "max_concurrent_runs", "workspace_mode"];
|
|
72
74
|
export const REMOVED_AGENT_KEYS = ["owns"];
|
|
75
|
+
/** One runtime's model ladder, lowest first; `[]` = no ladder for that runtime (overrides team.yaml). */
|
|
76
|
+
export const ModelTiersPartial = z
|
|
77
|
+
.object({
|
|
78
|
+
"claude-code": z.array(z.string().trim().min(1)).optional(),
|
|
79
|
+
codex: z.array(z.string().trim().min(1)).optional(),
|
|
80
|
+
})
|
|
81
|
+
.strict();
|
|
82
|
+
export const TIERS = ["low", "mid", "high"];
|
|
73
83
|
export const GlobalConfig = z
|
|
74
84
|
.object({
|
|
85
|
+
model_tiers: ModelTiersPartial.optional(),
|
|
75
86
|
agents: z.record(z.string(), AgentPartial).default({}),
|
|
76
87
|
})
|
|
77
88
|
.strict();
|
|
@@ -79,6 +90,7 @@ export const ProjectConfig = z
|
|
|
79
90
|
.object({
|
|
80
91
|
dir: z.string(),
|
|
81
92
|
team: z.object({ lead: z.string() }).strict(),
|
|
93
|
+
model_tiers: ModelTiersPartial.optional(),
|
|
82
94
|
dispatcher: DispatcherPartial.default({}),
|
|
83
95
|
agents: z.record(z.string(), AgentPartial).default({}),
|
|
84
96
|
})
|
package/dist/status.d.ts
CHANGED
|
@@ -48,6 +48,8 @@ export declare function cacheText(read: number | undefined, write: number | unde
|
|
|
48
48
|
export declare function progressBar(done: number, total: number, width?: number, c?: Paint): string;
|
|
49
49
|
export interface QueueItem {
|
|
50
50
|
agent: string;
|
|
51
|
+
/** Mail waiting for a free instance of the role in `agent` (spec 23.7): not woken yet, placed by the dispatcher. Only for roles with instances. */
|
|
52
|
+
held?: true;
|
|
51
53
|
from: string;
|
|
52
54
|
type: string;
|
|
53
55
|
subject: string;
|
|
@@ -105,6 +107,8 @@ export interface RunReport {
|
|
|
105
107
|
ok: boolean;
|
|
106
108
|
error?: string;
|
|
107
109
|
}>;
|
|
110
|
+
/** Only when the run has same-role instances: each role's open instances, the role itself first. */
|
|
111
|
+
instances?: Record<string, string[]>;
|
|
108
112
|
/** Present while the run waits for the user: the file to fill in and how many questions are still open. */
|
|
109
113
|
ask?: {
|
|
110
114
|
path: string;
|
|
@@ -116,6 +120,9 @@ export interface AgentReport {
|
|
|
116
120
|
name: string;
|
|
117
121
|
runtime?: string;
|
|
118
122
|
lead: boolean;
|
|
123
|
+
/** Only for the rows of a role that has instances (the role itself is instance 1): the role and the instance number. */
|
|
124
|
+
role?: string;
|
|
125
|
+
instance?: number;
|
|
119
126
|
unread: number;
|
|
120
127
|
/** Who this agent may send mail to (`all` expanded, itself left out). */
|
|
121
128
|
can_message: string[];
|
package/dist/status.js
CHANGED
|
@@ -5,7 +5,9 @@ import { RESULT_FILE } from "./dispatcher.js";
|
|
|
5
5
|
import { bindRunProject, loadRunState, outcomeOf, roundsLabel, runLabel } from "./run-store.js";
|
|
6
6
|
import { candidatesFor } from "./fallback.js";
|
|
7
7
|
import { briefOf, digestOf } from "./format.js";
|
|
8
|
-
import { listUnread } from "./mailbox.js";
|
|
8
|
+
import { PENDING_DIR, listUnread, readMessageNoFollow } from "./mailbox.js";
|
|
9
|
+
import { parseSlotName } from "./instances.js";
|
|
10
|
+
import { inboxDir } from "./policy.js";
|
|
9
11
|
import { tidyAdvice } from "./memory-tidy.js";
|
|
10
12
|
import { inspectRunLock, lockHolderAlive } from "./project-lock.js";
|
|
11
13
|
/** The run to show: the newest one still running (a resumed run keeps its old id, so newer runs may sit above it), else the newest run. */
|
|
@@ -154,7 +156,7 @@ function stepsLine(steps, c, finished = false) {
|
|
|
154
156
|
return `目前:${clip(cur.text, 60)}`;
|
|
155
157
|
}
|
|
156
158
|
/** Order the dispatcher will wake agents: the lead first, otherwise the oldest mail. */
|
|
157
|
-
function queueOf(project) {
|
|
159
|
+
function queueOf(project, instances) {
|
|
158
160
|
const rows = Object.values(project.agents)
|
|
159
161
|
.map((a) => ({ agent: a.name, msg: listUnread(project, a.name)[0] }))
|
|
160
162
|
.flatMap((r) => (r.msg ? [{ agent: r.agent, msg: r.msg }] : []));
|
|
@@ -163,7 +165,7 @@ function queueOf(project) {
|
|
|
163
165
|
return x.agent === project.lead ? -1 : 1;
|
|
164
166
|
return path.basename(x.msg.file).localeCompare(path.basename(y.msg.file));
|
|
165
167
|
});
|
|
166
|
-
|
|
168
|
+
const items = rows.map((r) => ({
|
|
167
169
|
agent: r.agent,
|
|
168
170
|
from: r.msg.meta.from,
|
|
169
171
|
type: r.msg.meta.type,
|
|
@@ -171,14 +173,44 @@ function queueOf(project) {
|
|
|
171
173
|
brief: briefOf(r.msg.body),
|
|
172
174
|
digest: digestOf(r.msg.meta.type, r.msg.body),
|
|
173
175
|
}));
|
|
176
|
+
return [...items, ...heldQueue(project, instances)];
|
|
174
177
|
}
|
|
178
|
+
/** Mail the assigner still holds for roles with instances, oldest first per role. Unreadable files are skipped. */
|
|
179
|
+
function heldQueue(project, instances) {
|
|
180
|
+
const out = [];
|
|
181
|
+
for (const role of Object.keys(instances?.roles ?? {}).sort()) {
|
|
182
|
+
const dir = path.join(inboxDir(project, role), PENDING_DIR);
|
|
183
|
+
let files = [];
|
|
184
|
+
try {
|
|
185
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".")).sort();
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
for (const f of files) {
|
|
191
|
+
try {
|
|
192
|
+
const m = readMessageNoFollow(path.join(dir, f));
|
|
193
|
+
out.push({ agent: role, held: true, from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* a half-written file: the next poll sees it */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/** How an agent is named on the text pages: an instance of a role shows as `role #k`. */
|
|
203
|
+
const shownName = (name, slots) => {
|
|
204
|
+
const i = parseSlotName(name);
|
|
205
|
+
return i && slots?.[i.role]?.[i.k - 1] === name ? `${i.role} #${i.k}` : name;
|
|
206
|
+
};
|
|
175
207
|
/** One run as `status` sees it. Without `project`, liveness falls back to the recorded pid and there is no queue. */
|
|
176
208
|
export function buildRunReport(run, project, opts = {}) {
|
|
177
209
|
const s = run.state;
|
|
178
210
|
const detail = opts.detail ?? true;
|
|
179
211
|
const live = runIsAlive(s, project) && !s.end_reason;
|
|
180
212
|
const o = outcomeOf(s);
|
|
181
|
-
const bound = project ? bindRunProject(project, run.dir, s.mail_layout) : undefined;
|
|
213
|
+
const bound = project ? bindRunProject(project, run.dir, s.mail_layout, s.instances) : undefined;
|
|
182
214
|
const result = path.join(run.dir, RESULT_FILE);
|
|
183
215
|
const hasResult = detail && s.end_reason === "done" && fs.existsSync(result);
|
|
184
216
|
const cur = s.steps?.find((x) => !x.done);
|
|
@@ -221,7 +253,7 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
221
253
|
current_step: cur?.text ?? null,
|
|
222
254
|
active: s.active,
|
|
223
255
|
// mail still waiting is what `resume` will pick up, so it is shown for every run that did not finish with a done
|
|
224
|
-
queue: detail && bound && s.end_reason !== "done" ? queueOf(bound) : [],
|
|
256
|
+
queue: detail && bound && s.end_reason !== "done" ? queueOf(bound, s.instances) : [],
|
|
225
257
|
wakes: s.wakes,
|
|
226
258
|
notes: s.notes ?? [],
|
|
227
259
|
blocked_integrations: s.blocked_integrations ?? [],
|
|
@@ -230,6 +262,7 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
230
262
|
mail_layout: s.mail_layout,
|
|
231
263
|
workspace_mode: s.workspace_mode,
|
|
232
264
|
last_wake: s.last_wake,
|
|
265
|
+
...(s.instances ? { instances: s.instances.roles } : {}),
|
|
233
266
|
...(ask ? { ask } : {}),
|
|
234
267
|
};
|
|
235
268
|
}
|
|
@@ -277,7 +310,7 @@ export function buildStatusReport(base, runId, now = Date.now()) {
|
|
|
277
310
|
else
|
|
278
311
|
run = currentRun(base);
|
|
279
312
|
// Mail is per run: count the unread mail of the run being shown.
|
|
280
|
-
const project = run ? bindRunProject(base, run.dir, run.state.mail_layout) : base;
|
|
313
|
+
const project = run ? bindRunProject(base, run.dir, run.state.mail_layout, run.state.instances) : base;
|
|
281
314
|
const report = run ? buildRunReport(run, base) : undefined;
|
|
282
315
|
let advice = [];
|
|
283
316
|
try {
|
|
@@ -286,13 +319,21 @@ export function buildStatusReport(base, runId, now = Date.now()) {
|
|
|
286
319
|
catch {
|
|
287
320
|
/* a hint only: never let it break status */
|
|
288
321
|
}
|
|
289
|
-
|
|
322
|
+
const wakes = run?.state.wakes ?? [];
|
|
323
|
+
const below = new Set();
|
|
324
|
+
for (const a of Object.values(project.agents)) {
|
|
325
|
+
const last = wakes.filter((w) => w.agent === a.name).at(-1);
|
|
326
|
+
if (last?.tier && !last.fallback_from && last.runtime === a.runtime && !candidatesFor(a).some((c) => c.primary && c.key === `${last.runtime}/${(last.model ?? "").trim().toLowerCase()}`))
|
|
327
|
+
below.add(a.name);
|
|
328
|
+
}
|
|
329
|
+
const out = {
|
|
290
330
|
schema_version: 1,
|
|
291
331
|
project: projectInfo(base),
|
|
292
332
|
agents: Object.values(project.agents).map((a) => ({
|
|
293
333
|
name: a.name,
|
|
294
334
|
runtime: a.runtime,
|
|
295
335
|
lead: a.name === project.lead,
|
|
336
|
+
...(a.instance ? { role: a.instance.role, instance: a.instance.k } : {}),
|
|
296
337
|
unread: listUnread(project, a.name).length,
|
|
297
338
|
can_message: (a.canMessage === "all" ? Object.keys(project.agents) : a.canMessage).filter((n) => n !== a.name),
|
|
298
339
|
last_wake: run?.state.last_wake[a.name],
|
|
@@ -301,6 +342,8 @@ export function buildStatusReport(base, runId, now = Date.now()) {
|
|
|
301
342
|
run: report,
|
|
302
343
|
...(advice.length ? { memory_advice: advice } : {}),
|
|
303
344
|
};
|
|
345
|
+
belowCeiling.set(out, below);
|
|
346
|
+
return out;
|
|
304
347
|
}
|
|
305
348
|
/** Every run of the project, newest first. */
|
|
306
349
|
export function buildTaskListReport(project) {
|
|
@@ -309,10 +352,10 @@ export function buildTaskListReport(project) {
|
|
|
309
352
|
// ---- text pages ----
|
|
310
353
|
/** Agents in wake order, newest last; the ones working now are bracketed. */
|
|
311
354
|
function flowLine(r, c) {
|
|
312
|
-
const names = (r.wakes ?? []).map((w) => w.agent);
|
|
355
|
+
const names = (r.wakes ?? []).map((w) => shownName(w.agent, r.instances));
|
|
313
356
|
const shown = names.slice(-7);
|
|
314
357
|
const parts = shown.map((n) => c.cyan(n));
|
|
315
|
-
const activeNames = r.live ? Object.keys(r.active ?? {}) : [];
|
|
358
|
+
const activeNames = r.live ? Object.keys(r.active ?? {}).map((n) => shownName(n, r.instances)) : [];
|
|
316
359
|
const out = (names.length > shown.length ? "… → " : "") + parts.join(" → ");
|
|
317
360
|
if (!activeNames.length)
|
|
318
361
|
return out || "(尚無)";
|
|
@@ -320,7 +363,7 @@ function flowLine(r, c) {
|
|
|
320
363
|
}
|
|
321
364
|
/** The state label of a report: an outcome that was only inferred (old runs) is not shown as a checked result. */
|
|
322
365
|
const reportLabel = (r) => runStateLabel({ end_reason: r.end_reason, outcome: r.outcome_verified ? r.outcome : undefined }, r.live);
|
|
323
|
-
const mailText = (m) => `來自 ${m.from} 的 ${m.type}:「${clip(m.subject, 60)}
|
|
366
|
+
const mailText = (m, slots) => `來自 ${shownName(m.from, slots)} 的 ${m.type}:「${clip(m.subject, 60)}」${m.held ? "(保留中,等待空閒實例)" : ""}`;
|
|
324
367
|
const taskText = (s) => s.task_summary ?? (s.task_source === "file" ? `檔案 ${s.task_path}` : "文字");
|
|
325
368
|
/** The readable top block: what is happening, what is next, how far along the task is. */
|
|
326
369
|
function summaryBlock(run, now, c) {
|
|
@@ -338,7 +381,7 @@ function summaryBlock(run, now, c) {
|
|
|
338
381
|
const active = Object.entries(s.active ?? {});
|
|
339
382
|
if (active.length) {
|
|
340
383
|
for (const [i, [agent, a]] of active.entries()) {
|
|
341
|
-
lines.push(`${i === 0 ? L("現在:") : " "}${c.cyan(agent)} ${live ? c.green(`工作中 ${since(a.since, now)}`) : c.yellow("曾在工作(已中斷)")},處理 ${handlingText(a.handling)}`);
|
|
384
|
+
lines.push(`${i === 0 ? L("現在:") : " "}${c.cyan(shownName(agent, s.instances))} ${live ? c.green(`工作中 ${since(a.since, now)}`) : c.yellow("曾在工作(已中斷)")},處理 ${handlingText(a.handling)}`);
|
|
342
385
|
}
|
|
343
386
|
}
|
|
344
387
|
else
|
|
@@ -346,7 +389,7 @@ function summaryBlock(run, now, c) {
|
|
|
346
389
|
const queue = s.queue;
|
|
347
390
|
if (queue.length) {
|
|
348
391
|
for (const [i, q] of queue.slice(0, 3).entries())
|
|
349
|
-
lines.push(`${i === 0 ? L("下一個:") : " "}${c.cyan(q.agent)} ← ${mailText(q)}`);
|
|
392
|
+
lines.push(`${i === 0 ? L("下一個:") : " "}${c.cyan(q.agent)} ← ${mailText(q, s.instances)}`);
|
|
350
393
|
if (queue.length > 3)
|
|
351
394
|
lines.push(c.dim(` … 還有 ${queue.length - 3} 筆`));
|
|
352
395
|
}
|
|
@@ -387,10 +430,10 @@ function describeRun(s, now, c, limit = Infinity) {
|
|
|
387
430
|
if (wakes.length > limit)
|
|
388
431
|
lines.push(c.dim(` … 省略較早的 ${wakes.length - limit} 筆(不帶 --monitor 可看完整紀錄)`));
|
|
389
432
|
for (const w of limit < wakes.length ? wakes.slice(-limit) : wakes) {
|
|
390
|
-
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)}`);
|
|
433
|
+
lines.push(` #${w.round} ${c.cyan(shownName(w.agent, s.instances))} ${okText(c, w.ok)} (${Math.round(w.duration_ms / 1000)}s, ${tokens(w.output_tokens)} tok) — ${handlingText(w.handling)}`, ` → ${sentText(w.sent)}`);
|
|
391
434
|
}
|
|
392
435
|
for (const [agent, a] of Object.entries(s.active ?? {})) {
|
|
393
|
-
lines.push(` #${a.round} ${c.cyan(agent)} ${c.green(`工作中 ${since(a.since, now)}`)} — ${handlingText(a.handling)}`);
|
|
436
|
+
lines.push(` #${a.round} ${c.cyan(shownName(agent, s.instances))} ${c.green(`工作中 ${since(a.since, now)}`)} — ${handlingText(a.handling)}`);
|
|
394
437
|
}
|
|
395
438
|
return lines;
|
|
396
439
|
}
|
|
@@ -479,6 +522,19 @@ function fallbackLines(agents, c) {
|
|
|
479
522
|
}
|
|
480
523
|
return out;
|
|
481
524
|
}
|
|
525
|
+
/** Agents whose latest wake ran on a tiered model below the ceiling; kept off the report so `status --json` is unchanged. */
|
|
526
|
+
const belowCeiling = new WeakMap();
|
|
527
|
+
/** An agent whose latest wake ran on a model below its ceiling (spec 24.9): one line each, in agent order. */
|
|
528
|
+
function tierLines(report, c) {
|
|
529
|
+
const out = [];
|
|
530
|
+
const wakes = report.run?.wakes ?? [];
|
|
531
|
+
for (const a of report.agents) {
|
|
532
|
+
const last = wakes.filter((w) => w.agent === a.name).at(-1);
|
|
533
|
+
if (last?.tier && last.runtime && belowCeiling.get(report)?.has(a.name))
|
|
534
|
+
out.push(` ${c.cyan(a.name)} ${c.yellow(`tier ${last.tier}`)}(${modelName(`${last.runtime}/${last.model ?? ""}`)})`);
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
482
538
|
export function formatStatusReport(report, now = Date.now(), color = false) {
|
|
483
539
|
const c = paint(color);
|
|
484
540
|
const run = report.run;
|
|
@@ -486,7 +542,7 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
|
|
|
486
542
|
const rows = [header];
|
|
487
543
|
for (const a of report.agents) {
|
|
488
544
|
const lw = a.last_wake;
|
|
489
|
-
rows.push([a.name + (a.lead ? " (lead)" : ""), a.runtime ?? "?", String(a.unread), lw ? `${lw.ok ? "成功" : "失敗"} ${lw.at}` : "-"]);
|
|
545
|
+
rows.push([(a.instance && a.instance >= 2 && a.role ? `${a.role} #${a.instance}` : a.name) + (a.lead ? " (lead)" : ""), a.runtime ?? "?", String(a.unread), lw ? `${lw.ok ? "成功" : "失敗"} ${lw.at}` : "-"]);
|
|
490
546
|
}
|
|
491
547
|
const w = header.map((_, i) => Math.max(...rows.map((r) => cols(r[i] ?? ""))));
|
|
492
548
|
// Pad on the plain text first so ANSI codes never skew the column widths.
|
|
@@ -509,6 +565,9 @@ export function formatStatusReport(report, now = Date.now(), color = false) {
|
|
|
509
565
|
const fb = fallbackLines(report.agents, c);
|
|
510
566
|
if (fb.length)
|
|
511
567
|
lines.push("", ...fb);
|
|
568
|
+
const tl = tierLines(report, c);
|
|
569
|
+
if (tl.length)
|
|
570
|
+
lines.push(...(fb.length ? [] : [""]), ...tl);
|
|
512
571
|
if (run)
|
|
513
572
|
lines.push("", `輸出 tokens:${tokens(run.output_tokens)}${cacheText(run.cache_read_tokens, run.cache_write_tokens) ? ` ${cacheText(run.cache_read_tokens, run.cache_write_tokens)}` : ""}`);
|
|
514
573
|
if (report.memory_advice?.length)
|
package/dist/tiers.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Tier } from "./schema.js";
|
|
2
|
+
export declare function isTier(v: unknown): v is Tier;
|
|
3
|
+
/** Index of `model` on `ladder` (trim + lowercase, like the fallback keys), or -1. */
|
|
4
|
+
export declare function ladderIndex(ladder: readonly string[], model: string | undefined): number;
|
|
5
|
+
/**
|
|
6
|
+
* The tiers an agent can run on: the ladder from its lowest entry up to and including `model` (the ceiling).
|
|
7
|
+
* `undefined` when there is nothing below the ceiling (no ladder, no model, model not on it, or it is the lowest entry).
|
|
8
|
+
*/
|
|
9
|
+
export declare function availableTiers(ladder: readonly string[] | undefined, model: string | undefined): string[] | undefined;
|
|
10
|
+
/** `low` = lowest, `high` = ceiling, `mid` = `tiers[floor(n/2)]` (the higher middle when n is even). */
|
|
11
|
+
export declare function modelForTier(tiers: readonly string[], tier: Tier): string;
|
|
12
|
+
export type TierFlag = "absent" | "invalid" | Tier | {
|
|
13
|
+
ignored: "not-lead" | "not-a-task" | "no-tiers";
|
|
14
|
+
};
|
|
15
|
+
/** Judge the `tier` frontmatter of one outgoing mail, in the order of spec 24.5.2. Not wired anywhere yet. */
|
|
16
|
+
export declare function tierFlag(i: {
|
|
17
|
+
value: unknown;
|
|
18
|
+
senderIsLead: boolean;
|
|
19
|
+
type: string;
|
|
20
|
+
recipientTiers?: string[];
|
|
21
|
+
}): TierFlag;
|
|
22
|
+
/** A mail without a valid tier counts as `high` (the ceiling). */
|
|
23
|
+
export declare function effectiveTier(meta: {
|
|
24
|
+
tier?: unknown;
|
|
25
|
+
}): Tier;
|
|
26
|
+
/** The tier a wake runs at: the highest of the batch when every mail has a valid one, else `undefined` (the ceiling). */
|
|
27
|
+
export declare function wakeTier(tiers: readonly unknown[]): Tier | undefined;
|
package/dist/tiers.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { TIERS } from "./schema.js";
|
|
2
|
+
const norm = (m) => (m ?? "").trim().toLowerCase();
|
|
3
|
+
export function isTier(v) {
|
|
4
|
+
return typeof v === "string" && TIERS.includes(v);
|
|
5
|
+
}
|
|
6
|
+
/** Index of `model` on `ladder` (trim + lowercase, like the fallback keys), or -1. */
|
|
7
|
+
export function ladderIndex(ladder, model) {
|
|
8
|
+
const m = norm(model);
|
|
9
|
+
return m ? ladder.findIndex((e) => norm(e) === m) : -1;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The tiers an agent can run on: the ladder from its lowest entry up to and including `model` (the ceiling).
|
|
13
|
+
* `undefined` when there is nothing below the ceiling (no ladder, no model, model not on it, or it is the lowest entry).
|
|
14
|
+
*/
|
|
15
|
+
export function availableTiers(ladder, model) {
|
|
16
|
+
if (!ladder?.length)
|
|
17
|
+
return undefined;
|
|
18
|
+
const i = ladderIndex(ladder, model);
|
|
19
|
+
return i >= 1 ? ladder.slice(0, i + 1) : undefined;
|
|
20
|
+
}
|
|
21
|
+
/** `low` = lowest, `high` = ceiling, `mid` = `tiers[floor(n/2)]` (the higher middle when n is even). */
|
|
22
|
+
export function modelForTier(tiers, tier) {
|
|
23
|
+
const n = tiers.length;
|
|
24
|
+
return tiers[tier === "low" ? 0 : tier === "high" ? n - 1 : Math.floor(n / 2)];
|
|
25
|
+
}
|
|
26
|
+
/** Judge the `tier` frontmatter of one outgoing mail, in the order of spec 24.5.2. Not wired anywhere yet. */
|
|
27
|
+
export function tierFlag(i) {
|
|
28
|
+
if (i.value === undefined || i.value === null || i.value === "")
|
|
29
|
+
return "absent";
|
|
30
|
+
if (!i.senderIsLead)
|
|
31
|
+
return { ignored: "not-lead" };
|
|
32
|
+
if (!isTier(i.value))
|
|
33
|
+
return "invalid";
|
|
34
|
+
if (i.type !== "task")
|
|
35
|
+
return { ignored: "not-a-task" };
|
|
36
|
+
if (!i.recipientTiers || i.recipientTiers.length < 2)
|
|
37
|
+
return { ignored: "no-tiers" };
|
|
38
|
+
return i.value;
|
|
39
|
+
}
|
|
40
|
+
/** A mail without a valid tier counts as `high` (the ceiling). */
|
|
41
|
+
export function effectiveTier(meta) {
|
|
42
|
+
return isTier(meta.tier) ? meta.tier : "high";
|
|
43
|
+
}
|
|
44
|
+
/** The tier a wake runs at: the highest of the batch when every mail has a valid one, else `undefined` (the ceiling). */
|
|
45
|
+
export function wakeTier(tiers) {
|
|
46
|
+
if (tiers.length === 0 || !tiers.every(isTier))
|
|
47
|
+
return undefined;
|
|
48
|
+
return TIERS[Math.max(...tiers.map((t) => TIERS.indexOf(t)))];
|
|
49
|
+
}
|
package/dist/validate.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { enforcementFor, memoryDirs } from "./policy.js";
|
|
3
3
|
import { isInside } from "./paths.js";
|
|
4
|
-
import { inferRuntime, RUNTIME_EFFORTS } from "./schema.js";
|
|
4
|
+
import { inferRuntime, RUNTIME_EFFORTS, RUNTIMES } from "./schema.js";
|
|
5
|
+
import { ladderIndex } from "./tiers.js";
|
|
5
6
|
import { must } from "./assert.js";
|
|
6
7
|
export function validateProject(project) {
|
|
7
8
|
const issues = [];
|
|
@@ -15,7 +16,30 @@ export function validateProject(project) {
|
|
|
15
16
|
if (!fs.existsSync(project.dir) || !fs.statSync(project.dir).isDirectory()) {
|
|
16
17
|
err(`Project dir does not exist: ${project.dir}`);
|
|
17
18
|
}
|
|
19
|
+
for (const rt of RUNTIMES) {
|
|
20
|
+
const ladder = project.modelTiers[rt];
|
|
21
|
+
if (!ladder)
|
|
22
|
+
continue;
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
ladder.forEach((m, i) => {
|
|
25
|
+
const other = inferRuntime(m);
|
|
26
|
+
if (other !== rt)
|
|
27
|
+
err(`model_tiers.${rt}[${i}]: "${m}" is not a ${rt} model (recognised as ${other ?? "nothing"}). List only ${rt} models, lowest first.`);
|
|
28
|
+
const k = m.trim().toLowerCase();
|
|
29
|
+
if (seen.has(k))
|
|
30
|
+
err(`model_tiers.${rt}: "${m}" is listed twice.`);
|
|
31
|
+
seen.add(k);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
18
34
|
for (const a of Object.values(project.agents)) {
|
|
35
|
+
const ladder = a.runtime ? project.modelTiers[a.runtime] : undefined;
|
|
36
|
+
if (a.runtime && ladder) {
|
|
37
|
+
if (!a.model)
|
|
38
|
+
warn(`Agent "${a.name}" has no model, so model_tiers.${a.runtime} does not apply to it: it always runs on its runtime's default model.`);
|
|
39
|
+
else if (ladderIndex(ladder, a.model) < 0) {
|
|
40
|
+
err(`Agent "${a.name}": model "${a.model}" is not on model_tiers.${a.runtime} (${ladder.join(", ")}). Add it to the ladder, or set model_tiers.${a.runtime}: [] in project.yaml to turn the ladder off for this project.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
19
43
|
if (!a.runtime)
|
|
20
44
|
err(`Agent "${a.name}" has no runtime (set runtime, or a recognizable model such as opus/sonnet/haiku/claude-* or gpt-*/codex-*).`);
|
|
21
45
|
const modelRuntime = inferRuntime(a.model);
|
|
@@ -39,6 +63,9 @@ export function validateProject(project) {
|
|
|
39
63
|
}
|
|
40
64
|
}
|
|
41
65
|
}
|
|
66
|
+
if (Object.keys(project.modelTiers).length > 0 && !Object.values(project.agents).some((a) => a.name !== project.lead && a.tiers)) {
|
|
67
|
+
warn("model_tiers is set, but no member can run below its model (each member's model is the lowest entry of its ladder): tiers have no effect.");
|
|
68
|
+
}
|
|
42
69
|
// Memory directories must not overlap between agents (or equal each other).
|
|
43
70
|
const mem = [];
|
|
44
71
|
for (const a of Object.values(project.agents))
|
package/dist/worktree.d.ts
CHANGED
|
@@ -81,6 +81,7 @@ export declare function snapshotHead(project: ResolvedProject, runId: string): s
|
|
|
81
81
|
* `from`: the checkout to snapshot — the lead's worktree in a concurrent run; by default your repo's working tree.
|
|
82
82
|
*/
|
|
83
83
|
export declare function snapshotBase(project: ResolvedProject, runId: string, n: number, from?: AgentWorkspace): string;
|
|
84
|
+
export declare const workspaceRoot: (project: ResolvedProject, runId: string, agent: string) => string;
|
|
84
85
|
/**
|
|
85
86
|
* The agent's own checkout of the snapshot `baseRef`, at `<project home>/worktrees/<run>/<agent>`.
|
|
86
87
|
* An existing checkout is reused: untouched, when it already is on this snapshot (resume) or holds unfinished
|
|
@@ -144,6 +145,11 @@ export declare function integrateAgentChanges(project: ResolvedProject, changes:
|
|
|
144
145
|
* Throws when the metadata predates recording the git dir and the git dir cannot be told for certain.
|
|
145
146
|
*/
|
|
146
147
|
export declare function existingAgentWorkspace(project: ResolvedProject, runId: string, agent: string): AgentWorkspace | undefined;
|
|
148
|
+
/**
|
|
149
|
+
* The git dir recorded in an agent's worktree metadata, read-only (never completes or rewrites legacy metadata). undefined when there
|
|
150
|
+
* is no readable metadata or it predates recording the git dir.
|
|
151
|
+
*/
|
|
152
|
+
export declare function recordedGitDir(project: ResolvedProject, runId: string, agent: string): string | undefined;
|
|
147
153
|
export interface WorkspaceWork {
|
|
148
154
|
agent: string;
|
|
149
155
|
root: string;
|
package/dist/worktree.js
CHANGED
|
@@ -4,6 +4,7 @@ import fs from "node:fs";
|
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { atomicWrite } from "./fs-util.js";
|
|
7
|
+
import { escapesParent } from "./paths.js";
|
|
7
8
|
const IDENTITY = {
|
|
8
9
|
GIT_AUTHOR_NAME: "agent-lyceum",
|
|
9
10
|
GIT_AUTHOR_EMAIL: "agent-lyceum@localhost",
|
|
@@ -290,7 +291,7 @@ export function snapshotBase(project, runId, n, from) {
|
|
|
290
291
|
fs.rmSync(index, { force: true });
|
|
291
292
|
}
|
|
292
293
|
}
|
|
293
|
-
const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
|
|
294
|
+
export const workspaceRoot = (project, runId, agent) => path.join(project.paths.root, "worktrees", runId, agent);
|
|
294
295
|
/** The user's repo's git common dir, as a real absolute path. Asked of the user's repo, never of a worktree. */
|
|
295
296
|
const commonDirOf = (top) => fs.realpathSync(git(top, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
|
|
296
297
|
/** The admin dirs under `<commonDir>/worktrees/` whose `gitdir` file names `<root>/.git` (the file may hold a relative path). */
|
|
@@ -474,7 +475,7 @@ export function collectAgentChanges(workspace) {
|
|
|
474
475
|
const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(p), target));
|
|
475
476
|
if (path.posix.isAbsolute(target))
|
|
476
477
|
violations.push(`${f.path}: symlink to an absolute path (${target})`);
|
|
477
|
-
else if (resolved
|
|
478
|
+
else if (escapesParent(resolved, "/"))
|
|
478
479
|
violations.push(`${f.path}: symlink leaves the project directory (${target})`);
|
|
479
480
|
}
|
|
480
481
|
}
|
|
@@ -594,6 +595,19 @@ export function existingAgentWorkspace(project, runId, agent) {
|
|
|
594
595
|
}
|
|
595
596
|
return fs.existsSync(root) ? describeWorkspace(project, root, agent, meta) : undefined;
|
|
596
597
|
}
|
|
598
|
+
/**
|
|
599
|
+
* The git dir recorded in an agent's worktree metadata, read-only (never completes or rewrites legacy metadata). undefined when there
|
|
600
|
+
* is no readable metadata or it predates recording the git dir.
|
|
601
|
+
*/
|
|
602
|
+
export function recordedGitDir(project, runId, agent) {
|
|
603
|
+
try {
|
|
604
|
+
const meta = JSON.parse(fs.readFileSync(`${workspaceRoot(project, runId, agent)}.json`, "utf8"));
|
|
605
|
+
return typeof meta.gitDir === "string" && meta.gitDir ? meta.gitDir : undefined;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return undefined;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
597
611
|
/** What is in an agent's worktree that exists nowhere else: uncommitted changes, and commits whose files are not in the main repo. */
|
|
598
612
|
export function inspectWorkspace(project, runId, agent, target) {
|
|
599
613
|
const root = workspaceRoot(project, runId, agent);
|