agent-lyceum 0.5.0 → 0.6.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 -3
- package/README.zh-TW.md +6 -3
- package/dist/adapters/claude.js +2 -0
- package/dist/ask-answer.d.ts +10 -0
- package/dist/ask-answer.js +21 -0
- package/dist/ask-reply.d.ts +65 -0
- package/dist/ask-reply.js +165 -0
- package/dist/cli.js +195 -14
- package/dist/config.d.ts +4 -0
- package/dist/config.js +5 -1
- package/dist/doctor.js +3 -0
- package/dist/mailbox.d.ts +9 -1
- package/dist/mailbox.js +11 -3
- package/dist/memory-tidy.d.ts +115 -0
- package/dist/memory-tidy.js +351 -0
- package/dist/prompt.d.ts +20 -0
- package/dist/prompt.js +75 -2
- package/dist/run-outcome.js +2 -0
- package/dist/run-session.d.ts +10 -0
- package/dist/run-session.js +98 -1
- package/dist/run-store.d.ts +3 -1
- package/dist/run-store.js +4 -3
- package/dist/schema.d.ts +5 -2
- package/dist/schema.js +4 -1
- package/dist/status.d.ts +9 -1
- package/dist/status.js +32 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -30,13 +30,16 @@ Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (already logged i
|
|
|
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
31
|
| `run ["task"] [--task-file f] [-p name]` | Give the task to the lead and run until done. [details](docs/commands.md#run) |
|
|
32
32
|
| `resume [run-id] [-p name]` | Continue an interrupted or failed run. [details](docs/commands.md#resume) |
|
|
33
|
+
| `answer <run-id> [--no-edit] [-p name]` | Answer the questions a waiting run asked, then continue it. [details](docs/commands.md#answer) |
|
|
33
34
|
| `status [-p name] [--monitor]` | Show agents, mail, the current wake-up and past runs (`--json`, `--monitor`). [details](docs/commands.md#status) |
|
|
34
35
|
| `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` | Delete a run with its task memory and worktrees. [details](docs/commands.md#clear) |
|
|
35
36
|
| `config show --resolved [-p name] [--json]` | Print every effective setting and where it came from. [details](docs/commands.md#config-show) |
|
|
36
37
|
| `doctor [-p name] [--json]` | Check config, git, the lock and runtime CLIs without running agents. [details](docs/commands.md#doctor) |
|
|
38
|
+
| `memory tidy [-p name] [--agent a] [--layer project\|global] [--dry-run]` | Have agents tidy their own memory (manual only; archives, never deletes). [details](docs/commands.md#memory-tidy) |
|
|
39
|
+
| `memory restore <timestamp> -p name --agent a` | Undo a tidy: move its archived files back. [details](docs/commands.md#memory-restore) |
|
|
37
40
|
| `unlock [-p name] --force` | Remove the project lock a crashed run left behind. [details](docs/commands.md#unlock) |
|
|
38
41
|
|
|
39
|
-
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, `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)).
|
|
42
|
+
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)).
|
|
40
43
|
|
|
41
44
|
## Monitor mod (Claude Code)
|
|
42
45
|
|
|
@@ -53,7 +56,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
53
56
|
**Use it.**
|
|
54
57
|
- **Status line:** always on once loaded (`team 讀取中…` until the first poll, `team 無法取得狀態:…` when the CLI cannot run); shows the run state, round `n/max`, output tokens, the current step and unread mail. With several projects it shows one overview line (`team 1/3 running · a ... | b ...`).
|
|
55
58
|
- **Toasts:** a message pops up when a run finishes, is interrupted, or an agent's wake fails (prefixed `[project]` when several are watched). Nothing to do.
|
|
56
|
-
- **`/status-monitor`:** type it to open (or, if already open, close) the pane. It is off at session start unless `autoOpen` is set. In the **fullscreen layout** (`"tui": "fullscreen"` in `~/.claude/settings.json`, restart the session to apply; `/tui fullscreen` does the same) from 110 terminal columns it docks on the right at half the terminal width, and tabs and **關閉** (Close) can be clicked. Otherwise it shows above the prompt; the layout is fixed when the session starts, so a session started on the main screen stays that way. Keyboard (works in both): `x` closes, `1`-`9` or Tab/Enter switch task, Esc returns to the prompt, running the command again also closes. It shows, per project: the run id and state, task, progress and checklist, the agents working now and the queued mail, blocked integrations, notes and result summary, output tokens per runtime, per-agent wake statistics, the last 8 wakes. Each task
|
|
59
|
+
- **`/status-monitor`:** type it to open (or, if already open, close) the pane. It is off at session start unless `autoOpen` is set. In the **fullscreen layout** (`"tui": "fullscreen"` in `~/.claude/settings.json`, restart the session to apply; `/tui fullscreen` does the same) from 110 terminal columns it docks on the right at half the terminal width, and tabs and **關閉** (Close) can be clicked. Otherwise it shows above the prompt; the layout is fixed when the session starts, so a session started on the main screen stays that way. Keyboard (works in both): `x` closes, `1`-`9` or Tab/Enter switch task, Esc returns to the prompt, running the command again also closes. It shows, per project: the run id and state, task, progress and checklist, the agents working now and the queued mail, blocked integrations, notes and result summary, output tokens per runtime, per-agent wake statistics, the last 8 wakes. Each running task is a tab, newest first; finished and interrupted runs are not listed. It refreshes with the polling interval while open.
|
|
57
60
|
|
|
58
61
|
**Configure it.** In `~/.claude/settings.json` under `pluginConfigs."status-monitor@inline"` (`options`):
|
|
59
62
|
|
|
@@ -93,7 +96,7 @@ agents:
|
|
|
93
96
|
qa-member: { can_message: [lead], owns: ["tests/**"] }
|
|
94
97
|
```
|
|
95
98
|
|
|
96
|
-
Agent fields: `runtime` (`claude-code`|`codex`; optional when `model` is recognizable: `opus`/`sonnet`/`haiku`/`claude-*` → Claude Code, `gpt-*`/`o3`/`*codex*` → Codex; precedence: project runtime > project model > global runtime > global model), `model`, `effort` (Claude Code: `low`|`medium`|`high`|`xhigh`|`max` via `--effort`; Codex: `minimal`|`low`|`medium`|`high`|`xhigh` via `model_reasoning_effort`; unset = CLI default), `agent_md`, `memory.global` / `memory.project`, `resume`, `can_message` (`all` or list; default `[lead]`, lead default `all`), `can_edit_agent_md` (default only the lead), `owns` (repo globs).
|
|
99
|
+
Agent fields: `runtime` (`claude-code`|`codex`; optional when `model` is recognizable: `opus`/`sonnet`/`haiku`/`claude-*` → Claude Code, `gpt-*`/`o3`/`*codex*` → Codex; precedence: project runtime > project model > global runtime > global model), `model`, `effort` (Claude Code: `low`|`medium`|`high`|`xhigh`|`max` via `--effort`; Codex: `minimal`|`low`|`medium`|`high`|`xhigh` via `model_reasoning_effort`; unset = CLI default), `agent_md`, `memory.global` / `memory.project`, `resume`, `can_message` (`all` or list; default `[lead]`, lead default `all`), `can_edit_agent_md` (default only the lead), `can_ask_user` (may send `type: ask` mail to you and pause the run for an answer; default only the lead), `owns` (repo globs).
|
|
97
100
|
|
|
98
101
|
Rules checked by `validate`: ≥ 2 agents (the lead and one member; the template has three), the lead is a listed agent, every agent has a runtime (explicit or inferred from `model`) and an existing `AGENT.md`, `effort` is valid for the agent's runtime (warning if `runtime` contradicts a recognizable `model`), `can_message` targets exist, memory dirs don't overlap, and with `max_parallel > 1` every non-lead agent needs non-overlapping `owns` and the repo must be a git repository.
|
|
99
102
|
|
package/README.zh-TW.md
CHANGED
|
@@ -30,13 +30,16 @@ npx agent-lyceum status
|
|
|
30
30
|
| `validate [-p name] [--task-file f]` | 驗證設定並顯示每個 agent 的防護等級。 [詳細](docs/commands.zh-TW.md#validate) |
|
|
31
31
|
| `run ["task"] [--task-file f] [-p name]` | 把任務交給 lead 並執行到結束。 [詳細](docs/commands.zh-TW.md#run) |
|
|
32
32
|
| `resume [run-id] [-p name]` | 接續被中斷或失敗的 run。 [詳細](docs/commands.zh-TW.md#resume) |
|
|
33
|
+
| `answer <run-id> [--no-edit] [-p name]` | 回答等待中的 run 提出的問題,並接續執行。 [詳細](docs/commands.zh-TW.md#answer) |
|
|
33
34
|
| `status [-p name] [--monitor]` | 顯示 agent、信件、目前執行與歷次 run(支援 `--json`、`--monitor`)。 [詳細](docs/commands.zh-TW.md#status) |
|
|
34
35
|
| `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` | 刪除 run 與其任務記憶、worktree。 [詳細](docs/commands.zh-TW.md#clear) |
|
|
35
36
|
| `config show --resolved [-p name] [--json]` | 印出每項生效設定與其來源。 [詳細](docs/commands.zh-TW.md#config-show) |
|
|
36
37
|
| `doctor [-p name] [--json]` | 不執行 agent,檢查設定、git、鎖與 runtime CLI。 [詳細](docs/commands.zh-TW.md#doctor) |
|
|
38
|
+
| `memory tidy [-p name] [--agent a] [--layer project\|global] [--dry-run]` | 讓 agent 整理自己的記憶(只能手動觸發;封存而非刪除)。 [詳細](docs/commands.zh-TW.md#memory-tidy) |
|
|
39
|
+
| `memory restore <時間戳> -p name --agent a` | 還原一次整理:把封存的檔案搬回。 [詳細](docs/commands.zh-TW.md#memory-restore) |
|
|
37
40
|
| `unlock [-p name] --force` | 移除當機 run 留下的專案鎖。 [詳細](docs/commands.zh-TW.md#unlock) |
|
|
38
41
|
|
|
39
|
-
Ctrl-C 會乾淨地取消 run,之後可用 `resume` 接續([中止 run](docs/commands.zh-TW.md#中止-run))。`run`/`resume` 只有在 lead 回報 `completed` 時才 exit `0`;`partial`/`blocked` 為 `2`,`failed` 為 `1`,取消為 `130`([退出碼](docs/commands.zh-TW.md#退出碼))。未指定 `-p` 時,專案由目前目錄推斷([選擇專案](docs/commands.zh-TW.md#選擇專案))。
|
|
42
|
+
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#選擇專案))。
|
|
40
43
|
|
|
41
44
|
## 監控 mod(Claude Code)
|
|
42
45
|
|
|
@@ -53,7 +56,7 @@ claude --plugin-dir /path/to/agent-lyceum/plugins/status-monitor
|
|
|
53
56
|
**操作。**
|
|
54
57
|
- **狀態列:** 載入後常駐(第一次輪詢前顯示 `team 讀取中…`,CLI 跑不起來時顯示 `team 無法取得狀態:…`),顯示執行狀態、輪數 `n/max`、output tokens、目前步驟與未讀信件。監看多個專案時顯示一行總覽(`team 1/3 執行中 · a ... | b ...`)。
|
|
55
58
|
- **通知:** 執行完成、被中斷或某個 agent 喚醒失敗時會跳出訊息(監看多個專案時前面加 `[專案名]`),不需要任何操作。
|
|
56
|
-
- **`/status-monitor`:** 輸入即開啟面板(已開著則關閉)。session 啟動時預設不開,除非設定 `autoOpen`。在 **fullscreen 版面**(`~/.claude/settings.json` 設 `"tui": "fullscreen"` 後重開 session;`/tui fullscreen` 效果相同)且終端寬度 110 欄以上時,面板會靠右 dock 在終端寬度的一半,分頁與 **關閉** 都可以用滑鼠點。否則顯示在輸入框上方;版面在 session 啟動時就固定,從主畫面啟動的 session 不會變。鍵盤操作兩種版面都能用:`x` 關閉,`1`-`9` 或 Tab/Enter 切換任務,Esc 回到輸入框,再執行一次指令也會關閉。每個專案顯示:執行 id 與狀態、任務、進度與清單、正在工作的 agent 與排隊中的信件、受阻的整合、備註與結果摘要、各 runtime 的 output tokens、各 agent 的喚醒統計、最近 8
|
|
59
|
+
- **`/status-monitor`:** 輸入即開啟面板(已開著則關閉)。session 啟動時預設不開,除非設定 `autoOpen`。在 **fullscreen 版面**(`~/.claude/settings.json` 設 `"tui": "fullscreen"` 後重開 session;`/tui fullscreen` 效果相同)且終端寬度 110 欄以上時,面板會靠右 dock 在終端寬度的一半,分頁與 **關閉** 都可以用滑鼠點。否則顯示在輸入框上方;版面在 session 啟動時就固定,從主畫面啟動的 session 不會變。鍵盤操作兩種版面都能用:`x` 關閉,`1`-`9` 或 Tab/Enter 切換任務,Esc 回到輸入框,再執行一次指令也會關閉。每個專案顯示:執行 id 與狀態、任務、進度與清單、正在工作的 agent 與排隊中的信件、受阻的整合、備註與結果摘要、各 runtime 的 output tokens、各 agent 的喚醒統計、最近 8 次喚醒。每個執行中的任務是一個分頁,最新的排最前面;已結束或已中斷的不列出。面板開著時會依輪詢間隔自動更新。
|
|
57
60
|
|
|
58
61
|
**設定。** 在 `~/.claude/settings.json` 的 `pluginConfigs."status-monitor@inline"`(`options`):
|
|
59
62
|
|
|
@@ -94,7 +97,7 @@ agents:
|
|
|
94
97
|
qa-member: { can_message: [lead], owns: ["tests/**"] }
|
|
95
98
|
```
|
|
96
99
|
|
|
97
|
-
Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可省略:`opus`/`sonnet`/`haiku`/`claude-*` → Claude Code,`gpt-*`/`o3`/`*codex*` → Codex;優先順序:專案 runtime > 專案 model > 全域 runtime > 全域 model)、`model`、`effort`(Claude Code:`low`|`medium`|`high`|`xhigh`|`max`,經 `--effort`;Codex:`minimal`|`low`|`medium`|`high`|`xhigh`,經 `model_reasoning_effort`;未設則用 CLI 預設)、`agent_md`、`memory.global` / `memory.project`、`resume`、`can_message`(`all` 或清單;預設 `[lead]`,lead 預設 `all`)、`can_edit_agent_md`(預設只有 lead)、`owns`(repo glob)。
|
|
100
|
+
Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可省略:`opus`/`sonnet`/`haiku`/`claude-*` → Claude Code,`gpt-*`/`o3`/`*codex*` → Codex;優先順序:專案 runtime > 專案 model > 全域 runtime > 全域 model)、`model`、`effort`(Claude Code:`low`|`medium`|`high`|`xhigh`|`max`,經 `--effort`;Codex:`minimal`|`low`|`medium`|`high`|`xhigh`,經 `model_reasoning_effort`;未設則用 CLI 預設)、`agent_md`、`memory.global` / `memory.project`、`resume`、`can_message`(`all` 或清單;預設 `[lead]`,lead 預設 `all`)、`can_edit_agent_md`(預設只有 lead)、`can_ask_user`(可用 `type: ask` 信件向你提問並暫停 run 等待回答;預設只有 lead)、`owns`(repo glob)。
|
|
98
101
|
|
|
99
102
|
`validate` 檢查的規則:至少 2 個 agent(lead 加一位成員;範本預設是三位)、lead 在 agent 清單內、每個 agent 都有 runtime(明設或由 `model` 推斷)且 `AGENT.md` 存在、`effort` 對該 runtime 合法(若 `runtime` 與可辨識的 `model` 矛盾則警告)、`can_message` 的目標存在、記憶資料夾互不重疊,且當 `max_parallel > 1` 時,每個非 lead 的 agent 都必須有互不重疊的 `owns`,repo 也必須是 git repository。
|
|
100
103
|
|
package/dist/adapters/claude.js
CHANGED
|
@@ -35,6 +35,8 @@ export function buildClaudeSettings(project, agent) {
|
|
|
35
35
|
allow.push(rule("Edit", f));
|
|
36
36
|
const deny = pol.deny.flatMap(denyRules);
|
|
37
37
|
return {
|
|
38
|
+
// The user's own and plugin hooks (e.g. a hook that asks the user a question) would block every tool call of a headless wake-up.
|
|
39
|
+
disableAllHooks: true,
|
|
38
40
|
permissions: { allow, deny },
|
|
39
41
|
sandbox: {
|
|
40
42
|
enabled: true,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface PrepareResult {
|
|
2
|
+
complete: boolean;
|
|
3
|
+
problems: string[];
|
|
4
|
+
path: string;
|
|
5
|
+
}
|
|
6
|
+
/** Read the ask-reply file of a waiting run. With `assumeDefaults`, unanswered questions that have a suggestion take it (and the file is saved). */
|
|
7
|
+
export declare function prepareAnswers(runDir: string, opts: {
|
|
8
|
+
assumeDefaults?: boolean;
|
|
9
|
+
}): PrepareResult;
|
|
10
|
+
export declare function openInEditor(file: string, env?: NodeJS.ProcessEnv): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { applyDefaults, askReplyPath, checkAnswers, describeCheck, isComplete, readAskReply, writeAskReply } from "./ask-reply.js";
|
|
3
|
+
/** Read the ask-reply file of a waiting run. With `assumeDefaults`, unanswered questions that have a suggestion take it (and the file is saved). */
|
|
4
|
+
export function prepareAnswers(runDir, opts) {
|
|
5
|
+
const file = askReplyPath(runDir);
|
|
6
|
+
let reply = readAskReply(runDir);
|
|
7
|
+
if (!reply)
|
|
8
|
+
return { complete: false, problems: ["ask-reply.md does not exist"], path: file };
|
|
9
|
+
if (opts.assumeDefaults) {
|
|
10
|
+
reply = applyDefaults(reply);
|
|
11
|
+
writeAskReply(runDir, reply);
|
|
12
|
+
}
|
|
13
|
+
const check = checkAnswers(reply);
|
|
14
|
+
return { complete: isComplete(check), problems: describeCheck(check), path: file };
|
|
15
|
+
}
|
|
16
|
+
export function openInEditor(file, env = process.env) {
|
|
17
|
+
const editor = env.VISUAL || env.EDITOR || "vi";
|
|
18
|
+
const r = spawnSync(`${editor} ${JSON.stringify(file)}`, { shell: true, stdio: "inherit", env });
|
|
19
|
+
if (r.error || (r.status ?? 0) !== 0)
|
|
20
|
+
throw new Error(`The editor "${editor}" failed${r.error ? `: ${r.error.message}` : ` (exit ${r.status})`}.`);
|
|
21
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export declare const MAX_QUESTIONS_PER_ASK = 10;
|
|
2
|
+
export declare const ASK_REPLY_FILE = "ask-reply.md";
|
|
3
|
+
export type AnswerSource = "user" | "default";
|
|
4
|
+
export interface AskQuestion {
|
|
5
|
+
id: string;
|
|
6
|
+
text: string;
|
|
7
|
+
options?: string[];
|
|
8
|
+
suggested?: {
|
|
9
|
+
value: string;
|
|
10
|
+
reason?: string;
|
|
11
|
+
};
|
|
12
|
+
/** The agent that asked; the answer goes back to it. */
|
|
13
|
+
asker: string;
|
|
14
|
+
answer?: {
|
|
15
|
+
value: string;
|
|
16
|
+
by: AnswerSource;
|
|
17
|
+
};
|
|
18
|
+
/** The answer was already sent to the asker. */
|
|
19
|
+
delivered?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface AskReply {
|
|
22
|
+
status: "pending" | "answered";
|
|
23
|
+
/** Who asked the latest batch. */
|
|
24
|
+
askedBy: string;
|
|
25
|
+
/** The round of the latest batch. */
|
|
26
|
+
round: number;
|
|
27
|
+
questions: AskQuestion[];
|
|
28
|
+
}
|
|
29
|
+
export type NewQuestion = Omit<AskQuestion, "asker" | "answer" | "delivered">;
|
|
30
|
+
export declare const askReplyPath: (runDir: string) => string;
|
|
31
|
+
export declare function parseAskBody(body: string): {
|
|
32
|
+
ok: true;
|
|
33
|
+
questions: NewQuestion[];
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function parseAskReply(text: string): AskReply;
|
|
39
|
+
export declare function renderAskReply(r: AskReply): string;
|
|
40
|
+
export declare function readAskReply(runDir: string): AskReply | undefined;
|
|
41
|
+
export declare function writeAskReply(runDir: string, r: AskReply): void;
|
|
42
|
+
/** Add a batch of questions, keeping everything already in the file. The same id with the same text is skipped (a re-routed ask after a crash). */
|
|
43
|
+
export declare function addBatch(existing: AskReply | undefined, asker: string, round: number, qs: NewQuestion[]): {
|
|
44
|
+
ok: true;
|
|
45
|
+
reply: AskReply;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
error: string;
|
|
49
|
+
};
|
|
50
|
+
export interface AnswerCheck {
|
|
51
|
+
missing: string[];
|
|
52
|
+
invalid: {
|
|
53
|
+
id: string;
|
|
54
|
+
reason: string;
|
|
55
|
+
}[];
|
|
56
|
+
}
|
|
57
|
+
export declare const isComplete: (c: AnswerCheck) => boolean;
|
|
58
|
+
/** Only questions whose answer was not sent yet count. */
|
|
59
|
+
export declare function checkAnswers(r: AskReply): AnswerCheck;
|
|
60
|
+
export declare function describeCheck(c: AnswerCheck): string[];
|
|
61
|
+
export declare function applyDefaults(r: AskReply): AskReply;
|
|
62
|
+
export declare function askersWithAnswers(r: AskReply): string[];
|
|
63
|
+
/** The body of the `reply` mail that carries the answers back to `asker`. */
|
|
64
|
+
export declare function renderAnswers(r: AskReply, asker: string): string;
|
|
65
|
+
export declare function markDelivered(r: AskReply): AskReply;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { atomicWrite } from "./fs-util.js";
|
|
4
|
+
export const MAX_QUESTIONS_PER_ASK = 10;
|
|
5
|
+
export const ASK_REPLY_FILE = "ask-reply.md";
|
|
6
|
+
export const askReplyPath = (runDir) => path.join(runDir, "mail", ASK_REPLY_FILE);
|
|
7
|
+
// ---- a tiny reader for the few tags used here (no XML dependency) ----
|
|
8
|
+
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
9
|
+
const unesc = (s) => s.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
|
10
|
+
function tags(xml, name) {
|
|
11
|
+
const re = new RegExp(`<${name}((?:\\s+[\\w-]+="[^"]*")*)\\s*(?:/>|>([\\s\\S]*?)</${name}>)`, "g");
|
|
12
|
+
return [...xml.matchAll(re)].map((m) => {
|
|
13
|
+
const attrs = {};
|
|
14
|
+
for (const a of (m[1] ?? "").matchAll(/([\w-]+)="([^"]*)"/g))
|
|
15
|
+
attrs[a[1]] = unesc(a[2]);
|
|
16
|
+
return { attrs, inner: m[2] ?? "" };
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const first = (xml, name) => tags(xml, name)[0];
|
|
20
|
+
const textOf = (t) => unesc((t?.inner ?? "").trim());
|
|
21
|
+
// ---- the body of an `ask` mail ----
|
|
22
|
+
export function parseAskBody(body) {
|
|
23
|
+
const block = first(body, "ask");
|
|
24
|
+
if (!block)
|
|
25
|
+
return { ok: false, error: "the body has no <ask>…</ask> block" };
|
|
26
|
+
const found = tags(block.inner, "question");
|
|
27
|
+
if (found.length === 0)
|
|
28
|
+
return { ok: false, error: "the <ask> block needs at least one <question>" };
|
|
29
|
+
if (found.length > MAX_QUESTIONS_PER_ASK)
|
|
30
|
+
return { ok: false, error: `at most ${MAX_QUESTIONS_PER_ASK} questions per ask (got ${found.length}); ask the most important ones first` };
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const questions = [];
|
|
33
|
+
for (const [i, q] of found.entries()) {
|
|
34
|
+
const id = q.attrs.id?.trim();
|
|
35
|
+
if (!id || !/^[\w.-]+$/.test(id))
|
|
36
|
+
return { ok: false, error: `question ${i + 1} needs an id attribute made of letters, digits, "_", "-" or "."` };
|
|
37
|
+
if (seen.has(id))
|
|
38
|
+
return { ok: false, error: `duplicate question id "${id}"` };
|
|
39
|
+
seen.add(id);
|
|
40
|
+
const text = textOf(first(q.inner, "text"));
|
|
41
|
+
if (!text)
|
|
42
|
+
return { ok: false, error: `question "${id}" needs a non-empty <text>` };
|
|
43
|
+
const options = tags(first(q.inner, "options")?.inner ?? "", "option").map((o) => textOf(o)).filter(Boolean);
|
|
44
|
+
const sug = first(q.inner, "suggested");
|
|
45
|
+
const suggested = sug && textOf(sug) ? { value: textOf(sug), ...(sug.attrs.reason ? { reason: sug.attrs.reason } : {}) } : undefined;
|
|
46
|
+
if (suggested && options.length && !options.includes(suggested.value))
|
|
47
|
+
return { ok: false, error: `the suggested value of question "${id}" must be one of its options` };
|
|
48
|
+
questions.push({ id, text, ...(options.length ? { options } : {}), ...(suggested ? { suggested } : {}) });
|
|
49
|
+
}
|
|
50
|
+
return { ok: true, questions };
|
|
51
|
+
}
|
|
52
|
+
// ---- the ask-reply file ----
|
|
53
|
+
export function parseAskReply(text) {
|
|
54
|
+
const root = first(text, "ask-reply");
|
|
55
|
+
if (!root)
|
|
56
|
+
throw new Error("ask-reply.md has no <ask-reply> root tag");
|
|
57
|
+
const askedBy = root.attrs.asked_by ?? "";
|
|
58
|
+
const questions = tags(root.inner, "question").map((q) => {
|
|
59
|
+
const ans = first(q.inner, "answer");
|
|
60
|
+
const value = textOf(ans);
|
|
61
|
+
const options = tags(first(q.inner, "options")?.inner ?? "", "option").map((o) => textOf(o));
|
|
62
|
+
const sug = first(q.inner, "suggested");
|
|
63
|
+
return {
|
|
64
|
+
id: q.attrs.id ?? "",
|
|
65
|
+
text: textOf(first(q.inner, "text")),
|
|
66
|
+
...(options.length ? { options } : {}),
|
|
67
|
+
...(sug && textOf(sug) ? { suggested: { value: textOf(sug), ...(sug.attrs.reason ? { reason: sug.attrs.reason } : {}) } } : {}),
|
|
68
|
+
asker: q.attrs.asker ?? askedBy,
|
|
69
|
+
...(value ? { answer: { value, by: ans?.attrs.by === "default" ? "default" : "user" } } : {}),
|
|
70
|
+
...(q.attrs.delivered === "true" ? { delivered: true } : {}),
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
return { status: root.attrs.status === "answered" ? "answered" : "pending", askedBy, round: Number(root.attrs.round) || 0, questions };
|
|
74
|
+
}
|
|
75
|
+
export function renderAskReply(r) {
|
|
76
|
+
const out = [`<ask-reply status="${r.status}" asked_by="${esc(r.askedBy)}" round="${r.round}">`];
|
|
77
|
+
for (const q of r.questions) {
|
|
78
|
+
out.push(` <question id="${esc(q.id)}" asker="${esc(q.asker)}"${q.delivered ? ` delivered="true"` : ""}>`);
|
|
79
|
+
out.push(` <text>${esc(q.text)}</text>`);
|
|
80
|
+
if (q.options?.length)
|
|
81
|
+
out.push(` <options>`, ...q.options.map((o) => ` <option>${esc(o)}</option>`), ` </options>`);
|
|
82
|
+
if (q.suggested)
|
|
83
|
+
out.push(` <suggested${q.suggested.reason ? ` reason="${esc(q.suggested.reason)}"` : ""}>${esc(q.suggested.value)}</suggested>`);
|
|
84
|
+
out.push(q.answer ? ` <answer by="${q.answer.by}">${esc(q.answer.value)}</answer>` : ` <answer></answer>`);
|
|
85
|
+
out.push(` </question>`);
|
|
86
|
+
}
|
|
87
|
+
out.push(`</ask-reply>`);
|
|
88
|
+
return out.join("\n") + "\n";
|
|
89
|
+
}
|
|
90
|
+
export function readAskReply(runDir) {
|
|
91
|
+
const file = askReplyPath(runDir);
|
|
92
|
+
if (!fs.existsSync(file))
|
|
93
|
+
return undefined;
|
|
94
|
+
return parseAskReply(fs.readFileSync(file, "utf8"));
|
|
95
|
+
}
|
|
96
|
+
export function writeAskReply(runDir, r) {
|
|
97
|
+
atomicWrite(askReplyPath(runDir), renderAskReply(r));
|
|
98
|
+
}
|
|
99
|
+
// ---- batches, answers ----
|
|
100
|
+
/** Add a batch of questions, keeping everything already in the file. The same id with the same text is skipped (a re-routed ask after a crash). */
|
|
101
|
+
export function addBatch(existing, asker, round, qs) {
|
|
102
|
+
const base = existing ?? { status: "pending", askedBy: asker, round, questions: [] };
|
|
103
|
+
const added = [];
|
|
104
|
+
for (const q of qs) {
|
|
105
|
+
const old = base.questions.find((x) => x.id === q.id);
|
|
106
|
+
if (old) {
|
|
107
|
+
if (old.text === q.text)
|
|
108
|
+
continue;
|
|
109
|
+
return { ok: false, error: `question id "${q.id}" is already used in this run for a different question; use a new id` };
|
|
110
|
+
}
|
|
111
|
+
added.push({ ...q, asker });
|
|
112
|
+
}
|
|
113
|
+
if (added.length === 0)
|
|
114
|
+
return { ok: true, reply: base };
|
|
115
|
+
return { ok: true, reply: { status: "pending", askedBy: asker, round, questions: [...base.questions, ...added] } };
|
|
116
|
+
}
|
|
117
|
+
export const isComplete = (c) => c.missing.length === 0 && c.invalid.length === 0;
|
|
118
|
+
function problem(q) {
|
|
119
|
+
const v = q.answer?.value.trim();
|
|
120
|
+
if (!v)
|
|
121
|
+
return undefined;
|
|
122
|
+
if (q.options?.length && !q.options.includes(v) && !/^other:\s*\S/.test(v))
|
|
123
|
+
return `must be one of: ${q.options.join(", ")} — or start with "other:" and write your own`;
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
/** Only questions whose answer was not sent yet count. */
|
|
127
|
+
export function checkAnswers(r) {
|
|
128
|
+
const c = { missing: [], invalid: [] };
|
|
129
|
+
for (const q of r.questions) {
|
|
130
|
+
if (q.delivered)
|
|
131
|
+
continue;
|
|
132
|
+
if (!q.answer?.value.trim())
|
|
133
|
+
c.missing.push(q.id);
|
|
134
|
+
else {
|
|
135
|
+
const reason = problem(q);
|
|
136
|
+
if (reason)
|
|
137
|
+
c.invalid.push({ id: q.id, reason });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return c;
|
|
141
|
+
}
|
|
142
|
+
export function describeCheck(c) {
|
|
143
|
+
return [...c.missing.map((id) => `${id}: no answer yet`), ...c.invalid.map((i) => `${i.id}: ${i.reason}`)];
|
|
144
|
+
}
|
|
145
|
+
export function applyDefaults(r) {
|
|
146
|
+
return {
|
|
147
|
+
...r,
|
|
148
|
+
questions: r.questions.map((q) => (!q.delivered && !q.answer?.value.trim() && q.suggested ? { ...q, answer: { value: q.suggested.value, by: "default" } } : q)),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function askersWithAnswers(r) {
|
|
152
|
+
return [...new Set(r.questions.filter((q) => !q.delivered && q.answer?.value.trim()).map((q) => q.asker))];
|
|
153
|
+
}
|
|
154
|
+
/** The body of the `reply` mail that carries the answers back to `asker`. */
|
|
155
|
+
export function renderAnswers(r, asker) {
|
|
156
|
+
const out = [`<answers>`];
|
|
157
|
+
for (const q of r.questions.filter((x) => !x.delivered && x.asker === asker && x.answer?.value.trim())) {
|
|
158
|
+
out.push(` <question id="${esc(q.id)}">`, ` <text>${esc(q.text)}</text>`, ` <answer by="${q.answer?.by}">${esc(q.answer?.value ?? "")}</answer>`, ` </question>`);
|
|
159
|
+
}
|
|
160
|
+
out.push(`</answers>`);
|
|
161
|
+
return out.join("\n");
|
|
162
|
+
}
|
|
163
|
+
export function markDelivered(r) {
|
|
164
|
+
return { ...r, status: "answered", questions: r.questions.map((q) => (q.answer?.value.trim() ? { ...q, delivered: true } : q)) };
|
|
165
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,8 @@ import path from "node:path";
|
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import { Command } from "commander";
|
|
6
6
|
import { ConfigError, findProjectForCwd, flattenResolved, listProjects, resolveProject, resolveProjectWithSources } from "./config.js";
|
|
7
|
+
import { openInEditor, prepareAnswers } from "./ask-answer.js";
|
|
8
|
+
import { restoreMemory, tidyMemory, tidyStamp } from "./memory-tidy.js";
|
|
7
9
|
import { RESULT_FILE, runTeam } from "./dispatcher.js";
|
|
8
10
|
import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
|
|
9
11
|
import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
|
|
@@ -177,7 +179,7 @@ function cancelOnSignals() {
|
|
|
177
179
|
},
|
|
178
180
|
};
|
|
179
181
|
}
|
|
180
|
-
function
|
|
182
|
+
function printRun(summary, runDir) {
|
|
181
183
|
console.log(`\nRun ${summary.runId} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
|
|
182
184
|
if (summary.outcomeNote)
|
|
183
185
|
console.log(`Note: ${summary.outcomeNote}`);
|
|
@@ -187,13 +189,72 @@ function reportRun(summary, runDir) {
|
|
|
187
189
|
console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
|
|
188
190
|
console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
|
|
189
191
|
}
|
|
192
|
+
}
|
|
193
|
+
function reportRun(summary, runDir) {
|
|
194
|
+
printRun(summary, runDir);
|
|
190
195
|
process.exit(exitCodeForOutcome(summary.outcome));
|
|
191
196
|
}
|
|
197
|
+
/** Take the lock and continue a run; the lock is released when it stops (also when it stops because it waits for answers). */
|
|
198
|
+
async function continueRun(pr, dir, runId) {
|
|
199
|
+
const lease = takeLock(pr, runId);
|
|
200
|
+
const cancel = cancelOnSignals();
|
|
201
|
+
try {
|
|
202
|
+
console.log(`Project ${pr.name} — repo ${pr.dir}`);
|
|
203
|
+
const state = loadRunState(dir);
|
|
204
|
+
console.log(`Resuming run ${state.run_id} (${state.end_reason ?? "interrupted"}) at round ${state.rounds}/${pr.dispatcher.max_rounds}: ${state.task_summary}`);
|
|
205
|
+
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
206
|
+
return await runTeam({ project: pr, resume: state, runDir: dir, signal: cancel.signal });
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
cancel.dispose();
|
|
210
|
+
lease.release();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const interactive = () => !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
214
|
+
function printWaiting(pr, runId, file, problems) {
|
|
215
|
+
console.log(`\nRun ${runId} is waiting for your answers.\n File: ${file}`);
|
|
216
|
+
for (const p of problems)
|
|
217
|
+
console.log(` - ${p}`);
|
|
218
|
+
console.log(`Answer with: agent-lyceum answer ${runId} -p ${pr.name} (or edit the file, then: agent-lyceum resume ${runId} -p ${pr.name})`);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Keep going while the run stops to ask and the questions can be settled right now: by --assume-defaults, or (in a terminal)
|
|
222
|
+
* by opening the editor. Otherwise print where to answer and exit 3.
|
|
223
|
+
*/
|
|
224
|
+
async function driveRun(pr, runDir, first, assumeDefaults) {
|
|
225
|
+
let summary = first;
|
|
226
|
+
while (summary.outcome === "waiting") {
|
|
227
|
+
let prep = prepareAnswers(runDir, { assumeDefaults });
|
|
228
|
+
while (!prep.complete && interactive()) {
|
|
229
|
+
console.log(`\nRun ${summary.runId} needs your answers (${prep.path}).`);
|
|
230
|
+
openInEditor(prep.path);
|
|
231
|
+
prep = prepareAnswers(runDir, {});
|
|
232
|
+
if (prep.complete)
|
|
233
|
+
break;
|
|
234
|
+
console.log("Some answers are missing or invalid:");
|
|
235
|
+
for (const p of prep.problems)
|
|
236
|
+
console.log(` - ${p}`);
|
|
237
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
238
|
+
const again = (await rl.question("Edit again? [Y/n] ")).trim().toLowerCase();
|
|
239
|
+
rl.close();
|
|
240
|
+
if (again === "n" || again === "no")
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
if (!prep.complete) {
|
|
244
|
+
printRun(summary, runDir);
|
|
245
|
+
printWaiting(pr, summary.runId, prep.path, prep.problems);
|
|
246
|
+
process.exit(3);
|
|
247
|
+
}
|
|
248
|
+
summary = await continueRun(pr, runDir, summary.runId);
|
|
249
|
+
}
|
|
250
|
+
return reportRun(summary, runDir);
|
|
251
|
+
}
|
|
192
252
|
program
|
|
193
253
|
.command("run [task]")
|
|
194
254
|
.description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>.')
|
|
195
255
|
.option("-p, --project <name>")
|
|
196
256
|
.option("--task-file <path>", "read the task from a file")
|
|
257
|
+
.option("--assume-defaults", "when the run stops to ask, use the suggested answer for every question that has one")
|
|
197
258
|
.action(async (task, opts) => {
|
|
198
259
|
try {
|
|
199
260
|
const pr = loadProject(opts.project);
|
|
@@ -218,7 +279,7 @@ program
|
|
|
218
279
|
cancel.dispose();
|
|
219
280
|
lease.release();
|
|
220
281
|
}
|
|
221
|
-
|
|
282
|
+
await driveRun(pr, runDir, summary, !!opts.assumeDefaults);
|
|
222
283
|
}
|
|
223
284
|
catch (e) {
|
|
224
285
|
fail(e instanceof ConfigError ? e.message : e.message);
|
|
@@ -228,6 +289,7 @@ program
|
|
|
228
289
|
.command("resume [run-id]")
|
|
229
290
|
.description("Continue an interrupted (or failed) run: same run dir, sessions and round count; the task is not re-sent. Without an id, picks the newest run that is not done or running; with an id, continues that run.")
|
|
230
291
|
.option("-p, --project <name>")
|
|
292
|
+
.option("--assume-defaults", "if the run waits for answers, use the suggested answer for every unanswered question that has one")
|
|
231
293
|
.action(async (runId, opts) => {
|
|
232
294
|
try {
|
|
233
295
|
const pr = loadProject(opts.project);
|
|
@@ -263,20 +325,51 @@ program
|
|
|
263
325
|
}
|
|
264
326
|
if (state.rounds >= pr.dispatcher.max_rounds)
|
|
265
327
|
fail(`Run ${state.run_id} used ${state.rounds}/${pr.dispatcher.max_rounds} rounds; raise dispatcher.max_rounds in project.yaml first.`);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// Read the state again under the lock: another process may have changed it while we were checking.
|
|
273
|
-
summary = await runTeam({ project: pr, resume: loadRunState(dir), runDir: dir, signal: cancel.signal });
|
|
328
|
+
if (state.end_reason === "waiting") {
|
|
329
|
+
const prep = prepareAnswers(dir, { assumeDefaults: !!opts.assumeDefaults });
|
|
330
|
+
if (!prep.complete) {
|
|
331
|
+
printWaiting(pr, state.run_id, prep.path, prep.problems);
|
|
332
|
+
process.exit(3);
|
|
333
|
+
}
|
|
274
334
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
335
|
+
const summary = await continueRun(pr, dir, state.run_id);
|
|
336
|
+
await driveRun(pr, dir, summary, !!opts.assumeDefaults);
|
|
337
|
+
}
|
|
338
|
+
catch (e) {
|
|
339
|
+
fail(e instanceof ConfigError ? e.message : e.message);
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
program
|
|
343
|
+
.command("answer <run-id>")
|
|
344
|
+
.description("Answer the questions a waiting run asked: opens ask-reply.md in $EDITOR, checks the answers, then continues the run. With --no-edit it only checks a file you already edited.")
|
|
345
|
+
.option("-p, --project <name>")
|
|
346
|
+
.option("--no-edit", "do not open the editor; check the file as it is")
|
|
347
|
+
.option("--assume-defaults", "use the suggested answer for every unanswered question that has one")
|
|
348
|
+
.action(async (runId, opts) => {
|
|
349
|
+
try {
|
|
350
|
+
assertName("run", runId);
|
|
351
|
+
const pr = loadProject(opts.project);
|
|
352
|
+
const dir = path.join(pr.paths.runs, runId);
|
|
353
|
+
if (!fs.existsSync(path.join(dir, "state.json")))
|
|
354
|
+
fail(`Run "${runId}" not found in ${pr.paths.runs}.`);
|
|
355
|
+
const state = loadRunState(dir);
|
|
356
|
+
if (state.end_reason !== "waiting")
|
|
357
|
+
fail(`Run ${runId} is not waiting for answers (${state.end_reason ?? "not finished"}).`);
|
|
358
|
+
if (opts.edit)
|
|
359
|
+
openInEditor(prepareAnswers(dir, {}).path);
|
|
360
|
+
const prep = prepareAnswers(dir, { assumeDefaults: !!opts.assumeDefaults });
|
|
361
|
+
if (!prep.complete) {
|
|
362
|
+
printWaiting(pr, runId, prep.path, prep.problems);
|
|
363
|
+
process.exit(3);
|
|
278
364
|
}
|
|
279
|
-
|
|
365
|
+
const res = validateProject(pr);
|
|
366
|
+
for (const i of res.issues)
|
|
367
|
+
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
368
|
+
if (!res.ok)
|
|
369
|
+
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
370
|
+
await preflight(pr);
|
|
371
|
+
const summary = await continueRun(pr, dir, runId);
|
|
372
|
+
await driveRun(pr, dir, summary, !!opts.assumeDefaults);
|
|
280
373
|
}
|
|
281
374
|
catch (e) {
|
|
282
375
|
fail(e instanceof ConfigError ? e.message : e.message);
|
|
@@ -407,6 +500,94 @@ program
|
|
|
407
500
|
fail(e.message);
|
|
408
501
|
}
|
|
409
502
|
});
|
|
503
|
+
const memoryCmd = program.command("memory").description("Look after the agents' long-term memory");
|
|
504
|
+
function layerOpt(v) {
|
|
505
|
+
if (v === undefined || v === "project")
|
|
506
|
+
return "project";
|
|
507
|
+
if (v === "global")
|
|
508
|
+
return "global";
|
|
509
|
+
return fail(`--layer must be "project" or "global" (task memory is not tidied: \`clear\` deletes it).`);
|
|
510
|
+
}
|
|
511
|
+
memoryCmd
|
|
512
|
+
.command("tidy")
|
|
513
|
+
.description("Have agents tidy their own memory: merge duplicates, fix stale entries, archive what is obsolete (never deletes). Manual only; nothing triggers it automatically.")
|
|
514
|
+
.option("-p, --project <name>")
|
|
515
|
+
.option("--agent <name>", "tidy only this agent (default: every agent)")
|
|
516
|
+
.option("--layer <layer>", "project (default) or global; global memory is shared by every project, so it is never tidied unless you ask")
|
|
517
|
+
.option("--dry-run", "only show the memory sizes and what changed in the project; wake nobody, change nothing")
|
|
518
|
+
.action(async (opts) => {
|
|
519
|
+
try {
|
|
520
|
+
const layer = layerOpt(opts.layer);
|
|
521
|
+
const pr = loadProject(opts.project);
|
|
522
|
+
if (opts.dryRun) {
|
|
523
|
+
const dry = await tidyMemory({ project: pr, layer, agent: opts.agent, dryRun: true });
|
|
524
|
+
if (dry.length === 0)
|
|
525
|
+
console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or use --layer global)." : "Set `memory.global` for an agent."}`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const res = validateProject(pr);
|
|
529
|
+
for (const i of res.issues)
|
|
530
|
+
console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
|
|
531
|
+
if (!res.ok)
|
|
532
|
+
fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
|
|
533
|
+
await preflight(pr);
|
|
534
|
+
const lease = takeLock(pr, `tidy-${tidyStamp()}`);
|
|
535
|
+
const cancel = cancelOnSignals();
|
|
536
|
+
let results;
|
|
537
|
+
try {
|
|
538
|
+
results = await tidyMemory({ project: pr, layer, agent: opts.agent, signal: cancel.signal });
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
cancel.dispose();
|
|
542
|
+
lease.release();
|
|
543
|
+
}
|
|
544
|
+
if (results.length === 0)
|
|
545
|
+
console.log(`No agent has ${layer} memory configured. ${layer === "project" ? "The template gives agents global memory only: set `memory.project` for an agent in project.yaml (or tidy global memory with --layer global)." : "Set `memory.global` for an agent."}`);
|
|
546
|
+
for (const r of results) {
|
|
547
|
+
if (r.status === "tidied")
|
|
548
|
+
console.log(` ${r.agent} (${r.layer}): tidied. Archive: ${path.join(r.dir, ".archive", r.archive ?? "")} — undo with: agent-lyceum memory restore ${r.archive} -p ${pr.name} --agent ${r.agent}${r.layer === "global" ? " --layer global" : ""}`);
|
|
549
|
+
else if (r.status === "skipped")
|
|
550
|
+
console.log(` ${r.agent} (${r.layer}): skipped — ${r.problems.join("; ")}`);
|
|
551
|
+
else if (r.status === "failed") {
|
|
552
|
+
console.error(` ${r.agent} (${r.layer}): FAILED, memory restored to how it was`);
|
|
553
|
+
for (const p of r.problems)
|
|
554
|
+
console.error(` - ${p}`);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
process.exit(results.some((r) => r.status === "failed") ? 1 : 0);
|
|
558
|
+
}
|
|
559
|
+
catch (e) {
|
|
560
|
+
fail(e instanceof ConfigError ? e.message : e.message);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
memoryCmd
|
|
564
|
+
.command("restore <stamp>")
|
|
565
|
+
.description("Move the files of one tidy's archive back (nothing that exists now is overwritten) and re-add them to MEMORY.md")
|
|
566
|
+
.option("-p, --project <name>")
|
|
567
|
+
.requiredOption("--agent <name>")
|
|
568
|
+
.option("--layer <layer>", "project (default) or global")
|
|
569
|
+
.action((stamp, opts) => {
|
|
570
|
+
try {
|
|
571
|
+
const layer = layerOpt(opts.layer);
|
|
572
|
+
const pr = loadProject(opts.project);
|
|
573
|
+
const lease = takeLock(pr, `restore-${tidyStamp()}`);
|
|
574
|
+
try {
|
|
575
|
+
const r = restoreMemory({ project: pr, agent: opts.agent, layer, stamp });
|
|
576
|
+
for (const f of r.restored)
|
|
577
|
+
console.log(` restored ${f}`);
|
|
578
|
+
for (const s of r.skipped)
|
|
579
|
+
console.log(` skipped ${s.rel}: ${s.reason}`);
|
|
580
|
+
if (!r.restored.length && !r.skipped.length)
|
|
581
|
+
console.log(`Archive ${stamp} holds no files to restore.`);
|
|
582
|
+
}
|
|
583
|
+
finally {
|
|
584
|
+
lease.release();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
catch (e) {
|
|
588
|
+
fail(e.message);
|
|
589
|
+
}
|
|
590
|
+
});
|
|
410
591
|
function useColor() {
|
|
411
592
|
if (process.env.NO_COLOR)
|
|
412
593
|
return false;
|
package/dist/config.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface ResolvedAgent {
|
|
|
18
18
|
resume: boolean;
|
|
19
19
|
canMessage: "all" | string[];
|
|
20
20
|
canEditAgentMd: boolean;
|
|
21
|
+
/** May send `type: ask` mail to the user (the run then waits for the answer). */
|
|
22
|
+
canAskUser: boolean;
|
|
21
23
|
owns: string[];
|
|
22
24
|
/** Where each field's value came from (for `validate` output). */
|
|
23
25
|
sources: Record<string, Source>;
|
|
@@ -53,6 +55,7 @@ export declare function loadGlobal(home: string): {
|
|
|
53
55
|
resume?: boolean | undefined;
|
|
54
56
|
can_message?: "all" | string[] | undefined;
|
|
55
57
|
can_edit_agent_md?: boolean | undefined;
|
|
58
|
+
can_ask_user?: boolean | undefined;
|
|
56
59
|
owns?: string[] | undefined;
|
|
57
60
|
}>;
|
|
58
61
|
};
|
|
@@ -82,6 +85,7 @@ export declare function loadProjectRaw(home: string, name: string): {
|
|
|
82
85
|
resume?: boolean | undefined;
|
|
83
86
|
can_message?: "all" | string[] | undefined;
|
|
84
87
|
can_edit_agent_md?: boolean | undefined;
|
|
88
|
+
can_ask_user?: boolean | undefined;
|
|
85
89
|
owns?: string[] | undefined;
|
|
86
90
|
}>;
|
|
87
91
|
};
|