Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion LifeOS/install/hooks/EventLogger.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 */ }
Expand Down
10 changes: 10 additions & 0 deletions LifeOS/install/hooks/ISAFoldGate.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
Expand Down
20 changes: 17 additions & 3 deletions LifeOS/install/hooks/ModelRungGuard.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -110,24 +110,38 @@ 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<string, unknown>;
if (row.isSidechain === true) continue; // a subagent's carrier, not ours
const msg = row.message as Record<string, unknown> | undefined;
if (msg && typeof msg.model === "string" && msg.model.trim()) return msg.model;
} catch { /* partial line at the tail boundary */ }
}
return null;
} catch {
return null;
} finally {
if (fd !== null) { try { closeSync(fd); } catch { /* already gone */ } }
}
}

Expand Down
20 changes: 17 additions & 3 deletions LifeOS/install/hooks/VerificationGate.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(/(?<!\d)[.!?;,\n]+|[.!?;,\n]+(?!\d)/)
.split(/\n+|(?<!\d)[.!?;,]+|[.!?;,]+(?!\d)/)
.map((u) => (u ?? "").trim())
.filter(Boolean);
}
Expand Down Expand Up @@ -215,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
Expand Down
12 changes: 10 additions & 2 deletions LifeOS/install/hooks/lib/transcript-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 ?? "");
Expand All @@ -181,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") {
Expand Down