agent-lyceum 0.6.0 → 0.7.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 +14 -1
- package/README.zh-TW.md +14 -1
- package/dist/call.d.ts +35 -0
- package/dist/call.js +162 -0
- package/dist/cli.js +54 -6
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -2
- package/dist/policy.js +3 -2
- package/dist/prompt.d.ts +4 -0
- package/dist/prompt.js +38 -0
- package/dist/run-session.d.ts +2 -0
- package/dist/run-session.js +6 -2
- package/dist/run-store.d.ts +2 -0
- package/dist/run-store.js +1 -0
- package/dist/solo.d.ts +12 -0
- package/dist/solo.js +28 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,7 +28,8 @@ Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (already logged i
|
|
|
28
28
|
| `project add <name> --dir <repo>` | Register a project. [details](docs/commands.md#project-add) |
|
|
29
29
|
| `project list` / `project remove <name> [--purge]` | List or unregister projects. [details](docs/commands.md#project-list-and-remove) |
|
|
30
30
|
| `validate [-p name] [--task-file f]` | Validate the merged config and show each agent's enforcement level. [details](docs/commands.md#validate) |
|
|
31
|
-
| `run ["task"] [--task-file f] [-p name]` | Give the task to the lead and run until done. [details](docs/commands.md#run) |
|
|
31
|
+
| `run ["task"] [--task-file f] [--agent a] [-p name]` | Give the task to the lead and run until done; with `--agent`, to one member alone. [details](docs/commands.md#run) |
|
|
32
|
+
| `call <agent> ["task"] [--task-file f] [--dir d]` | Call one global agent on its own, outside any project; prints its answer. [details](docs/commands.md#call) |
|
|
32
33
|
| `resume [run-id] [-p name]` | Continue an interrupted or failed run. [details](docs/commands.md#resume) |
|
|
33
34
|
| `answer <run-id> [--no-edit] [-p name]` | Answer the questions a waiting run asked, then continue it. [details](docs/commands.md#answer) |
|
|
34
35
|
| `status [-p name] [--monitor]` | Show agents, mail, the current wake-up and past runs (`--json`, `--monitor`). [details](docs/commands.md#status) |
|
|
@@ -41,6 +42,17 @@ Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (already logged i
|
|
|
41
42
|
|
|
42
43
|
Ctrl-C cancels a run cleanly and it can be continued with `resume` ([Stopping a run](docs/commands.md#stopping-a-run)). `run` and `resume` exit `0` only when the lead reported `completed`, `2` for partial or blocked, `1` for failed, `3` while waiting for your answers, `130` for cancelled ([Exit codes](docs/commands.md#exit-codes)). Without `-p`, the project is inferred from the current directory ([Choosing the project](docs/commands.md#choosing-the-project)).
|
|
43
44
|
|
|
45
|
+
## Running one agent on its own
|
|
46
|
+
|
|
47
|
+
Two ways to give a task to a single agent instead of the whole team. The agent works alone: it cannot mail teammates, ask you questions or edit any `AGENT.md`.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx agent-lyceum run "tidy the form" --agent fe-member -p web # a member of the project, as an ordinary run
|
|
51
|
+
npx agent-lyceum call pm "is this ready to build?" --dir ~/code/web-app # a global agent, outside any project
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`run --agent` has a run id and the project lock, shows in `status`, and can be resumed; the agent ends the run with its own `done` mail. `call` needs no project at all: the agent comes from `team.yaml`, works in `--dir` (default: the current directory, which must not be inside or above the agent-lyceum home), prints only its final answer on stdout, and keeps `task.md`, `log/` and `result.md` in `<home>/calls/<call-id>/`. Details: [run](docs/commands.md#run) and [call](docs/commands.md#call).
|
|
55
|
+
|
|
44
56
|
## Monitor mod (Claude Code)
|
|
45
57
|
|
|
46
58
|
`plugins/status-monitor` is a Claude Code **mod**, not an ordinary plugin: it is written against Claude Code's mod hooks API (`import type { Register } from 'claude-code'`) and runs inside the Claude Code session, where it adds a status line entry, toasts and a `/status-monitor` pane that poll `agent-lyceum status --json`. It needs a Claude Code build that supports mods; it does not change how agent-lyceum itself runs. It shows live run status for one or several projects.
|
|
@@ -75,6 +87,7 @@ Changes take effect on the next session start. If the pane shows `無法取得
|
|
|
75
87
|
~/agent-lyceum-config/
|
|
76
88
|
├── team.yaml # global agent library
|
|
77
89
|
├── agents/<agent>/{AGENT.md, memory/} # global persona + cross-project memory
|
|
90
|
+
├── calls/<call-id>/{task.md, log/, result.md} # one folder per `call` (outside any project), plus .lock-<agent>
|
|
78
91
|
└── projects/<project>/
|
|
79
92
|
├── project.yaml # team, repo dir, overrides
|
|
80
93
|
├── agents/<agent>/{AGENT.md?, memory/} # project-level persona (optional) + project memory
|
package/README.zh-TW.md
CHANGED
|
@@ -28,7 +28,8 @@ npx agent-lyceum status
|
|
|
28
28
|
| `project add <name> --dir <repo>` | 註冊專案。 [詳細](docs/commands.zh-TW.md#project-add) |
|
|
29
29
|
| `project list` / `project remove <name> [--purge]` | 列出或取消註冊專案。 [詳細](docs/commands.zh-TW.md#project-list-and-remove) |
|
|
30
30
|
| `validate [-p name] [--task-file f]` | 驗證設定並顯示每個 agent 的防護等級。 [詳細](docs/commands.zh-TW.md#validate) |
|
|
31
|
-
| `run ["task"] [--task-file f] [-p name]` | 把任務交給 lead
|
|
31
|
+
| `run ["task"] [--task-file f] [--agent a] [-p name]` | 把任務交給 lead 並執行到結束;加 `--agent` 則只交給某個成員單獨做。 [詳細](docs/commands.zh-TW.md#run) |
|
|
32
|
+
| `call <agent> ["task"] [--task-file f] [--dir d]` | 單獨呼叫一個 global agent(不需專案),印出它的回答。 [詳細](docs/commands.zh-TW.md#call) |
|
|
32
33
|
| `resume [run-id] [-p name]` | 接續被中斷或失敗的 run。 [詳細](docs/commands.zh-TW.md#resume) |
|
|
33
34
|
| `answer <run-id> [--no-edit] [-p name]` | 回答等待中的 run 提出的問題,並接續執行。 [詳細](docs/commands.zh-TW.md#answer) |
|
|
34
35
|
| `status [-p name] [--monitor]` | 顯示 agent、信件、目前執行與歷次 run(支援 `--json`、`--monitor`)。 [詳細](docs/commands.zh-TW.md#status) |
|
|
@@ -41,6 +42,17 @@ npx agent-lyceum status
|
|
|
41
42
|
|
|
42
43
|
Ctrl-C 會乾淨地取消 run,之後可用 `resume` 接續([中止 run](docs/commands.zh-TW.md#中止-run))。`run`/`resume` 只有在 lead 回報 `completed` 時才 exit `0`;`partial`/`blocked` 為 `2`,`failed` 為 `1`,等待你回答時為 `3`,取消為 `130`([退出碼](docs/commands.zh-TW.md#退出碼))。未指定 `-p` 時,專案由目前目錄推斷([選擇專案](docs/commands.zh-TW.md#選擇專案))。
|
|
43
44
|
|
|
45
|
+
## 單獨呼叫一個 agent
|
|
46
|
+
|
|
47
|
+
有兩種方式把任務只交給單一 agent,而不是整個團隊。該 agent 獨自工作:不能寄信給隊友、不能向你提問、不能修改任何 `AGENT.md`。
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx agent-lyceum run "整理表單" --agent fe-member -p web # 專案裡的一個成員,當作一般 run
|
|
51
|
+
npx agent-lyceum call pm "這個需求可以開工了嗎?" --dir ~/code/web-app # global agent,不屬於任何專案
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`run --agent` 有 run id 與專案鎖、會出現在 `status`、可以 `resume`;agent 用自己寄出的 `done` 結束 run。`call` 完全不需要專案:agent 取自 `team.yaml`,在 `--dir`(預設是目前目錄,且不可位於 agent-lyceum home 之內或之上)工作,stdout 只印它的最終回答,並在 `<home>/calls/<call-id>/` 留下 `task.md`、`log/` 與 `result.md`。細節見 [run](docs/commands.zh-TW.md#run) 與 [call](docs/commands.zh-TW.md#call)。
|
|
55
|
+
|
|
44
56
|
## 監控 mod(Claude Code)
|
|
45
57
|
|
|
46
58
|
`plugins/status-monitor` 是 Claude Code **mod**,不是一般的 plugin:它依 Claude Code 的 mod hooks API 撰寫(`import type { Register } from 'claude-code'`),在 Claude Code session 內執行,提供狀態列、通知與 `/status-monitor` 面板,並輪詢 `agent-lyceum status --json`。需要支援 mod 的 Claude Code 版本;不會改變 agent-lyceum 本身的運作。會顯示一個或多個專案的即時執行狀態。
|
|
@@ -75,6 +87,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
75
87
|
~/agent-lyceum-config/
|
|
76
88
|
├── team.yaml # 全域 agent 庫
|
|
77
89
|
├── agents/<agent>/{AGENT.md, memory/} # 全域人設 + 跨專案記憶
|
|
90
|
+
├── calls/<call-id>/{task.md, log/, result.md} # 每次 `call`(不屬於任何專案)一個資料夾,另有 .lock-<agent>
|
|
78
91
|
└── projects/<project>/
|
|
79
92
|
├── project.yaml # 團隊、repo 路徑、覆寫設定
|
|
80
93
|
├── agents/<agent>/{AGENT.md?, memory/} # 專案層人設(選用)+ 專案記憶
|
package/dist/call.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Invoker } from "./adapters/index.js";
|
|
2
|
+
import { type ResolvedAgent, type ResolvedProject } from "./config.js";
|
|
3
|
+
export declare const CALL_PROJECT = "(call)";
|
|
4
|
+
export declare const callsDir: (home: string) => string;
|
|
5
|
+
export interface CallOptions {
|
|
6
|
+
home: string;
|
|
7
|
+
agent: string;
|
|
8
|
+
task: string;
|
|
9
|
+
/** Working directory; must exist. */
|
|
10
|
+
dir: string;
|
|
11
|
+
invoker?: Invoker;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
say?: (line: string) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface CallResult {
|
|
16
|
+
callId: string;
|
|
17
|
+
callDir: string;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
cancelled: boolean;
|
|
20
|
+
/** The agent's final reply (empty when the call failed). */
|
|
21
|
+
text: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
/** Protected files the agent changed and agent-lyceum put back. */
|
|
24
|
+
reverted: string[];
|
|
25
|
+
}
|
|
26
|
+
/** A global agent (from team.yaml) as a `call` uses it: global persona and memory, nobody to mail, nothing to ask, nothing to edit. */
|
|
27
|
+
export declare function resolveCallAgent(home: string, name: string): ResolvedAgent;
|
|
28
|
+
/** The made-up project a call runs in: the working directory is its repo, and every other path is inside the call's own folder. */
|
|
29
|
+
export declare function callProject(home: string, agent: ResolvedAgent, dir: string, callDir: string): ResolvedProject;
|
|
30
|
+
/** One call per agent at a time (two would write the same global memory). A lock whose process is gone, or that cannot be read, is taken over. */
|
|
31
|
+
export declare function lockAgent(home: string, agent: string, callId: string): {
|
|
32
|
+
release(): void;
|
|
33
|
+
};
|
|
34
|
+
/** Wake one global agent once, alone, in `dir`. Leaves task.md, log/ and result.md in `<home>/calls/<call-id>/`. */
|
|
35
|
+
export declare function callAgent(o: CallOptions): Promise<CallResult>;
|
package/dist/call.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { realInvoker } from "./adapters/index.js";
|
|
4
|
+
import { must } from "./assert.js";
|
|
5
|
+
import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
|
|
6
|
+
import { atomicWrite } from "./fs-util.js";
|
|
7
|
+
import { ProtectedGuard } from "./guard.js";
|
|
8
|
+
import { assertName, homePaths, isInside } from "./paths.js";
|
|
9
|
+
import { memoryDirs } from "./policy.js";
|
|
10
|
+
import { lockHolderAlive } from "./project-lock.js";
|
|
11
|
+
import { buildCallUserPrompt, buildSoloSystemPrompt } from "./prompt.js";
|
|
12
|
+
import { newRunId } from "./run-store.js";
|
|
13
|
+
import { DISPATCHER_DEFAULTS } from "./schema.js";
|
|
14
|
+
export const CALL_PROJECT = "(call)";
|
|
15
|
+
export const callsDir = (home) => path.join(home, "calls");
|
|
16
|
+
/** A global agent (from team.yaml) as a `call` uses it: global persona and memory, nobody to mail, nothing to ask, nothing to edit. */
|
|
17
|
+
export function resolveCallAgent(home, name) {
|
|
18
|
+
assertName("agent", name);
|
|
19
|
+
const g = loadGlobal(home);
|
|
20
|
+
if (!g.agents[name]) {
|
|
21
|
+
throw new ConfigError(`"${name}" is not an agent of the global library (${homePaths(home).globalConfig}). Global agents: ${Object.keys(g.agents).join(", ") || "(none)"}.`);
|
|
22
|
+
}
|
|
23
|
+
const p = resolveProjectFrom(home, CALL_PROJECT, g, { dir: home, team: { lead: name }, dispatcher: {}, agents: { [name]: {} } });
|
|
24
|
+
const a = must(p.agents[name], "the resolved agent");
|
|
25
|
+
if (!a.runtime)
|
|
26
|
+
throw new ConfigError(`Agent "${name}" has no runtime: set runtime (or a model) for it in team.yaml.`);
|
|
27
|
+
if (!fs.existsSync(a.agentMd))
|
|
28
|
+
throw new ConfigError(`AGENT.md of "${name}" not found: ${a.agentMd}`);
|
|
29
|
+
return {
|
|
30
|
+
...a,
|
|
31
|
+
memory: { global: a.memory.global ?? path.join(homePaths(home).globalAgentsDir, name, "memory") },
|
|
32
|
+
resume: false,
|
|
33
|
+
canMessage: [],
|
|
34
|
+
canAskUser: false,
|
|
35
|
+
canEditAgentMd: false,
|
|
36
|
+
owns: [],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The made-up project a call runs in: the working directory is its repo, and every other path is inside the call's own folder. */
|
|
40
|
+
export function callProject(home, agent, dir, callDir) {
|
|
41
|
+
const paths = {
|
|
42
|
+
root: callDir,
|
|
43
|
+
config: path.join(callDir, "call.yaml"),
|
|
44
|
+
agentsDir: path.join(callDir, "agents"),
|
|
45
|
+
shared: path.join(callDir, "shared"),
|
|
46
|
+
common: path.join(callDir, "shared", "COMMON.md"),
|
|
47
|
+
inboxRoot: path.join(callDir, "mail", "inbox"),
|
|
48
|
+
outboxRoot: path.join(callDir, "mail", "outbox"),
|
|
49
|
+
runs: path.join(callDir, "runs"),
|
|
50
|
+
taskMemory: path.join(callDir, "task-memory"),
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
home,
|
|
54
|
+
name: CALL_PROJECT,
|
|
55
|
+
dir,
|
|
56
|
+
lead: agent.name,
|
|
57
|
+
dispatcher: { ...DISPATCHER_DEFAULTS, max_parallel: 1, workspace_mode: "shared" },
|
|
58
|
+
agents: { [agent.name]: agent },
|
|
59
|
+
paths,
|
|
60
|
+
solo: "call",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function readHolder(file) {
|
|
64
|
+
try {
|
|
65
|
+
const j = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
66
|
+
return typeof j?.pid === "number" && typeof j?.call_id === "string" ? j : undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** One call per agent at a time (two would write the same global memory). A lock whose process is gone, or that cannot be read, is taken over. */
|
|
73
|
+
export function lockAgent(home, agent, callId) {
|
|
74
|
+
const dir = callsDir(home);
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
const file = path.join(dir, `.lock-${agent}`);
|
|
77
|
+
const mine = JSON.stringify({ pid: process.pid, call_id: callId });
|
|
78
|
+
for (let attempt = 0;; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
fs.writeFileSync(file, mine, { flag: "wx" });
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
if (e.code !== "EEXIST")
|
|
85
|
+
throw e;
|
|
86
|
+
const cur = readHolder(file);
|
|
87
|
+
if (cur && lockHolderAlive({ pid: cur.pid }))
|
|
88
|
+
throw new Error(`${agent} is already being called (call ${cur.call_id}, pid ${cur.pid}). Wait for it to finish.`);
|
|
89
|
+
if (attempt >= 1)
|
|
90
|
+
throw new Error(`Could not take ${file}; try again.`);
|
|
91
|
+
fs.rmSync(file, { force: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let released = false;
|
|
95
|
+
const release = () => {
|
|
96
|
+
if (released)
|
|
97
|
+
return;
|
|
98
|
+
released = true;
|
|
99
|
+
process.off("exit", release);
|
|
100
|
+
if (readHolder(file)?.call_id === callId)
|
|
101
|
+
fs.rmSync(file, { force: true });
|
|
102
|
+
};
|
|
103
|
+
process.on("exit", release);
|
|
104
|
+
return { release };
|
|
105
|
+
}
|
|
106
|
+
/** Wake one global agent once, alone, in `dir`. Leaves task.md, log/ and result.md in `<home>/calls/<call-id>/`. */
|
|
107
|
+
export async function callAgent(o) {
|
|
108
|
+
const say = o.say ?? ((s) => console.error(s));
|
|
109
|
+
const agent = resolveCallAgent(o.home, o.agent);
|
|
110
|
+
const dir = path.resolve(o.dir);
|
|
111
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory())
|
|
112
|
+
throw new Error(`Working directory not found: ${dir}`);
|
|
113
|
+
const real = (p) => {
|
|
114
|
+
try {
|
|
115
|
+
return fs.realpathSync(p);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return path.resolve(p);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const realDir = real(dir);
|
|
122
|
+
const realHome = real(o.home);
|
|
123
|
+
// The called agent is the only one listed, so only its files are protected: nothing may run where the rest of the home is writable.
|
|
124
|
+
if (isInside(realHome, realDir) || isInside(realDir, realHome)) {
|
|
125
|
+
throw new Error(`Working directory ${dir} overlaps the agent-lyceum home ${o.home}: a call could change other agents' files there. Pass a --dir that is neither inside nor above the agent-lyceum home.`);
|
|
126
|
+
}
|
|
127
|
+
if (!o.task.trim())
|
|
128
|
+
throw new Error("No task given: pass it as text or with --task-file.");
|
|
129
|
+
const callId = newRunId();
|
|
130
|
+
const callDir = path.join(callsDir(o.home), callId);
|
|
131
|
+
const lease = lockAgent(o.home, agent.name, callId);
|
|
132
|
+
try {
|
|
133
|
+
fs.mkdirSync(path.join(callDir, "log"), { recursive: true });
|
|
134
|
+
fs.writeFileSync(path.join(callDir, "task.md"), o.task);
|
|
135
|
+
for (const d of memoryDirs(agent))
|
|
136
|
+
fs.mkdirSync(d, { recursive: true });
|
|
137
|
+
const project = callProject(o.home, agent, dir, callDir);
|
|
138
|
+
const guard = new ProtectedGuard(project);
|
|
139
|
+
say(`Calling ${agent.name} in ${dir} (call ${callId})`);
|
|
140
|
+
const invoke = o.invoker ?? realInvoker;
|
|
141
|
+
const result = await invoke({
|
|
142
|
+
project,
|
|
143
|
+
agent,
|
|
144
|
+
workDir: callDir,
|
|
145
|
+
systemPrompt: buildSoloSystemPrompt(project, agent),
|
|
146
|
+
userPrompt: buildCallUserPrompt(agent, o.task),
|
|
147
|
+
timeoutSec: project.dispatcher.wake_timeout_sec,
|
|
148
|
+
signal: o.signal,
|
|
149
|
+
logDir: path.join(callDir, "log"),
|
|
150
|
+
}).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message }));
|
|
151
|
+
const reverted = guard.check([agent]).map((v) => v.file);
|
|
152
|
+
for (const f of reverted)
|
|
153
|
+
say(`warn ${agent.name} changed ${f}; it was put back.`);
|
|
154
|
+
const cancelled = !!result.cancelled;
|
|
155
|
+
const error = result.ok ? undefined : cancelled ? "cancelled" : (result.error ?? (result.timedOut ? "timed out" : "failed"));
|
|
156
|
+
atomicWrite(path.join(callDir, "result.md"), result.ok ? `${result.text.trimEnd()}\n` : `# Call ${cancelled ? "cancelled" : "failed"}\n\n${error}\n`);
|
|
157
|
+
return { callId, callDir, ok: result.ok, cancelled, text: result.ok ? result.text : "", error, reverted };
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
lease.release();
|
|
161
|
+
}
|
|
162
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { ConfigError, findProjectForCwd, flattenResolved, listProjects, resolveProject, resolveProjectWithSources } from "./config.js";
|
|
6
|
+
import { ConfigError, findProjectForCwd, flattenResolved, listProjects, loadGlobal, resolveProject, resolveProjectWithSources } from "./config.js";
|
|
7
7
|
import { openInEditor, prepareAnswers } from "./ask-answer.js";
|
|
8
8
|
import { restoreMemory, tidyMemory, tidyStamp } from "./memory-tidy.js";
|
|
9
9
|
import { RESULT_FILE, runTeam } from "./dispatcher.js";
|
|
@@ -16,8 +16,10 @@ import { absPath, assertName, projectPaths, resolveHome } from "./paths.js";
|
|
|
16
16
|
import { addProject, initHome, removeProject } from "./scaffold.js";
|
|
17
17
|
import { buildStatusReport, buildTaskListReport, formatMonitor, formatRunDetail, formatStatusWithLog, formatTaskList, latestUnfinishedRun, runIsAlive } from "./status.js";
|
|
18
18
|
import { prepareTask, readTaskFile } from "./task.js";
|
|
19
|
+
import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
|
|
19
20
|
import { formatEnforcement, validateProject } from "./validate.js";
|
|
20
21
|
import { must } from "./assert.js";
|
|
22
|
+
import { onlyAgent, soloProject } from "./solo.js";
|
|
21
23
|
const program = new Command();
|
|
22
24
|
program
|
|
23
25
|
.name("agent-lyceum")
|
|
@@ -251,9 +253,10 @@ async function driveRun(pr, runDir, first, assumeDefaults) {
|
|
|
251
253
|
}
|
|
252
254
|
program
|
|
253
255
|
.command("run [task]")
|
|
254
|
-
.description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>.')
|
|
256
|
+
.description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>. With --agent, give it to that one member alone.')
|
|
255
257
|
.option("-p, --project <name>")
|
|
256
258
|
.option("--task-file <path>", "read the task from a file")
|
|
259
|
+
.option("--agent <name>", "run only this member of the project, alone: it cannot mail teammates or ask you, and its own `done` ends the run")
|
|
257
260
|
.option("--assume-defaults", "when the run stops to ask, use the suggested answer for every question that has one")
|
|
258
261
|
.action(async (task, opts) => {
|
|
259
262
|
try {
|
|
@@ -263,7 +266,16 @@ program
|
|
|
263
266
|
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
264
267
|
if (!res.ok)
|
|
265
268
|
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
266
|
-
|
|
269
|
+
let checked = pr;
|
|
270
|
+
if (opts.agent) {
|
|
271
|
+
assertName("agent", opts.agent);
|
|
272
|
+
if (opts.assumeDefaults)
|
|
273
|
+
console.error("warn --assume-defaults is ignored with --agent: a solo agent cannot ask questions.");
|
|
274
|
+
// Fails with the member list (and a hint for a global-only agent) before anything is written.
|
|
275
|
+
const sp = soloProject(pr, opts.agent, { globalAgents: Object.keys(loadGlobal(home()).agents) });
|
|
276
|
+
checked = onlyAgent(sp, opts.agent);
|
|
277
|
+
}
|
|
278
|
+
await preflight(checked);
|
|
267
279
|
const runId = newRunId();
|
|
268
280
|
const runDir = path.join(pr.paths.runs, runId);
|
|
269
281
|
const lease = takeLock(pr, runId);
|
|
@@ -271,9 +283,9 @@ program
|
|
|
271
283
|
const cancel = cancelOnSignals();
|
|
272
284
|
try {
|
|
273
285
|
const prepared = prepareTask({ text: task, file: opts.taskFile, cwd: process.cwd(), runDir });
|
|
274
|
-
console.log(`Project ${pr.name} — repo ${pr.dir}`);
|
|
286
|
+
console.log(`Project ${pr.name} — repo ${pr.dir}${opts.agent ? ` — solo: ${opts.agent}` : ""}`);
|
|
275
287
|
console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
|
|
276
|
-
summary = await runTeam({ project: pr, task: prepared, runDir, signal: cancel.signal });
|
|
288
|
+
summary = await runTeam({ project: pr, task: prepared, runDir, soloAgent: opts.agent, signal: cancel.signal });
|
|
277
289
|
}
|
|
278
290
|
finally {
|
|
279
291
|
cancel.dispose();
|
|
@@ -298,7 +310,6 @@ program
|
|
|
298
310
|
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
299
311
|
if (!res.ok)
|
|
300
312
|
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
301
|
-
await preflight(pr);
|
|
302
313
|
let found;
|
|
303
314
|
if (runId) {
|
|
304
315
|
assertName("run", runId);
|
|
@@ -313,6 +324,8 @@ program
|
|
|
313
324
|
fail("No unfinished run to resume. Start one with: agent-lyceum run \"<task>\"");
|
|
314
325
|
}
|
|
315
326
|
const { dir, state } = found;
|
|
327
|
+
// A solo run needs only its own agent's runtime, as when it started.
|
|
328
|
+
await preflight(state.solo_agent ? onlyAgent(soloProject(pr, state.solo_agent), state.solo_agent) : pr);
|
|
316
329
|
if (!state.end_reason && runIsAlive(state, pr))
|
|
317
330
|
fail(`Run ${state.run_id} is still running (pid ${state.pid}).`);
|
|
318
331
|
if (state.end_reason === "done") {
|
|
@@ -500,6 +513,41 @@ program
|
|
|
500
513
|
fail(e.message);
|
|
501
514
|
}
|
|
502
515
|
});
|
|
516
|
+
program
|
|
517
|
+
.command("call <agent> [task]")
|
|
518
|
+
.description("Call one agent of the global library on its own, outside any project: it works in --dir (default: the current directory) and its final answer is printed. It cannot mail anyone or ask you.")
|
|
519
|
+
.option("--task-file <path>", "read the task from a file")
|
|
520
|
+
.option("--dir <path>", "the directory the agent works in (default: the current directory)")
|
|
521
|
+
.action(async (agentName, task, opts) => {
|
|
522
|
+
try {
|
|
523
|
+
if (task !== undefined && opts.taskFile)
|
|
524
|
+
fail("Give the task either as text or with --task-file, not both.");
|
|
525
|
+
const text = opts.taskFile ? readTaskFile(absPath(opts.taskFile, process.cwd())) : task;
|
|
526
|
+
if (!text?.trim())
|
|
527
|
+
fail("Give the task as text or with --task-file.");
|
|
528
|
+
const h = home();
|
|
529
|
+
const dir = absPath(opts.dir ?? ".", process.cwd());
|
|
530
|
+
const agent = resolveCallAgent(h, agentName);
|
|
531
|
+
await preflight(callProject(h, agent, dir, path.join(callsDir(h), "preflight")));
|
|
532
|
+
const cancel = cancelOnSignals();
|
|
533
|
+
let r;
|
|
534
|
+
try {
|
|
535
|
+
r = await callAgent({ home: h, agent: agentName, task: text, dir, signal: cancel.signal });
|
|
536
|
+
}
|
|
537
|
+
finally {
|
|
538
|
+
cancel.dispose();
|
|
539
|
+
}
|
|
540
|
+
console.error(`Call ${r.callId}: ${r.callDir}`);
|
|
541
|
+
if (r.cancelled)
|
|
542
|
+
fail("Cancelled.", 130);
|
|
543
|
+
if (!r.ok)
|
|
544
|
+
fail(`Call failed: ${r.error}\nLog: ${path.join(r.callDir, "log")}`);
|
|
545
|
+
console.log(r.text.trimEnd());
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
fail(e instanceof ConfigError ? e.message : e.message);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
503
551
|
const memoryCmd = program.command("memory").description("Look after the agents' long-term memory");
|
|
504
552
|
function layerOpt(v) {
|
|
505
553
|
if (v === undefined || v === "project")
|
package/dist/config.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface ResolvedProject {
|
|
|
41
41
|
};
|
|
42
42
|
/** Agents working in their own git worktree instead of `dir` (set by the dispatcher for the wake-ups in progress). */
|
|
43
43
|
workspaces?: Record<string, AgentWorkspace>;
|
|
44
|
+
/** Set for a single agent working alone: "run" = inside a run of this project (`run --agent`), "call" = outside any project (`call`). */
|
|
45
|
+
solo?: "run" | "call";
|
|
44
46
|
}
|
|
45
47
|
export declare function loadGlobal(home: string): {
|
|
46
48
|
agents: Record<string, {
|
|
@@ -90,6 +92,8 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
90
92
|
}>;
|
|
91
93
|
};
|
|
92
94
|
export declare function resolveProject(home: string, name: string): ResolvedProject;
|
|
95
|
+
/** The merge `resolveProject` does, from configs already read: `call` uses it with a made-up project that lists one global agent. */
|
|
96
|
+
export declare function resolveProjectFrom(home: string, name: string, g: ReturnType<typeof loadGlobal>, p: ReturnType<typeof loadProjectRaw>): ResolvedProject;
|
|
93
97
|
/** Registered projects: name -> repo dir (best effort; skips broken configs). */
|
|
94
98
|
export declare function listProjects(home: string): {
|
|
95
99
|
name: string;
|
package/dist/config.js
CHANGED
|
@@ -51,8 +51,10 @@ function resolveLayer(p, base) {
|
|
|
51
51
|
return out;
|
|
52
52
|
}
|
|
53
53
|
export function resolveProject(home, name) {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
return resolveProjectFrom(home, name, loadGlobal(home), loadProjectRaw(home, name));
|
|
55
|
+
}
|
|
56
|
+
/** The merge `resolveProject` does, from configs already read: `call` uses it with a made-up project that lists one global agent. */
|
|
57
|
+
export function resolveProjectFrom(home, name, g, p) {
|
|
56
58
|
const paths = projectPaths(home, name);
|
|
57
59
|
const hp = homePaths(home);
|
|
58
60
|
const lead = p.team.lead;
|
package/dist/policy.js
CHANGED
|
@@ -49,9 +49,10 @@ export function repoDirFor(project, agent) {
|
|
|
49
49
|
return project.workspaces?.[agent]?.dir ?? project.dir;
|
|
50
50
|
}
|
|
51
51
|
export function writePolicy(project, agent) {
|
|
52
|
-
const isLead = agent.name === project.lead;
|
|
52
|
+
const isLead = agent.name === project.lead && !project.solo;
|
|
53
53
|
const ws = project.workspaces?.[agent.name];
|
|
54
|
-
|
|
54
|
+
// A `call` has no mailboxes at all; a solo member of a run keeps its outbox to send `done`.
|
|
55
|
+
const allowDirs = [...memoryDirs(agent), ...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]), ...(ws ? [ws.gitDir] : [])];
|
|
55
56
|
const allowFiles = [];
|
|
56
57
|
if (isLead)
|
|
57
58
|
allowFiles.push(commonFile(project));
|
package/dist/prompt.d.ts
CHANGED
|
@@ -5,6 +5,10 @@ export declare const COMMON_LIMIT: number;
|
|
|
5
5
|
export declare const MEMORY_INDEX_LIMIT: number;
|
|
6
6
|
export declare const LEAD_BATCH_LIMIT = 5;
|
|
7
7
|
export declare function readPersona(agent: ResolvedAgent): string;
|
|
8
|
+
/** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
|
|
9
|
+
export declare function buildSoloSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
|
|
10
|
+
/** The prompt of a `call`: the memory indexes the agent is used to, then the task. */
|
|
11
|
+
export declare function buildCallUserPrompt(agent: ResolvedAgent, task: string): string;
|
|
8
12
|
/** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
|
|
9
13
|
export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
|
|
10
14
|
/** Messages one wake-up handles: the oldest one, or for the lead a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so it decides once on all of it. */
|
package/dist/prompt.js
CHANGED
|
@@ -22,8 +22,46 @@ function readCapped(file, limit) {
|
|
|
22
22
|
export function readPersona(agent) {
|
|
23
23
|
return fs.readFileSync(agent.agentMd, "utf8").trim();
|
|
24
24
|
}
|
|
25
|
+
/** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
|
|
26
|
+
export function buildSoloSystemPrompt(project, agent) {
|
|
27
|
+
const isCall = project.solo === "call";
|
|
28
|
+
const lines = [readPersona(agent), "", "---", "", "# Solo protocol (injected by agent-lyceum)", ""];
|
|
29
|
+
lines.push(`You are **${agent.name}**, working alone: there is no team, no lead and nobody to mail, and you cannot ask the user questions. You work in \`${repoDirFor(project, agent.name)}\`.`, isCall
|
|
30
|
+
? "Do the task you are given. Your final reply text is printed to the user as your answer, so end with the answer or a short report of what you did; nothing else is delivered."
|
|
31
|
+
: "");
|
|
32
|
+
if (!isCall) {
|
|
33
|
+
lines.push("## Finishing", `When the job is finished, create a Markdown file (any name ending in \`.md\`) directly inside \`${outboxDir(project, agent.name)}\` with this exact shape:`, "", "```", "---", "type: done", "subject: <short subject>", "outcome: completed|partial|blocked|failed", "---", "", "## Result", "## Files", "## Verification", "## Not done", "```", "", "That ends the run. `## Result` is the actual deliverable or conclusion (not just \"done\"), `## Files` the paths you created or changed, `## Verification` what you ran or checked and what it showed, `## Not done` anything skipped or unverified (or `None`). Only `completed` means success and it needs all four headings; if work remains or is blocked say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A summary in your reply text is never delivered: without a `done` mail the run ends as idle with no report.");
|
|
34
|
+
}
|
|
35
|
+
lines.push("", "## Memory");
|
|
36
|
+
const mems = memoryDirs(agent);
|
|
37
|
+
if (mems.length === 0)
|
|
38
|
+
lines.push("You have no long-term memory directory.");
|
|
39
|
+
for (const m of mems)
|
|
40
|
+
lines.push(`- ${memoryLabel(agent, m)}: \`${m}\``);
|
|
41
|
+
if (mems.length) {
|
|
42
|
+
lines.push("Keep a `MEMORY.md` index in each memory directory (one line per entry, pointing at a file). Its contents are shown in your prompt on every wake-up; you can write only inside these directories.", "Use memory only for durable lessons that stay true across tasks; never record task-specific details there.");
|
|
43
|
+
}
|
|
44
|
+
lines.push("", "## Rules", "- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`.", "- Never touch another agent's memory, inbox or outbox, or the configuration files of agent-lyceum.");
|
|
45
|
+
return lines.join("\n");
|
|
46
|
+
}
|
|
47
|
+
/** The prompt of a `call`: the memory indexes the agent is used to, then the task. */
|
|
48
|
+
export function buildCallUserPrompt(agent, task) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
for (const dir of memoryDirs(agent)) {
|
|
51
|
+
const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
|
|
52
|
+
parts.push(`# Your ${memoryLabel(agent, dir).split(" ")[0]} memory index (${dir}/MEMORY.md)`);
|
|
53
|
+
if (!idx)
|
|
54
|
+
parts.push("(no MEMORY.md yet — create one when you have something worth keeping)", "");
|
|
55
|
+
else
|
|
56
|
+
parts.push(idx.text.trim(), ...(idx.truncated ? [`[index truncated at ${MEMORY_INDEX_LIMIT} bytes — trim it]`] : []), "");
|
|
57
|
+
}
|
|
58
|
+
parts.push("# Task", task.trim());
|
|
59
|
+
return parts.join("\n");
|
|
60
|
+
}
|
|
25
61
|
/** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
|
|
26
62
|
export function buildSystemPrompt(project, agent) {
|
|
63
|
+
if (project.solo)
|
|
64
|
+
return buildSoloSystemPrompt(project, agent);
|
|
27
65
|
const outbox = outboxDir(project, agent.name);
|
|
28
66
|
const mates = Object.keys(project.agents).filter((n) => n !== agent.name);
|
|
29
67
|
const recipients = agent.canMessage === "all" ? mates : agent.canMessage.filter((n) => project.agents[n] && n !== agent.name);
|
package/dist/run-session.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface RunSummary {
|
|
|
28
28
|
}
|
|
29
29
|
export interface RunOptions {
|
|
30
30
|
project: ResolvedProject;
|
|
31
|
+
/** Run only this member of the project, alone (`run --agent`). A resumed run uses the one recorded in its state. */
|
|
32
|
+
soloAgent?: string;
|
|
31
33
|
/** The task for a new run; omit when resuming. */
|
|
32
34
|
task?: PreparedTask;
|
|
33
35
|
/** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
|
package/dist/run-session.js
CHANGED
|
@@ -14,6 +14,7 @@ import { buildSystemPrompt, buildUserPrompt, pickMessages } from "./prompt.js";
|
|
|
14
14
|
import { atomicWrite } from "./fs-util.js";
|
|
15
15
|
import { assertWorktreeRunnable, collectAgentChanges, existingAgentWorkspace, integrateAgentChanges, prepareAgentWorkspace, resolveWorkspaceMode, snapshotBase } from "./worktree.js";
|
|
16
16
|
import { bindRunProject, newRunId, newRunState, saveRunState } from "./run-store.js";
|
|
17
|
+
import { soloProject } from "./solo.js";
|
|
17
18
|
import { taskMessageBody } from "./task.js";
|
|
18
19
|
import { must } from "./assert.js";
|
|
19
20
|
export { newRunId };
|
|
@@ -62,11 +63,13 @@ export class RunSession {
|
|
|
62
63
|
throw new Error("runTeam needs a task or a run to resume");
|
|
63
64
|
const invoke = opts.invoker ?? realInvoker;
|
|
64
65
|
const say = opts.log ?? ((s) => console.log(s));
|
|
65
|
-
const
|
|
66
|
+
const soloName = resume ? resume.solo_agent : opts.soloAgent;
|
|
67
|
+
const base = soloName ? soloProject(opts.project, soloName) : opts.project;
|
|
68
|
+
const cfg = base.dispatcher;
|
|
66
69
|
const runId = path.basename(runDir);
|
|
67
70
|
const layout = resume ? resume.mail_layout : "run";
|
|
68
71
|
// Each run gets its own memory directory per agent and (new runs) its own mailboxes.
|
|
69
|
-
const project = bindRunProject(
|
|
72
|
+
const project = bindRunProject(base, runDir, layout);
|
|
70
73
|
// Parallel agents work in their own git worktrees; refuse early (before anything is written) when that cannot be done.
|
|
71
74
|
const wsMode = resolveWorkspaceMode(cfg);
|
|
72
75
|
if (wsMode === "worktree")
|
|
@@ -104,6 +107,7 @@ export class RunSession {
|
|
|
104
107
|
task_summary: t.subject,
|
|
105
108
|
mail_layout: layout,
|
|
106
109
|
workspace_mode: wsMode,
|
|
110
|
+
solo_agent: soloName,
|
|
107
111
|
});
|
|
108
112
|
const seed = parseSteps(task.content, false);
|
|
109
113
|
if (seed.length)
|
package/dist/run-store.d.ts
CHANGED
|
@@ -62,6 +62,8 @@ export interface RunState {
|
|
|
62
62
|
notes?: string[];
|
|
63
63
|
/** "worktree": non-lead agents work in their own git worktrees. */
|
|
64
64
|
workspace_mode?: "shared" | "worktree";
|
|
65
|
+
/** Set when the run was started with `run --agent <name>`: that member works alone (a resumed run keeps it). */
|
|
66
|
+
solo_agent?: string;
|
|
65
67
|
/** Agents whose finished work could not be brought into the repo yet; their work is kept on their branch. */
|
|
66
68
|
blocked_integrations?: {
|
|
67
69
|
agent: string;
|
package/dist/run-store.js
CHANGED
|
@@ -37,6 +37,7 @@ const WireState = z
|
|
|
37
37
|
task_summary: z.string().optional(),
|
|
38
38
|
active: z.record(z.string(), z.any()).optional(),
|
|
39
39
|
wakes: z.array(z.any()).optional(),
|
|
40
|
+
solo_agent: z.string().optional(),
|
|
40
41
|
steps: z.array(z.object({ text: z.string(), done: z.boolean() })).optional(),
|
|
41
42
|
})
|
|
42
43
|
.passthrough();
|
package/dist/solo.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ResolvedProject } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* The project as `run --agent <name>` sees it: `name` is the lead of a team of one, so the task goes to it and its `done` ends the run.
|
|
4
|
+
* It cannot mail anyone, ask the user, edit any AGENT.md or share the repo with a parallel agent. The other members stay listed, only
|
|
5
|
+
* so that their files stay protected; nobody mails them, so they are never woken.
|
|
6
|
+
* `globalAgents` (names in team.yaml) only improves the error message.
|
|
7
|
+
*/
|
|
8
|
+
export declare function soloProject(project: ResolvedProject, name: string, hints?: {
|
|
9
|
+
globalAgents?: string[];
|
|
10
|
+
}): ResolvedProject;
|
|
11
|
+
/** `project` with only `name` listed: what a preflight of a solo run needs to look at. */
|
|
12
|
+
export declare function onlyAgent(project: ResolvedProject, name: string): ResolvedProject;
|
package/dist/solo.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ConfigError } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* The project as `run --agent <name>` sees it: `name` is the lead of a team of one, so the task goes to it and its `done` ends the run.
|
|
4
|
+
* It cannot mail anyone, ask the user, edit any AGENT.md or share the repo with a parallel agent. The other members stay listed, only
|
|
5
|
+
* so that their files stay protected; nobody mails them, so they are never woken.
|
|
6
|
+
* `globalAgents` (names in team.yaml) only improves the error message.
|
|
7
|
+
*/
|
|
8
|
+
export function soloProject(project, name, hints = {}) {
|
|
9
|
+
const a = project.agents[name];
|
|
10
|
+
if (!a) {
|
|
11
|
+
const call = hints.globalAgents?.includes(name) ? ` "${name}" is in the global library: use \`agent-lyceum call ${name}\` to run it outside the project.` : "";
|
|
12
|
+
throw new ConfigError(`Agent "${name}" is not a member of project "${project.name}" (members: ${Object.keys(project.agents).join(", ")}).${call}`);
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
...project,
|
|
16
|
+
solo: "run",
|
|
17
|
+
lead: name,
|
|
18
|
+
dispatcher: { ...project.dispatcher, max_parallel: 1, workspace_mode: "shared" },
|
|
19
|
+
agents: { ...project.agents, [name]: { ...a, canMessage: [], canAskUser: false, canEditAgentMd: false, owns: [] } },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** `project` with only `name` listed: what a preflight of a solo run needs to look at. */
|
|
23
|
+
export function onlyAgent(project, name) {
|
|
24
|
+
const a = project.agents[name];
|
|
25
|
+
if (!a)
|
|
26
|
+
throw new ConfigError(`Agent "${name}" is not a member of project "${project.name}" (members: ${Object.keys(project.agents).join(", ")}).`);
|
|
27
|
+
return { ...project, agents: { [name]: a } };
|
|
28
|
+
}
|