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 +26 -0
- package/package.json +1 -1
- package/src/adapters/omp/doctor.ts +10 -4
- package/src/adapters/omp/index.ts +200 -9
- package/src/adapters/omp/install.ts +29 -9
- package/src/adapters/omp/package-ops.ts +28 -0
- package/src/core/adapter.ts +1 -1
- package/src/core/config.ts +3 -0
- package/src/core/guards.ts +152 -6
- package/src/core/main-start.ts +35 -21
- package/src/core/queue/lib.ts +129 -12
- package/src/core/rollover.ts +86 -0
- package/templates/config.yml +5 -0
- package/templates/queue/TEMPLATE.md +4 -0
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
|
@@ -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/
|
|
41
|
-
source:
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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 [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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 {
|
|
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);
|
package/src/core/adapter.ts
CHANGED
|
@@ -46,7 +46,7 @@ export interface Decision {
|
|
|
46
46
|
allow: boolean;
|
|
47
47
|
reason: string;
|
|
48
48
|
inject?: string;
|
|
49
|
-
restore?: { path: string; text: string };
|
|
49
|
+
restore?: { path: string; text: string | null }; // null: the file did not exist before; delete it
|
|
50
50
|
/** Next keep-working counters for the caller to feed back into later StopEvents. */
|
|
51
51
|
state?: { callsSinceStop: number; idleStops: number };
|
|
52
52
|
}
|
package/src/core/config.ts
CHANGED
|
@@ -98,6 +98,7 @@ export interface Config {
|
|
|
98
98
|
main_edit_limit: number;
|
|
99
99
|
main_window_min: number;
|
|
100
100
|
unlock_minutes: number;
|
|
101
|
+
plan_read_max_hours: number;
|
|
101
102
|
idle_stops: number;
|
|
102
103
|
blocker_timeout_ms: number;
|
|
103
104
|
skip_tools: string[];
|
|
@@ -110,6 +111,7 @@ export interface Config {
|
|
|
110
111
|
protected: string[];
|
|
111
112
|
ledger_command: string;
|
|
112
113
|
};
|
|
114
|
+
rollover: { enabled: boolean; max_session_mb: number };
|
|
113
115
|
machine: {
|
|
114
116
|
load_over_cores: number;
|
|
115
117
|
load_max: number;
|
|
@@ -257,6 +259,7 @@ function check(cfg: Config): void {
|
|
|
257
259
|
if (cfg.guards.sub_warn_calls > cfg.guards.sub_max_calls) {
|
|
258
260
|
throw new ConfigError("guards.sub_warn_calls must be <= guards.sub_max_calls");
|
|
259
261
|
}
|
|
262
|
+
if (!(cfg.rollover.max_session_mb > 0)) throw new ConfigError("rollover.max_session_mb must be > 0");
|
|
260
263
|
for (const [key, value] of Object.entries(cfg.paths)) {
|
|
261
264
|
if (key === "work_roots") {
|
|
262
265
|
if (!Array.isArray(value) || value.some(v => typeof v !== "string")) {
|
package/src/core/guards.ts
CHANGED
|
@@ -157,6 +157,15 @@ export function bypassRegExp(entry: string): RegExp {
|
|
|
157
157
|
return new RegExp(`\\b${head}\\b[^\\n]*\\b${tail}\\b`);
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
const RESEARCH_ASK = /\b(research|investigate|find where|map the code|read the code for)\b/i;
|
|
161
|
+
const PLAN_WRITE = /\b(write|writing|draft|drafting|author)\b[^\n]*\bplan\b/i;
|
|
162
|
+
|
|
163
|
+
// Phase ids (P1, P2 ...) in the plan's Phases section that are not ticked yet.
|
|
164
|
+
function unticked(p: Plan): string[] {
|
|
165
|
+
const phases = q.sections(p.text).get("phases") ?? "";
|
|
166
|
+
return [...phases.matchAll(/^\s*[-*]\s+\[ \]\s+(P\d+)\b/gm)].map(m => m[1]!);
|
|
167
|
+
}
|
|
168
|
+
|
|
160
169
|
// Plans an implementer brief may run on; returns [reason, null] or [null, plan files].
|
|
161
170
|
export function planGate(text: string, label: string): [string | null, string[]] {
|
|
162
171
|
const plans = q.activePlans();
|
|
@@ -172,6 +181,11 @@ export function planGate(text: string, label: string): [string | null, string[]]
|
|
|
172
181
|
`${label}: an implementer needs an open plan. Name the plan's absolute path in the brief; the plan must be listed in <repo>/INDEX.md and the repo in ${q.INDEX}.`,
|
|
173
182
|
[],
|
|
174
183
|
];
|
|
184
|
+
if (RESEARCH_ASK.test(text) && PLAN_WRITE.test(text))
|
|
185
|
+
return [
|
|
186
|
+
`${label}: a brief must not ask for research for a plan to be written. Please never ever ever write a plan when another agent is doing the research or reading for you. Main reads the code for its plans itself.`,
|
|
187
|
+
[],
|
|
188
|
+
];
|
|
175
189
|
const spawns = readSpawns();
|
|
176
190
|
for (const p of named) {
|
|
177
191
|
if (p.errors.length) return [`${label}: plan ${p.file} is not ready (${p.errors.join("; ")}).`, []];
|
|
@@ -185,6 +199,10 @@ export function planGate(text: string, label: string): [string | null, string[]]
|
|
|
185
199
|
`${label}: plan ${p.file} has no new "## Progress" entry since the last agent ran on it. Write that run's progress (from its report) into the plan first.`,
|
|
186
200
|
[],
|
|
187
201
|
];
|
|
202
|
+
const open = unticked(p);
|
|
203
|
+
if (!open.length) return [`${label}: plan ${p.file} has no unticked phase left.`, []];
|
|
204
|
+
if (!open.some(id => new RegExp(`\\b${id}\\b`).test(text)))
|
|
205
|
+
return [`${label}: the brief must name an unticked phase of ${p.file} (${open.join(", ")}).`, []];
|
|
188
206
|
}
|
|
189
207
|
return [null, named.map(p => p.file)];
|
|
190
208
|
}
|
|
@@ -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")
|
|
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
|
-
|
|
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
|
|
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
|
package/src/core/main-start.ts
CHANGED
|
@@ -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
|
-
|
|
11
|
+
// Main's session-start preflight as one text. The CLI prints it; the session rollover handoff
|
|
12
|
+
// (src/core/rollover.ts) carries the same text into the new session. Children's stdout is
|
|
13
|
+
// collected in order, so the printed output matches the original script line for line.
|
|
14
|
+
export function mainStartText(args: string[], cfg: Config): string {
|
|
15
|
+
const out: string[] = [];
|
|
16
|
+
const say = (line: string) => out.push(`${line}\n`);
|
|
12
17
|
const h = args[0] ?? String(cfg.health.default_hours);
|
|
13
|
-
|
|
18
|
+
say("=== machine");
|
|
14
19
|
const mg = spawnSync(bin("machine-guard", cfg), [], { encoding: "utf8", env: process.env });
|
|
15
20
|
const mgLines = (mg.stdout ?? "").trim().split("\n");
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
say(mgLines.at(-1) ?? "");
|
|
22
|
+
say(readFileSync("/proc/loadavg", "utf8").trim());
|
|
23
|
+
say("");
|
|
24
|
+
say(`=== agents (last ${h}h; DIED = check its worktree, relaunch FRESH with its progress file)`);
|
|
20
25
|
const ledger = spawnSync(bin("agent-ledger", cfg), ["--hours", h], { encoding: "utf8", env: process.env });
|
|
21
26
|
const awk = spawnSync(
|
|
22
27
|
"awk",
|
|
23
28
|
["NR==1 || /DIED/ {print; next} {ok[++n]=$0} END {for (i=1;i<=n;i++) print ok[i]}"],
|
|
24
29
|
{ input: ledger.stdout ?? "", encoding: "utf8" },
|
|
25
30
|
);
|
|
26
|
-
|
|
31
|
+
out.push(awk.stdout ?? "");
|
|
27
32
|
const cap = spawnSync(bin("agent-ledger", cfg), ["--cap"], { encoding: "utf8", env: process.env, stdio: ["ignore", "pipe", "ignore"] });
|
|
28
33
|
for (const line of (cap.stdout ?? "").split("\n")) {
|
|
29
|
-
if (/^(PRIORITY IDLE|FULL)/.test(line))
|
|
34
|
+
if (/^(PRIORITY IDLE|FULL)/.test(line)) say(line);
|
|
30
35
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
spawnSync(bin("main-health", cfg), [], {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
say("");
|
|
37
|
+
say("=== main thread");
|
|
38
|
+
const health = spawnSync(bin("main-health", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
|
|
39
|
+
out.push(health.stdout ?? "");
|
|
40
|
+
say("");
|
|
41
|
+
say("=== queue");
|
|
42
|
+
const check = spawnSync(bin("queue-check", cfg), [], { encoding: "utf8", env: process.env, stdio: ["inherit", "pipe", "inherit"] });
|
|
43
|
+
out.push(check.stdout ?? "");
|
|
44
|
+
say("");
|
|
45
|
+
say("=== heavy-run lock");
|
|
39
46
|
try {
|
|
40
47
|
const lines = readFileSync(cfg.paths.heavy_lock, "utf8").split("\n").slice(0, 2);
|
|
41
48
|
while (lines.length && lines.at(-1) === "") lines.pop();
|
|
42
|
-
if (lines.length)
|
|
49
|
+
if (lines.length) say(lines.join("\n"));
|
|
43
50
|
} catch {}
|
|
44
|
-
|
|
51
|
+
say("=== doctor");
|
|
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(
|
|
55
|
+
const doctor = spawnSync(cli, ["doctor"], { encoding: "utf8", env: process.env });
|
|
47
56
|
const text = `${doctor.stdout ?? ""}${doctor.stderr ?? ""}`;
|
|
48
|
-
|
|
49
|
-
if (doctor.error)
|
|
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
|
}
|
package/src/core/queue/lib.ts
CHANGED
|
@@ -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
|
|
142
|
+
const stamp = /^(\d{8}_\d{4})/.exec(path.basename(file))?.[1] ?? "";
|
|
143
|
+
if (stamp >= READ_CUTOVER && !items(secs.get("code read by main") ?? "").length)
|
|
144
|
+
errors.push(`missing or empty "## Code read by Main" section (plans stamped ${READ_CUTOVER} or later list each file they cite)`);
|
|
145
|
+
const known = new Set([...REQUIRED, "applies to", "background", "code read by main"]);
|
|
140
146
|
for (const s of secs.keys()) if (!known.has(s)) errors.push(`unknown section "## ${s}" (allowed: ${[...known].join(", ")})`);
|
|
141
147
|
const status = statusOf(text);
|
|
142
148
|
if (!status) errors.push('"## Progress" must start with a line "Status: open", "Status: complete" or "Status: cancelled"');
|
|
@@ -169,6 +175,7 @@ export function parsePlan(repo: string, file: string, text: string | null, block
|
|
|
169
175
|
branch: headerField(text, "Branch")?.replace(/^-$/, "") || null,
|
|
170
176
|
appliesTo: items(secs.get("applies to") ?? "").map(p => path.resolve(repo, expandHome(p.replace(/`/g, "")))),
|
|
171
177
|
waiting: /^WAITING on owner:/m.test(text),
|
|
178
|
+
waitingLine: text.match(/^WAITING on owner:[ \t]*(.*)$/m)?.[1] ?? null,
|
|
172
179
|
priorityId: text.match(/^PRIORITY-ID:\s*`?([A-Z]{2}[0-9]{2})`?/m)?.[1] ?? null,
|
|
173
180
|
errors,
|
|
174
181
|
};
|
|
@@ -188,6 +195,11 @@ export function activePlans(index = indexPath(), blockersDir = blockersDirPath()
|
|
|
188
195
|
return readIndex(index).flatMap(r => readPlans(r, blockersDir));
|
|
189
196
|
}
|
|
190
197
|
|
|
198
|
+
/** Owner holds: one line per plan with a WAITING on owner line. The only real holds; the summary, the rollover context and the handoff all read them here. */
|
|
199
|
+
export function ownerHolds(index = INDEX): string[] {
|
|
200
|
+
return readIndex(index).flatMap(r => readPlans(r)).filter(p => p.waiting).map(p => `${p.file}: WAITING on owner: ${p.waitingLine ?? ""}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
191
203
|
export function gitTop(p: string): { top: string; repo: string } | null {
|
|
192
204
|
let dir = path.resolve(p);
|
|
193
205
|
for (;;) {
|
|
@@ -238,6 +250,19 @@ export function isOrchestrationPath(p: string, plans: Plan[] = activePlans()): b
|
|
|
238
250
|
}
|
|
239
251
|
|
|
240
252
|
const SHELL_TOOLS = new Set(["bash", "eval"]);
|
|
253
|
+
// A shell command is a list of segments; each `cd` segment moves the directory for the ones after it.
|
|
254
|
+
const SHELL_SEGMENT = /&&|\|\||[;|\n]/;
|
|
255
|
+
const CD_SEGMENT = /^\s*cd\s+(\S+)/;
|
|
256
|
+
|
|
257
|
+
/** The directory a shell command leaves in effect: `base`, moved by each `cd` segment in order. */
|
|
258
|
+
export function shellCwd(command: string, base: string): string {
|
|
259
|
+
let dir = base;
|
|
260
|
+
for (const seg of command.split(SHELL_SEGMENT)) {
|
|
261
|
+
const cd = CD_SEGMENT.exec(seg);
|
|
262
|
+
if (cd) dir = path.resolve(dir, expandHome(cd[1] ?? ""));
|
|
263
|
+
}
|
|
264
|
+
return dir;
|
|
265
|
+
}
|
|
241
266
|
export function touchedPaths(toolName: string, input: Record<string, unknown>, cwd: string): string[] {
|
|
242
267
|
const out = new Set<string>();
|
|
243
268
|
const base = typeof input?.cwd === "string" ? path.resolve(cwd, expandHome(input.cwd)) : cwd;
|
|
@@ -258,14 +283,16 @@ export function touchedPaths(toolName: string, input: Record<string, unknown>, c
|
|
|
258
283
|
}
|
|
259
284
|
if (SHELL_TOOLS.has(toolName)) {
|
|
260
285
|
out.add(base);
|
|
261
|
-
const cmd = String(input?.command ?? input?.code ?? "");
|
|
262
286
|
let dir = base;
|
|
263
|
-
for (const
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
287
|
+
for (const seg of String(input?.command ?? input?.code ?? "").split(SHELL_SEGMENT)) {
|
|
288
|
+
for (const word of seg.split(/[\s<>()'"`,=]+/)) {
|
|
289
|
+
const w = word.trim();
|
|
290
|
+
if (!w || w.startsWith("-") || w.includes("://") || !/[./]/.test(w) || /^\d+(\.\d+)*$/.test(w)) continue;
|
|
291
|
+
add(w, dir);
|
|
292
|
+
}
|
|
293
|
+
const cd = CD_SEGMENT.exec(seg);
|
|
294
|
+
if (cd) dir = path.resolve(dir, expandHome(cd[1] ?? ""));
|
|
267
295
|
}
|
|
268
|
-
for (const m of cmd.matchAll(/(?:^|&&|;|\|\|)\s*cd\s+(\S+)/g)) dir = path.resolve(dir, expandHome(m[1] ?? ""));
|
|
269
296
|
}
|
|
270
297
|
return [...out];
|
|
271
298
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/templates/config.yml
CHANGED
|
@@ -101,6 +101,7 @@ guards:
|
|
|
101
101
|
main_edit_limit: 5
|
|
102
102
|
main_window_min: 15
|
|
103
103
|
unlock_minutes: 30
|
|
104
|
+
plan_read_max_hours: 24
|
|
104
105
|
idle_stops: 3
|
|
105
106
|
blocker_timeout_ms: 10000
|
|
106
107
|
skip_tools: [todo, wait, ask, web_search, job, cancel_job]
|
|
@@ -113,6 +114,10 @@ guards:
|
|
|
113
114
|
protected: []
|
|
114
115
|
ledger_command: "agent-ledger --hours 12"
|
|
115
116
|
|
|
117
|
+
rollover:
|
|
118
|
+
enabled: true
|
|
119
|
+
max_session_mb: 10
|
|
120
|
+
|
|
116
121
|
machine:
|
|
117
122
|
load_over_cores: 4
|
|
118
123
|
load_max: 0
|
|
@@ -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 +
|