agent-lyceum 0.29.0 → 0.31.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 +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/adapters/codex.js +5 -2
- package/dist/cli.js +16 -1
- package/dist/code-change.d.ts +6 -0
- package/dist/code-change.js +37 -0
- package/dist/guard.d.ts +1 -1
- package/dist/guard.js +57 -5
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +62 -3
- package/dist/project-lock.d.ts +15 -1
- package/dist/project-lock.js +69 -32
- package/dist/prompt.d.ts +11 -1
- package/dist/prompt.js +64 -14
- package/dist/status-watch.d.ts +4 -4
- package/dist/status-watch.js +6 -6
- package/dist/status.d.ts +2 -2
- package/dist/status.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Give a task to a **team of agents** (Claude Code and/or Codex) instead of one ov
|
|
|
9
9
|
## Why
|
|
10
10
|
|
|
11
11
|
- **Roles that stay in role.** Each agent has its own persona (`AGENT.md`) and long-term memory, so a reviewer keeps reviewing instead of drifting into writing code.
|
|
12
|
-
- **Agents can't rewrite the rules.** An agent can
|
|
12
|
+
- **Agents can't rewrite the rules.** An agent can write its own memory, its outbox, and its project agent directory (`agents/<name>/`, including a project persona it creates there). It can't touch other agents' context, `COMMON.md`, the project records, or a global persona. This is enforced by the OS sandbox where possible, not just by a prompt. See [Write scope](docs/write-scope.md).
|
|
13
13
|
- **Mix runtimes.** Claude Code and Codex agents work in the same team.
|
|
14
14
|
- **Backup models.** If a model is rate-limited, out of quota or unreachable, a run can switch to a backup model or runtime instead of stopping. See [Configuration](docs/configuration.md#fallback-models).
|
|
15
15
|
- **Nothing hidden.** Agents talk through plain-file mailboxes, so every message, decision and memory is a file you can read, grep and diff. The dispatcher never merges for you.
|
package/README.zh-TW.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
## 為什麼用它
|
|
10
10
|
|
|
11
11
|
- **角色不會跑掉。** 每個 agent 有自己的人設(`AGENT.md`)與長期記憶,reviewer 會一直做 review,不會漸漸變成在寫程式。
|
|
12
|
-
- **agent 改不了規則。** agent
|
|
12
|
+
- **agent 改不了規則。** agent 可以寫自己的記憶、寄件匣(outbox),以及自己的專案 agent 目錄(`agents/<name>/`,含它在那裡建立的專案人設)。它不能改其他 agent 的 context、`COMMON.md`、專案紀錄,或全域人設。能做到的地方由 OS 沙箱強制,而不只靠 prompt。見 [Write scope](docs/write-scope.zh-TW.md)。
|
|
13
13
|
- **混用 runtime。** Claude Code 與 Codex agent 可在同一個團隊。
|
|
14
14
|
- **備用模型。** 模型被限流、額度用完或連不上時,run 可以改用備用的模型或 runtime 繼續,而不是直接停下。見[設定](docs/configuration.zh-TW.md#備援模型)。
|
|
15
15
|
- **沒有黑箱。** agent 透過純檔案信箱溝通,每則訊息、決策與記憶都是你能讀、能 grep、能 diff 的檔案;dispatcher 不會替你 merge。
|
package/dist/adapters/codex.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
|
|
3
|
+
import { projectCommonDir, projectCommonDirAllowed, repoDirFor, scratchCwd, writePolicy } from "../policy.js";
|
|
4
4
|
export function codexWritableRoots(input) {
|
|
5
5
|
const pol = input.policy ?? writePolicy(input.project, input.agent);
|
|
6
6
|
const work = input.policy ? scratchCwd(input.workDir) : repoDirFor(input.project, input.agent.name);
|
|
7
|
-
const
|
|
7
|
+
const common = projectCommonDir(input.project);
|
|
8
|
+
const withholdCommon = !input.policy && !projectCommonDirAllowed(input.project, input.agent);
|
|
9
|
+
const fileRoots = pol.allowFiles.map((f) => path.dirname(f)).filter((d) => !withholdCommon || (d !== common && !d.startsWith(common + path.sep)));
|
|
10
|
+
const roots = [...pol.allowDirs, ...fileRoots].filter((d) => !(d === work || d.startsWith(work + path.sep)));
|
|
8
11
|
return [...new Set(roots)];
|
|
9
12
|
}
|
|
10
13
|
function sessionIdOf(line) {
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
|
|
|
24
24
|
import { formatEnforcement, validateProject } from "./validate.js";
|
|
25
25
|
import { must } from "./assert.js";
|
|
26
26
|
import { runMonitor } from "./monitor-lifecycle.js";
|
|
27
|
+
import { codeFingerprint, watchCodeChange, WATCH_RESTART_EXIT, WATCH_RESTART_MESSAGE } from "./code-change.js";
|
|
27
28
|
import { createStatusWatcher } from "./status-watch.js";
|
|
28
29
|
import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
|
|
29
30
|
import { onlyAgent, soloProject } from "./solo.js";
|
|
@@ -832,8 +833,22 @@ program
|
|
|
832
833
|
write: (line) => process.stdout.write(`${line}\n`),
|
|
833
834
|
debounceMs: 100,
|
|
834
835
|
});
|
|
836
|
+
const codeFile = fileURLToPath(import.meta.url);
|
|
837
|
+
const changed = watchCodeChange(() => codeFingerprint(path.dirname(codeFile), path.extname(codeFile)));
|
|
835
838
|
runMonitor({
|
|
836
|
-
draw:
|
|
839
|
+
draw: () => {
|
|
840
|
+
if (changed()) {
|
|
841
|
+
try {
|
|
842
|
+
fs.writeSync(2, `${WATCH_RESTART_MESSAGE}\n`);
|
|
843
|
+
}
|
|
844
|
+
catch {
|
|
845
|
+
// A closed stderr must not prevent replacement.
|
|
846
|
+
}
|
|
847
|
+
watcher.close();
|
|
848
|
+
process.exit(WATCH_RESTART_EXIT);
|
|
849
|
+
}
|
|
850
|
+
watcher.beat();
|
|
851
|
+
},
|
|
837
852
|
intervalMs: beat * 1000,
|
|
838
853
|
stdout: process.stdout,
|
|
839
854
|
stdin: process.stdin,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const WATCH_RESTART_EXIT = 75;
|
|
2
|
+
export declare const WATCH_RESTART_MESSAGE = "status --watch: agent-lyceum code changed on disk; restart to load it";
|
|
3
|
+
/** Sorted relative paths, sizes and mtimes; unreadable trees never trigger a restart. */
|
|
4
|
+
export declare function codeFingerprint(dir: string, ext: string): string;
|
|
5
|
+
/** Two equal reads debounce changes; they do not establish build completion. */
|
|
6
|
+
export declare function watchCodeChange(read: () => string): () => boolean;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const WATCH_RESTART_EXIT = 75;
|
|
4
|
+
export const WATCH_RESTART_MESSAGE = "status --watch: agent-lyceum code changed on disk; restart to load it";
|
|
5
|
+
/** Sorted relative paths, sizes and mtimes; unreadable trees never trigger a restart. */
|
|
6
|
+
export function codeFingerprint(dir, ext) {
|
|
7
|
+
const files = [];
|
|
8
|
+
function visit(relative) {
|
|
9
|
+
for (const entry of fs.readdirSync(path.join(dir, relative), { withFileTypes: true })) {
|
|
10
|
+
const rel = path.join(relative, entry.name);
|
|
11
|
+
if (entry.isDirectory())
|
|
12
|
+
visit(rel);
|
|
13
|
+
else if (entry.isFile() && path.extname(entry.name) === ext) {
|
|
14
|
+
const stat = fs.statSync(path.join(dir, rel));
|
|
15
|
+
files.push(`${rel}:${stat.size}:${stat.mtimeMs}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
visit("");
|
|
21
|
+
return files.sort().join("\n");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Two equal reads debounce changes; they do not establish build completion. */
|
|
28
|
+
export function watchCodeChange(read) {
|
|
29
|
+
const initial = read();
|
|
30
|
+
let previous = initial;
|
|
31
|
+
return () => {
|
|
32
|
+
const current = read();
|
|
33
|
+
const changed = current !== "" && current !== initial && current === previous;
|
|
34
|
+
previous = current;
|
|
35
|
+
return changed;
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/guard.d.ts
CHANGED
|
@@ -24,7 +24,7 @@ export declare class ProtectedGuard {
|
|
|
24
24
|
private project;
|
|
25
25
|
private otherLiveRuns;
|
|
26
26
|
private baseline;
|
|
27
|
-
/** Regular files of the records tree (including `.archive/`);
|
|
27
|
+
/** Regular files of the records tree (including `.archive/`); any agent in the running batch may change them. */
|
|
28
28
|
private records;
|
|
29
29
|
/** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
|
|
30
30
|
private unattributed;
|
package/dist/guard.js
CHANGED
|
@@ -16,7 +16,7 @@ export class ProtectedGuard {
|
|
|
16
16
|
project;
|
|
17
17
|
otherLiveRuns;
|
|
18
18
|
baseline = new Map();
|
|
19
|
-
/** Regular files of the records tree (including `.archive/`);
|
|
19
|
+
/** Regular files of the records tree (including `.archive/`); any agent in the running batch may change them. */
|
|
20
20
|
records = new Map();
|
|
21
21
|
/** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
|
|
22
22
|
unattributed = [];
|
|
@@ -92,6 +92,16 @@ export class ProtectedGuard {
|
|
|
92
92
|
const violations = [];
|
|
93
93
|
const other = [...new Set([...this.liveAtBase, ...this.otherLiveRuns()])];
|
|
94
94
|
for (const [file, before] of this.baseline) {
|
|
95
|
+
if (unsafeToRead(file)) {
|
|
96
|
+
const kept = this.keep(file, null);
|
|
97
|
+
fs.rmSync(file, { force: true });
|
|
98
|
+
if (before !== null) {
|
|
99
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
100
|
+
fs.writeFileSync(file, before);
|
|
101
|
+
}
|
|
102
|
+
violations.push({ file, action: before === null ? "removed" : "restored", suspects: running.map((a) => a.name), ...kept });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
95
105
|
const now = read(file);
|
|
96
106
|
if (same(before, now))
|
|
97
107
|
continue;
|
|
@@ -126,7 +136,7 @@ export class ProtectedGuard {
|
|
|
126
136
|
if (isLink(path.dirname(root)) || isLink(path.dirname(path.dirname(root))))
|
|
127
137
|
return;
|
|
128
138
|
const now = readTree(root);
|
|
129
|
-
const
|
|
139
|
+
const authorized = running.some((a) => a.name === this.project.lead);
|
|
130
140
|
const suspects = running.map((a) => a.name);
|
|
131
141
|
const fixedDirs = new Set();
|
|
132
142
|
for (const file of new Set([...this.records.keys(), ...now.keys()])) {
|
|
@@ -134,7 +144,7 @@ export class ProtectedGuard {
|
|
|
134
144
|
const cur = now.get(file) ?? null;
|
|
135
145
|
if (same(before, cur))
|
|
136
146
|
continue;
|
|
137
|
-
if (
|
|
147
|
+
if (authorized)
|
|
138
148
|
continue;
|
|
139
149
|
if (other.length) {
|
|
140
150
|
this.unattributed.push({ file, agents: suspects, otherRuns: other });
|
|
@@ -155,7 +165,7 @@ export class ProtectedGuard {
|
|
|
155
165
|
violations.push({ file, action: "restored", suspects, ...kept });
|
|
156
166
|
}
|
|
157
167
|
}
|
|
158
|
-
if (
|
|
168
|
+
if (authorized || other.length)
|
|
159
169
|
this.records = now;
|
|
160
170
|
}
|
|
161
171
|
/**
|
|
@@ -242,13 +252,55 @@ function readTree(dir) {
|
|
|
242
252
|
walk(dir);
|
|
243
253
|
return out;
|
|
244
254
|
}
|
|
255
|
+
const GUARD_READ_MAX = MAX_KEEP_BYTES * 2;
|
|
256
|
+
/** A FIFO, device, directory, or a symlink to one. Opening it would block or is not a file the guard can compare. */
|
|
257
|
+
function unsafeToRead(file) {
|
|
258
|
+
let fd;
|
|
259
|
+
try {
|
|
260
|
+
const listed = fs.lstatSync(file);
|
|
261
|
+
if (listed.isFIFO() || listed.isSocket() || listed.isCharacterDevice() || listed.isBlockDevice() || listed.isDirectory())
|
|
262
|
+
return true;
|
|
263
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
264
|
+
return !fs.fstatSync(fd).isFile();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
if (fd !== undefined) {
|
|
271
|
+
try {
|
|
272
|
+
fs.closeSync(fd);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// already closed
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
245
280
|
function read(file) {
|
|
281
|
+
let fd;
|
|
246
282
|
try {
|
|
247
|
-
|
|
283
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
284
|
+
const st = fs.fstatSync(fd);
|
|
285
|
+
if (!st.isFile())
|
|
286
|
+
return null;
|
|
287
|
+
if (st.size > GUARD_READ_MAX)
|
|
288
|
+
return null;
|
|
289
|
+
return fs.readFileSync(fd);
|
|
248
290
|
}
|
|
249
291
|
catch {
|
|
250
292
|
return null;
|
|
251
293
|
}
|
|
294
|
+
finally {
|
|
295
|
+
if (fd !== undefined) {
|
|
296
|
+
try {
|
|
297
|
+
fs.closeSync(fd);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
// already closed
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
252
304
|
}
|
|
253
305
|
function same(a, b) {
|
|
254
306
|
if (a === null || b === null)
|
package/dist/policy.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface WritePolicy {
|
|
|
23
23
|
deny: string[];
|
|
24
24
|
}
|
|
25
25
|
export declare function commonFile(project: ResolvedProject): string;
|
|
26
|
-
/** Durable decisions and lessons of the project, one file per entry
|
|
26
|
+
/** Durable decisions and lessons of the project, one file per entry. Only the lead may write them. `.archive/` is tidy's output, never shown in prompts. */
|
|
27
27
|
export declare function recordsDir(project: ResolvedProject): string;
|
|
28
28
|
export declare function recordsKindDir(project: ResolvedProject, kind: "decisions" | "lessons"): string;
|
|
29
29
|
/** The lead-maintained files shared by every run of the project. */
|
|
@@ -43,6 +43,12 @@ export declare function sharedIndex(project: ResolvedProject): string | undefine
|
|
|
43
43
|
export declare const scratchCwd: (workDir: string) => string;
|
|
44
44
|
/** The repo directory this agent works in: its own worktree, or the main repo. */
|
|
45
45
|
export declare function repoDirFor(project: ResolvedProject, agent: string): string;
|
|
46
|
+
export declare function projectAgentDir(project: ResolvedProject, agentName: string): string;
|
|
47
|
+
export declare function projectCommonDir(project: ResolvedProject): string;
|
|
48
|
+
/** True when `agents/<name>/` stays inside the project agents directory and does not overlap another agent's protected paths. */
|
|
49
|
+
export declare function ownAgentDirAllowed(project: ResolvedProject, agent: ResolvedAgent): boolean;
|
|
50
|
+
/** True when `shared/common/` does not overlap another agent's persona, memory, inbox or outbox. */
|
|
51
|
+
export declare function projectCommonDirAllowed(project: ResolvedProject, agent: ResolvedAgent): boolean;
|
|
46
52
|
export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
|
|
47
53
|
export declare function osSandboxAvailable(): boolean;
|
|
48
54
|
/** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
|
package/dist/policy.js
CHANGED
|
@@ -5,7 +5,7 @@ import { canonicalPath } from "./paths.js";
|
|
|
5
5
|
export function commonFile(project) {
|
|
6
6
|
return path.join(project.paths.shared, "common", "COMMON.md");
|
|
7
7
|
}
|
|
8
|
-
/** Durable decisions and lessons of the project, one file per entry
|
|
8
|
+
/** Durable decisions and lessons of the project, one file per entry. Only the lead may write them. `.archive/` is tidy's output, never shown in prompts. */
|
|
9
9
|
export function recordsDir(project) {
|
|
10
10
|
return path.join(project.paths.shared, "common", "records");
|
|
11
11
|
}
|
|
@@ -76,6 +76,57 @@ export const scratchCwd = (workDir) => path.join(workDir, "cwd");
|
|
|
76
76
|
export function repoDirFor(project, agent) {
|
|
77
77
|
return project.workspaces?.[agent]?.dir ?? project.dir;
|
|
78
78
|
}
|
|
79
|
+
export function projectAgentDir(project, agentName) {
|
|
80
|
+
return path.join(project.paths.agentsDir, agentName);
|
|
81
|
+
}
|
|
82
|
+
export function projectCommonDir(project) {
|
|
83
|
+
return path.join(project.paths.shared, "common");
|
|
84
|
+
}
|
|
85
|
+
function covers(dir, target) {
|
|
86
|
+
const root = canonicalPath(dir);
|
|
87
|
+
const file = canonicalPath(target);
|
|
88
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
89
|
+
return file === root || file.startsWith(prefix);
|
|
90
|
+
}
|
|
91
|
+
/** Another agent's persona, memory, inbox or outbox. A slot that shares this agent's persona file is not another persona. */
|
|
92
|
+
function foreignProtectedPaths(project, agent, other) {
|
|
93
|
+
return [
|
|
94
|
+
...(other.agentMd === agent.agentMd ? [] : [other.agentMd]),
|
|
95
|
+
inboxDir(project, other.name),
|
|
96
|
+
outboxDir(project, other.name),
|
|
97
|
+
...memoryDirs(other),
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* True when `dir` contains a foreign protected path, or sits inside one.
|
|
102
|
+
* Codex has no deny list inside a writable root, so either direction closes the root.
|
|
103
|
+
* Compared on real paths: a symlink grant is the target the sandbox would write if it follows links.
|
|
104
|
+
* Whether a live Claude or Codex sandbox follows that symlink was not verified; the directory is not granted either way.
|
|
105
|
+
*/
|
|
106
|
+
function overlapsForeign(project, agent, dir) {
|
|
107
|
+
for (const other of Object.values(project.agents)) {
|
|
108
|
+
if (other.name === agent.name)
|
|
109
|
+
continue;
|
|
110
|
+
if (foreignProtectedPaths(project, agent, other).some((p) => covers(dir, p) || covers(p, dir)))
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
/** True when the real path of `dir` is `parent` or a directory inside it. */
|
|
116
|
+
function staysInside(dir, parent) {
|
|
117
|
+
const real = canonicalPath(dir);
|
|
118
|
+
const root = canonicalPath(parent);
|
|
119
|
+
return real === root || real.startsWith(root + path.sep);
|
|
120
|
+
}
|
|
121
|
+
/** True when `agents/<name>/` stays inside the project agents directory and does not overlap another agent's protected paths. */
|
|
122
|
+
export function ownAgentDirAllowed(project, agent) {
|
|
123
|
+
const dir = projectAgentDir(project, agent.name);
|
|
124
|
+
return staysInside(dir, project.paths.agentsDir) && !overlapsForeign(project, agent, dir);
|
|
125
|
+
}
|
|
126
|
+
/** True when `shared/common/` does not overlap another agent's persona, memory, inbox or outbox. */
|
|
127
|
+
export function projectCommonDirAllowed(project, agent) {
|
|
128
|
+
return !overlapsForeign(project, agent, projectCommonDir(project));
|
|
129
|
+
}
|
|
79
130
|
export function writePolicy(project, agent) {
|
|
80
131
|
const isLead = agent.name === project.lead && !project.solo;
|
|
81
132
|
const ws = project.workspaces?.[agent.name];
|
|
@@ -83,12 +134,15 @@ export function writePolicy(project, agent) {
|
|
|
83
134
|
const shared = sharedDir(project);
|
|
84
135
|
const sharedIdx = sharedIndex(project);
|
|
85
136
|
const mayWriteShared = !!shared && agent.name === project.lead;
|
|
137
|
+
const ownDir = projectAgentDir(project, agent.name);
|
|
138
|
+
const grantOwnDir = ownAgentDirAllowed(project, agent);
|
|
86
139
|
const allowDirs = [
|
|
87
140
|
...memoryDirs(agent),
|
|
88
141
|
...(project.solo === "call" ? [] : [outboxDir(project, agent.name)]),
|
|
89
142
|
...(ws ? [ws.gitDir] : []),
|
|
90
143
|
...(shared && mayWriteShared ? [shared] : []),
|
|
91
144
|
...(isLead ? [recordsDir(project)] : []),
|
|
145
|
+
...(grantOwnDir ? [ownDir] : []),
|
|
92
146
|
// Spec 25: canonical, because the OS sandbox compares real paths (`/tmp` is `/private/tmp` on macOS).
|
|
93
147
|
...(agent.sandbox?.write ?? []).map(canonicalPath),
|
|
94
148
|
];
|
|
@@ -104,8 +158,9 @@ export function writePolicy(project, agent) {
|
|
|
104
158
|
}
|
|
105
159
|
const deny = [];
|
|
106
160
|
const allowedFileSet = new Set(allowFiles);
|
|
161
|
+
const grantedRoots = [...(grantOwnDir ? [ownDir] : [])];
|
|
107
162
|
const addDeny = (p) => {
|
|
108
|
-
if (!allowedFileSet.has(p))
|
|
163
|
+
if (!allowedFileSet.has(p) && !grantedRoots.some((d) => covers(d, p)))
|
|
109
164
|
deny.push(p);
|
|
110
165
|
};
|
|
111
166
|
for (const a of Object.values(project.agents)) {
|
|
@@ -230,7 +285,11 @@ function sandboxNotes(agent, os) {
|
|
|
230
285
|
}
|
|
231
286
|
/** Can `agent` legitimately change this protected file? */
|
|
232
287
|
export function mayEditProtected(project, agent, file) {
|
|
233
|
-
|
|
288
|
+
const pol = writePolicy(project, agent);
|
|
289
|
+
if (pol.allowFiles.includes(file))
|
|
290
|
+
return true;
|
|
291
|
+
const roots = [projectAgentDir(project, agent.name)].filter((d) => pol.allowDirs.includes(d));
|
|
292
|
+
return roots.some((d) => file === d || file.startsWith(d + path.sep));
|
|
234
293
|
}
|
|
235
294
|
const within = (parent, p) => p === parent || p.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
|
|
236
295
|
/** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
|
package/dist/project-lock.d.ts
CHANGED
|
@@ -44,13 +44,27 @@ export interface PidStartIo {
|
|
|
44
44
|
}
|
|
45
45
|
/** A start time that identifies one process: `proc:<ticks>` on Linux, the `ps` text elsewhere or when /proc cannot be read. */
|
|
46
46
|
export declare function pidStartFrom(pid: number, io: PidStartIo): string | undefined;
|
|
47
|
+
/** Reader side: C locale, so `ps` start times stay in one language and `samePidStart` can compare them. */
|
|
48
|
+
export declare const readerPs: (pid: number) => string | undefined;
|
|
49
|
+
/** Writer side: the inherited environment, exactly what versions before 0.29.0 wrote and compare against raw; never pin it, or an older reader in a non-C locale judges the lock dead and takes the run over. */
|
|
50
|
+
export declare const writerPs: (pid: number) => string | undefined;
|
|
47
51
|
/** True when both strings name the same process start. Locale-specific `ps` text for one instant matches. */
|
|
48
52
|
export declare function samePidStart(a: string, b: string): boolean;
|
|
53
|
+
export interface LivenessIo extends PidStartIo {
|
|
54
|
+
signal0(pid: number): boolean;
|
|
55
|
+
psUtc(pid: number): string | undefined;
|
|
56
|
+
}
|
|
57
|
+
export declare const utcPsEnv: (base: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
|
|
58
|
+
export declare const readerPsUtc: (pid: number) => string | undefined;
|
|
59
|
+
/** Only ESRCH proves absence; permissions and unknown signal errors are conservative. */
|
|
60
|
+
export declare function signalAlive(pid: number, kill?: (pid: number, signal: 0) => unknown): boolean;
|
|
61
|
+
/** Conventional quarter-hour offsets only; arbitrary POSIX TZ offsets remain a limitation. */
|
|
62
|
+
export declare function sameStartAnyZone(written: string, utc: string): boolean | undefined;
|
|
49
63
|
/**
|
|
50
64
|
* Is the process that wrote this lock still the same running process? Judged by pid and its start time,
|
|
51
65
|
* never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
|
|
52
66
|
*/
|
|
53
|
-
export declare function lockHolderAlive(info: LockInfo): boolean;
|
|
67
|
+
export declare function lockHolderAlive(info: Pick<LockInfo, "pid" | "pid_started">, io?: LivenessIo): boolean;
|
|
54
68
|
/** Every lock of a project that can be read: one per run, plus an older version's `lock.json`. Oldest first. */
|
|
55
69
|
export declare function listRunLocks(root: string): LockInfo[];
|
|
56
70
|
/** Run ids whose per-run lock file exists but cannot be read (it can only be removed with `forceUnlock(root, runId)`). */
|
package/dist/project-lock.js
CHANGED
|
@@ -39,28 +39,27 @@ export function pidStartFrom(pid, io) {
|
|
|
39
39
|
}
|
|
40
40
|
return io.ps(pid);
|
|
41
41
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
},
|
|
42
|
+
const psLstart = (pid, env) => {
|
|
43
|
+
const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env });
|
|
44
|
+
return (r.status === 0 ? r.stdout.trim() : "") || undefined;
|
|
45
|
+
};
|
|
46
|
+
/** Reader side: C locale, so `ps` start times stay in one language and `samePidStart` can compare them. */
|
|
47
|
+
export const readerPs = (pid) => psLstart(pid, { ...process.env, LANG: "C", LC_ALL: "C" });
|
|
48
|
+
/** Writer side: the inherited environment, exactly what versions before 0.29.0 wrote and compare against raw; never pin it, or an older reader in a non-C locale judges the lock dead and takes the run over. */
|
|
49
|
+
export const writerPs = (pid) => psLstart(pid, process.env);
|
|
50
|
+
const fileReader = (p) => {
|
|
51
|
+
try {
|
|
52
|
+
return fs.readFileSync(p, "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
58
57
|
};
|
|
58
|
+
const realIo = { platform: process.platform, readFile: fileReader, ps: readerPs };
|
|
59
|
+
const writerIo = { ...realIo, ps: writerPs };
|
|
59
60
|
function pidStart(pid) {
|
|
60
|
-
return pidStartFrom(pid,
|
|
61
|
+
return pidStartFrom(pid, writerIo);
|
|
61
62
|
}
|
|
62
|
-
/** Two start times can only be compared when they were taken the same way. */
|
|
63
|
-
const sameKind = (a, b) => a.startsWith("proc:") === b.startsWith("proc:");
|
|
64
63
|
const MONTHS = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" };
|
|
65
64
|
/** `ps -o lstart` in the C locale, or the same instant from a Chinese macOS locale, as `YYYY-MM-DDTHH:mm:ss`. */
|
|
66
65
|
function canonicalLstart(text) {
|
|
@@ -85,24 +84,62 @@ export function samePidStart(a, b) {
|
|
|
85
84
|
const right = canonicalLstart(b);
|
|
86
85
|
return !!left && left === right;
|
|
87
86
|
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
export function lockHolderAlive(info) {
|
|
87
|
+
export const utcPsEnv = (base) => ({ ...base, LANG: "C", LC_ALL: "C", TZ: "UTC0" });
|
|
88
|
+
export const readerPsUtc = (pid) => psLstart(pid, utcPsEnv(process.env));
|
|
89
|
+
/** Only ESRCH proves absence; permissions and unknown signal errors are conservative. */
|
|
90
|
+
export function signalAlive(pid, kill = process.kill) {
|
|
93
91
|
try {
|
|
94
|
-
|
|
92
|
+
kill(pid, 0);
|
|
93
|
+
return true;
|
|
95
94
|
}
|
|
96
95
|
catch (e) {
|
|
97
|
-
|
|
98
|
-
return false;
|
|
96
|
+
return e?.code !== "ESRCH";
|
|
99
97
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
}
|
|
99
|
+
const realLivenessIo = {
|
|
100
|
+
...realIo,
|
|
101
|
+
signal0: signalAlive,
|
|
102
|
+
psUtc: readerPsUtc,
|
|
103
|
+
};
|
|
104
|
+
function lstartFieldsMs(text) {
|
|
105
|
+
const c = canonicalLstart(text);
|
|
106
|
+
const ms = c === undefined ? NaN : Date.parse(`${c}Z`);
|
|
107
|
+
return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 19) === c ? ms : undefined;
|
|
108
|
+
}
|
|
109
|
+
/** Conventional quarter-hour offsets only; arbitrary POSIX TZ offsets remain a limitation. */
|
|
110
|
+
export function sameStartAnyZone(written, utc) {
|
|
111
|
+
const a = lstartFieldsMs(written);
|
|
112
|
+
const b = lstartFieldsMs(utc);
|
|
113
|
+
if (a === undefined || b === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
const d = (a - b) / 1000;
|
|
116
|
+
return Number.isInteger(d) && d % 900 === 0 && Math.abs(d) <= 26 * 3600;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Is the process that wrote this lock still the same running process? Judged by pid and its start time,
|
|
120
|
+
* never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
|
|
121
|
+
*/
|
|
122
|
+
export function lockHolderAlive(info, io = realLivenessIo) {
|
|
123
|
+
if (!io.signal0(info.pid))
|
|
124
|
+
return false;
|
|
125
|
+
const written = info.pid_started;
|
|
126
|
+
if (typeof written !== "string" || !written)
|
|
127
|
+
return true;
|
|
128
|
+
if (io.platform === "linux") {
|
|
129
|
+
if (!written.startsWith("proc:"))
|
|
130
|
+
return true;
|
|
131
|
+
const ticks = parseProcStat(io.readFile(`/proc/${info.pid}/stat`) ?? "");
|
|
132
|
+
return !ticks || `proc:${ticks}` === written;
|
|
104
133
|
}
|
|
105
|
-
|
|
134
|
+
if (written.startsWith("proc:"))
|
|
135
|
+
return true;
|
|
136
|
+
const local = io.ps(info.pid);
|
|
137
|
+
if (!local || samePidStart(written, local))
|
|
138
|
+
return true;
|
|
139
|
+
const utc = io.psUtc(info.pid);
|
|
140
|
+
if (!utc)
|
|
141
|
+
return true;
|
|
142
|
+
return sameStartAnyZone(written, utc) !== false;
|
|
106
143
|
}
|
|
107
144
|
function readLockFile(file) {
|
|
108
145
|
let text;
|
package/dist/prompt.d.ts
CHANGED
|
@@ -20,7 +20,12 @@ export declare const CONTEXT_LIMIT: number;
|
|
|
20
20
|
/** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
|
|
21
21
|
export declare const MAIL_BODY_LIMIT: number;
|
|
22
22
|
export declare const MAIL_BATCH_LIMIT: number;
|
|
23
|
+
/** Same-thread `reply`/`failure` messages a non-lead agent takes in one wake-up. The lead uses `MAIL_BATCH_LIMIT` and `LEAD_REPLY_CAP` instead. */
|
|
23
24
|
export declare const LEAD_BATCH_LIMIT = 5;
|
|
25
|
+
/** Most waiting `reply`/`failure` messages the lead takes in one wake-up; headers are not counted in `MAIL_BATCH_LIMIT`, so tiny replies need a count cap too. */
|
|
26
|
+
export declare const LEAD_REPLY_CAP = 20;
|
|
27
|
+
/** A project persona is injected into every wake-up. Cap it, and refuse a FIFO or other non-file, so one write cannot stall the dispatcher. */
|
|
28
|
+
export declare const PERSONA_LIMIT: number;
|
|
24
29
|
export declare function readPersona(agent: ResolvedAgent): string;
|
|
25
30
|
/** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
|
|
26
31
|
export declare function buildSoloSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
|
|
@@ -28,7 +33,12 @@ export declare function buildSoloSystemPrompt(project: ResolvedProject, agent: R
|
|
|
28
33
|
export declare function buildCallUserPrompt(agent: ResolvedAgent, task: string): string;
|
|
29
34
|
/** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
|
|
30
35
|
export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
|
|
31
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* Messages one wake-up handles: the oldest one, or a run of `reply`/`failure` so the agent decides once.
|
|
38
|
+
* The lead takes every such message that is already waiting and whose body still fits in `MAIL_BATCH_LIMIT`
|
|
39
|
+
* (a later one that would show none of its body stays unread), at most `LEAD_REPLY_CAP`. Other agents take
|
|
40
|
+
* at most `LEAD_BATCH_LIMIT` on the same thread.
|
|
41
|
+
*/
|
|
32
42
|
export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
|
|
33
43
|
export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number, handoff?: boolean): string;
|
|
34
44
|
export interface TidyRecordsContext {
|
package/dist/prompt.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
1
|
import { cutUtf8, readFileCapped } from "./fs-util.js";
|
|
3
2
|
import path from "node:path";
|
|
4
3
|
import { REQUIRED_SECTIONS } from "./format.js";
|
|
5
4
|
import { senderLabel, slotName } from "./instances.js";
|
|
6
5
|
import { modelForTier } from "./tiers.js";
|
|
7
6
|
import { buildRecordsView, RECORDS_BUDGET } from "./records.js";
|
|
8
|
-
import { commonFile, memoryDirs, outboxDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
|
|
7
|
+
import { commonFile, memoryDirs, outboxDir, ownAgentDirAllowed, projectAgentDir, recordsKindDir, repoDirFor, sharedDir, sharedIndex } from "./policy.js";
|
|
9
8
|
function memoryLabel(agent, dir) {
|
|
10
9
|
if (dir === agent.memory.task)
|
|
11
10
|
return "task (this task only, isolated)";
|
|
@@ -49,7 +48,10 @@ export const CONTEXT_LIMIT = RECORDS_BUDGET;
|
|
|
49
48
|
/** Largest mail body put in a prompt (the inline task limit), and the total for the mails handled in one wake-up. */
|
|
50
49
|
export const MAIL_BODY_LIMIT = 16 * 1024;
|
|
51
50
|
export const MAIL_BATCH_LIMIT = 64 * 1024;
|
|
51
|
+
/** Same-thread `reply`/`failure` messages a non-lead agent takes in one wake-up. The lead uses `MAIL_BATCH_LIMIT` and `LEAD_REPLY_CAP` instead. */
|
|
52
52
|
export const LEAD_BATCH_LIMIT = 5;
|
|
53
|
+
/** Most waiting `reply`/`failure` messages the lead takes in one wake-up; headers are not counted in `MAIL_BATCH_LIMIT`, so tiny replies need a count cap too. */
|
|
54
|
+
export const LEAD_REPLY_CAP = 20;
|
|
53
55
|
const BATCHABLE = new Set(["reply", "failure"]);
|
|
54
56
|
const readCapped = readFileCapped;
|
|
55
57
|
/** A PR only needs to say agent-lyceum opened it; no agent or role names. */
|
|
@@ -58,8 +60,14 @@ const PR_ATTRIBUTION_RULE = "- If you open a pull request, end its description w
|
|
|
58
60
|
const NO_DAEMONS_RULE = "- Never start a server, daemon or watcher (pm2, `serve`, `dev`, `--watch`) in the foreground of a command, and never leave one running. Run it in the background, bound every such command with `timeout 60`, stop what you started before you finish, and if it cannot start (e.g. a sandbox denies binding a port) report that as unverified instead of retrying.";
|
|
59
61
|
/** Only the lead (or a solo agent, the lead of a team of one) can send `done`, so only it can ask for a pull request. */
|
|
60
62
|
const OPEN_PR_RULE = "Add the frontmatter line `open_pr: true` to `done` only when the user's task explicitly asks for a pull request; otherwise leave it out. It only has an effect when the run ends `completed` with changes on its own branch, and agent-lyceum (not you) then pushes the branch and opens the pull request. Keep agent and role names out of the report, because it becomes the pull request description.";
|
|
63
|
+
/** A project persona is injected into every wake-up. Cap it, and refuse a FIFO or other non-file, so one write cannot stall the dispatcher. */
|
|
64
|
+
export const PERSONA_LIMIT = 64 * 1024;
|
|
61
65
|
export function readPersona(agent) {
|
|
62
|
-
|
|
66
|
+
const read = readFileCapped(agent.agentMd, PERSONA_LIMIT);
|
|
67
|
+
if (!read)
|
|
68
|
+
return "";
|
|
69
|
+
const text = read.text.trim();
|
|
70
|
+
return read.truncated ? `${text}\n\n[persona truncated at ${PERSONA_LIMIT} bytes]` : text;
|
|
63
71
|
}
|
|
64
72
|
/** What the lead (or a solo agent) is told about the handoff notes: they are what a reopened task starts from. */
|
|
65
73
|
function handoffRule(dir) {
|
|
@@ -69,6 +77,32 @@ function handoffRule(dir) {
|
|
|
69
77
|
`This task may be reopened later with a follow-up request, and the agents woken then start with fresh sessions. Before you send \`done\`, write what they need to carry on into \`${dir}\`: keep a \`MEMORY.md\` index there (one line per entry, pointing at a file) and record the decisions made and why, the interfaces agreed, the current state, what is left and what to do next, and the pitfalls met. Your teammates can read this folder but cannot write it; if a report from a teammate holds something worth keeping, add it yourself. Only the index is shown in prompts, and only the first ${HANDOFF_LIMIT / 1024} KB of it, so keep it short. These notes are about this task only; lessons that outlive it belong in your project or global memory.`,
|
|
70
78
|
];
|
|
71
79
|
}
|
|
80
|
+
function extraWritableDirs(project, agent) {
|
|
81
|
+
if (project.solo === "call")
|
|
82
|
+
return [];
|
|
83
|
+
return ownAgentDirAllowed(project, agent) ? [projectAgentDir(project, agent.name)] : [];
|
|
84
|
+
}
|
|
85
|
+
function extraWriteClause(dirs) {
|
|
86
|
+
return dirs.length ? ` You may also write ${dirs.map((d) => `\`${d}/\``).join(" and ")}.` : "";
|
|
87
|
+
}
|
|
88
|
+
function writeScopeRules(project, agent) {
|
|
89
|
+
const isLead = agent.name === project.lead && !project.solo;
|
|
90
|
+
const ownDir = projectAgentDir(project, agent.name);
|
|
91
|
+
const mayWriteOwn = project.solo !== "call" && ownAgentDirAllowed(project, agent);
|
|
92
|
+
const ownPersona = mayWriteOwn && (agent.agentMd === path.join(ownDir, "AGENT.md") || agent.agentMd.startsWith(ownDir + path.sep));
|
|
93
|
+
const records = `decisions in \`${recordsKindDir(project, "decisions")}/\` and lessons in \`${recordsKindDir(project, "lessons")}/\`. The file name without \`.md\` is the entry id (lowercase letters, digits, \`-\`). Each file starts with YAML frontmatter between \`---\` lines: \`status: active|superseded\`, \`date: YYYY-MM-DD\`, \`title: <one line>\`, optionally \`run: <run id>\` and \`supersedes: [<ids of entries of the same kind>]\`; the body is the decision or lesson and its reason. Prompts show only effective-active entries (\`status: active\` and not named in another entry's \`supersedes\`), newest first, up to ${CONTEXT_LIMIT / 1024} KB per kind, and list what does not fit as one line each. When an entry becomes outdated, mark it \`status: superseded\` or name it in a new entry's \`supersedes\`.`;
|
|
94
|
+
return [
|
|
95
|
+
isLead
|
|
96
|
+
? `- You keep the shared team context \`${commonFile(project)}\` (you maintain it; keep it short) and two kinds of durable records for every future run of this project, one Markdown file per entry: ${records} Add or update entries before you send \`done\`.`
|
|
97
|
+
: `- The shared team context is in \`${commonFile(project)}\` (read-only). Project records are ${records} Both are read-only.`,
|
|
98
|
+
...(mayWriteOwn ? [`- You may write your project agent directory \`${ownDir}/\`. Creating \`AGENT.md\` there makes it this project's persona on the next run; the global persona file stays read-only.`] : []),
|
|
99
|
+
...(ownPersona ? [`- You may edit your project persona \`${agent.agentMd}\`.`] : []),
|
|
100
|
+
...(agent.canEditAgentMd
|
|
101
|
+
? ["- You may edit every `AGENT.md` and the repository's `CLAUDE.md`/`AGENTS.md`; those changes are logged."]
|
|
102
|
+
: ["- Never edit another agent's AGENT.md, a global `AGENT.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`."]),
|
|
103
|
+
"- Never touch another agent's memory, inbox or outbox, or the configuration files of agent-lyceum (`project.yaml`, `team.yaml`, `runs/`).",
|
|
104
|
+
];
|
|
105
|
+
}
|
|
72
106
|
/** The protocol of an agent working alone: no teammates, no ask. `run --agent` still ends with a `done` mail; `call` just answers. */
|
|
73
107
|
export function buildSoloSystemPrompt(project, agent) {
|
|
74
108
|
const isCall = project.solo === "call";
|
|
@@ -89,9 +123,10 @@ export function buildSoloSystemPrompt(project, agent) {
|
|
|
89
123
|
for (const m of mems)
|
|
90
124
|
lines.push(`- ${memoryLabel(agent, m)}: \`${m}\``);
|
|
91
125
|
if (mems.length) {
|
|
92
|
-
|
|
126
|
+
const extra = extraWritableDirs(project, agent);
|
|
127
|
+
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.${extraWriteClause(extra)}`, "Use memory only for durable lessons that stay true across tasks; never record task-specific details there.");
|
|
93
128
|
}
|
|
94
|
-
lines.push("", "## Rules",
|
|
129
|
+
lines.push("", "## Rules", ...writeScopeRules(project, agent), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
|
|
95
130
|
return lines.join("\n");
|
|
96
131
|
}
|
|
97
132
|
/** The prompt of a `call`: the memory indexes the agent is used to, then the task. */
|
|
@@ -175,17 +210,13 @@ export function buildSystemPrompt(project, agent) {
|
|
|
175
210
|
lines.push(`- ${label}: \`${m}\``);
|
|
176
211
|
}
|
|
177
212
|
if (mems.length) {
|
|
178
|
-
|
|
213
|
+
const extra = extraWritableDirs(project, agent);
|
|
214
|
+
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.${extraWriteClause(extra)}`, "Put anything specific to the current task (decisions, progress, findings, branch names) in the **task** memory: it is private to this task and starts empty for every new task.", "Also record approaches you considered but rejected, with the reason, so they can be revisited or reversed later.", ...(sharedMems.length
|
|
179
215
|
? ["You cannot write the shared memory: put lessons worth keeping in your reply to the lead."]
|
|
180
216
|
: ["Use the global/project memory only for durable lessons that stay true across tasks; never record task-specific details there."]));
|
|
181
217
|
}
|
|
182
218
|
lines.push("", "## Rules");
|
|
183
|
-
lines.push(
|
|
184
|
-
(isLead || agent.canEditAgentMd ? " unless you need to as lead (changes are logged)." : "."), "- Never touch another agent's memory, inbox or outbox, or the team's config files.", `- The shared team context is in \`${commonFile(project)}\`${isLead ? " (you maintain it; keep it short)" : " (read-only)"}.`, ...(isLead
|
|
185
|
-
? [
|
|
186
|
-
`- You also keep two kinds of durable records for every future run of this project, one Markdown file per entry: decisions in \`${recordsKindDir(project, "decisions")}/\` (what was decided and why) and lessons in \`${recordsKindDir(project, "lessons")}/\` (heuristics and pitfalls that stay true). The file name without \`.md\` is the entry id (lowercase letters, digits, \`-\`). Each file starts with YAML frontmatter between \`---\` lines: \`status: active|superseded\`, \`date: YYYY-MM-DD\`, \`title: <one line>\`, optionally \`run: <run id>\` and \`supersedes: [<ids of entries of the same kind>]\`; the body is the decision or lesson and its reason. Prompts show only effective-active entries (\`status: active\` and not named in another entry's \`supersedes\`), newest first, up to ${CONTEXT_LIMIT / 1024} KB per kind, and list what does not fit as one line each. When an entry becomes outdated, mark it \`status: superseded\` or name it in a new entry's \`supersedes\`. Add or update entries before you send \`done\`.`,
|
|
187
|
-
]
|
|
188
|
-
: []), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
|
|
219
|
+
lines.push(...writeScopeRules(project, agent), PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
|
|
189
220
|
let text = lines.join("\n");
|
|
190
221
|
if (isLead && project.run?.instances)
|
|
191
222
|
text = `${text}\n\n${PARALLEL_SECTION(project.run.instances.max)}`;
|
|
@@ -199,7 +230,18 @@ export function buildSystemPrompt(project, agent) {
|
|
|
199
230
|
}
|
|
200
231
|
return text;
|
|
201
232
|
}
|
|
202
|
-
/**
|
|
233
|
+
/** Bytes of `body` this wake-up would actually show, using the same caps as the prompt. */
|
|
234
|
+
function shownBodyBytes(body, remaining) {
|
|
235
|
+
if (remaining <= 0)
|
|
236
|
+
return 0;
|
|
237
|
+
return cutUtf8(Buffer.from(body, "utf8"), Math.min(MAIL_BODY_LIMIT, remaining)).length;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Messages one wake-up handles: the oldest one, or a run of `reply`/`failure` so the agent decides once.
|
|
241
|
+
* The lead takes every such message that is already waiting and whose body still fits in `MAIL_BATCH_LIMIT`
|
|
242
|
+
* (a later one that would show none of its body stays unread), at most `LEAD_REPLY_CAP`. Other agents take
|
|
243
|
+
* at most `LEAD_BATCH_LIMIT` on the same thread.
|
|
244
|
+
*/
|
|
203
245
|
export function pickMessages(project, agent, queue) {
|
|
204
246
|
const sorted = [...queue].sort((a, b) => path.basename(a.file).localeCompare(path.basename(b.file)));
|
|
205
247
|
const first = sorted[0];
|
|
@@ -207,12 +249,20 @@ export function pickMessages(project, agent, queue) {
|
|
|
207
249
|
return sorted.slice(0, 1);
|
|
208
250
|
const isLead = agent.name === project.lead;
|
|
209
251
|
const run = [];
|
|
252
|
+
let remaining = MAIL_BATCH_LIMIT;
|
|
210
253
|
for (const m of sorted) {
|
|
211
|
-
if (!BATCHABLE.has(m.meta.type)
|
|
254
|
+
if (!BATCHABLE.has(m.meta.type))
|
|
255
|
+
break;
|
|
256
|
+
if (run.length >= (isLead ? LEAD_REPLY_CAP : LEAD_BATCH_LIMIT))
|
|
212
257
|
break;
|
|
213
258
|
if (!isLead && m.meta.thread !== first.meta.thread)
|
|
214
259
|
break;
|
|
260
|
+
const shown = isLead ? shownBodyBytes(m.body, remaining) : 0;
|
|
261
|
+
if (isLead && Buffer.byteLength(m.body, "utf8") > 0 && shown === 0)
|
|
262
|
+
break;
|
|
215
263
|
run.push(m);
|
|
264
|
+
if (isLead)
|
|
265
|
+
remaining -= shown;
|
|
216
266
|
}
|
|
217
267
|
return run;
|
|
218
268
|
}
|
package/dist/status-watch.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ResolvedProject } from "./config.js";
|
|
2
2
|
import { type StatusReport } from "./status.js";
|
|
3
|
-
/** What `status --json --watch` prints, one JSON object per line: the project's report plus one per task that is not completed. */
|
|
3
|
+
/** What `status --json --watch` prints, one JSON object per line: the project's report plus one per task that is not finished (completed or partial). */
|
|
4
4
|
export type WatchFrame = {
|
|
5
5
|
schema_version: 1;
|
|
6
6
|
report?: StatusReport;
|
|
@@ -8,9 +8,9 @@ export type WatchFrame = {
|
|
|
8
8
|
error?: string;
|
|
9
9
|
};
|
|
10
10
|
/**
|
|
11
|
-
* The frame for now. Every task that is not
|
|
12
|
-
*
|
|
13
|
-
* A task that was running in the previous frame and has completed since is included once more, so the reader sees
|
|
11
|
+
* The frame for now. Every task that is not finished (running, interrupted, waiting for answers, or ended blocked,
|
|
12
|
+
* failed or cancelled) gets its own report, so it stays listed until it is resumed or cleared.
|
|
13
|
+
* A task that was running in the previous frame and has finished (completed or partial) since is included once more, so the reader sees
|
|
14
14
|
* it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
|
|
15
15
|
*/
|
|
16
16
|
export declare function buildWatchFrame(project: ResolvedProject, tracked: Set<string>, opts?: {
|
package/dist/status-watch.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { buildStatusReport, buildTaskListReport,
|
|
2
|
+
import { buildStatusReport, buildTaskListReport, isFinishedRun } from "./status.js";
|
|
3
3
|
/**
|
|
4
|
-
* The frame for now. Every task that is not
|
|
5
|
-
*
|
|
6
|
-
* A task that was running in the previous frame and has completed since is included once more, so the reader sees
|
|
4
|
+
* The frame for now. Every task that is not finished (running, interrupted, waiting for answers, or ended blocked,
|
|
5
|
+
* failed or cancelled) gets its own report, so it stays listed until it is resumed or cleared.
|
|
6
|
+
* A task that was running in the previous frame and has finished (completed or partial) since is included once more, so the reader sees
|
|
7
7
|
* it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
|
|
8
8
|
*/
|
|
9
9
|
export function buildWatchFrame(project, tracked, opts) {
|
|
10
10
|
const all = buildTaskListReport(project).runs;
|
|
11
11
|
const running = all.filter((r) => r.state === "running").map((r) => r.run_id);
|
|
12
|
-
const open = all.filter((r) => !
|
|
12
|
+
const open = all.filter((r) => !isFinishedRun(r)).map((r) => r.run_id);
|
|
13
13
|
const runs = {};
|
|
14
|
-
// The web page keeps every task. The terminal watch drops one after it has
|
|
14
|
+
// The web page keeps every task. The terminal watch drops one after it has finished, once the finish has been seen.
|
|
15
15
|
const ids = opts?.allTasks ? all.map((r) => r.run_id) : [...open, ...tracked];
|
|
16
16
|
for (const id of new Set(ids))
|
|
17
17
|
runs[id] = buildStatusReport(project, id);
|
package/dist/status.d.ts
CHANGED
|
@@ -173,8 +173,8 @@ export declare function buildRunReport(run: {
|
|
|
173
173
|
}, project?: ResolvedProject, opts?: {
|
|
174
174
|
detail?: boolean;
|
|
175
175
|
}): RunReport;
|
|
176
|
-
/** A run that ended with a
|
|
177
|
-
export declare const
|
|
176
|
+
/** A run that ended with a `completed` or `partial` outcome: the only kinds the monitor stops listing (blocked, failed and cancelled stay). */
|
|
177
|
+
export declare const isFinishedRun: (r: Pick<RunReport, "state" | "outcome">) => boolean;
|
|
178
178
|
/** The status of a project: its agents and one run (the newest running one, else the newest, or the one named by `runId`). */
|
|
179
179
|
export declare function buildStatusReport(base: ResolvedProject, runId?: string, now?: number): StatusReport;
|
|
180
180
|
/** Every run of the project, newest first. */
|
package/dist/status.js
CHANGED
|
@@ -266,8 +266,8 @@ export function buildRunReport(run, project, opts = {}) {
|
|
|
266
266
|
...(ask ? { ask } : {}),
|
|
267
267
|
};
|
|
268
268
|
}
|
|
269
|
-
/** A run that ended with a
|
|
270
|
-
export const
|
|
269
|
+
/** A run that ended with a `completed` or `partial` outcome: the only kinds the monitor stops listing (blocked, failed and cancelled stay). */
|
|
270
|
+
export const isFinishedRun = (r) => r.state === "ended" && (r.outcome === "completed" || r.outcome === "partial");
|
|
271
271
|
const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
|
|
272
272
|
/** The fallback part of an agent's report: nothing at all for an agent without a chain. */
|
|
273
273
|
function fallbackView(a, state, now) {
|