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,120 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { atomicWrite } from "./fs-util.js";
|
|
4
|
+
import { assertName } from "./paths.js";
|
|
5
|
+
import { loadRunState } from "./run-store.js";
|
|
6
|
+
import { runIsAlive } from "./status.js";
|
|
7
|
+
import { deleteSnapshotRefs, inspectWorkspace, listSnapshotRefs, listWorkspaceAgents, removeAgentWorkspace } from "./worktree.js";
|
|
8
|
+
const journalDir = (p) => path.join(p.paths.root, "cleanup");
|
|
9
|
+
const journalFile = (p, runId) => path.join(journalDir(p), `${runId}.json`);
|
|
10
|
+
const lexists = (p) => {
|
|
11
|
+
try {
|
|
12
|
+
fs.lstatSync(p);
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* What clearing a run would do. Deletes nothing. Refuses (see `refusals`) while the run is running, or while
|
|
21
|
+
* a worktree holds work that is not in the repo, unless `keepWorktrees` keeps the worktrees and their branches.
|
|
22
|
+
*/
|
|
23
|
+
export function planRunCleanup(project, runId, opts = {}) {
|
|
24
|
+
assertName("run", runId);
|
|
25
|
+
const keepWorktrees = !!opts.keepWorktrees;
|
|
26
|
+
const runDir = path.join(project.paths.runs, runId);
|
|
27
|
+
const hasRun = lexists(path.join(runDir, "state.json"));
|
|
28
|
+
const hasJournal = fs.existsSync(journalFile(project, runId));
|
|
29
|
+
if (!hasRun && !hasJournal)
|
|
30
|
+
throw new Error(`Run "${runId}" not found in ${project.paths.runs}. List ids with: agent-lyceum status --task-list`);
|
|
31
|
+
const plan = { project, runId, runDir, keepWorktrees, items: [], refusals: [], keep: [], summary: runId };
|
|
32
|
+
if (hasRun) {
|
|
33
|
+
const state = loadRunState(runDir);
|
|
34
|
+
plan.summary = `${runId}: ${state.task_summary}`;
|
|
35
|
+
if (!state.end_reason && runIsAlive(state, project))
|
|
36
|
+
plan.refusals.push(`Run ${runId} is still running (pid ${state.pid}); stop it first.`);
|
|
37
|
+
}
|
|
38
|
+
for (const agent of listWorkspaceAgents(project, runId)) {
|
|
39
|
+
const w = inspectWorkspace(project, runId, agent);
|
|
40
|
+
if (!w)
|
|
41
|
+
continue;
|
|
42
|
+
const files = [...new Set([...w.dirty, ...w.unintegrated])];
|
|
43
|
+
const holdsWork = files.length > 0 || !!w.unknown;
|
|
44
|
+
if (keepWorktrees || holdsWork) {
|
|
45
|
+
plan.keep.push({ agent, branch: w.branch, path: w.root, files: files.length ? files : w.unknown ? [`(${w.unknown})`] : [] });
|
|
46
|
+
if (holdsWork && !keepWorktrees) {
|
|
47
|
+
plan.refusals.push(`${agent} has work that is not in the repo: ${files.length ? files.join(", ") : w.unknown} (branch ${w.branch}, worktree ${w.root}). ` +
|
|
48
|
+
`Bring it in or discard it by hand, or use --keep-worktrees to keep the worktree and branch and clear the rest.`);
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
plan.items.push({ kind: "workspace", agent, path: w.root, label: `worktree and branch of ${agent} (${w.root}, ${w.branch}): its work is in the repo` });
|
|
53
|
+
}
|
|
54
|
+
if (!keepWorktrees && listSnapshotRefs(project, runId).length) {
|
|
55
|
+
plan.items.push({ kind: "refs", label: `snapshot refs refs/agent-lyceum/${runId}/*` });
|
|
56
|
+
}
|
|
57
|
+
const mem = path.join(project.paths.taskMemory, runId);
|
|
58
|
+
if (lexists(mem))
|
|
59
|
+
plan.items.push({ kind: "task-memory", path: mem, label: `task memory ${mem}` });
|
|
60
|
+
if (hasRun || lexists(runDir))
|
|
61
|
+
plan.items.push({ kind: "run", path: runDir, label: `run directory ${runDir} (state, log, result, snapshots, mailboxes)` });
|
|
62
|
+
return plan;
|
|
63
|
+
}
|
|
64
|
+
/** Remove a path without ever following a symlink, and only inside the project home. */
|
|
65
|
+
function safeRemove(project, target) {
|
|
66
|
+
if (!lexists(target))
|
|
67
|
+
return;
|
|
68
|
+
if (fs.lstatSync(target).isSymbolicLink()) {
|
|
69
|
+
fs.unlinkSync(target); // the link, never what it points to
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const root = fs.realpathSync(project.paths.root);
|
|
73
|
+
const inside = (p) => p === root || p.startsWith(root + path.sep);
|
|
74
|
+
if (!inside(fs.realpathSync(path.dirname(target))) || !inside(fs.realpathSync(target))) {
|
|
75
|
+
throw new Error(`refusing to delete ${target}: it is not inside the project home ${project.paths.root}`);
|
|
76
|
+
}
|
|
77
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Carry out a plan, step by step. Progress is written to a journal in the project home (outside the run being
|
|
81
|
+
* deleted), so an interrupted clear can simply be repeated. Stops at the first step that fails, leaving the run
|
|
82
|
+
* directory in place for the retry.
|
|
83
|
+
*/
|
|
84
|
+
export function executeRunCleanup(plan, hooks = {}) {
|
|
85
|
+
if (plan.refusals.length)
|
|
86
|
+
throw new Error(`Cleanup refused:\n${plan.refusals.map((r) => `- ${r}`).join("\n")}`);
|
|
87
|
+
const { project, runId } = plan;
|
|
88
|
+
const report = { done: [], failed: [] };
|
|
89
|
+
const jf = journalFile(project, runId);
|
|
90
|
+
const record = (state) => atomicWrite(jf, JSON.stringify({ run_id: runId, ...state }, null, 2));
|
|
91
|
+
const journal = { started_at: new Date().toISOString(), items: plan.items.map((i) => ({ ...i, done: false })) };
|
|
92
|
+
record(journal);
|
|
93
|
+
for (let n = 0; n < plan.items.length; n++) {
|
|
94
|
+
const item = plan.items[n];
|
|
95
|
+
try {
|
|
96
|
+
if (item.kind === "workspace") {
|
|
97
|
+
removeAgentWorkspace(project, runId, item.agent);
|
|
98
|
+
const dir = path.dirname(item.path);
|
|
99
|
+
if (fs.existsSync(dir) && fs.readdirSync(dir).length === 0)
|
|
100
|
+
fs.rmdirSync(dir);
|
|
101
|
+
}
|
|
102
|
+
else if (item.kind === "refs")
|
|
103
|
+
deleteSnapshotRefs(project, runId);
|
|
104
|
+
else
|
|
105
|
+
safeRemove(project, item.path);
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
report.failed.push({ item, error: e.message });
|
|
109
|
+
return report; // keep the journal and the run directory: the next `clear` picks up here
|
|
110
|
+
}
|
|
111
|
+
report.done.push(item);
|
|
112
|
+
journal.items[n].done = true;
|
|
113
|
+
record(journal);
|
|
114
|
+
hooks.afterItem?.(item);
|
|
115
|
+
}
|
|
116
|
+
fs.rmSync(jf, { force: true });
|
|
117
|
+
if (fs.existsSync(journalDir(project)) && fs.readdirSync(journalDir(project)).length === 0)
|
|
118
|
+
fs.rmdirSync(journalDir(project));
|
|
119
|
+
return report;
|
|
120
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { ResolvedProject } from "./config.js";
|
|
2
|
+
import type { Step } from "./format.js";
|
|
3
|
+
import type { RunOutcome } from "./schema.js";
|
|
4
|
+
export declare const STATE_FILE = "state.json";
|
|
5
|
+
export declare const STATE_SCHEMA_VERSION = 2;
|
|
6
|
+
export type EndReason = "done" | "idle" | "max_rounds" | "lead_failed" | "cancelled";
|
|
7
|
+
export type MailLayout = "run" | "legacy";
|
|
8
|
+
export interface WakeTopic {
|
|
9
|
+
from: string;
|
|
10
|
+
type: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
/** What the message is about, in one line. */
|
|
13
|
+
brief?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SentTopic {
|
|
16
|
+
to: string;
|
|
17
|
+
type: string;
|
|
18
|
+
subject: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ActiveWake {
|
|
21
|
+
round: number;
|
|
22
|
+
since: string;
|
|
23
|
+
handling: WakeTopic[];
|
|
24
|
+
}
|
|
25
|
+
export interface WakeRecord {
|
|
26
|
+
round: number;
|
|
27
|
+
agent: string;
|
|
28
|
+
at: string;
|
|
29
|
+
duration_ms: number;
|
|
30
|
+
ok: boolean;
|
|
31
|
+
output_tokens?: number;
|
|
32
|
+
handling: WakeTopic[];
|
|
33
|
+
/** Mail this wake handed to others; `to` is "(done)" for the lead's final message. */
|
|
34
|
+
sent?: SentTopic[];
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface RunState {
|
|
38
|
+
/** 1 for runs written before versioning; 2 for runs with their own mailboxes. */
|
|
39
|
+
schema_version: number;
|
|
40
|
+
/** "run": mail lives under the run dir. "legacy": the shared project mailboxes of older versions. */
|
|
41
|
+
mail_layout: MailLayout;
|
|
42
|
+
run_id: string;
|
|
43
|
+
project: string;
|
|
44
|
+
task_source: "text" | "file";
|
|
45
|
+
task_path?: string;
|
|
46
|
+
started_at: string;
|
|
47
|
+
ended_at?: string;
|
|
48
|
+
rounds: number;
|
|
49
|
+
max_rounds: number;
|
|
50
|
+
end_reason?: EndReason;
|
|
51
|
+
/** How the work turned out; absent on runs that ended before outcomes existed. */
|
|
52
|
+
outcome?: RunOutcome;
|
|
53
|
+
/** Why the outcome is not what the lead declared (completion contract not met, legacy done, ...). */
|
|
54
|
+
outcome_note?: string;
|
|
55
|
+
/** First lines of the lead's `## Verification`, for display. */
|
|
56
|
+
verification?: string;
|
|
57
|
+
/** How many `done` mails were sent back because they broke the completion contract. */
|
|
58
|
+
done_rejections?: number;
|
|
59
|
+
/** Recovery remarks (interrupted attempts whose side effects may have happened). */
|
|
60
|
+
notes?: string[];
|
|
61
|
+
/** "worktree": non-lead agents work in their own git worktrees. */
|
|
62
|
+
workspace_mode?: "shared" | "worktree";
|
|
63
|
+
/** Agents whose finished work could not be brought into the repo yet; their work is kept on their branch. */
|
|
64
|
+
blocked_integrations?: {
|
|
65
|
+
agent: string;
|
|
66
|
+
branch: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
report: string;
|
|
69
|
+
}[];
|
|
70
|
+
/** Snapshots of the repo taken so far (`refs/agent-lyceum/<run>/base-<n>`). */
|
|
71
|
+
snapshots?: number;
|
|
72
|
+
sessions: Record<string, string>;
|
|
73
|
+
output_tokens: number;
|
|
74
|
+
last_wake: Record<string, {
|
|
75
|
+
at: string;
|
|
76
|
+
ok: boolean;
|
|
77
|
+
error?: string;
|
|
78
|
+
}>;
|
|
79
|
+
/** Process running this dispatcher, so a monitor can tell "running" from "interrupted". */
|
|
80
|
+
pid: number;
|
|
81
|
+
/** One line describing the run's task. */
|
|
82
|
+
task_summary: string;
|
|
83
|
+
/** Agents being woken right now and what they were asked to handle. */
|
|
84
|
+
active: Record<string, ActiveWake>;
|
|
85
|
+
/** Finished wakes, oldest first. */
|
|
86
|
+
wakes: WakeRecord[];
|
|
87
|
+
/** Checklist progress: seeded from the task, then replaced by the lead's latest `## Steps`. */
|
|
88
|
+
steps?: Step[];
|
|
89
|
+
}
|
|
90
|
+
/** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
|
|
91
|
+
export declare function newRunId(d?: Date): string;
|
|
92
|
+
/** A fresh v2 state; every collection present. */
|
|
93
|
+
export declare function newRunState(init: Partial<RunState> & {
|
|
94
|
+
run_id: string;
|
|
95
|
+
}): RunState;
|
|
96
|
+
/** Read and validate `<runDir>/state.json`; throws an error naming the file and the problem. */
|
|
97
|
+
export declare function loadRunState(runDir: string): RunState;
|
|
98
|
+
export declare function saveRunState(runDir: string, state: RunState): void;
|
|
99
|
+
/**
|
|
100
|
+
* The project as one run sees it. Per-task memory is always private to the run. With the "run" layout the
|
|
101
|
+
* mailboxes live inside the run dir; with "legacy" they stay the shared project mailboxes older versions used.
|
|
102
|
+
*/
|
|
103
|
+
export declare function bindRunProject(project: ResolvedProject, runDir: string, layout: MailLayout): ResolvedProject;
|
|
104
|
+
/**
|
|
105
|
+
* How a finished run turned out. Runs that ended before outcomes existed have none recorded: they read as
|
|
106
|
+
* partial (failed for a failed lead) and `verified: false`, never as a success nobody checked.
|
|
107
|
+
*/
|
|
108
|
+
export declare function outcomeOf(s: Pick<RunState, "outcome" | "end_reason">): {
|
|
109
|
+
outcome: RunOutcome;
|
|
110
|
+
verified: boolean;
|
|
111
|
+
} | undefined;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { atomicWrite } from "./fs-util.js";
|
|
6
|
+
export const STATE_FILE = "state.json";
|
|
7
|
+
export const STATE_SCHEMA_VERSION = 2;
|
|
8
|
+
/** `20261006T010203456Z-<128 random bits>`: sorts by start time and cannot collide, even for two starts in one millisecond. */
|
|
9
|
+
export function newRunId(d = new Date()) {
|
|
10
|
+
const t = d.toISOString().replace(/[-:]/g, "").replace(/\.(\d+)Z$/, "$1Z");
|
|
11
|
+
return `${t}-${crypto.randomBytes(16).toString("hex")}`;
|
|
12
|
+
}
|
|
13
|
+
const nonNeg = z.number().finite();
|
|
14
|
+
const WireState = z
|
|
15
|
+
.object({
|
|
16
|
+
schema_version: z.number().int().optional(),
|
|
17
|
+
mail_layout: z.enum(["run", "legacy"]).optional(),
|
|
18
|
+
run_id: z.string().min(1),
|
|
19
|
+
project: z.string().optional(),
|
|
20
|
+
task_source: z.enum(["text", "file"]).optional(),
|
|
21
|
+
task_path: z.string().optional(),
|
|
22
|
+
started_at: z.string().optional(),
|
|
23
|
+
ended_at: z.string().optional(),
|
|
24
|
+
rounds: nonNeg.optional(),
|
|
25
|
+
max_rounds: nonNeg.optional(),
|
|
26
|
+
end_reason: z.enum(["done", "idle", "max_rounds", "lead_failed", "cancelled"]).optional(),
|
|
27
|
+
outcome: z.enum(["completed", "partial", "blocked", "failed", "cancelled"]).optional(),
|
|
28
|
+
outcome_note: z.string().optional(),
|
|
29
|
+
verification: z.string().optional(),
|
|
30
|
+
done_rejections: nonNeg.optional(),
|
|
31
|
+
notes: z.array(z.string()).optional(),
|
|
32
|
+
sessions: z.record(z.string()).optional(),
|
|
33
|
+
output_tokens: nonNeg.optional(),
|
|
34
|
+
last_wake: z.record(z.any()).optional(),
|
|
35
|
+
pid: z.number().optional(),
|
|
36
|
+
task_summary: z.string().optional(),
|
|
37
|
+
active: z.record(z.any()).optional(),
|
|
38
|
+
wakes: z.array(z.any()).optional(),
|
|
39
|
+
steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
40
|
+
})
|
|
41
|
+
.passthrough();
|
|
42
|
+
/** A fresh v2 state; every collection present. */
|
|
43
|
+
export function newRunState(init) {
|
|
44
|
+
return normalize({ ...init, schema_version: STATE_SCHEMA_VERSION, mail_layout: init.mail_layout ?? "run" });
|
|
45
|
+
}
|
|
46
|
+
function normalize(w) {
|
|
47
|
+
const version = w.schema_version ?? 1;
|
|
48
|
+
return {
|
|
49
|
+
...w,
|
|
50
|
+
schema_version: version,
|
|
51
|
+
mail_layout: version >= 2 ? (w.mail_layout ?? "run") : "legacy",
|
|
52
|
+
run_id: w.run_id,
|
|
53
|
+
project: w.project ?? "",
|
|
54
|
+
task_source: w.task_source ?? "text",
|
|
55
|
+
started_at: w.started_at ?? "",
|
|
56
|
+
rounds: w.rounds ?? 0,
|
|
57
|
+
max_rounds: w.max_rounds ?? 0,
|
|
58
|
+
sessions: w.sessions ?? {},
|
|
59
|
+
output_tokens: w.output_tokens ?? 0,
|
|
60
|
+
last_wake: w.last_wake ?? {},
|
|
61
|
+
pid: w.pid ?? 0,
|
|
62
|
+
task_summary: w.task_summary ?? "",
|
|
63
|
+
active: w.active ?? {},
|
|
64
|
+
wakes: w.wakes ?? [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Read and validate `<runDir>/state.json`; throws an error naming the file and the problem. */
|
|
68
|
+
export function loadRunState(runDir) {
|
|
69
|
+
const file = path.join(runDir, STATE_FILE);
|
|
70
|
+
let text;
|
|
71
|
+
try {
|
|
72
|
+
text = fs.readFileSync(file, "utf8");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error(`Run state not found: ${file}`);
|
|
76
|
+
}
|
|
77
|
+
let raw;
|
|
78
|
+
try {
|
|
79
|
+
raw = JSON.parse(text);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
throw new Error(`Run state is not valid JSON (${file}): ${e.message}`);
|
|
83
|
+
}
|
|
84
|
+
const parsed = WireState.safeParse(raw);
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
const msg = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
87
|
+
throw new Error(`Run state is invalid (${file}): ${msg}`);
|
|
88
|
+
}
|
|
89
|
+
if ((parsed.data.schema_version ?? 1) > STATE_SCHEMA_VERSION) {
|
|
90
|
+
throw new Error(`Run state ${file} uses schema_version ${parsed.data.schema_version}, newer than this agent-lyceum understands (${STATE_SCHEMA_VERSION}). Upgrade agent-lyceum.`);
|
|
91
|
+
}
|
|
92
|
+
return normalize(parsed.data);
|
|
93
|
+
}
|
|
94
|
+
export function saveRunState(runDir, state) {
|
|
95
|
+
atomicWrite(path.join(runDir, STATE_FILE), JSON.stringify(state, null, 2));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The project as one run sees it. Per-task memory is always private to the run. With the "run" layout the
|
|
99
|
+
* mailboxes live inside the run dir; with "legacy" they stay the shared project mailboxes older versions used.
|
|
100
|
+
*/
|
|
101
|
+
export function bindRunProject(project, runDir, layout) {
|
|
102
|
+
const runId = path.basename(runDir);
|
|
103
|
+
const agents = Object.fromEntries(Object.entries(project.agents).map(([n, a]) => [n, { ...a, memory: { ...a.memory, task: path.join(project.paths.taskMemory, runId, n) } }]));
|
|
104
|
+
const paths = layout === "run"
|
|
105
|
+
? { ...project.paths, inboxRoot: path.join(runDir, "mail", "inbox"), outboxRoot: path.join(runDir, "mail", "outbox") }
|
|
106
|
+
: project.paths;
|
|
107
|
+
return { ...project, agents, paths, run: { id: runId, dir: runDir, layout } };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* How a finished run turned out. Runs that ended before outcomes existed have none recorded: they read as
|
|
111
|
+
* partial (failed for a failed lead) and `verified: false`, never as a success nobody checked.
|
|
112
|
+
*/
|
|
113
|
+
export function outcomeOf(s) {
|
|
114
|
+
if (s.outcome)
|
|
115
|
+
return { outcome: s.outcome, verified: true };
|
|
116
|
+
if (!s.end_reason)
|
|
117
|
+
return undefined;
|
|
118
|
+
return { outcome: s.end_reason === "lead_failed" ? "failed" : s.end_reason === "cancelled" ? "cancelled" : "partial", verified: false };
|
|
119
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface InitResult {
|
|
2
|
+
home: string;
|
|
3
|
+
created: string[];
|
|
4
|
+
skipped: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function initHome(home: string): InitResult;
|
|
7
|
+
export declare function addProject(home: string, name: string, dir: string): {
|
|
8
|
+
root: string;
|
|
9
|
+
config: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function removeProject(home: string, name: string, purge: boolean): string;
|
package/dist/scaffold.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertName, homePaths, projectPaths } from "./paths.js";
|
|
4
|
+
const PERSONAS = {
|
|
5
|
+
lead: `# Lead
|
|
6
|
+
|
|
7
|
+
You coordinate the team. You receive the user's task, break it into concrete pieces, and delegate each piece to the right teammate by mail.
|
|
8
|
+
|
|
9
|
+
- Keep \`COMMON.md\` (shared team context: goal, architecture decisions, conventions) accurate and short.
|
|
10
|
+
- Write every task with the standard task headings, and cite the mail it derives from under Upstream so teammates can trace it.
|
|
11
|
+
- When a teammate reports back, decide the next step: assign follow-up work, ask another teammate to verify, or accept the result.
|
|
12
|
+
- Teammates cannot message each other by default; route everything through yourself.
|
|
13
|
+
- When the whole job is verified and finished, send a \`done\` message summarising what was delivered.
|
|
14
|
+
`,
|
|
15
|
+
"fe-member": `# Frontend member
|
|
16
|
+
|
|
17
|
+
You implement frontend work assigned by the lead: components, pages, styling, client-side logic.
|
|
18
|
+
|
|
19
|
+
- Read the lead's message carefully and restate the acceptance criteria to yourself before editing.
|
|
20
|
+
- Keep changes small and focused; run the project's own checks (lint, type-check, tests) when they exist.
|
|
21
|
+
- Fill the standard reply headings: list changed files under Changes, how you checked them under Verification, and note anything unfinished or risky.
|
|
22
|
+
`,
|
|
23
|
+
"qa-member": `# QA member
|
|
24
|
+
|
|
25
|
+
You verify work: run and write tests, reproduce bugs, and review changes against the acceptance criteria.
|
|
26
|
+
|
|
27
|
+
- Do not fix product code yourself unless the lead asks; report findings to the lead with clear reproduction steps.
|
|
28
|
+
- Prefer concrete evidence (commands run, output, failing test names) over opinions.
|
|
29
|
+
- Besides the standard reply headings, add a \`## Verdict\` section containing \`pass\` or \`fail\`, and put your prioritised list of issues under Open items.
|
|
30
|
+
`,
|
|
31
|
+
};
|
|
32
|
+
const GLOBAL_TEAM_YAML = `# Global agent library. Projects pick agents from here by name and may override any field.
|
|
33
|
+
# Relative paths here are resolved against this folder. "~" is allowed.
|
|
34
|
+
agents:
|
|
35
|
+
lead:
|
|
36
|
+
runtime: claude-code # claude-code | codex
|
|
37
|
+
agent_md: agents/lead/AGENT.md
|
|
38
|
+
memory: { global: agents/lead/memory }
|
|
39
|
+
fe-member:
|
|
40
|
+
runtime: claude-code
|
|
41
|
+
agent_md: agents/fe-member/AGENT.md
|
|
42
|
+
memory: { global: agents/fe-member/memory }
|
|
43
|
+
qa-member:
|
|
44
|
+
runtime: claude-code
|
|
45
|
+
agent_md: agents/qa-member/AGENT.md
|
|
46
|
+
memory: { global: agents/qa-member/memory }
|
|
47
|
+
`;
|
|
48
|
+
export function initHome(home) {
|
|
49
|
+
const hp = homePaths(home);
|
|
50
|
+
const created = [];
|
|
51
|
+
const skipped = [];
|
|
52
|
+
const writeOnce = (file, content) => {
|
|
53
|
+
if (fs.existsSync(file)) {
|
|
54
|
+
skipped.push(file);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
58
|
+
fs.writeFileSync(file, content);
|
|
59
|
+
created.push(file);
|
|
60
|
+
};
|
|
61
|
+
fs.mkdirSync(hp.projectsDir, { recursive: true });
|
|
62
|
+
writeOnce(hp.globalConfig, GLOBAL_TEAM_YAML);
|
|
63
|
+
for (const [name, text] of Object.entries(PERSONAS)) {
|
|
64
|
+
writeOnce(path.join(hp.globalAgentsDir, name, "AGENT.md"), text);
|
|
65
|
+
fs.mkdirSync(path.join(hp.globalAgentsDir, name, "memory"), { recursive: true });
|
|
66
|
+
}
|
|
67
|
+
return { home, created, skipped };
|
|
68
|
+
}
|
|
69
|
+
export function addProject(home, name, dir) {
|
|
70
|
+
assertName("project", name);
|
|
71
|
+
const hp = homePaths(home);
|
|
72
|
+
if (!fs.existsSync(hp.globalConfig)) {
|
|
73
|
+
throw new Error(`Not initialised: ${hp.globalConfig} is missing. Run \`agent-lyceum init\` first.`);
|
|
74
|
+
}
|
|
75
|
+
const repo = path.resolve(dir);
|
|
76
|
+
if (!fs.existsSync(repo) || !fs.statSync(repo).isDirectory())
|
|
77
|
+
throw new Error(`--dir is not a directory: ${repo}`);
|
|
78
|
+
const pp = projectPaths(home, name);
|
|
79
|
+
if (fs.existsSync(pp.config))
|
|
80
|
+
throw new Error(`Project "${name}" already exists (${pp.config}).`);
|
|
81
|
+
fs.mkdirSync(path.join(pp.shared, "common"), { recursive: true });
|
|
82
|
+
fs.mkdirSync(pp.runs, { recursive: true });
|
|
83
|
+
const yaml = `# Project "${name}". Paths here are relative to this folder; "~" is allowed.
|
|
84
|
+
dir: ${repo} # the repo the agents work in (the tool never writes config into it)
|
|
85
|
+
team:
|
|
86
|
+
lead: lead
|
|
87
|
+
dispatcher:
|
|
88
|
+
max_rounds: 30 # stop after this many agent wake-ups
|
|
89
|
+
max_parallel: 1 # >1 needs non-overlapping "owns" for every non-lead agent, and a git repo
|
|
90
|
+
workspace_mode: auto # auto = a git worktree per non-lead agent when max_parallel > 1; shared | worktree to force
|
|
91
|
+
wake_timeout_sec: 600
|
|
92
|
+
retry: 1
|
|
93
|
+
strict: false # true = refuse to start unless context protection is OS-enforced
|
|
94
|
+
agents:
|
|
95
|
+
# Fields not set here are inherited from the global team.yaml.
|
|
96
|
+
lead:
|
|
97
|
+
resume: true # keep the lead's session between wake-ups
|
|
98
|
+
can_message: all
|
|
99
|
+
can_edit_agent_md: true
|
|
100
|
+
fe-member:
|
|
101
|
+
can_message: [lead]
|
|
102
|
+
# owns: ["src/web/**"]
|
|
103
|
+
# memory: { project: agents/fe-member/memory }
|
|
104
|
+
qa-member:
|
|
105
|
+
can_message: [lead]
|
|
106
|
+
# owns: ["tests/**"]
|
|
107
|
+
`;
|
|
108
|
+
fs.writeFileSync(pp.config, yaml);
|
|
109
|
+
const common = path.join(pp.shared, "common", "COMMON.md");
|
|
110
|
+
if (!fs.existsSync(common)) {
|
|
111
|
+
fs.writeFileSync(common, `# ${name} — shared team context\n\n(The lead maintains this file. Goal, architecture decisions, conventions. Keep it under 8 KB.)\n`);
|
|
112
|
+
}
|
|
113
|
+
return { root: pp.root, config: pp.config };
|
|
114
|
+
}
|
|
115
|
+
export function removeProject(home, name, purge) {
|
|
116
|
+
const pp = projectPaths(home, name);
|
|
117
|
+
if (!fs.existsSync(pp.config))
|
|
118
|
+
throw new Error(`Project "${name}" is not registered.`);
|
|
119
|
+
if (purge) {
|
|
120
|
+
fs.rmSync(pp.root, { recursive: true, force: true });
|
|
121
|
+
return `Removed ${pp.root} (registration and all context).`;
|
|
122
|
+
}
|
|
123
|
+
const dest = `${pp.config}.removed-${Date.now()}`;
|
|
124
|
+
fs.renameSync(pp.config, dest);
|
|
125
|
+
return `Unregistered "${name}". Context kept in ${pp.root} (config renamed to ${path.basename(dest)}). Use --purge to delete it.`;
|
|
126
|
+
}
|