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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neurastream",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Harness-agnostic agent orchestration: queue, guards, blockers, scoring, ledger.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
- function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string, snap: Map<string, string>): void {
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
- if (isSub) snapshotPlans(tool, input, cwd, snap);
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
- writeFileSync(d.restore.path, d.restore.text);
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. */
@@ -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
  }
@@ -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");
@@ -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")) {
@@ -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") return mainTool(e, input, cfg);
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 ALLOW;
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
@@ -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
- export function mainStart(args: string[], cfg: Config): number {
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
- console.log("=== machine");
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
- console.log(mgLines.at(-1) ?? "");
17
- console.log(readFileSync("/proc/loadavg", "utf8").trim());
18
- console.log("");
19
- console.log(`=== agents (last ${h}h; DIED = check its worktree, relaunch FRESH with its progress file)`);
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
- process.stdout.write(awk.stdout ?? "");
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)) console.log(line);
34
+ if (/^(PRIORITY IDLE|FULL)/.test(line)) say(line);
30
35
  }
31
- console.log("");
32
- console.log("=== main thread");
33
- spawnSync(bin("main-health", cfg), [], { stdio: "inherit", env: process.env });
34
- console.log("");
35
- console.log("=== queue");
36
- spawnSync(bin("queue-check", cfg), [], { stdio: "inherit", env: process.env });
37
- console.log("");
38
- console.log("=== heavy-run lock");
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) console.log(lines.join("\n"));
49
+ if (lines.length) say(lines.join("\n"));
43
50
  } catch {}
44
- console.log("=== doctor");
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
- process.stdout.write(text.endsWith("\n") || text === "" ? text : `${text}\n`);
49
- if (doctor.error) console.log(doctor.error.message);
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
  }
@@ -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 known = new Set([...REQUIRED, "applies to", "background"]);
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 word of cmd.split(/[\s;&|<>()'"`,=]+/)) {
264
- const w = word.trim();
265
- if (!w || w.startsWith("-") || w.includes("://") || !/[./]/.test(w) || /^\d+(\.\d+)*$/.test(w)) continue;
266
- add(w, dir);
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 flags = [status, p.waiting ? "WAITING on owner" : "", p.priorityId ?? ""].filter(Boolean).join(", ");
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
+ }
@@ -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 +