neurastream 0.1.2 → 0.1.5

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
@@ -27,6 +27,32 @@ Scoring: `agent-score accept` records accepted work by commit; `agent-score vali
27
27
 
28
28
  Green is `bun run typecheck` and `bun test`, both exit 0.
29
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
+
30
56
  ## Changing a script
31
57
 
32
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.2",
3
+ "version": "0.1.5",
4
4
  "description": "Harness-agnostic agent orchestration: queue, guards, blockers, scoring, ledger.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,13 @@ export function adapterLiveFiles(home: string, root: string = PACKAGE_ROOT): Liv
36
36
  for (const bin of packageBins(root)) {
37
37
  files.push({ live: join(home, ".local/bin", bin.name), source: bin.source });
38
38
  }
39
+ // P9: the extension symlink is gone (ONE load route — the plugin below);
40
+ // what the adapter installs under the agent dir is the omp plugin link,
41
+ // whose realpath is an installed neurastream package root. `omp plugin
42
+ // link` owns this file; install must never ensureLink it.
39
43
  files.push({
40
- live: join(home, ".omp/agent/extensions/neurastream.ts"),
41
- source: join(root, "src/adapters/omp/index.ts"),
44
+ live: join(home, ".omp/plugins/node_modules/neurastream"),
45
+ source: root,
42
46
  });
