neurastream 0.1.1 → 0.1.4
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 +28 -0
- package/package.json +1 -1
- package/src/adapters/omp/index.ts +194 -7
- package/src/core/adapter.ts +1 -1
- package/src/core/agent-score.ts +78 -4
- package/src/core/config.ts +3 -0
- package/src/core/guards.ts +133 -2
- package/src/core/main-start.ts +32 -20
- package/src/core/queue/lib.ts +37 -9
- package/src/core/rollover.ts +86 -0
- package/templates/config.yml +5 -0
- package/templates/help/agent-score.txt +2 -0
- package/templates/queue/TEMPLATE.md +4 -0
package/README.md
CHANGED
|
@@ -23,8 +23,36 @@ neurastream config --json
|
|
|
23
23
|
|
|
24
24
|
Compatibility bins keep today's names (`agent-score`, `agent-ledger`, `brief-lint`, `oc-run`, `wip-check`, `wip-backup`, `wip-snapshot`, `pm-fail-facts`, `main-health`, `queue-check`, `queue-close`, `queue-cancel`, `main-start`, `machine-guard`, `glm-reset`, `priority-watch`, `resource-alerts`, `tmp-snapshot`, `heavy-run`). They are the same program, selected by the bin name.
|
|
25
25
|
|
|
26
|
+
Scoring: `agent-score accept` records accepted work by commit; `agent-score validate <Agent> --plan <plan.md> --phase <P> --evidence <report.md> --section "<heading prefix>"` records a validation phase (checks/tests ran, report section written, no commits) with proof Main cannot fake by typing: the plan line must be ticked `- [x]`, the cited section body must name a heavy-run label run by that agent (from `paths.heavy_log`), and the phase must not be scored already. The ranking board counts it in `Accepted (diag/val)` as `N (d/v)`.
|
|
27
|
+
|
|
26
28
|
Green is `bun run typecheck` and `bun test`, both exit 0.
|
|
27
29
|
|
|
30
|
+
## Main reads the code for its plans
|
|
31
|
+
|
|
32
|
+
Owner rule: never write a plan when another agent is doing the research or reading for you. When Main writes a plan, Main reads the relevant code itself and lists those files in the plan.
|
|
33
|
+
|
|
34
|
+
- Logged: every non-`task` call Main makes adds the paths it touches to `main-reads.json` in the queue state dir (`paths.queue_state`), with the time. A `grep -n` result adds each file it names (`path:line:` at a line start), resolved against the call's directory.
|
|
35
|
+
- Window: `guards.plan_read_max_hours` (default 24, `docs/config.md`). Every file listed under `## Code read by Main` must exist and have a logged read within that many hours. A backticked path cited elsewhere in the plan, outside `docs/plans/`, must also be listed there.
|
|
36
|
+
- Cutoff: plans whose filename stamp is `20261021_0100` or later must carry a non-empty `## Code read by Main` section. Older plans are grandfathered.
|
|
37
|
+
- Retention: entries older than 7 days are dropped on each write.
|
|
38
|
+
- Spawn briefs: a brief must name an unticked phase id of the plan (for example `P4`). A brief that asks for research for a plan to be written is refused, quoting the owner rule.
|
|
39
|
+
|
|
40
|
+
## Queue holds
|
|
41
|
+
|
|
42
|
+
A plan parked for the owner has one line: `WAITING on owner: <what>`. That line is the only hold, and only Main writes it (plan-guard refuses subagents). The queue summary shows it as a flag on the plan, cut at 80 characters. Compaction and the rollover handoff read the same lines through `ownerHolds`: the compaction context lists each `<plan file>: WAITING on owner: <text>` line (or `none`) and says any other "needs the owner" or "paused" claim is unverified; the handoff's generated state carries the same lines. Main does not keep holds in a handoff note.
|
|
43
|
+
|
|
44
|
+
## Session rollover
|
|
45
|
+
|
|
46
|
+
Main's session file grows with every turn. When it reaches `rollover.max_session_mb` (default 10, `docs/config.md`), Main is asked once per session file to write a handoff note to `<queue_state>/rollover/<session id>.md`. The note covers the work in progress and its state, the decisions made and why, the next step, and the files involved.
|
|
47
|
+
|
|
48
|
+
The switch is never automatic:
|
|
49
|
+
|
|
50
|
+
- A note counts only if it was written after the threshold was crossed and is at most one hour old.
|
|
51
|
+
- The owner's next plain message then closes the session and starts a fresh one. The fresh session opens with the note verbatim, the generated `main-start` state (including the owner holds), and then the owner's message. The previous session file stays where it is.
|
|
52
|
+
- While own jobs (subagents included) are still running, the switch waits: a rollover would cancel them, so the owner's message stays in the current session until they finish.
|
|
53
|
+
- `/main-rollover <message>` does the same by hand. It refuses below the threshold, without a fresh note, or while jobs run.
|
|
54
|
+
- `rollover.enabled: false` turns the feature off.
|
|
55
|
+
|
|
28
56
|
## Changing a script
|
|
29
57
|
|
|
30
58
|
Edit the script in this repo. Green is `bun run typecheck` and `bun test`, both exit 0. Main tags the release. The publish workflow puts that tag on npm. Then:
|
package/package.json
CHANGED
|
@@ -16,17 +16,21 @@
|
|
|
16
16
|
// Harness payloads are untyped at the boundary: every field read goes through a runtime check.
|
|
17
17
|
|
|
18
18
|
import { execFileSync, spawn } from "node:child_process";
|
|
19
|
-
import { appendFileSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { appendFileSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import type { Decision } from "../../core/adapter.ts";
|
|
22
22
|
import { loadConfig, type Config } from "../../core/config.ts";
|
|
23
|
-
import { checkInject, checkPlanEdit, checkStop, checkTool } from "../../core/guards.ts";
|
|
23
|
+
import { checkInject, checkPlanEdit, checkStop, checkTool, recordMainResult } from "../../core/guards.ts";
|
|
24
24
|
import { applyQueueEnv } from "../../core/queue/env.ts";
|
|
25
|
+
import { mainStartText } from "../../core/main-start.ts";
|
|
26
|
+
import { buildHandoff, holdsBlock, notePath, noteRequest, type RolloverState, rolloverState } from "../../core/rollover.ts";
|
|
25
27
|
import * as q from "../../core/queue/lib.ts";
|
|
26
28
|
|
|
27
29
|
/** Subset of the harness extension host the adapter needs (pi.on; herdr also uses pi.events). */
|
|
28
30
|
export interface ExtensionHost {
|
|
29
31
|
on(event: string, cb: (...args: unknown[]) => unknown): void;
|
|
32
|
+
registerCommand(name: string, opts: { description?: string; handler: (args: unknown, ctx: unknown) => unknown }): void;
|
|
33
|
+
sendUserMessage(content: string): void;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
/** The parts of a harness call context the handlers look at. */
|
|
@@ -130,11 +134,22 @@ function ledgerText(cfg: Config): string {
|
|
|
130
134
|
|
|
131
135
|
// Plan files a subagent write/edit may touch: snapshot before the call so tool_result can verify
|
|
132
136
|
// and restore (the original snapshotted inside checkCall; core returns pure decisions now).
|
|
133
|
-
|
|
137
|
+
const MAIN_PLAN = /\/docs\/plans\/(?!archive\/)[^/]+\.md$/;
|
|
138
|
+
function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string, snap: Map<string, string>, main: boolean): void {
|
|
134
139
|
if (tool !== "write" && tool !== "edit") return;
|
|
135
140
|
const plans = q.activePlans();
|
|
136
141
|
for (const p of q.writeTargets(tool, input, cwd)) {
|
|
137
142
|
if (snap.has(p)) continue;
|
|
143
|
+
if (main) {
|
|
144
|
+
// Main's own plan text is checked too; a plan that did not exist yet snapshots as "".
|
|
145
|
+
if (!MAIN_PLAN.test(p)) continue;
|
|
146
|
+
try {
|
|
147
|
+
snap.set(p, readFileSync(p, "utf8"));
|
|
148
|
+
} catch {
|
|
149
|
+
snap.set(p, "");
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
138
153
|
if (!q.isOrchestrationPath(p, plans) || !q.planAt(p, plans)) continue;
|
|
139
154
|
try {
|
|
140
155
|
snap.set(p, readFileSync(p, "utf8"));
|
|
@@ -142,6 +157,65 @@ function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string
|
|
|
142
157
|
}
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
// Session rollover helpers: the harness context read through runtime checks, and the file size and
|
|
161
|
+
// mtime reads. The decisions are in src/core/rollover.ts.
|
|
162
|
+
const WAIT_NOTICE_MS = 15 * 60_000;
|
|
163
|
+
|
|
164
|
+
interface RolloverCtx {
|
|
165
|
+
sessionId: string;
|
|
166
|
+
sessionFile: string;
|
|
167
|
+
runningJobs(): string[];
|
|
168
|
+
waitForIdle(): unknown;
|
|
169
|
+
newSession(opts: { parentSession: string }): unknown;
|
|
170
|
+
notify(message: string): void;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Call a method on a harness object by name; undefined when it is not a function. */
|
|
174
|
+
function call(obj: Record<string, unknown>, key: string, args: unknown[] = []): unknown {
|
|
175
|
+
const fn = obj[key];
|
|
176
|
+
return typeof fn === "function" ? Reflect.apply(fn, obj, args) : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function asRolloverCtx(v: unknown): RolloverCtx | null {
|
|
180
|
+
const r = asRecord(v);
|
|
181
|
+
const sm = asRecord(r.sessionManager);
|
|
182
|
+
const sessionId = call(sm, "getSessionId");
|
|
183
|
+
const sessionFile = call(sm, "getSessionFile");
|
|
184
|
+
if (typeof sessionId !== "string" || typeof sessionFile !== "string") return null;
|
|
185
|
+
return {
|
|
186
|
+
sessionId,
|
|
187
|
+
sessionFile,
|
|
188
|
+
runningJobs: () => {
|
|
189
|
+
const running = asRecord(call(r, "getAsyncJobSnapshot")).running;
|
|
190
|
+
return Array.isArray(running) ? running.map(j => asString(asRecord(j).label, "job")) : [];
|
|
191
|
+
},
|
|
192
|
+
waitForIdle: () => call(r, "waitForIdle"),
|
|
193
|
+
newSession: opts => call(r, "newSession", [opts]),
|
|
194
|
+
notify: message => call(asRecord(r.ui), "notify", [message]),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isMain(ctx: unknown): boolean {
|
|
199
|
+
const c = asCtx(ctx);
|
|
200
|
+
return !c.hasAgent || c.kind === "main";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function mtimeOf(path: string): number | null {
|
|
204
|
+
try {
|
|
205
|
+
return statSync(path).mtimeMs;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function sizeOf(path: string): number {
|
|
212
|
+
try {
|
|
213
|
+
return statSync(path).size;
|
|
214
|
+
} catch {
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
145
219
|
/** Register the guard handlers on one extension host. `cfg` is injectable for tests. */
|
|
146
220
|
export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
|
|
147
221
|
// The queue library resolves its paths from env snapshots; point them at this config first.
|
|
@@ -172,10 +246,11 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
|
|
|
172
246
|
const d = checkInject({ inject: asString(asRecord(event).prompt), agent: "main" }, cfg);
|
|
173
247
|
callsSinceStop = d.state?.callsSinceStop ?? callsSinceStop;
|
|
174
248
|
idleStops = d.state?.idleStops ?? idleStops;
|
|
249
|
+
const notice = rolloverNotice(ctx);
|
|
175
250
|
return {
|
|
176
251
|
message: {
|
|
177
252
|
customType: "main-guard.queue",
|
|
178
|
-
content: d.inject
|
|
253
|
+
content: [d.inject, notice].filter(Boolean).join("\n\n"),
|
|
179
254
|
display: false,
|
|
180
255
|
},
|
|
181
256
|
};
|
|
@@ -208,7 +283,7 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
|
|
|
208
283
|
if (!isSub) callsSinceStop++;
|
|
209
284
|
const cwd = c.cwd ?? process.cwd();
|
|
210
285
|
const snap = new Map<string, string>();
|
|
211
|
-
|
|
286
|
+
snapshotPlans(tool, input, cwd, snap, !isSub);
|
|
212
287
|
const d = checkTool(
|
|
213
288
|
{ tool, input, cwd, agent, agentId: id, calls: n - 1, paths: [], writes: [], command: asString(input.command) },
|
|
214
289
|
cfg,
|
|
@@ -221,6 +296,7 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
|
|
|
221
296
|
});
|
|
222
297
|
|
|
223
298
|
pi.on("tool_result", (event, ctx) => {
|
|
299
|
+
onSizeCheck(event, ctx);
|
|
224
300
|
const e = asRecord(event);
|
|
225
301
|
const c = asCtx(ctx);
|
|
226
302
|
const id = asString(e.toolCallId);
|
|
@@ -232,10 +308,11 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
|
|
|
232
308
|
try {
|
|
233
309
|
after = readFileSync(file, "utf8");
|
|
234
310
|
} catch {}
|
|
235
|
-
const d = checkPlanEdit({ path: file, before, after, agent: "sub" }, cfg);
|
|
311
|
+
const d = checkPlanEdit({ path: file, before, after, agent: c.kind === "sub" ? "sub" : "main" }, cfg);
|
|
236
312
|
if (!d.allow && d.restore) {
|
|
237
313
|
try {
|
|
238
|
-
|
|
314
|
+
if (d.restore.text === null) rmSync(d.restore.path, { force: true });
|
|
315
|
+
else writeFileSync(d.restore.path, d.restore.text);
|
|
239
316
|
} catch {}
|
|
240
317
|
return { isError: true, content: [{ type: "text", text: d.reason }] };
|
|
241
318
|
}
|
|
@@ -244,6 +321,11 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
|
|
|
244
321
|
if ((!c.hasAgent || c.kind === "main") && asString(e.toolName) === "bash" && !e.isError) {
|
|
245
322
|
if (PUSH_MERGE.test(asString(asRecord(e.input).command))) autoClose(cfg);
|
|
246
323
|
}
|
|
324
|
+
if ((!c.hasAgent || c.kind === "main") && !e.isError) {
|
|
325
|
+
const content = Array.isArray(e.content) ? e.content : [];
|
|
326
|
+
const text = content.map(p => asString(asRecord(p).text)).join("\n");
|
|
327
|
+
recordMainResult(asString(e.toolName), asRecord(e.input), c.cwd ?? process.cwd(), text);
|
|
328
|
+
}
|
|
247
329
|
return undefined;
|
|
248
330
|
});
|
|
249
331
|
|
|
@@ -255,6 +337,111 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
|
|
|
255
337
|
idleStops = d.state?.idleStops ?? idleStops;
|
|
256
338
|
return d.allow ? undefined : { decision: "block", reason: d.reason };
|
|
257
339
|
});
|
|
340
|
+
|
|
341
|
+
// Session rollover (src/core/rollover.ts). Main's session size decides when a handoff note is
|
|
342
|
+
// asked for; the owner's next message then starts a fresh session from that note. The size is
|
|
343
|
+
// re-read at every turn end and tool result; the note instruction goes out once per session file.
|
|
344
|
+
let crossed: { file: string; atMs: number } | null = null;
|
|
345
|
+
let asked: string | null = null;
|
|
346
|
+
let waitNoticeMs = 0;
|
|
347
|
+
|
|
348
|
+
const stateOf = (c: RolloverCtx): RolloverState => {
|
|
349
|
+
const nowMs = Date.now();
|
|
350
|
+
const bytes = sizeOf(c.sessionFile);
|
|
351
|
+
const maxMb = cfg.rollover.max_session_mb;
|
|
352
|
+
if (bytes < maxMb * 1024 * 1024) crossed = null;
|
|
353
|
+
else if (crossed?.file !== c.sessionFile) crossed = { file: c.sessionFile, atMs: nowMs };
|
|
354
|
+
return rolloverState({
|
|
355
|
+
sessionBytes: bytes,
|
|
356
|
+
maxMb,
|
|
357
|
+
runningJobs: c.runningJobs(),
|
|
358
|
+
crossedAtMs: crossed?.atMs ?? null,
|
|
359
|
+
noteMtimeMs: mtimeOf(notePath(c.sessionId)),
|
|
360
|
+
nowMs,
|
|
361
|
+
});
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const onSizeCheck = (_e: unknown, ctx: unknown): void => {
|
|
365
|
+
const c = asRolloverCtx(ctx);
|
|
366
|
+
if (!cfg.rollover.enabled || !c || !isMain(ctx)) return;
|
|
367
|
+
stateOf(c);
|
|
368
|
+
};
|
|
369
|
+
pi.on("agent_end", onSizeCheck);
|
|
370
|
+
|
|
371
|
+
// Compaction keeps the owner's holds true: a summary may state a hold only as a plans' WAITING on owner line says it.
|
|
372
|
+
pi.on("session.compacting", (_e, ctx) => {
|
|
373
|
+
if (!isMain(ctx)) return undefined;
|
|
374
|
+
const holds = q.ownerHolds();
|
|
375
|
+
return {
|
|
376
|
+
context: [
|
|
377
|
+
"Owner holds/blockers: only plans' WAITING on owner lines are real:",
|
|
378
|
+
...(holds.length > 0 ? holds : ["none"]),
|
|
379
|
+
"Any other hold, 'needs the owner' or 'paused' claim carried in this summary is unverified: re-read the plan before repeating it to the owner.",
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
/** Rollover notice for Main's next turn, or null. Asks for the note once per session file; throttles the wait-jobs notice. */
|
|
385
|
+
const rolloverNotice = (ctx: unknown): string | null => {
|
|
386
|
+
const c = asRolloverCtx(ctx);
|
|
387
|
+
if (!cfg.rollover.enabled || !c) return null;
|
|
388
|
+
const state = stateOf(c);
|
|
389
|
+
if (state === "need-note" && asked !== c.sessionFile) {
|
|
390
|
+
asked = c.sessionFile;
|
|
391
|
+
return noteRequest(notePath(c.sessionId));
|
|
392
|
+
}
|
|
393
|
+
if (state === "wait-jobs" && Date.now() - waitNoticeMs >= WAIT_NOTICE_MS) {
|
|
394
|
+
waitNoticeMs = Date.now();
|
|
395
|
+
const jobs = c.runningJobs().join(", ");
|
|
396
|
+
return `Session rollover: the handoff note is ready, but own jobs are still running (${jobs}). A rollover would cancel them, so the owner's messages stay in this session until they finish.`;
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// A plain interactive message in the ready state becomes the owner's first message of the new session.
|
|
402
|
+
pi.on("input", (event, ctx) => {
|
|
403
|
+
const e = asRecord(event);
|
|
404
|
+
const c = asRolloverCtx(ctx);
|
|
405
|
+
const text = asString(e.text);
|
|
406
|
+
if (!cfg.rollover.enabled || !c || !isMain(ctx)) return undefined;
|
|
407
|
+
if (asString(e.source) !== "interactive" || text === "" || text.startsWith("/")) return undefined;
|
|
408
|
+
if (stateOf(c) !== "ready") return undefined;
|
|
409
|
+
return { text: `/main-rollover ${text}` };
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
pi.registerCommand("main-rollover", {
|
|
413
|
+
description: "Start a fresh session from Main's handoff note; the rest of the message is the owner's first message",
|
|
414
|
+
handler: async (args: unknown, ctx: unknown) => {
|
|
415
|
+
const c = asRolloverCtx(ctx);
|
|
416
|
+
if (!c) return;
|
|
417
|
+
const path = notePath(c.sessionId);
|
|
418
|
+
const state = stateOf(c);
|
|
419
|
+
if (state === "below") {
|
|
420
|
+
c.notify("main-rollover: this session is below rollover.max_session_mb; nothing to roll over.");
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (state === "need-note") {
|
|
424
|
+
c.notify(`main-rollover: no fresh handoff note at ${path}; Main must write it first.`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (state === "wait-jobs") {
|
|
428
|
+
c.notify(`main-rollover: own jobs are still running (${c.runningJobs().join(", ")}); a rollover would cancel them.`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const generated = [await mainStartText([], cfg), holdsBlock(q.ownerHolds())].join("\n");
|
|
432
|
+
const handoff = buildHandoff({
|
|
433
|
+
generated,
|
|
434
|
+
note: readFileSync(path, "utf8"),
|
|
435
|
+
previousSessionFile: c.sessionFile,
|
|
436
|
+
ownerMessage: asString(args),
|
|
437
|
+
});
|
|
438
|
+
adapterLog(cfg, `rollover: new session from ${c.sessionFile}; note ${path}`);
|
|
439
|
+
await c.waitForIdle();
|
|
440
|
+
const r = asRecord(await c.newSession({ parentSession: c.sessionFile }));
|
|
441
|
+
if (r.cancelled === true) return;
|
|
442
|
+
pi.sendUserMessage(handoff);
|
|
443
|
+
},
|
|
444
|
+
});
|
|
258
445
|
}
|
|
259
446
|
|
|
260
447
|
/** package.json omp.extensions entry. */
|
package/src/core/adapter.ts
CHANGED
|
@@ -46,7 +46,7 @@ export interface Decision {
|
|
|
46
46
|
allow: boolean;
|
|
47
47
|
reason: string;
|
|
48
48
|
inject?: string;
|
|
49
|
-
restore?: { path: string; text: string };
|
|
49
|
+
restore?: { path: string; text: string | null }; // null: the file did not exist before; delete it
|
|
50
50
|
/** Next keep-working counters for the caller to feed back into later StopEvents. */
|
|
51
51
|
state?: { callsSinceStop: number; idleStops: number };
|
|
52
52
|
}
|
package/src/core/agent-score.ts
CHANGED
|
@@ -88,6 +88,55 @@ function phaseDifficulty(plan: string, phase: string): number | null {
|
|
|
88
88
|
throw new Error("stop");
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** True when the plan's phase line exists and is ticked `- [x]`/`- [X]` (ticking = reviewed by Main). */
|
|
92
|
+
function phaseTicked(plan: string, phase: string): boolean {
|
|
93
|
+
if (!existsSync(plan)) {
|
|
94
|
+
exitText(`plan not found: ${plan}`);
|
|
95
|
+
throw new Error("stop");
|
|
96
|
+
}
|
|
97
|
+
const re = new RegExp(`^- \\[[ xX]\\] ${phase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
|
98
|
+
for (const line of readFileSync(plan, "utf8").split("\n")) {
|
|
99
|
+
if (re.test(line)) return /^- \[[xX]\]/.test(line);
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Body of the first heading whose text starts with `section` and ends at end-of-line, space or `(`,
|
|
106
|
+
* so `## CP` matches `## CP (P5, ...)` but not `## CPX`. The body runs to the next heading of the
|
|
107
|
+
* same or higher level. Null when no heading matches.
|
|
108
|
+
*/
|
|
109
|
+
function sectionBody(text: string, section: string): string | null {
|
|
110
|
+
const lines = text.split("\n");
|
|
111
|
+
for (let i = 0; i < lines.length; i++) {
|
|
112
|
+
const line = lines[i]!;
|
|
113
|
+
if (!/^#{1,6} /.test(line)) continue;
|
|
114
|
+
if (!(line === section || line.startsWith(`${section} `) || line.startsWith(`${section}(`))) continue;
|
|
115
|
+
const level = (line.match(/^#+/) ?? ["#"])[0]!.length;
|
|
116
|
+
const body: string[] = [];
|
|
117
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
118
|
+
const next = lines[j]!;
|
|
119
|
+
const m = next.match(/^#+(?= )/);
|
|
120
|
+
if (m && m[0].length <= level) break;
|
|
121
|
+
body.push(next);
|
|
122
|
+
}
|
|
123
|
+
return body.join("\n");
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Labels from the heavy-run log's `END <agent> | <label> | exit <n>` lines (START lines never match). */
|
|
129
|
+
function heavyEndLabels(log: string, agent: string): string[] {
|
|
130
|
+
if (!existsSync(log)) return [];
|
|
131
|
+
const re = new RegExp(`END\\s+${agent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} \\| (.*) \\| exit \\d+$`);
|
|
132
|
+
const out: string[] = [];
|
|
133
|
+
for (const line of readFileSync(log, "utf8").split("\n")) {
|
|
134
|
+
const m = line.match(re);
|
|
135
|
+
if (m) out.push(m[1]!);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
91
140
|
function weightFor(args: string[], plan: string, phase: string, cfg: Config, fallback: number | null): { w: number; src: string } {
|
|
92
141
|
if (args.includes("--weight")) return { w: Number(needOpt(args, "--weight")), src: "flag" };
|
|
93
142
|
const d = phaseDifficulty(plan, phase);
|
|
@@ -110,6 +159,7 @@ interface Row {
|
|
|
110
159
|
first: number;
|
|
111
160
|
pen: number;
|
|
112
161
|
diag: number;
|
|
162
|
+
val: number;
|
|
113
163
|
net: number;
|
|
114
164
|
total: number;
|
|
115
165
|
ppd: number | null;
|
|
@@ -183,7 +233,7 @@ function table(days: number, cfg: Config, source: RunSource): { rows: Row[]; byA
|
|
|
183
233
|
for (const [n, r] of byAgent) agentModel.set(n, r.model);
|
|
184
234
|
const rows = new Map<string, Row>();
|
|
185
235
|
const blank = (model: string): Row => ({
|
|
186
|
-
model, runs: 0, done: 0, running: 0, cost: 0, steer: 0, hours: 0, pts: 0, acc: 0, first: 0, pen: 0, diag: 0,
|
|
236
|
+
model, runs: 0, done: 0, running: 0, cost: 0, steer: 0, hours: 0, pts: 0, acc: 0, first: 0, pen: 0, diag: 0, val: 0,
|
|
187
237
|
net: 0, total: 0, ppd: null, pph: null, surv: null, fp: null,
|
|
188
238
|
});
|
|
189
239
|
for (const r of byAgent.values()) {
|
|
@@ -203,11 +253,12 @@ function table(days: number, cfg: Config, source: RunSource): { rows: Row[]; byA
|
|
|
203
253
|
const agent = String(e.agent ?? "");
|
|
204
254
|
const model = (typeof e.model === "string" && e.model) || agentModel.get(agent) || "?";
|
|
205
255
|
const m = rows.get(model) ?? blank(model);
|
|
206
|
-
if (e.type === "accept" || e.type === "diagnose") {
|
|
256
|
+
if (e.type === "accept" || e.type === "diagnose" || e.type === "validate") {
|
|
207
257
|
m.pts += Number(e.weight);
|
|
208
258
|
m.acc += 1;
|
|
209
259
|
m.first += e.rework ? 0 : 1;
|
|
210
260
|
m.diag += e.type === "diagnose" ? 1 : 0;
|
|
261
|
+
m.val += e.type === "validate" ? 1 : 0;
|
|
211
262
|
} else if (e.type === "penalty") {
|
|
212
263
|
m.pen += cfg.score.penalties[String(e.kind)] ?? 0;
|
|
213
264
|
}
|
|
@@ -247,12 +298,12 @@ function board(days: number, cfg: Config, source: RunSource): void {
|
|
|
247
298
|
+ "(false-claim 5, rule-break 2, escaped-defect 8). Survival = finished runs that ended with a final result. Steering is approximate "
|
|
248
299
|
+ "(Main turns naming the agent).",
|
|
249
300
|
"",
|
|
250
|
-
"| # | Worker model | Runs (live) | Survival | Accepted (diag) | First-pass | Net pts | $ worker | $ steering | pts/$ | pts/h |",
|
|
301
|
+
"| # | Worker model | Runs (live) | Survival | Accepted (diag/val) | First-pass | Net pts | $ worker | $ steering | pts/$ | pts/h |",
|
|
251
302
|
"|---|---|---|---|---|---|---|---|---|---|---|",
|
|
252
303
|
];
|
|
253
304
|
rows.forEach((r, i) => {
|
|
254
305
|
lines.push(
|
|
255
|
-
`| ${i + 1} | ${r.model} | ${r.runs} (${r.running}) | ${fmt(r.surv, "pct")} | ${r.acc} (${r.diag}) | ${fmt(r.fp, "pct")} | ${r.net} | ${pyFixed(r.cost, 2)} | ${pyFixed(r.steer, 2)} | ${fmt(r.ppd, "1f")} | ${fmt(r.pph, "1f")} |`,
|
|
306
|
+
`| ${i + 1} | ${r.model} | ${r.runs} (${r.running}) | ${fmt(r.surv, "pct")} | ${r.acc} (${r.diag}/${r.val}) | ${fmt(r.fp, "pct")} | ${r.net} | ${pyFixed(r.cost, 2)} | ${pyFixed(r.steer, 2)} | ${fmt(r.ppd, "1f")} | ${fmt(r.pph, "1f")} |`,
|
|
256
307
|
);
|
|
257
308
|
});
|
|
258
309
|
const text = `${lines.join("\n")}\n`;
|
|
@@ -301,6 +352,29 @@ export function agentScore(args: string[], cfg: Config, source: RunSource): numb
|
|
|
301
352
|
}, cfg);
|
|
302
353
|
return 0;
|
|
303
354
|
}
|
|
355
|
+
if (cmd === "validate") {
|
|
356
|
+
const agent = rest[0];
|
|
357
|
+
const plan = opt(rest, "--plan");
|
|
358
|
+
const phase = opt(rest, "--phase");
|
|
359
|
+
const ev = opt(rest, "--evidence");
|
|
360
|
+
const section = opt(rest, "--section");
|
|
361
|
+
if (!agent || !plan || !phase || !ev || !section) return exitText("validate needs --plan, --phase, --evidence and --section");
|
|
362
|
+
if (!phaseTicked(plan, phase)) return exitText(`refused: ${phase} not ticked in ${plan}`);
|
|
363
|
+
const { w, src } = weightFor(rest, plan, phase, cfg, null);
|
|
364
|
+
if (!existsSync(ev)) return exitText(`refused: evidence file not found: ${ev}`);
|
|
365
|
+
const body = sectionBody(readFileSync(ev, "utf8"), section);
|
|
366
|
+
if (body === null) return exitText(`refused: no heading starting with ${section} in ${ev}`);
|
|
367
|
+
if (!body.split("\n").some(l => l.trim())) return exitText(`refused: section ${section} has no body in ${ev}`);
|
|
368
|
+
const labels = heavyEndLabels(cfg.paths.heavy_log, agent).filter(l => body.includes(l));
|
|
369
|
+
if (labels.length === 0) return exitText(`refused: section cites no heavy-run label run by ${agent}`);
|
|
370
|
+
const scored = readEvents(cfg).some(e => (e.type === "accept" || e.type === "validate") && e.agent === agent && e.plan === plan && e.phase === phase);
|
|
371
|
+
if (scored) return exitText("refused: already scored");
|
|
372
|
+
record({
|
|
373
|
+
type: "validate", agent, plan, phase, weight: w, weight_src: src, evidence: ev, section,
|
|
374
|
+
labels, rework: rest.includes("--rework"), note: opt(rest, "--note") ?? "",
|
|
375
|
+
}, cfg);
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
304
378
|
if (cmd === "penalty") {
|
|
305
379
|
const kind = opt(rest, "--kind");
|
|
306
380
|
const note = opt(rest, "--note");
|
package/src/core/config.ts
CHANGED
|
@@ -98,6 +98,7 @@ export interface Config {
|
|
|
98
98
|
main_edit_limit: number;
|
|
99
99
|
main_window_min: number;
|
|
100
100
|
unlock_minutes: number;
|
|
101
|
+
plan_read_max_hours: number;
|
|
101
102
|
idle_stops: number;
|
|
102
103
|
blocker_timeout_ms: number;
|
|
103
104
|
skip_tools: string[];
|
|
@@ -110,6 +111,7 @@ export interface Config {
|
|
|
110
111
|
protected: string[];
|
|
111
112
|
ledger_command: string;
|
|
112
113
|
};
|
|
114
|
+
rollover: { enabled: boolean; max_session_mb: number };
|
|
113
115
|
machine: {
|
|
114
116
|
load_over_cores: number;
|
|
115
117
|
load_max: number;
|
|
@@ -257,6 +259,7 @@ function check(cfg: Config): void {
|
|
|
257
259
|
if (cfg.guards.sub_warn_calls > cfg.guards.sub_max_calls) {
|
|
258
260
|
throw new ConfigError("guards.sub_warn_calls must be <= guards.sub_max_calls");
|
|
259
261
|
}
|
|
262
|
+
if (!(cfg.rollover.max_session_mb > 0)) throw new ConfigError("rollover.max_session_mb must be > 0");
|
|
260
263
|
for (const [key, value] of Object.entries(cfg.paths)) {
|
|
261
264
|
if (key === "work_roots") {
|
|
262
265
|
if (!Array.isArray(value) || value.some(v => typeof v !== "string")) {
|
package/src/core/guards.ts
CHANGED
|
@@ -157,6 +157,15 @@ export function bypassRegExp(entry: string): RegExp {
|
|
|
157
157
|
return new RegExp(`\\b${head}\\b[^\\n]*\\b${tail}\\b`);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
const RESEARCH_ASK = /\b(research|investigate|find where|map the code|read the code for)\b/i;
|
|
161
|
+
const PLAN_WRITE = /\b(write|writing|draft|drafting|author)\b[^\n]*\bplan\b/i;
|
|
162
|
+
|
|
163
|
+
// Phase ids (P1, P2 ...) in the plan's Phases section that are not ticked yet.
|
|
164
|
+
function unticked(p: Plan): string[] {
|
|
165
|
+
const phases = q.sections(p.text).get("phases") ?? "";
|
|
166
|
+
return [...phases.matchAll(/^\s*[-*]\s+\[ \]\s+(P\d+)\b/gm)].map(m => m[1]!);
|
|
167
|
+
}
|
|
168
|
+
|
|
160
169
|
// Plans an implementer brief may run on; returns [reason, null] or [null, plan files].
|
|
161
170
|
export function planGate(text: string, label: string): [string | null, string[]] {
|
|
162
171
|
const plans = q.activePlans();
|
|
@@ -172,6 +181,11 @@ export function planGate(text: string, label: string): [string | null, string[]]
|
|
|
172
181
|
`${label}: an implementer needs an open plan. Name the plan's absolute path in the brief; the plan must be listed in <repo>/INDEX.md and the repo in ${q.INDEX}.`,
|
|
173
182
|
[],
|
|
174
183
|
];
|
|
184
|
+
if (RESEARCH_ASK.test(text) && PLAN_WRITE.test(text))
|
|
185
|
+
return [
|
|
186
|
+
`${label}: a brief must not ask for research for a plan to be written. Please never ever ever write a plan when another agent is doing the research or reading for you. Main reads the code for its plans itself.`,
|
|
187
|
+
[],
|
|
188
|
+
];
|
|
175
189
|
const spawns = readSpawns();
|
|
176
190
|
for (const p of named) {
|
|
177
191
|
if (p.errors.length) return [`${label}: plan ${p.file} is not ready (${p.errors.join("; ")}).`, []];
|
|
@@ -185,6 +199,10 @@ export function planGate(text: string, label: string): [string | null, string[]]
|
|
|
185
199
|
`${label}: plan ${p.file} has no new "## Progress" entry since the last agent ran on it. Write that run's progress (from its report) into the plan first.`,
|
|
186
200
|
[],
|
|
187
201
|
];
|
|
202
|
+
const open = unticked(p);
|
|
203
|
+
if (!open.length) return [`${label}: plan ${p.file} has no unticked phase left.`, []];
|
|
204
|
+
if (!open.some(id => new RegExp(`\\b${id}\\b`).test(text)))
|
|
205
|
+
return [`${label}: the brief must name an unticked phase of ${p.file} (${open.join(", ")}).`, []];
|
|
188
206
|
}
|
|
189
207
|
return [null, named.map(p => p.file)];
|
|
190
208
|
}
|
|
@@ -339,7 +357,12 @@ export function checkTool(e: ToolEvent, cfg: Config): Decision {
|
|
|
339
357
|
`plan-guard: ${hit} is a protected guard file. Only the owner changes it (unlock: \`touch ${q.UNLOCK}\` in a terminal, valid ${cfg.guards.unlock_minutes} min).`,
|
|
340
358
|
);
|
|
341
359
|
}
|
|
342
|
-
if (e.agent === "main")
|
|
360
|
+
if (e.agent === "main") {
|
|
361
|
+
const d = mainTool(e, input, cfg);
|
|
362
|
+
// A task brief names paths it does not read; only the other tools' paths count as reads.
|
|
363
|
+
if (d.allow && e.tool !== "task") recordMainReads(paths.filter(p => !writes.includes(p)));
|
|
364
|
+
return d;
|
|
365
|
+
}
|
|
343
366
|
const why = subRules(e, input, paths, writes, cfg);
|
|
344
367
|
if (why) {
|
|
345
368
|
log(cfg.paths.plan_guard_log, `BLOCK sub:${e.agentId} tool=${e.tool} ${why}`);
|
|
@@ -355,6 +378,46 @@ export function checkTool(e: ToolEvent, cfg: Config): Decision {
|
|
|
355
378
|
return ALLOW;
|
|
356
379
|
}
|
|
357
380
|
|
|
381
|
+
// --- Main's read log (`main-reads.json` in the queue state dir): files Main has read itself ---
|
|
382
|
+
|
|
383
|
+
const MAIN_READ_KEEP_MS = 7 * 86_400_000;
|
|
384
|
+
const GREP_LINE = /^([^\s:]+):\d+:/gm;
|
|
385
|
+
/** Logs existing files as read now; entries older than 7 days are dropped on each write. */
|
|
386
|
+
function recordMainReads(files: string[], now = Date.now()): void {
|
|
387
|
+
const file = path.join(q.STATE_DIR, "main-reads.json");
|
|
388
|
+
const existing = files.filter(f => {
|
|
389
|
+
try {
|
|
390
|
+
return statSync(f).isFile();
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
if (existing.length === 0) return;
|
|
396
|
+
let reads: Record<string, string> = {};
|
|
397
|
+
try {
|
|
398
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
|
|
399
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) reads = parsed as Record<string, string>;
|
|
400
|
+
} catch {}
|
|
401
|
+
const stamp = new Date(now).toISOString();
|
|
402
|
+
for (const f of existing) reads[f] = stamp;
|
|
403
|
+
const cutoff = now - MAIN_READ_KEEP_MS;
|
|
404
|
+
for (const [f, t] of Object.entries(reads)) if (!(Date.parse(t) >= cutoff)) delete reads[f];
|
|
405
|
+
try {
|
|
406
|
+
mkdirSync(q.STATE_DIR, { recursive: true });
|
|
407
|
+
writeFileSync(file, JSON.stringify(reads, null, 2));
|
|
408
|
+
} catch {}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Main's tool result: each `path:line:` at a line start (grep -n / grep -rn output) names a file
|
|
413
|
+
* Main read. Paths resolve against the directory the call ran in (its `cwd`, then any `cd`).
|
|
414
|
+
*/
|
|
415
|
+
export function recordMainResult(tool: string, input: Record<string, unknown>, cwd: string, text: string): void {
|
|
416
|
+
const base = typeof input.cwd === "string" ? path.resolve(cwd, q.expandHome(input.cwd)) : cwd;
|
|
417
|
+
const dir = q.shellCwd(String(input.command ?? input.code ?? ""), base);
|
|
418
|
+
recordMainReads([...text.matchAll(GREP_LINE)].map(m => path.resolve(dir, m[1] ?? "")));
|
|
419
|
+
}
|
|
420
|
+
|
|
358
421
|
/**
|
|
359
422
|
* spawn: the allow-list, task-only and open-plan gates for one implementer launch. Covers both
|
|
360
423
|
* original paths (the extension spawn event and a `task` tool call); the brief names the plan.
|
|
@@ -433,7 +496,7 @@ function mainOnlyLines(text: string): string {
|
|
|
433
496
|
* write (the original restored the file itself before reporting).
|
|
434
497
|
*/
|
|
435
498
|
export function checkPlanEdit(e: PlanEditEvent, cfg: Config): Decision {
|
|
436
|
-
if (e.agent === "main") return
|
|
499
|
+
if (e.agent === "main") return checkMainPlan(e, cfg);
|
|
437
500
|
if (q.outsideProgress(e.after) !== q.outsideProgress(e.before) || mainOnlyLines(e.after) !== mainOnlyLines(e.before)) {
|
|
438
501
|
log(cfg.paths.plan_guard_log, `RESTORE ${e.path}`);
|
|
439
502
|
return {
|
|
@@ -445,6 +508,74 @@ export function checkPlanEdit(e: PlanEditEvent, cfg: Config): Decision {
|
|
|
445
508
|
return ALLOW;
|
|
446
509
|
}
|
|
447
510
|
|
|
511
|
+
// Main's plan text outside "## Progress" must rest on files Main read itself (planReadProblems).
|
|
512
|
+
// An empty `before` means the plan file did not exist yet; a refusal then deletes it.
|
|
513
|
+
function checkMainPlan(e: PlanEditEvent, cfg: Config): Decision {
|
|
514
|
+
if (q.outsideProgress(e.after) === q.outsideProgress(e.before)) return ALLOW;
|
|
515
|
+
const problems = planReadProblems(e.after, e.path, cfg);
|
|
516
|
+
if (!problems.length) return ALLOW;
|
|
517
|
+
log(cfg.paths.plan_guard_log, `REFUSE-READ ${e.path}: ${problems.join("; ")}`);
|
|
518
|
+
return {
|
|
519
|
+
allow: false,
|
|
520
|
+
reason: `plan-guard: ${e.path} cites code Main has not read for this plan:\n- ${problems.join("\n- ")}\nRead each file (read or grep it), list it under "## Code read by Main", then edit again. ${e.before ? "The file was restored." : "The new file was removed."}`,
|
|
521
|
+
restore: { path: e.path, text: e.before || null },
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const READ_SECTION = /^## Code read by Main[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m;
|
|
526
|
+
const LISTED_PATH = /`([^`\s]+)`/g;
|
|
527
|
+
const CITED_PATH = /((?:~\/|\/|\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)*\.[A-Za-z0-9]+):\d+/g;
|
|
528
|
+
|
|
529
|
+
/** Read evidence behind a plan edit: section present, each listed path read within the window, no unlisted cited path. */
|
|
530
|
+
function planReadProblems(text: string, planPath: string, cfg: Config): string[] {
|
|
531
|
+
const section = READ_SECTION.exec(text)?.[1] ?? "";
|
|
532
|
+
const listed = [...section.matchAll(LISTED_PATH)].map(m => (m[1] ?? "").replace(/:[\d,+\- ]+$/, ""));
|
|
533
|
+
if (!listed.length) return [`no "## Code read by Main" section lists a file (list each file you read for this plan as a backticked path)`];
|
|
534
|
+
const reads = readMainReads();
|
|
535
|
+
const maxMs = cfg.guards.plan_read_max_hours * 3_600_000;
|
|
536
|
+
const problems: string[] = [];
|
|
537
|
+
const named = new Set<string>();
|
|
538
|
+
for (const p of listed) {
|
|
539
|
+
const abs = planFile(p, planPath, text);
|
|
540
|
+
named.add(abs);
|
|
541
|
+
if (!isFile(abs)) problems.push(`${p} does not exist`);
|
|
542
|
+
else if (!(Date.parse(reads[abs] ?? "") >= Date.now() - maxMs)) problems.push(`${p} was not read by Main within ${cfg.guards.plan_read_max_hours} h`);
|
|
543
|
+
}
|
|
544
|
+
for (const [, cited = ""] of text.matchAll(CITED_PATH)) {
|
|
545
|
+
const abs = planFile(cited, planPath, text);
|
|
546
|
+
if (abs.includes("/docs/plans/") || named.has(abs) || !isFile(abs)) continue;
|
|
547
|
+
problems.push(`${cited} is cited but not listed under "## Code read by Main"`);
|
|
548
|
+
}
|
|
549
|
+
return problems;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** A plan-cited path: absolute, else under the plan's Worktree (when set) or its repo root. */
|
|
553
|
+
function planFile(p: string, planPath: string, text: string): string {
|
|
554
|
+
const home = q.expandHome(p);
|
|
555
|
+
if (path.isAbsolute(home)) return home;
|
|
556
|
+
const wt = /^Worktree:\s*(\S+)/m.exec(text)?.[1];
|
|
557
|
+
const repo = path.dirname(path.dirname(path.dirname(planPath)));
|
|
558
|
+
const roots = wt && wt !== "-" ? [wt, repo] : [repo];
|
|
559
|
+
const root = roots.find(r => isFile(path.join(r, home))) ?? repo;
|
|
560
|
+
return path.join(root, home);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function isFile(file: string): boolean {
|
|
564
|
+
try {
|
|
565
|
+
return statSync(file).isFile();
|
|
566
|
+
} catch {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function readMainReads(): Record<string, string> {
|
|
572
|
+
try {
|
|
573
|
+
return JSON.parse(readFileSync(path.join(q.STATE_DIR, "main-reads.json"), "utf8")) as Record<string, string>;
|
|
574
|
+
} catch {
|
|
575
|
+
return {};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
448
579
|
// Plans still responsible for: open plans (parked "WAITING on owner" excluded) plus queue problems.
|
|
449
580
|
export function openQueue(): string[] {
|
|
450
581
|
const open = q
|
package/src/core/main-start.ts
CHANGED
|
@@ -8,44 +8,56 @@ function bin(name: string, cfg: Config): string {
|
|
|
8
8
|
return join(cfg.paths.home, ".local/bin", name);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Main's session-start preflight as one text. The CLI prints it; the session rollover handoff
|
|
12
|
+
// (src/core/rollover.ts) carries the same text into the new session. Children's stdout is
|
|
13
|
+
// collected in order, so the printed output matches the original script line for line.
|
|
14
|
+
export function mainStartText(args: string[], cfg: Config): string {
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
const say = (line: string) => out.push(`${line}\n`);
|
|
12
17
|
const h = args[0] ?? String(cfg.health.default_hours);
|
|
13
|
-
|
|
18
|
+
say("=== machine");
|
|
14
19
|
const mg = spawnSync(bin("machine-guard", cfg), [], { encoding: "utf8", env: process.env });
|
|
15
20
|
const mgLines = (mg.stdout ?? "").trim().split("\n");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
say(mgLines.at(-1) ?? "");
|
|
22
|
+
say(readFileSync("/proc/loadavg", "utf8").trim());
|
|
23
|
+
say("");
|
|
24
|
+
say(`=== agents (last ${h}h; DIED = check its worktree, relaunch FRESH with its progress file)`);
|
|
20
25
|
const ledger = spawnSync(bin("agent-ledger", cfg), ["--hours", h], { encoding: "utf8", env: process.env });
|
|
21
26
|
const awk = spawnSync(
|
|
22
27
|
"awk",
|
|
23
28
|
["NR==1 || /DIED/ {print; next} {ok[++n]=$0} END {for (i=1;i<=n;i++) print ok[i]}"],
|
|
24
29
|
{ input: ledger.stdout ?? "", encoding: "utf8" },
|
|
25
30
|
);
|
|
26
|
-
|
|
31
|
+
out.push(awk.stdout ?? "");
|
|
27
32
|
const cap = spawnSync(bin("agent-ledger", cfg), ["--cap"], { encoding: "utf8", env: process.env, stdio: ["ignore", "pipe", "ignore"] });
|
|
28
33
|
for (const line of (cap.stdout ?? "").split("\n")) {
|
|
29
|
-
if (/^(PRIORITY IDLE|FULL)/.test(line))
|
|
34
|
+
if (/^(PRIORITY IDLE|FULL)/.test(line)) say(line);
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
spawnSync(bin("main-health", cfg), [], {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
say("");
|
|
37
|
+
say("=== main thread");
|
|
38
|
+
const health = spawnSync(bin("main-health", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
|
|
39
|
+
out.push(health.stdout ?? "");
|
|
40
|
+
say("");
|
|
41
|
+
say("=== queue");
|
|
42
|
+
const check = spawnSync(bin("queue-check", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
|
|
43
|
+
out.push(check.stdout ?? "");
|
|
44
|
+
say("");
|
|
45
|
+
say("=== heavy-run lock");
|
|
39
46
|
try {
|
|
40
47
|
const lines = readFileSync(cfg.paths.heavy_lock, "utf8").split("\n").slice(0, 2);
|
|
41
48
|
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
42
|
-
if (lines.length)
|
|
49
|
+
if (lines.length) say(lines.join("\n"));
|
|
43
50
|
} catch {}
|
|
44
|
-
|
|
51
|
+
say("=== doctor");
|
|
45
52
|
const cli = join(dirname(fileURLToPath(import.meta.url)), "../cli/main.ts");
|
|
46
53
|
const doctor = spawnSync(process.execPath, [cli, "doctor"], { encoding: "utf8", env: process.env });
|
|
47
54
|
const text = `${doctor.stdout ?? ""}${doctor.stderr ?? ""}`;
|
|
48
|
-
|
|
49
|
-
if (doctor.error)
|
|
55
|
+
out.push(text.endsWith("\n") || text === "" ? text : `${text}\n`);
|
|
56
|
+
if (doctor.error) say(doctor.error.message);
|
|
57
|
+
return out.join("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function mainStart(args: string[], cfg: Config): number {
|
|
61
|
+
process.stdout.write(mainStartText(args, cfg));
|
|
50
62
|
return 0;
|
|
51
63
|
}
|
package/src/core/queue/lib.ts
CHANGED
|
@@ -29,6 +29,8 @@ export const BACKGROUND_MAX_LINES = 40;
|
|
|
29
29
|
export const SUB_WARN_CALLS = 90;
|
|
30
30
|
export const SUB_MAX_CALLS = 100;
|
|
31
31
|
const REQUIRED = ["goal", "forbidden", "blockers", "phases", "done check", "progress"] as const;
|
|
32
|
+
// Plans stamped at or after this must carry a "## Code read by Main" section; older plans are grandfathered.
|
|
33
|
+
const READ_CUTOVER = "20261021_0100";
|
|
32
34
|
|
|
33
35
|
export interface Blocker {
|
|
34
36
|
script: string;
|
|
@@ -46,6 +48,7 @@ export interface Plan {
|
|
|
46
48
|
branch: string | null;
|
|
47
49
|
appliesTo: string[];
|
|
48
50
|
waiting: boolean;
|
|
51
|
+
waitingLine: string | null;
|
|
49
52
|
priorityId: string | null;
|
|
50
53
|
errors: string[];
|
|
51
54
|
}
|
|
@@ -128,7 +131,7 @@ function headerField(text: string, name: string): string | null {
|
|
|
128
131
|
}
|
|
129
132
|
|
|
130
133
|
export function parsePlan(repo: string, file: string, text: string | null, blockersDir = blockersDirPath()): Plan {
|
|
131
|
-
const empty = { repo, file, text: "", status: null, blockers: [], doneCheck: null, worktree: null, branch: null, appliesTo: [], waiting: false, priorityId: null };
|
|
134
|
+
const empty = { repo, file, text: "", status: null, blockers: [], doneCheck: null, worktree: null, branch: null, appliesTo: [], waiting: false, waitingLine: null, priorityId: null };
|
|
132
135
|
if (text === null) return { ...empty, errors: ["plan file listed in INDEX.md does not exist"] };
|
|
133
136
|
const errors: string[] = [];
|
|
134
137
|
const secs = sections(text);
|
|
@@ -136,7 +139,10 @@ export function parsePlan(repo: string, file: string, text: string | null, block
|
|
|
136
139
|
if (s === "progress" ? !secs.has(s) : !items(secs.get(s) ?? "").length)
|
|
137
140
|
errors.push(`missing or empty "## ${s.replace(/^\w/, c => c.toUpperCase())}" section (template: ${agentDir()}/queue/TEMPLATE.md)`);
|
|
138
141
|
}
|
|
139
|
-
const
|
|
142
|
+
const stamp = /^(\d{8}_\d{4})/.exec(path.basename(file))?.[1] ?? "";
|
|
143
|
+
if (stamp >= READ_CUTOVER && !items(secs.get("code read by main") ?? "").length)
|
|
144
|
+
errors.push(`missing or empty "## Code read by Main" section (plans stamped ${READ_CUTOVER} or later list each file they cite)`);
|
|
145
|
+
const known = new Set([...REQUIRED, "applies to", "background", "code read by main"]);
|
|
140
146
|
for (const s of secs.keys()) if (!known.has(s)) errors.push(`unknown section "## ${s}" (allowed: ${[...known].join(", ")})`);
|
|
141
147
|
const status = statusOf(text);
|
|
142
148
|
if (!status) errors.push('"## Progress" must start with a line "Status: open", "Status: complete" or "Status: cancelled"');
|
|
@@ -169,6 +175,7 @@ export function parsePlan(repo: string, file: string, text: string | null, block
|
|
|
169
175
|
branch: headerField(text, "Branch")?.replace(/^-$/, "") || null,
|
|
170
176
|
appliesTo: items(secs.get("applies to") ?? "").map(p => path.resolve(repo, expandHome(p.replace(/`/g, "")))),
|
|
171
177
|
waiting: /^WAITING on owner:/m.test(text),
|
|
178
|
+
waitingLine: text.match(/^WAITING on owner:[ \t]*(.*)$/m)?.[1] ?? null,
|
|
172
179
|
priorityId: text.match(/^PRIORITY-ID:\s*`?([A-Z]{2}[0-9]{2})`?/m)?.[1] ?? null,
|
|
173
180
|
errors,
|
|
174
181
|
};
|
|
@@ -188,6 +195,11 @@ export function activePlans(index = indexPath(), blockersDir = blockersDirPath()
|
|
|
188
195
|
return readIndex(index).flatMap(r => readPlans(r, blockersDir));
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
/** Owner holds: one line per plan with a WAITING on owner line. The only real holds; the summary, the rollover context and the handoff all read them here. */
|
|
199
|
+
export function ownerHolds(index = INDEX): string[] {
|
|
200
|
+
return readIndex(index).flatMap(r => readPlans(r)).filter(p => p.waiting).map(p => `${p.file}: WAITING on owner: ${p.waitingLine ?? ""}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
191
203
|
export function gitTop(p: string): { top: string; repo: string } | null {
|
|
192
204
|
let dir = path.resolve(p);
|
|
193
205
|
for (;;) {
|
|
@@ -238,6 +250,19 @@ export function isOrchestrationPath(p: string, plans: Plan[] = activePlans()): b
|
|
|
238
250
|
}
|
|
239
251
|
|
|
240
252
|
const SHELL_TOOLS = new Set(["bash", "eval"]);
|
|
253
|
+
// A shell command is a list of segments; each `cd` segment moves the directory for the ones after it.
|
|
254
|
+
const SHELL_SEGMENT = /&&|\|\||[;|\n]/;
|
|
255
|
+
const CD_SEGMENT = /^\s*cd\s+(\S+)/;
|
|
256
|
+
|
|
257
|
+
/** The directory a shell command leaves in effect: `base`, moved by each `cd` segment in order. */
|
|
258
|
+
export function shellCwd(command: string, base: string): string {
|
|
259
|
+
let dir = base;
|
|
260
|
+
for (const seg of command.split(SHELL_SEGMENT)) {
|
|
261
|
+
const cd = CD_SEGMENT.exec(seg);
|
|
262
|
+
if (cd) dir = path.resolve(dir, expandHome(cd[1] ?? ""));
|
|
263
|
+
}
|
|
264
|
+
return dir;
|
|
265
|
+
}
|
|
241
266
|
export function touchedPaths(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
|
|
242
267
|
const out = new Set<string>();
|
|
243
268
|
const base = typeof input?.cwd === "string" ? path.resolve(cwd, expandHome(input.cwd)) : cwd;
|
|
@@ -258,14 +283,16 @@ export function touchedPaths(toolName: string, input: Record<string, unknown>, c
|
|
|
258
283
|
}
|
|
259
284
|
if (SHELL_TOOLS.has(toolName)) {
|
|
260
285
|
out.add(base);
|
|
261
|
-
const cmd = String(input?.command ?? input?.code ?? "");
|
|
262
286
|
let dir = base;
|
|
263
|
-
for (const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
287
|
+
for (const seg of String(input?.command ?? input?.code ?? "").split(SHELL_SEGMENT)) {
|
|
288
|
+
for (const word of seg.split(/[\s<>()'"`,=]+/)) {
|
|
289
|
+
const w = word.trim();
|
|
290
|
+
if (!w || w.startsWith("-") || w.includes("://") || !/[./]/.test(w) || /^\d+(\.\d+)*$/.test(w)) continue;
|
|
291
|
+
add(w, dir);
|
|
292
|
+
}
|
|
293
|
+
const cd = CD_SEGMENT.exec(seg);
|
|
294
|
+
if (cd) dir = path.resolve(dir, expandHome(cd[1] ?? ""));
|
|
267
295
|
}
|
|
268
|
-
for (const m of cmd.matchAll(/(?:^|&&|;|\|\|)\s*cd\s+(\S+)/g)) dir = path.resolve(dir, expandHome(m[1] ?? ""));
|
|
269
296
|
}
|
|
270
297
|
return [...out];
|
|
271
298
|
}
|
|
@@ -552,7 +579,8 @@ export function queueSummary(index = INDEX): string {
|
|
|
552
579
|
lines.push(`- ${repo}${nCancelled ? ` (${nCancelled} cancelled)` : ""}`);
|
|
553
580
|
for (const p of readPlans(repo)) {
|
|
554
581
|
const status = awaitingMerge(p) ? "complete, awaiting merge" : (p.status ?? "INVALID");
|
|
555
|
-
const
|
|
582
|
+
const hold = p.waiting ? `WAITING on owner: ${(p.waitingLine ?? "").slice(0, 80)}` : "";
|
|
583
|
+
const flags = [status, hold, p.priorityId ?? ""].filter(Boolean).join(", ");
|
|
556
584
|
const goal = items(sections(p.text).get("goal") ?? "")[0]?.slice(0, 100) ?? "";
|
|
557
585
|
lines.push(` - ${p.file} [${flags}] ${goal}`);
|
|
558
586
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Session rollover, pure core: when Main's session file has grown past `rollover.max_session_mb`,
|
|
2
|
+
// Main writes a handoff note first; the owner's next message then opens a fresh session that
|
|
3
|
+
// starts from the note plus the generated state. Decisions only: the adapter reads the file size,
|
|
4
|
+
// the jobs and the note mtime, and performs the switch. No harness names here.
|
|
5
|
+
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { stateDirPath } from "./queue/lib.ts";
|
|
8
|
+
|
|
9
|
+
export type RolloverState = "below" | "need-note" | "wait-jobs" | "ready";
|
|
10
|
+
|
|
11
|
+
/** A note counts only if it is at most this old. */
|
|
12
|
+
export const MAX_NOTE_AGE_MS = 60 * 60_000;
|
|
13
|
+
|
|
14
|
+
export interface RolloverInput {
|
|
15
|
+
sessionBytes: number;
|
|
16
|
+
maxMb: number;
|
|
17
|
+
/** Labels of the own async jobs (subagents included) that are still running. */
|
|
18
|
+
runningJobs: readonly string[];
|
|
19
|
+
/** When the session file was first seen at or over the threshold; null if never. */
|
|
20
|
+
crossedAtMs: number | null;
|
|
21
|
+
/** Mtime of the handoff note; null when the note does not exist. */
|
|
22
|
+
noteMtimeMs: number | null;
|
|
23
|
+
nowMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* below: under the threshold. need-note: over it and no fresh note (written after the crossing,
|
|
28
|
+
* at most MAX_NOTE_AGE_MS old). wait-jobs: note is fresh but own jobs still run (rolling over
|
|
29
|
+
* would cancel them). ready: the next owner message may roll over.
|
|
30
|
+
*/
|
|
31
|
+
export function rolloverState(p: RolloverInput): RolloverState {
|
|
32
|
+
if (p.sessionBytes < p.maxMb * 1024 * 1024) return "below";
|
|
33
|
+
const noteFresh =
|
|
34
|
+
p.noteMtimeMs !== null &&
|
|
35
|
+
p.crossedAtMs !== null &&
|
|
36
|
+
p.noteMtimeMs > p.crossedAtMs &&
|
|
37
|
+
p.nowMs - p.noteMtimeMs <= MAX_NOTE_AGE_MS;
|
|
38
|
+
if (!noteFresh) return "need-note";
|
|
39
|
+
return p.runningJobs.length > 0 ? "wait-jobs" : "ready";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface HandoffInput {
|
|
43
|
+
/** Output of main-start (the generated state). */
|
|
44
|
+
generated: string;
|
|
45
|
+
/** Main's handoff note, inserted as written (trailing newlines dropped). */
|
|
46
|
+
note: string;
|
|
47
|
+
previousSessionFile: string;
|
|
48
|
+
/** The owner's message exactly as typed; it is the last thing in the handoff. */
|
|
49
|
+
ownerMessage: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The first user message of the new session: header, Main's note, generated state, owner message. */
|
|
53
|
+
export function buildHandoff(p: HandoffInput): string {
|
|
54
|
+
return [
|
|
55
|
+
`Previous session file: ${p.previousSessionFile}. Its full history stays there; this session continues from the handoff below.`,
|
|
56
|
+
"",
|
|
57
|
+
"## Main's handoff note (verbatim)",
|
|
58
|
+
p.note.trimEnd(),
|
|
59
|
+
"",
|
|
60
|
+
"## Generated state (main-start)",
|
|
61
|
+
p.generated.trimEnd(),
|
|
62
|
+
"",
|
|
63
|
+
"Owner's message:",
|
|
64
|
+
p.ownerMessage,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The owner-holds part of the generated state: each WAITING on owner line verbatim, or none. */
|
|
69
|
+
export function holdsBlock(holds: readonly string[]): string {
|
|
70
|
+
return ["=== owner holds (plans' WAITING on owner lines)", ...(holds.length > 0 ? holds : ["none"]), ""].join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The one-time note instruction for Main's next turn. */
|
|
74
|
+
export function noteRequest(path: string): string {
|
|
75
|
+
return [
|
|
76
|
+
"Session rollover: this session has grown past rollover.max_session_mb.",
|
|
77
|
+
`Write a handoff note to ${path} (overwrite it). Cover the work in progress and its state, the decisions made and why, the next step, and the files involved.`,
|
|
78
|
+
"Do not list holds or owner blockers in the note; they come from the plans' WAITING on owner lines.",
|
|
79
|
+
"When the owner's next message arrives, this session closes and a fresh one starts from that note verbatim plus the generated main-start state.",
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Where Main writes the handoff note for one session. */
|
|
84
|
+
export function notePath(sessionId: string, stateDir = stateDirPath()): string {
|
|
85
|
+
return join(stateDir, "rollover", `${sessionId}.md`);
|
|
86
|
+
}
|
package/templates/config.yml
CHANGED
|
@@ -101,6 +101,7 @@ guards:
|
|
|
101
101
|
main_edit_limit: 5
|
|
102
102
|
main_window_min: 15
|
|
103
103
|
unlock_minutes: 30
|
|
104
|
+
plan_read_max_hours: 24
|
|
104
105
|
idle_stops: 3
|
|
105
106
|
blocker_timeout_ms: 10000
|
|
106
107
|
skip_tools: [todo, wait, ask, web_search, job, cancel_job]
|
|
@@ -113,6 +114,10 @@ guards:
|
|
|
113
114
|
protected: []
|
|
114
115
|
ledger_command: "agent-ledger --hours 12"
|
|
115
116
|
|
|
117
|
+
rollover:
|
|
118
|
+
enabled: true
|
|
119
|
+
max_session_mb: 10
|
|
120
|
+
|
|
116
121
|
machine:
|
|
117
122
|
load_over_cores: 4
|
|
118
123
|
load_max: 0
|
|
@@ -7,6 +7,8 @@ Cost = the worker's own list cost + Main's steering cost (Main tokens spent on t
|
|
|
7
7
|
commits must be ancestors of --branch (default pm/pr01-counts in ~/Work/pm-counts); --rework = needed fixes by Main/another agent
|
|
8
8
|
agent-score diagnose <Agent> --plan <plan.md> --phase P --evidence <file> [--weight N]
|
|
9
9
|
a real product bug found and recorded (no commit needed); default weight 4
|
|
10
|
+
agent-score validate <Agent> --plan <plan.md> --phase P --evidence <report.md> --section "## Heading" [--weight N] [--rework] [--note T]
|
|
11
|
+
validation phase with proof, no commits: phase ticked in the plan, the section body cites a heavy-run label run by <Agent>
|
|
10
12
|
agent-score penalty <Agent> --kind false-claim|rule-break|escaped-defect --note T [--plan <plan.md>]
|
|
11
13
|
agent-score exempt <sha,...> --note T [--repo R] commits that are Main's own (merges, rebuilds), not a worker's
|
|
12
14
|
agent-score check [--repo R] [--branch B] exit 1 = commits on the branch since the base with no accept/exempt record
|
|
@@ -22,6 +22,10 @@ PRIORITY-ID: <optional, e.g. PR01>
|
|
|
22
22
|
## Done check
|
|
23
23
|
`<one command, run in the worktree; exit 0 = the Goal is met and the work is landed>`
|
|
24
24
|
|
|
25
|
+
## Code read by Main
|
|
26
|
+
<!-- Main lists each file it read to write this plan's code claims, as backticked paths. Required for plans stamped 20261021_0100 or later. -->
|
|
27
|
+
- `<path/to/file.ts>`
|
|
28
|
+
|
|
25
29
|
## Progress
|
|
26
30
|
Status: open
|
|
27
31
|
<!-- The implementer may only append dated entries here: what was done, what was verified (command +
|