From f06dec411d132a95bcd7622fd042c4f082ef9e18 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:04 +0000 Subject: [PATCH 1/5] fix(hooks): read only the transcript tail and skip sidechain rows Two defects in the same function, hence one commit. liveModel() read the entire transcript before slicing TAIL_BYTES, contradicting the file's own "<20ms hot path" comment; and it did not filter isSidechain, so a subagent's model was read as the main loop's, injecting a false MODEL RUNG warning and writing a bogus off-pin telemetry row. --- LifeOS/install/hooks/ModelRungGuard.hook.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/LifeOS/install/hooks/ModelRungGuard.hook.ts b/LifeOS/install/hooks/ModelRungGuard.hook.ts index 2e0b1c75e9..68dd8eb3e4 100755 --- a/LifeOS/install/hooks/ModelRungGuard.hook.ts +++ b/LifeOS/install/hooks/ModelRungGuard.hook.ts @@ -35,7 +35,7 @@ for (const __k of ["LIFEOS_DIR", "LIFEOS_CONFIG_DIR", "PROJECTS_DIR"]) { * Failure mode: any error logs to stderr and exits 0, never blocking prompts. */ -import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs"; +import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync } from "node:fs"; import { join } from "node:path"; const STDIN_TIMEOUT_MS = 300; @@ -110,17 +110,29 @@ export function pinnedRung(settingsPath: string = SETTINGS_PATH): Rung | null { * The carrier actually serving this session, read from the last assistant * message in the transcript. Null on the very first prompt, when no assistant * message exists yet — the check simply starts on turn two. + * + * Sidechain rows are skipped: a subagent writes its own `message.model` into the + * same transcript, so a delegate dispatched to a lower rung would otherwise be + * read as the main loop's carrier and warn about a gap that does not exist. */ export function liveModel(transcriptPath: string | undefined): string | null { + let fd: number | null = null; try { if (!transcriptPath || !existsSync(transcriptPath)) return null; const size = statSync(transcriptPath).size; - const fd = readFileSync(transcriptPath); - const tail = fd.subarray(Math.max(0, size - TAIL_BYTES)).toString("utf8"); + // Read ONLY the tail — the file reaches hundreds of MB and this is a hot path. + const start = Math.max(0, size - TAIL_BYTES); + const len = size - start; + if (len <= 0) return null; + const buf = Buffer.allocUnsafe(len); + fd = openSync(transcriptPath, "r"); + const got = readSync(fd, buf, 0, len, start); + const tail = buf.subarray(0, got).toString("utf8"); const lines = tail.split("\n").filter((l) => l.trim().length > 0); for (let i = lines.length - 1; i >= 0; i--) { try { const row = JSON.parse(lines[i] as string) as Record; + if (row.isSidechain === true) continue; // a subagent's carrier, not ours const msg = row.message as Record | undefined; if (msg && typeof msg.model === "string" && msg.model.trim()) return msg.model; } catch { /* partial line at the tail boundary */ } @@ -128,6 +140,8 @@ export function liveModel(transcriptPath: string | undefined): string | null { return null; } catch { return null; + } finally { + if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } } } } From 2f04673f67d2c700b531e3afd605f2d261f4d1b4 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:04 +0000 Subject: [PATCH 2/5] fix(hooks): let the verification gate split units on a newline between digits The digit-lookaround refused to break there, so unrelated lines merged into one "unit" that could satisfy both halves of a T5 block, defeating the gate. --- LifeOS/install/hooks/VerificationGate.hook.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/LifeOS/install/hooks/VerificationGate.hook.ts b/LifeOS/install/hooks/VerificationGate.hook.ts index 9f8036baf1..a5ee88bc2c 100755 --- a/LifeOS/install/hooks/VerificationGate.hook.ts +++ b/LifeOS/install/hooks/VerificationGate.hook.ts @@ -72,8 +72,14 @@ export function splitIntoUnits(text: string): string[] { // claim vanished before any type could see it. That blind spot hit every type // carrying a version, IP, or decimal. Found 2026-07-31 by testing the gate // against the exact sentence it was built to catch. + // + // A newline is exempt from that protection and ALWAYS splits: no number is + // written across two lines, so the lookarounds only ever fused unrelated lines + // — a line ending in a digit followed by a line starting with one became a + // single unit, which both invented claims spanning two sentences and let a + // neighbouring line's hedge ("local", "not") suppress a real claim. return text - .split(/(? (u ?? "").trim()) .filter(Boolean); } From aee48b3799f9943ee6ee316c191c1e7d2e4f2ede Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:04 +0000 Subject: [PATCH 3/5] fix(hooks): decide hard-fail from is_error, not from words in the output A successful `rg -n "exit 1"` tripped the gate via the new "command" event fallback. Adds isToolError to TxEvent as the raw flag, with no text heuristic mixed in. --- LifeOS/install/hooks/VerificationGate.hook.ts | 12 ++++++++++-- LifeOS/install/hooks/lib/transcript-evidence.ts | 6 +++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/LifeOS/install/hooks/VerificationGate.hook.ts b/LifeOS/install/hooks/VerificationGate.hook.ts index a5ee88bc2c..973a92327f 100755 --- a/LifeOS/install/hooks/VerificationGate.hook.ts +++ b/LifeOS/install/hooks/VerificationGate.hook.ts @@ -221,10 +221,18 @@ const ACKNOWLEDGES_FAILURE = /\b(fail(s|ed|ure|ing)?|error(s|ed)?|traceback|exception|broke|broken|didn'?t\s+(work|run|parse)|couldn'?t|hit\s+a\s+(snag|wall)|blocked)\b/i; /** Returns the claiming unit iff the message asserts completion while the - * turn's final tool event hard-failed and nothing succeeded after it. */ -export function contradictedCompletionUnit(message: string, evs: { isError: boolean; resultText: string }[]): string | null { + * turn's final tool event hard-failed and nothing succeeded after it. + * + * The raw `is_error` flag is required alongside the text match. HARD_FAIL alone + * reads the OUTPUT, so any command that merely quotes failure words fired it — + * a successful `rg -n "exit 1"` was a contradicted completion. Bash sets + * `is_error` on every non-zero exit, so the founding class (a traceback then a + * success claim) is untouched; what no longer fires is a command that printed + * failure text and still exited 0. */ +export function contradictedCompletionUnit(message: string, evs: { isToolError: boolean; resultText: string }[]): string | null { if (evs.length === 0) return null; const last = evs[evs.length - 1]!; + if (!last.isToolError) return null; if (!HARD_FAIL.test(last.resultText)) return null; const stripped = stripNoise(message); if (ACKNOWLEDGES_FAILURE.test(stripped)) return null; // honest about the failure ⇒ not a contradiction diff --git a/LifeOS/install/hooks/lib/transcript-evidence.ts b/LifeOS/install/hooks/lib/transcript-evidence.ts index 9e5be34cf9..26b4c5a878 100644 --- a/LifeOS/install/hooks/lib/transcript-evidence.ts +++ b/LifeOS/install/hooks/lib/transcript-evidence.ts @@ -38,6 +38,10 @@ export interface TxEvent { resultText: string; /** True when the tool_result was an error or the result text signals failure. */ isError: boolean; + /** The tool_result's RAW `is_error` flag, with no text heuristic mixed in. + * Ground truth for "the tool actually failed": a read-only command whose OUTPUT + * quotes failure words (`rg -n "exit 1"`) sets `isError` but never this. */ + isToolError: boolean; /** Doc-only edits (.md / MEMORY / ISA) don't count as code mutations. */ isCode: boolean; } @@ -163,7 +167,7 @@ export function parseTurnEvents( const resultText = res?.text ?? ""; const isErrorFlag = res?.isError === true || (resultText ? ERROR_MARKERS.test(resultText) : false); const push = (kind: EventKind, target: string, isCode = false) => - events.push({ seq: seq++, kind, tool: name, target, resultText, isError: isErrorFlag, isCode }); + events.push({ seq: seq++, kind, tool: name, target, resultText, isError: isErrorFlag, isToolError: res?.isError === true, isCode }); if (name === "Edit" || name === "Write" || name === "NotebookEdit") { const p = String(input.file_path ?? input.notebook_path ?? ""); From 382ac46f0f0944ffc8ebdfcea083bbfc79868e74 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:04 +0000 Subject: [PATCH 4/5] fix(hooks): stop ISAFoldGate reading a read-only command as a prod mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROD_MUTATION_RES matched raw command text, so `rg -n "Tools/Release.ts"` counted as a production mutation and blocked Stop. The 120-char truncation separately cut real `wrangler … --command "INSERT …"` before its write verb, so the evidence text is widened too. --- LifeOS/install/hooks/ISAFoldGate.hook.ts | 10 ++++++++++ LifeOS/install/hooks/lib/transcript-evidence.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/LifeOS/install/hooks/ISAFoldGate.hook.ts b/LifeOS/install/hooks/ISAFoldGate.hook.ts index 217bf21056..72a1db64bf 100755 --- a/LifeOS/install/hooks/ISAFoldGate.hook.ts +++ b/LifeOS/install/hooks/ISAFoldGate.hook.ts @@ -49,11 +49,21 @@ const PROD_MUTATION_RES: RegExp[] = [ /\s--apply\b/, // our importers' one write flag (ImportBackfill, ProvisionPortal, …) ]; +/** A search/display command whose ARGUMENTS routinely contain the exact strings + * PROD_MUTATION_RES hunts for — `rg -n "Tools/Release.ts"` is a grep, not a + * release. Only these two lists together are safe: the exemption applies solely + * to a single unchained invocation, so `rg x && wrangler secret put y` keeps its + * teeth, and none of these binaries can run a subcommand from argv (which is why + * `find`/`fd`/`awk`/`sed` are deliberately absent — they have -exec/system()). */ +const READ_ONLY_CMD_RE = /^\s*(rg|grep|egrep|fgrep|ag|ack|cat|bat|head|tail|less|ls|wc)\b/; +const SHELL_CHAIN_RE = /[;&|`\n]|\$\(/; + export function prodMutations(ev: TxEvent[]): TxEvent[] { return ev.filter((e) => { if (e.isError) return false; // a failed attempt changed nothing if (e.kind === "deploy") return true; if (e.kind !== "command") return false; + if (READ_ONLY_CMD_RE.test(e.target) && !SHELL_CHAIN_RE.test(e.target)) return false; // Match on the command text only, never its output. return PROD_MUTATION_RES.some((re) => re.test(e.target)); }); diff --git a/LifeOS/install/hooks/lib/transcript-evidence.ts b/LifeOS/install/hooks/lib/transcript-evidence.ts index 26b4c5a878..8ed9bb8113 100644 --- a/LifeOS/install/hooks/lib/transcript-evidence.ts +++ b/LifeOS/install/hooks/lib/transcript-evidence.ts @@ -185,7 +185,11 @@ export function parseTurnEvents( else push("interceptor-nav", extractHost(cmd)); } else if (TEST_RE.test(cmd) && !/--dry-run/.test(cmd)) push("test-run", cmd.slice(0, 120)); else if (PROBE_RE.test(cmd)) push("probe", extractHost(cmd)); - else push("command", cmd.slice(0, 120)); // fallback: a plain command's failure must still be visible evidence + // Fallback: a plain command's failure must still be visible evidence. + // Kept long because ISAFoldGate matches PROD_MUTATION_RES against this + // text — at 120 chars a real `cd … && wrangler d1 execute … --remote + // --command "INSERT …"` was cut before its write verb and read as benign. + else push("command", cmd.slice(0, 2000)); } else if (name === "WebFetch") { push("probe", eTLD1(String(input.url ?? ""))); } else if (name === "Read") { From 0a7551af1958211f8185a10bf2fc2bda0533c2b7 Mon Sep 17 00:00:00 2001 From: elhoim Date: Mon, 3 Aug 2026 07:01:04 +0000 Subject: [PATCH 5/5] fix(hooks): resolve the bun binary instead of spawning it bare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventLogger spawned bare `bun` detached — the exact class hooks/lib/resolve-bin.ts was added this release to fix. ENOENT surfaced asynchronously past the sync try/catch, and the resulting missing state file made the self-heal fire on every tool call. --- LifeOS/install/hooks/EventLogger.hook.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/LifeOS/install/hooks/EventLogger.hook.ts b/LifeOS/install/hooks/EventLogger.hook.ts index e25ea2ceaa..52e60335d1 100755 --- a/LifeOS/install/hooks/EventLogger.hook.ts +++ b/LifeOS/install/hooks/EventLogger.hook.ts @@ -49,6 +49,7 @@ import { import { dirname, join } from 'path'; import { execFileSync, spawn } from 'child_process'; import { paiPath, getSettingsPath } from './lib/paths'; +import { resolveBun } from './lib/resolve-bin'; import { getISOTimestamp, getPSTDate, getYearMonth } from './lib/time'; import { bumpLastToolActivity, bumpLastToolActivityByUUID } from './lib/isa-utils'; import { @@ -232,10 +233,15 @@ function handlePostToolUse(raw: string): void { due = Date.now() - statSync(reconcileState).mtimeMs > 60_000; } catch { /* no state file yet — run it */ } if (due) { - const proc = spawn('bun', [paiPath('TOOLS', 'WorkReconcile.ts')], { + // Absolute bun path: a detached child inherits a minimal PATH, so the + // bare name ENOENTs. That failure arrives on the 'error' EVENT, past + // this sync try/catch — so it was swallowed, WorkReconcile never wrote + // its state file, and `due` stayed true on every single tool call. + const proc = spawn(resolveBun(), [paiPath('TOOLS', 'WorkReconcile.ts')], { detached: true, stdio: 'ignore', }); + proc.on('error', (err) => console.error('[WorkReconcile spawn]', err instanceof Error ? err.message : String(err))); proc.unref(); } } catch { /* healing is best-effort; never break the logger */ }