43
47
  files.push({
44
48
  live: join(home, ".omp/agent/queue/lib.ts"),
@@ -68,8 +72,10 @@ function bunLiveFiles(home: string, root: string): LiveFile[] {
68
72
  }
69
73
 
70
74
  function packageRootOf(file: string): string | null {
71
- let dir = dirname(file);
72
- for (let i = 0; i < 8; i++) {
75
+ // `file` itself may be the package root (the omp plugin link's realpath is
76
+ // an installed neurastream root), so the self dir is checked first.
77
+ let dir = file;
78
+ for (let i = 0; i < 9; i++) {
73
79
  const manifest = join(dir, "package.json");
74
80
  if (existsSync(manifest)) {
75
81
  try {
@@ -16,17 +16,23 @@
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, MAIN_PLAN_FILE, 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";
28
+ // Registers the plugin-command matcher into core's writeTargets (side effect).
29
+ import "./package-ops.ts";
26
30
 
27
31
  /** Subset of the harness extension host the adapter needs (pi.on; herdr also uses pi.events). */
28
32
  export interface ExtensionHost {
29
33
  on(event: string, cb: (...args: unknown[]) => unknown): void;
34
+ registerCommand(name: string, opts: { description?: string; handler: (args: unknown, ctx: unknown) => unknown }): void;
35
+ sendUserMessage(content: string): void;
30
36
  }
31
37
 
32
38
  /** The parts of a harness call context the handlers look at. */
@@ -82,11 +88,14 @@ function adapterLog(cfg: Config, line: string): void {
82
88
  // Background queue-close for every plan Main marked complete (owner 2026-10-15). Ported from the
83
89
  // original autoClose: same filter, same "--- auto-close attempt" log line, detached child with
84
90
  // stdio on the per-plan close log. QUEUE_CLOSE_BIN overrides the binary (tests); without it the
85
- // package's own CLI runs via the current bun binary, so no PATH lookup is needed.
91
+ // package's own executable bin/queue-close runs (its `#!/usr/bin/env bun` shebang picks bun).
92
+ // Never process.execPath: inside the harness that is the harness binary itself, so the child was
93
+ // a new agent session that loaded this plugin and auto-closed again — a process fork bomb
94
+ // (2026-10-21 OOMs, ~175 processes).
86
95
  function closeChildArgs(plan: string): [string, string[]] {
87
96
  const bin = process.env.QUEUE_CLOSE_BIN;
88
97
  if (bin) return [bin, [plan]];
89
- return [process.execPath, [join(import.meta.dir, "..", "..", "cli", "main.ts"), "queue-close", plan]];
98
+ return [join(import.meta.dir, "..", "..", "..", "bin", "queue-close"), [plan]];
90
99
  }
91
100
 
92
101
  export function autoClose(cfg: Config): string[] {
@@ -130,11 +139,21 @@ function ledgerText(cfg: Config): string {
130
139
 
131
140
  // Plan files a subagent write/edit may touch: snapshot before the call so tool_result can verify
132
141
  // 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 {
142
+ function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string, snap: Map<string, string>, main: boolean): void {
134
143
  if (tool !== "write" && tool !== "edit") return;
135
144
  const plans = q.activePlans();
136
145
  for (const p of q.writeTargets(tool, input, cwd)) {
137
146
  if (snap.has(p)) continue;
147
+ if (main) {
148
+ // Main's own plan text is checked too; a plan that did not exist yet snapshots as "".
149
+ if (!MAIN_PLAN_FILE.test(p)) continue;
150
+ try {
151
+ snap.set(p, readFileSync(p, "utf8"));
152
+ } catch {
153
+ snap.set(p, "");
154
+ }
155
+ continue;
156
+ }
138
157
  if (!q.isOrchestrationPath(p, plans) || !q.planAt(p, plans)) continue;
139
158
  try {
140
159
  snap.set(p, readFileSync(p, "utf8"));
@@ -142,6 +161,65 @@ function snapshotPlans(tool: string, input: Record<string, unknown>, cwd: string
142
161
  }
143
162
  }
144
163
 
164
+ // Session rollover helpers: the harness context read through runtime checks, and the file size and
165
+ // mtime reads. The decisions are in src/core/rollover.ts.
166
+ const WAIT_NOTICE_MS = 15 * 60_000;
167
+
168
+ interface RolloverCtx {
169
+ sessionId: string;
170
+ sessionFile: string;
171
+ runningJobs(): string[];
172
+ waitForIdle(): unknown;
173
+ newSession(opts: { parentSession: string }): unknown;
174
+ notify(message: string): void;
175
+ }
176
+
177
+ /** Call a method on a harness object by name; undefined when it is not a function. */
178
+ function call(obj: Record<string, unknown>, key: string, args: unknown[] = []): unknown {
179
+ const fn = obj[key];
180
+ return typeof fn === "function" ? Reflect.apply(fn, obj, args) : undefined;
181
+ }
182
+
183
+ function asRolloverCtx(v: unknown): RolloverCtx | null {
184
+ const r = asRecord(v);
185
+ const sm = asRecord(r.sessionManager);
186
+ const sessionId = call(sm, "getSessionId");
187
+ const sessionFile = call(sm, "getSessionFile");
188
+ if (typeof sessionId !== "string" || typeof sessionFile !== "string") return null;
189
+ return {
190
+ sessionId,
191
+ sessionFile,
192
+ runningJobs: () => {
193
+ const running = asRecord(call(r, "getAsyncJobSnapshot")).running;
194
+ return Array.isArray(running) ? running.map(j => asString(asRecord(j).label, "job")) : [];
195
+ },
196
+ waitForIdle: () => call(r, "waitForIdle"),
197
+ newSession: opts => call(r, "newSession", [opts]),
198
+ notify: message => call(asRecord(r.ui), "notify", [message]),
199
+ };
200
+ }
201
+
202
+ function isMain(ctx: unknown): boolean {
203
+ const c = asCtx(ctx);
204
+ return !c.hasAgent || c.kind === "main";
205
+ }
206
+
207
+ function mtimeOf(path: string): number | null {
208
+ try {
209
+ return statSync(path).mtimeMs;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+
215
+ function sizeOf(path: string): number {
216
+ try {
217
+ return statSync(path).size;
218
+ } catch {
219
+ return 0;
220
+ }
221
+ }
222
+
145
223
  /** Register the guard handlers on one extension host. `cfg` is injectable for tests. */
146
224
  export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
147
225
  // The queue library resolves its paths from env snapshots; point them at this config first.
@@ -172,10 +250,11 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
172
250
  const d = checkInject({ inject: asString(asRecord(event).prompt), agent: "main" }, cfg);
173
251
  callsSinceStop = d.state?.callsSinceStop ?? callsSinceStop;
174
252
  idleStops = d.state?.idleStops ?? idleStops;
253
+ const notice = rolloverNotice(ctx);
175
254
  return {
176
255
  message: {
177
256
  customType: "main-guard.queue",
178
- content: d.inject ?? "",
257
+ content: [d.inject, notice].filter(Boolean).join("\n\n"),
179
258
  display: false,
180
259
  },
181
260
  };
@@ -208,7 +287,7 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
208
287
  if (!isSub) callsSinceStop++;
209
288
  const cwd = c.cwd ?? process.cwd();
210
289
  const snap = new Map<string, string>();
211
- if (isSub) snapshotPlans(tool, input, cwd, snap);
290
+ snapshotPlans(tool, input, cwd, snap, !isSub);
212
291
  const d = checkTool(
213
292
  { tool, input, cwd, agent, agentId: id, calls: n - 1, paths: [], writes: [], command: asString(input.command) },
214
293
  cfg,
@@ -221,6 +300,7 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
221
300
  });
222
301
 
223
302
  pi.on("tool_result", (event, ctx) => {
303
+ onSizeCheck(event, ctx);
224
304
  const e = asRecord(event);
225
305
  const c = asCtx(ctx);
226
306
  const id = asString(e.toolCallId);
@@ -232,10 +312,11 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
232
312
  try {
233
313
  after = readFileSync(file, "utf8");
234
314
  } catch {}
235
- const d = checkPlanEdit({ path: file, before, after, agent: "sub" }, cfg);
315
+ const d = checkPlanEdit({ path: file, before, after, agent: c.kind === "sub" ? "sub" : "main" }, cfg);
236
316
  if (!d.allow && d.restore) {
237
317
  try {
238
- writeFileSync(d.restore.path, d.restore.text);
318
+ if (d.restore.text === null) rmSync(d.restore.path, { force: true });
319
+ else writeFileSync(d.restore.path, d.restore.text);
239
320
  } catch {}
240
321
  return { isError: true, content: [{ type: "text", text: d.reason }] };
241
322
  }
@@ -244,6 +325,11 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
244
325
  if ((!c.hasAgent || c.kind === "main") && asString(e.toolName) === "bash" && !e.isError) {
245
326
  if (PUSH_MERGE.test(asString(asRecord(e.input).command))) autoClose(cfg);
246
327
  }
328
+ if ((!c.hasAgent || c.kind === "main") && !e.isError) {
329
+ const content = Array.isArray(e.content) ? e.content : [];
330
+ const text = content.map(p => asString(asRecord(p).text)).join("\n");
331
+ recordMainResult(asString(e.toolName), asRecord(e.input), c.cwd ?? process.cwd(), text);
332
+ }
247
333
  return undefined;
248
334
  });
249
335
 
@@ -255,6 +341,111 @@ export function createOmpAdapter(pi: ExtensionHost, cfg: Config): void {
255
341
  idleStops = d.state?.idleStops ?? idleStops;
256
342
  return d.allow ? undefined : { decision: "block", reason: d.reason };
257
343
  });
344
+
345
+ // Session rollover (src/core/rollover.ts). Main's session size decides when a handoff note is
346
+ // asked for; the owner's next message then starts a fresh session from that note. The size is
347
+ // re-read at every turn end and tool result; the note instruction goes out once per session file.
348
+ let crossed: { file: string; atMs: number } | null = null;
349
+ let asked: string | null = null;
350
+ let waitNoticeMs = 0;
351
+
352
+ const stateOf = (c: RolloverCtx): RolloverState => {
353
+ const nowMs = Date.now();
354
+ const bytes = sizeOf(c.sessionFile);
355
+ const maxMb = cfg.rollover.max_session_mb;
356
+ if (bytes < maxMb * 1024 * 1024) crossed = null;
357
+ else if (crossed?.file !== c.sessionFile) crossed = { file: c.sessionFile, atMs: nowMs };
358
+ return rolloverState({
359
+ sessionBytes: bytes,
360
+ maxMb,
361
+ runningJobs: c.runningJobs(),
362
+ crossedAtMs: crossed?.atMs ?? null,
363
+ noteMtimeMs: mtimeOf(notePath(c.sessionId)),
364
+ nowMs,
365
+ });
366
+ };
367
+
368
+ const onSizeCheck = (_e: unknown, ctx: unknown): void => {
369
+ const c = asRolloverCtx(ctx);
370
+ if (!cfg.rollover.enabled || !c || !isMain(ctx)) return;
371
+ stateOf(c);
372
+ };
373
+ pi.on("agent_end", onSizeCheck);
374
+
375
+ // Compaction keeps the owner's holds true: a summary may state a hold only as a plans' WAITING on owner line says it.
376
+ pi.on("session.compacting", (_e, ctx) => {
377
+ if (!isMain(ctx)) return undefined;
378
+ const holds = q.ownerHolds();
379
+ return {
380
+ context: [
381
+ "Owner holds/blockers: only plans' WAITING on owner lines are real:",
382
+ ...(holds.length > 0 ? holds : ["none"]),
383
+ "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.",
384
+ ],
385
+ };
386
+ });
387
+
388
+ /** Rollover notice for Main's next turn, or null. Asks for the note once per session file; throttles the wait-jobs notice. */
389
+ const rolloverNotice = (ctx: unknown): string | null => {
390
+ const c = asRolloverCtx(ctx);
391
+ if (!cfg.rollover.enabled || !c) return null;
392
+ const state = stateOf(c);
393
+ if (state === "need-note" && asked !== c.sessionFile) {
394
+ asked = c.sessionFile;
395
+ return noteRequest(notePath(c.sessionId));
396
+ }
397
+ if (state === "wait-jobs" && Date.now() - waitNoticeMs >= WAIT_NOTICE_MS) {
398
+ waitNoticeMs = Date.now();
399
+ const jobs = c.runningJobs().join(", ");
400
+ 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.`;
401
+ }
402
+ return null;
403
+ };
404
+
405
+ // A plain interactive message in the ready state becomes the owner's first message of the new session.
406
+ pi.on("input", (event, ctx) => {
407
+ const e = asRecord(event);
408
+ const c = asRolloverCtx(ctx);
409
+ const text = asString(e.text);
410
+ if (!cfg.rollover.enabled || !c || !isMain(ctx)) return undefined;
411
+ if (asString(e.source) !== "interactive" || text === "" || text.startsWith("/")) return undefined;
412
+ if (stateOf(c) !== "ready") return undefined;
413
+ return { text: `/main-rollover ${text}` };
414
+ });
415
+
416
+ pi.registerCommand("main-rollover", {
417
+ description: "Start a fresh session from Main's handoff note; the rest of the message is the owner's first message",
418
+ handler: async (args: unknown, ctx: unknown) => {
419
+ const c = asRolloverCtx(ctx);
420
+ if (!c) return;
421
+ const path = notePath(c.sessionId);
422
+ const state = stateOf(c);
423
+ if (state === "below") {
424
+ c.notify("main-rollover: this session is below rollover.max_session_mb; nothing to roll over.");
425
+ return;
426
+ }
427
+ if (state === "need-note") {
428
+ c.notify(`main-rollover: no fresh handoff note at ${path}; Main must write it first.`);
429
+ return;
430
+ }
431
+ if (state === "wait-jobs") {
432
+ c.notify(`main-rollover: own jobs are still running (${c.runningJobs().join(", ")}); a rollover would cancel them.`);
433
+ return;
434
+ }
435
+ const generated = [await mainStartText([], cfg), holdsBlock(q.ownerHolds())].join("\n");
436
+ const handoff = buildHandoff({
437
+ generated,
438
+ note: readFileSync(path, "utf8"),
439
+ previousSessionFile: c.sessionFile,
440
+ ownerMessage: asString(args),
441
+ });
442
+ adapterLog(cfg, `rollover: new session from ${c.sessionFile}; note ${path}`);
443
+ await c.waitForIdle();
444
+ const r = asRecord(await c.newSession({ parentSession: c.sessionFile }));
445
+ if (r.cancelled === true) return;
446
+ pi.sendUserMessage(handoff);
447
+ },
448
+ });
258
449
  }
259
450
 
260
451
  /** package.json omp.extensions entry. */
@@ -2,13 +2,15 @@
2
2
  // Does not read or write the real user's agent directory: every path is under `home`.
3
3
 
4
4
  import { spawnSync } from "node:child_process";
5
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs";
5
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { configFile } from "../../core/config.ts";
10
10
 
11
11
  import { adapterLiveFiles } from "./doctor.ts";
12
+ // Registers the plugin-command matcher into core's writeTargets (side effect).
13
+ import "./package-ops.ts";
12
14
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
13
15
 
14
16
  const OVERLAY = `adapter:
@@ -18,11 +20,14 @@ const OVERLAY = `adapter:
18
20
  extensions: "~/.omp/agent/extensions"
19
21
  `;
20
22
 
21
- const PROTECTED = ["~/.omp/agent/agents/", "~/.omp/agent/extensions/"];
23
+ // P8: the plugin dir is adapter surface and lands in the config's
24
+ // guards.protected, so core's protected check covers it without naming a
25
+ // harness path. installOmp writes it via the same overlay pass.
26
+ const PROTECTED = ["~/.omp/agent/agents/", "~/.omp/agent/extensions/", "~/.omp/plugins/"];
22
27
 
23
28
  export interface InstallResult {
24
- extension: string;
25
29
  plugin: string;
30
+ removedStaleLink: string | null;
26
31
  pinsCopied: string[];
27
32
  pinsKept: string[];
28
33
  overlay: "written" | "unchanged";
@@ -46,7 +51,9 @@ function ensureLink(link: string, target: string): void {
46
51
 
47
52
  function linkInstalled(home: string): void {
48
53
  for (const file of adapterLiveFiles(home)) {
49
- if (file.live.includes("/.omp/agent/agents/")) continue;
54
+ // agents/ are copied, not linked; the plugin link is `omp plugin link`'s
55
+ // own output (linkPlugin below), never ours to ensureLink.
56
+ if (file.live.includes("/.omp/agent/agents/") || file.live.includes("/.omp/plugins/")) continue;
50
57
  ensureLink(file.live, file.source);
51
58
  }
52
59
  }
@@ -109,24 +116,37 @@ function linkPlugin(home: string): string {
109
116
  return join(home, ".omp/plugins/node_modules/neurastream");
110
117
  }
111
118
 
112
- /** Link the extension, copy missing pins, write the overlay. HOME is the sandbox or the caller's home. */
119
+ /** Link the plugin, copy missing pins, write the overlay. HOME is the sandbox or the caller's home. */
113
120
  export function installOmp(home: string): InstallResult {
114
121
  const file = configFile(home);
115
122
  if (!existsSync(file)) throw new Error(`no config at ${file}; run neurastream init first`);
123
+ // P9: ONE load route. omp loads extensions-dir files AND a plugin package's
124
+ // package.json#omp.extensions, so an extensions/neurastream.ts link beside
125
+ // `omp plugin link` runs every hook twice. Only the plugin route remains —
126
+ // the one guard parity locks (~/.omp/plugins/ + the package root). A stale
127
+ // link an older install made is removed, but only when it is a symlink: a
128
+ // real file there is never ours to delete.
116
129
  const extension = join(home, ".omp/agent/extensions/neurastream.ts");
117
- const target = join(PACKAGE_ROOT, "src/adapters/omp/index.ts");
118
- ensureLink(extension, target);
130
+ let removedStaleLink: string | null = null;
131
+ try {
132
+ if (lstatSync(extension).isSymbolicLink()) {
133
+ unlinkSync(extension);
134
+ removedStaleLink = extension;
135
+ }
136
+ } catch {
137
+ // absent: nothing stale to remove
138
+ }
119
139
  mkdirSync(join(home, ".omp/agent/sessions"), { recursive: true });
120
140
  const pins = copyPins(join(home, ".omp/agent/agents"));
121
141
  linkInstalled(home);
122
142
  const plugin = linkPlugin(home);
123
143
  const overlay = writeOverlay(file);
124
- return { extension, plugin, pinsCopied: pins.copied, pinsKept: pins.kept, overlay };
144
+ return { plugin, removedStaleLink, pinsCopied: pins.copied, pinsKept: pins.kept, overlay };
125
145
  }
126
146
 
127
147
  export function printInstall(result: InstallResult): void {
128
- console.log(`neurastream adapter install omp: linked ${result.extension}`);
129
148
  console.log(`neurastream adapter install omp: plugin ${result.plugin}`);
149
+ if (result.removedStaleLink) console.log(`neurastream adapter install omp: removed stale extension link ${result.removedStaleLink}`);
130
150
  console.log(
131
151
  `neurastream adapter install omp: pins copied ${result.pinsCopied.length}, kept ${result.pinsKept.length}; overlay ${result.overlay}`,
132
152
  );
@@ -0,0 +1,28 @@
1
+ // Adapter-owned package-operation shapes for the P8 guard parity: core's
2
+ // writeTargets never names a harness, so the plugin-command matcher lives here
3
+ // and registers itself into core at module load. Every plugin command writes
4
+ // under the plugin directory, which src/adapters/omp/install.ts lists in the
5
+ // config's guards.protected — so mapping the commands onto that directory puts
6
+ // them behind the owner's unlock like every other guard file.
7
+
8
+ import { homedir } from "node:os";
9
+ import { registerPackageOpTarget, type PackageOpMatcher } from "../../core/queue/lib.ts";
10
+
11
+ const PLUGIN_ACTIONS = ["install", "uninstall", "link", "unlink", "update"];
12
+
13
+ /** Target for `<harness> plugin <action>` segments; null when none matches. */
14
+ export const pluginCommandTarget: PackageOpMatcher = (code: string): string | null => {
15
+ for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
16
+ const toks = seg.trim().split(/\s+/).filter(Boolean);
17
+ let i = 0;
18
+ while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i] ?? "")) i++;
19
+ const cmd = (toks[i] ?? "").replace(/^["']|["']$/g, "").replace(/^.*\//, "");
20
+ if (cmd !== "omp") continue;
21
+ const sub = (toks[i + 1] ?? "").replace(/^["']+|["']+$/g, "");
22
+ if (sub === "plugin" && PLUGIN_ACTIONS.includes((toks[i + 2] ?? "").replace(/^["']+|["']+$/g, "")))
23
+ return `${homedir()}/.omp/plugins/`;
24
+ }
25
+ return null;
26
+ };
27
+
28
+ registerPackageOpTarget(pluginCommandTarget);
@@ -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
  }
@@ -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
  }
@@ -320,6 +338,9 @@ function mainTool(e: ToolEvent, input: Record<string, unknown>, cfg: Config): De
320
338
  return editWindow(e, input, cfg);
321
339
  }
322
340
 
341
+ /** A live plan file (archived plans excluded); Main's plan-text checks run on these. */
342
+ export const MAIN_PLAN_FILE = /\/docs\/plans\/(?!archive\/)[^/]+\.md$/;
343
+
323
344
  /**
324
345
  * pre-tool: plan-guard checkCall for subs (protected files, budget, plan-edit rules, blockers)
325
346
  * plus, for Main, the spawn gates and the project-file edit window.
@@ -339,7 +360,19 @@ export function checkTool(e: ToolEvent, cfg: Config): Decision {
339
360
  `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
361
  );
341
362
  }
342
- if (e.agent === "main") return mainTool(e, input, cfg);
363
+ if (e.agent === "main") {
364
+ // The plan-text checks (checkPlanEdit) see only edit/write results; a shell redirect,
365
+ // sed -i or heredoc into a plan would skip them, so plans change through edit/write only.
366
+ const shellPlan = e.tool !== "write" && e.tool !== "edit" ? writes.find(p => MAIN_PLAN_FILE.test(p)) : undefined;
367
+ if (shellPlan) {
368
+ log(cfg.paths.plan_guard_log, `BLOCK main tool=${e.tool} shell write to plan ${shellPlan}`);
369
+ return deny(`plan-guard: ${shellPlan} is a plan file: change it with the edit or write tool (close or cancel it with queue-close / queue-cancel).`);
370
+ }
371
+ const d = mainTool(e, input, cfg);
372
+ // A task brief names paths it does not read; only the other tools' paths count as reads.
373
+ if (d.allow && e.tool !== "task") recordMainReads(paths.filter(p => !writes.includes(p)));
374
+ return d;
375
+ }
343
376
  const why = subRules(e, input, paths, writes, cfg);
344
377
  if (why) {
345
378
  log(cfg.paths.plan_guard_log, `BLOCK sub:${e.agentId} tool=${e.tool} ${why}`);
@@ -355,6 +388,46 @@ export function checkTool(e: ToolEvent, cfg: Config): Decision {
355
388
  return ALLOW;
356
389
  }
357
390
 
391
+ // --- Main's read log (`main-reads.json` in the queue state dir): files Main has read itself ---
392
+
393
+ const MAIN_READ_KEEP_MS = 7 * 86_400_000;
394
+ const GREP_LINE = /^([^\s:]+):\d+:/gm;
395
+ /** Logs existing files as read now; entries older than 7 days are dropped on each write. */
396
+ function recordMainReads(files: string[], now = Date.now()): void {
397
+ const file = path.join(q.STATE_DIR, "main-reads.json");
398
+ const existing = files.filter(f => {
399
+ try {
400
+ return statSync(f).isFile();
401
+ } catch {
402
+ return false;
403
+ }
404
+ });
405
+ if (existing.length === 0) return;
406
+ let reads: Record<string, string> = {};
407
+ try {
408
+ const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown;
409
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) reads = parsed as Record<string, string>;
410
+ } catch {}
411
+ const stamp = new Date(now).toISOString();
412
+ for (const f of existing) reads[f] = stamp;
413
+ const cutoff = now - MAIN_READ_KEEP_MS;
414
+ for (const [f, t] of Object.entries(reads)) if (!(Date.parse(t) >= cutoff)) delete reads[f];
415
+ try {
416
+ mkdirSync(q.STATE_DIR, { recursive: true });
417
+ writeFileSync(file, JSON.stringify(reads, null, 2));
418
+ } catch {}
419
+ }
420
+
421
+ /**
422
+ * Main's tool result: each `path:line:` at a line start (grep -n / grep -rn output) names a file
423
+ * Main read. Paths resolve against the directory the call ran in (its `cwd`, then any `cd`).
424
+ */
425
+ export function recordMainResult(tool: string, input: Record<string, unknown>, cwd: string, text: string): void {
426
+ const base = typeof input.cwd === "string" ? path.resolve(cwd, q.expandHome(input.cwd)) : cwd;
427
+ const dir = q.shellCwd(String(input.command ?? input.code ?? ""), base);
428
+ recordMainReads([...text.matchAll(GREP_LINE)].map(m => path.resolve(dir, m[1] ?? "")));
429
+ }
430
+
358
431
  /**
359
432
  * spawn: the allow-list, task-only and open-plan gates for one implementer launch. Covers both
360
433
  * original paths (the extension spawn event and a `task` tool call); the brief names the plan.
@@ -399,23 +472,28 @@ export function flagCancel(text: string, cfg: Config): string {
399
472
 
400
473
  /**
401
474
  * prompt/owner message: inject the queue summary, flag cancel wording, reset Main's edit window
402
- * and the stop counters (the original resets them at every user message).
475
+ * and the stop counters (the original resets them at every user message). A turn the harness
476
+ * starts from this guard's own stop continuation ("main-guard: …") is not an owner message: it
477
+ * keeps the counters and the edit window, else every continuation reset the idle streak and a
478
+ * model that only answers text was nudged again forever (2026-10-21 OOM: ~205 agent starts in one
479
+ * non-interactive session).
403
480
  */
404
481
  export function checkInject(e: InjectEvent, cfg: Config): Decision {
405
482
  if (e.agent !== "main") return ALLOW;
406
- writeWindow({ used: [], windowStart: 0 });
483
+ const continuation = e.inject.trimStart().startsWith("main-guard:");
484
+ if (!continuation) writeWindow({ used: [], windowStart: 0 });
407
485
  let summary: string;
408
486
  try {
409
487
  summary = q.queueSummary();
410
488
  } catch (err) {
411
489
  summary = `Queue unreadable (${q.INDEX}): ${err}`;
412
490
  }
413
- const cancel = flagCancel(e.inject, cfg);
491
+ const cancel = continuation ? "" : flagCancel(e.inject, cfg);
414
492
  return {
415
493
  allow: true,
416
494
  reason: "",
417
495
  inject: `${summary}\n(main-guard: read ${q.INDEX} and the listed repo INDEX.md files are summarised above; work the open plans, close finished ones with queue-close.)${cancel}`,
418
- state: { callsSinceStop: 0, idleStops: 0 },
496
+ ...(continuation ? {} : { state: { callsSinceStop: 0, idleStops: 0 } }),
419
497
  };
420
498
  }
421
499
 
@@ -433,7 +511,7 @@ function mainOnlyLines(text: string): string {
433
511
  * write (the original restored the file itself before reporting).
434
512
  */
435
513
  export function checkPlanEdit(e: PlanEditEvent, cfg: Config): Decision {
436
- if (e.agent === "main") return ALLOW;
514
+ if (e.agent === "main") return checkMainPlan(e, cfg);
437
515
  if (q.outsideProgress(e.after) !== q.outsideProgress(e.before) || mainOnlyLines(e.after) !== mainOnlyLines(e.before)) {
438
516
  log(cfg.paths.plan_guard_log, `RESTORE ${e.path}`);
439
517
  return {
@@ -445,6 +523,74 @@ export function checkPlanEdit(e: PlanEditEvent, cfg: Config): Decision {
445
523
  return ALLOW;
446
524
  }
447
525
 
526
+ // Main's plan text outside "## Progress" must rest on files Main read itself (planReadProblems).
527
+ // An empty `before` means the plan file did not exist yet; a refusal then deletes it.
528
+ function checkMainPlan(e: PlanEditEvent, cfg: Config): Decision {
529
+ if (q.outsideProgress(e.after) === q.outsideProgress(e.before)) return ALLOW;
530
+ const problems = planReadProblems(e.after, e.path, cfg);
531
+ if (!problems.length) return ALLOW;
532
+ log(cfg.paths.plan_guard_log, `REFUSE-READ ${e.path}: ${problems.join("; ")}`);
533
+ return {
534
+ allow: false,
535
+ 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."}`,
536
+ restore: { path: e.path, text: e.before || null },
537
+ };
538
+ }
539
+
540
+ const READ_SECTION = /^## Code read by Main[^\n]*\n([\s\S]*?)(?=^## |(?![\s\S]))/m;
541
+ const LISTED_PATH = /`([^`\s]+)`/g;
542
+ const CITED_PATH = /((?:~\/|\/|\.{1,2}\/)?[\w@.-]+(?:\/[\w@.-]+)*\.[A-Za-z0-9]+):\d+/g;
543
+
544
+ /** Read evidence behind a plan edit: section present, each listed path read within the window, no unlisted cited path. */
545
+ function planReadProblems(text: string, planPath: string, cfg: Config): string[] {
546
+ const section = READ_SECTION.exec(text)?.[1] ?? "";
547
+ const listed = [...section.matchAll(LISTED_PATH)].map(m => (m[1] ?? "").replace(/:[\d,+\- ]+$/, ""));
548
+ 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)`];
549
+ const reads = readMainReads();
550
+ const maxMs = cfg.guards.plan_read_max_hours * 3_600_000;
551
+ const problems: string[] = [];
552
+ const named = new Set<string>();
553
+ for (const p of listed) {
554
+ const abs = planFile(p, planPath, text);
555
+ named.add(abs);
556
+ if (!isFile(abs)) problems.push(`${p} does not exist`);
557
+ 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`);
558
+ }
559
+ for (const [, cited = ""] of text.matchAll(CITED_PATH)) {
560
+ const abs = planFile(cited, planPath, text);
561
+ if (abs.includes("/docs/plans/") || named.has(abs) || !isFile(abs)) continue;
562
+ problems.push(`${cited} is cited but not listed under "## Code read by Main"`);
563
+ }
564
+ return problems;
565
+ }
566
+
567
+ /** A plan-cited path: absolute, else under the plan's Worktree (when set) or its repo root. */
568
+ function planFile(p: string, planPath: string, text: string): string {
569
+ const home = q.expandHome(p);
570
+ if (path.isAbsolute(home)) return home;
571
+ const wt = /^Worktree:\s*(\S+)/m.exec(text)?.[1];
572
+ const repo = path.dirname(path.dirname(path.dirname(planPath)));
573
+ const roots = wt && wt !== "-" ? [wt, repo] : [repo];
574
+ const root = roots.find(r => isFile(path.join(r, home))) ?? repo;
575
+ return path.join(root, home);
576
+ }
577
+
578
+ function isFile(file: string): boolean {
579
+ try {
580
+ return statSync(file).isFile();
581
+ } catch {
582
+ return false;
583
+ }
584
+ }
585
+
586
+ function readMainReads(): Record<string, string> {
587
+ try {
588
+ return JSON.parse(readFileSync(path.join(q.STATE_DIR, "main-reads.json"), "utf8")) as Record<string, string>;
589
+ } catch {
590
+ return {};
591
+ }
592
+ }
593
+
448
594
  // Plans still responsible for: open plans (parked "WAITING on owner" excluded) plus queue problems.
449
595
  export function openQueue(): string[] {
450
596
  const open = q
@@ -8,44 +8,58 @@ 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");
52
+ // The CLI file itself (shebang `#!/usr/bin/env bun`), never process.execPath: inside a host
53
+ // harness that is the harness binary, which would start a new agent session instead.
45
54
  const cli = join(dirname(fileURLToPath(import.meta.url)), "../cli/main.ts");
46
- const doctor = spawnSync(process.execPath, [cli, "doctor"], { encoding: "utf8", env: process.env });
55
+ const doctor = spawnSync(cli, ["doctor"], { encoding: "utf8", env: process.env });
47
56
  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);
57
+ out.push(text.endsWith("\n") || text === "" ? text : `${text}\n`);
58
+ if (doctor.error) say(doctor.error.message);
59
+ return out.join("");
60
+ }
61
+
62
+ export function mainStart(args: string[], cfg: Config): number {
63
+ process.stdout.write(mainStartText(args, cfg));
50
64
  return 0;
51
65
  }
@@ -1,7 +1,7 @@
1
1
  // Queue model: main index, repo INDEX.md, plan shape (templates/queue/TEMPLATE.md).
2
2
 
3
3
  import { createHash } from "node:crypto";
4
- import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
4
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
7
 
@@ -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
  }
@@ -279,6 +306,60 @@ export function changesFiles(toolName: string, input: Record<string, unknown>):
279
306
  return SHELL_TOOLS.has(toolName) && MUTATING.test(String(input?.command ?? input?.code ?? ""));
280
307
  }
281
308
 
309
+ // P8: package-manager and cutover commands change the running guard package.
310
+ // Their write target is the protected root they touch, so the ordinary
311
+ // protected-path check refuses them without the owner's unlock. Matched per
312
+ // shell segment, with leading KEY=VALUE assignments stripped; the cutover
313
+ // scripts stay free when the same segment pins HOME to /tmp (how every sandbox
314
+ // test and the P9 session test run them). Harness-owned command shapes are NOT
315
+ // matched here: the adapter registers them through registerPackageOpTarget,
316
+ // because core never names a harness.
317
+ function isNeurastreamSpec(t: string): boolean {
318
+ const base = t.replace(/^["']|["']$/g, "").replace(/^\.?\//, "").replace(/^.*\//, "");
319
+ return base === "neurastream" || base.startsWith("neurastream@") || /^neurastream-[^/]*\.tgz$/.test(base);
320
+ }
321
+
322
+ // Adapter-registered matcher for harness package operations (plugin install,
323
+ // link, ...). Returns the protected root the command writes, or null.
324
+ export type PackageOpMatcher = (code: string) => string | null;
325
+ export let packageOpMatcher: PackageOpMatcher | null = null;
326
+
327
+ export function registerPackageOpTarget(fn: PackageOpMatcher): void {
328
+ packageOpMatcher = fn;
329
+ }
330
+
331
+ function packageOpTarget(code: string): string | null {
332
+ for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
333
+ const toks = seg.trim().split(/\s+/).filter(Boolean);
334
+ let i = 0;
335
+ while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i] ?? "")) i++;
336
+ const cmd = (toks[i] ?? "").replace(/^["']|["']$/g, "").replace(/^.*\//, "");
337
+ const rest = toks.slice(i + 1).map(t => t.replace(/^["']+|["']+$/g, ""));
338
+ const sub = rest[0] ?? "";
339
+ const hasGlobal = rest.some(t => t === "-g" || t === "--global");
340
+ const names = rest.slice(1).filter(t => !t.startsWith("-"));
341
+ if (cmd === "bun" && ["add", "install", "remove", "rm", "update", "link", "unlink"].includes(sub) && hasGlobal) {
342
+ if (!names.length || names.some(isNeurastreamSpec)) return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
343
+ continue;
344
+ }
345
+ if (cmd === "npm" && ["install", "i", "uninstall", "update", "link"].includes(sub) && hasGlobal) {
346
+ if (!names.length || names.some(isNeurastreamSpec)) return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
347
+ continue;
348
+ }
349
+ if (cmd === "neurastream" && sub === "adapter" && ["install", "uninstall"].includes(rest[1] ?? ""))
350
+ return `${bunGlobalRoot()}/install/global/node_modules/neurastream`;
351
+ if ((cmd === "bash" || cmd === "sh") && rest.some(t => {
352
+ const base = t.replace(/^.*\//, "");
353
+ return base === "cutover.sh" || base === "rollback.sh";
354
+ })) {
355
+ const envHome = toks.slice(0, i).find(t => t.startsWith("HOME="));
356
+ if (envHome && envHome.slice("HOME=".length).replace(/^["']|["']$/g, "").startsWith("/tmp/")) continue;
357
+ return `${agentDir()}/extensions/`;
358
+ }
359
+ }
360
+ return packageOpMatcher?.(code) ?? null;
361
+ }
362
+
282
363
  export function writeTargets(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
283
364
  const out = new Set<string>();
284
365
  const add = (raw: string, from: string) => {
@@ -307,7 +388,10 @@ export function writeTargets(toolName: string, input: Record<string, unknown>, c
307
388
  }
308
389
  if (!SHELL_TOOLS.has(toolName)) return [];
309
390
  const code = String(input?.command ?? input?.code ?? "");
310
- if (!MUTATING.test(code)) return [];
391
+ // A package/plugin/cutover command is a write even though none of the
392
+ // generic mutating heuristics fire on its text.
393
+ const pkgOp = packageOpTarget(code);
394
+ if (!MUTATING.test(code) && !pkgOp) return [];
311
395
  let dir = typeof input?.cwd === "string" ? path.resolve(cwd, expandHome(input.cwd)) : cwd;
312
396
  for (const seg of code.split(/&&|\|\||;|\n|\|/)) {
313
397
  const s = seg.trim();
@@ -337,6 +421,7 @@ export function writeTargets(toolName: string, input: Record<string, unknown>, c
337
421
  /\bshutil\.\w+\(\s*["'][^"']+["']\s*,\s*(["'][^"']+["'])/g,
338
422
  ])
339
423
  for (const m of code.matchAll(re)) add((m[1] ?? "").replace(/`/g, ""), dir);
424
+ if (pkgOp) out.add(pkgOp);
340
425
  return [...out];
341
426
  }
342
427
 
@@ -345,9 +430,29 @@ export function unlockPath(): string {
345
430
  }
346
431
  export let UNLOCK = unlockPath();
347
432
  export const UNLOCK_MINUTES = 30;
433
+ // P8 (2026-10-21): after the npm cutover the running guards load from the
434
+ // installed package, so the package itself is guard surface. The package root
435
+ // is only claimed when this file really is part of a published neurastream
436
+ // package (package.json name check): the pre-cutover standalone copy under
437
+ // <agent>/queue/ has no package.json above it and must not protect the home.
438
+ function packageManifest(): { root: string; bins: string[] } | null {
439
+ try {
440
+ const root = realpathSync(path.resolve(path.dirname(import.meta.path), "..", "..", ".."));
441
+ const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { name?: string; bin?: Record<string, string> };
442
+ if (pkg.name !== "neurastream") return null;
443
+ return { root, bins: Object.keys(pkg.bin ?? {}) };
444
+ } catch {
445
+ return null;
446
+ }
447
+ }
448
+
449
+ function bunGlobalRoot(): string {
450
+ return process.env.BUN_INSTALL ? path.resolve(expandHome(process.env.BUN_INSTALL)) : `${HOME}/.bun`;
451
+ }
452
+
348
453
  function protectedPaths(): string[] {
349
454
  const agent = agentDir();
350
- return [
455
+ const paths = [
351
456
  `${agent}/extensions/`,
352
457
  `${agent}/queue/`,
353
458
  `${agent}/blockers/`,
@@ -368,7 +473,18 @@ function protectedPaths(): string[] {
368
473
  `${HOME}/.local/bin/git`,
369
474
  `${HOME}/.local/bin/git-secret-gate`,
370
475
  `${HOME}/.config/git-secret-gate/`,
476
+ // Where `bun add -g neurastream` lands; writeTargets points package
477
+ // operations here so they need the owner's unlock like any guard edit.
478
+ // The plugin dir is adapter surface: the installer lists it in the
479
+ // config's guards.protected (core never names a harness path).
480
+ `${bunGlobalRoot()}/install/global/node_modules/neurastream/`,
371
481
  ];
482
+ const pkg = packageManifest();
483
+ if (pkg) {
484
+ paths.push(`${pkg.root}/`);
485
+ for (const name of pkg.bins) paths.push(`${HOME}/.bun/bin/${name}`);
486
+ }
487
+ return paths;
372
488
  }
373
489
  export let PROTECTED = protectedPaths();
374
490
 
@@ -552,7 +668,8 @@ export function queueSummary(index = INDEX): string {
552
668
  lines.push(`- ${repo}${nCancelled ? ` (${nCancelled} cancelled)` : ""}`);
553
669
  for (const p of readPlans(repo)) {
554
670
  const status = awaitingMerge(p) ? "complete, awaiting merge" : (p.status ?? "INVALID");
555
- const flags = [status, p.waiting ? "WAITING on owner" : "", p.priorityId ?? ""].filter(Boolean).join(", ");
671
+ const hold = p.waiting ? `WAITING on owner: ${(p.waitingLine ?? "").slice(0, 80)}` : "";
672
+ const flags = [status, hold, p.priorityId ?? ""].filter(Boolean).join(", ");
556
673
  const goal = items(sections(p.text).get("goal") ?? "")[0]?.slice(0, 100) ?? "";
557
674
  lines.push(` - ${p.file} [${flags}] ${goal}`);
558
675
  }
@@ -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
@@ -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 +