diff --git a/LifeOS/install/LIFEOS/TOOLS/ConfigEvalOnChange.ts b/LifeOS/install/LIFEOS/TOOLS/ConfigEvalOnChange.ts index 88aa648074..071a8bd9fe 100644 --- a/LifeOS/install/LIFEOS/TOOLS/ConfigEvalOnChange.ts +++ b/LifeOS/install/LIFEOS/TOOLS/ConfigEvalOnChange.ts @@ -14,7 +14,7 @@ * change together. Regression (suite below threshold) POSTs a voice/Pulse notice. */ -import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync, appendFileSync } from 'node:fs'; +import { mkdirSync, writeFileSync, rmSync, readFileSync, appendFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; import { runSuite } from '../../skills/Evals/Tools/EvalRunner.ts'; @@ -61,17 +61,30 @@ async function main(): Promise { const trigger = process.argv[2] ?? 'unknown'; mkdirSync(RESULTS_DIR, { recursive: true }); - // Single-flight: stale lock older than 10 min is ignored. - if (existsSync(LOCK)) { + // Single-flight: stale lock older than 10 min is reclaimed. The 'wx' flag is + // load-bearing — an existsSync-then-write let two sentinel files edited in the + // same instant both pass the check, both run the suite, and both notify. + try { + writeFileSync(LOCK, new Date().toISOString(), { flag: 'wx' }); + } catch { + let stale = true; try { const age = Date.now() - Date.parse(readFileSync(LOCK, 'utf8').trim() || ''); - if (Number.isFinite(age) && age < 10 * 60_000) { - log({ event: 'skip-locked', trigger }); - return; - } - } catch { /* fall through and re-acquire */ } + stale = !Number.isFinite(age) || age >= 10 * 60_000; + } catch { /* unreadable lock — treat as stale and reclaim */ } + if (!stale) { + log({ event: 'skip-locked', trigger }); + return; + } + rmSync(LOCK, { force: true }); + try { + writeFileSync(LOCK, new Date().toISOString(), { flag: 'wx' }); + } catch { + // Another run reclaimed the stale lock first; it owns this fire. + log({ event: 'skip-locked', trigger }); + return; + } } - writeFileSync(LOCK, new Date().toISOString()); try { const result = await runSuite(SUITE); @@ -84,7 +97,14 @@ async function main(): Promise { ); } } catch (e) { - log({ event: 'error', trigger, error: (e as Error)?.message ?? String(e) }); + // Notify, don't just log. A suite that cannot RUN is the same operational + // fact as a suite that fails: the config change went unverified. Logging to + // a JSONL nobody tails made a permanently-broken suite — the shipped one is + // still legacy v1 `tasks:`, which the runner cannot execute — look exactly + // like a clean pass on every config edit. + const msg = (e as Error)?.message ?? String(e); + log({ event: 'error', trigger, error: msg }); + await notify(`Behavioural eval could not run after editing ${trigger}: ${SUITE} errored (${msg}). The change is unverified.`); } finally { try { rmSync(LOCK, { force: true }); } catch { /* best-effort */ } } diff --git a/LifeOS/install/LIFEOS/TOOLS/CreateUpdate.ts b/LifeOS/install/LIFEOS/TOOLS/CreateUpdate.ts index 666f0e8bb0..b9b1617018 100755 --- a/LifeOS/install/LIFEOS/TOOLS/CreateUpdate.ts +++ b/LifeOS/install/LIFEOS/TOOLS/CreateUpdate.ts @@ -675,4 +675,9 @@ async function main() { console.log(`Change Type: ${changeType}`); } -main().catch(console.error); +// Exit non-zero on failure: IntegrityMaintenance.ts records a ledger entry on +// `code === 0`, so swallowing the error into a 0 booked work that never happened. +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/LifeOS/install/LIFEOS/TOOLS/ISARender.ts b/LifeOS/install/LIFEOS/TOOLS/ISARender.ts index 3895262570..5ea05b2509 100644 --- a/LifeOS/install/LIFEOS/TOOLS/ISARender.ts +++ b/LifeOS/install/LIFEOS/TOOLS/ISARender.ts @@ -19,7 +19,7 @@ import { readFileSync, writeFileSync, existsSync, statSync, renameSync, readdirS import { resolve, dirname, basename, join } from "node:path"; import { spawn } from "node:child_process"; import { homedir } from "node:os"; -import { ASCENT, ASCENT_BRACKETS, PHASE_TO_ASCENT } from "./ascent"; +import { ASCENT, ASCENT_BRACKETS, PHASE_TO_ASCENT, type AscentState } from "./ascent"; const HOME = process.env.HOME || homedir(); const TOOLS_DIR = resolve(HOME, ".claude/LIFEOS/TOOLS"); @@ -38,8 +38,25 @@ const WORK_JSON = resolve(HOME, ".claude/LIFEOS/MEMORY/STATE/work.json"); // and the Pulse board use. Never define a private stage vocabulary here: a second list is // how a mirror ends up disagreeing with the tab generated beside it. const STAGES = ASCENT_BRACKETS; + +// ASCENT_BRACKETS is a three-slot subset of the six run states, so a phase that +// maps to an off-bracket state has no slot of its own: `verify` → `anchoring`, +// `native` → `traverse`, `idle` → `idle`. Letting indexOf's -1 clamp to 0 sent +// all three to "Marking", so a `phase: verify` ISA rendered Marking in the bar +// while renderHeroBadges on the same page said ANCHORING. Fold to the last +// bracket at or before the state in the table's arc order instead. +function bracketIndex(state: AscentState): number { + const exact = ASCENT_BRACKETS.indexOf(state); + if (exact >= 0) return exact; + let idx = 0; + for (let i = 0; i < ASCENT_BRACKETS.length; i++) { + if (ASCENT[ASCENT_BRACKETS[i]].order <= ASCENT[state].order) idx = i; + } + return idx; +} + const STAGE_MAP: Record = Object.fromEntries( - Object.entries(PHASE_TO_ASCENT).map(([phase, state]) => [phase, Math.max(0, ASCENT_BRACKETS.indexOf(state))]), + Object.entries(PHASE_TO_ASCENT).map(([phase, state]) => [phase, bracketIndex(state)]), ); // ─────────── BRAND LOGO LOADER ─────────── diff --git a/LifeOS/install/LIFEOS/TOOLS/IntegrityCheck.ts b/LifeOS/install/LIFEOS/TOOLS/IntegrityCheck.ts index fa7cceeb04..a327068427 100755 --- a/LifeOS/install/LIFEOS/TOOLS/IntegrityCheck.ts +++ b/LifeOS/install/LIFEOS/TOOLS/IntegrityCheck.ts @@ -859,6 +859,15 @@ function checkRuleDuplication(): void { function checkReplayCorpus(): void { const findings: Finding[] = []; let note: string | undefined; + // Same reason as checkRetirementRegistry: the replay fixtures live in the + // private source tree and are stripped from every public release. Without this + // guard `bun test` exited non-zero with no (fail) lines on every public + // install, recording a BLOCKING finding for a directory that is not supposed to + // exist there — a permanently red /ic on a clean install. + if (!existsSync(join(CLAUDE_DIR, 'test', 'regression'))) { + record('replay-corpus', [], 'skipped — replay corpus not installed'); + return; + } try { const out = execFileSync('bun', ['test', join(CLAUDE_DIR, 'test', 'regression')], { encoding: 'utf8', cwd: CLAUDE_DIR, stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, diff --git a/LifeOS/install/LIFEOS/TOOLS/RecurrenceLedger.ts b/LifeOS/install/LIFEOS/TOOLS/RecurrenceLedger.ts index ba4d3d3493..504449b285 100644 --- a/LifeOS/install/LIFEOS/TOOLS/RecurrenceLedger.ts +++ b/LifeOS/install/LIFEOS/TOOLS/RecurrenceLedger.ts @@ -144,7 +144,13 @@ const STREAMS: StreamDef[] = [ }, { file: "writing-gate.jsonl", - toEvent: r => r.decision === "block" + // The hook's `decision: "block"` is its RESPONSE to the harness; the row it + // appends carries the telemetry vocabulary (hooks/WritingGate.hook.ts writes + // block-strong-no-run / pass-run-verified / telemetry-weak / no-content / + // skip-recovery / telemetry-no-detector). Matching the literal "block" + // dropped every writing-gate block from this ledger. Prefix-match so new + // block-* reasons land here without a third place to edit. + toEvent: r => String(r.decision ?? "").startsWith("block") ? { classId: "wgate:block", ts: r.ts, source: "writing-gate", detail: `strong=${r.strong} weak=${r.weak}`, sessionId: r.session_id } : null, }, diff --git a/LifeOS/install/LIFEOS/TOOLS/Reflect.ts b/LifeOS/install/LIFEOS/TOOLS/Reflect.ts index d0eb3f313e..fa701d8df6 100644 --- a/LifeOS/install/LIFEOS/TOOLS/Reflect.ts +++ b/LifeOS/install/LIFEOS/TOOLS/Reflect.ts @@ -41,8 +41,8 @@ * written. A malformed record is never appended — that is the corpus gate. */ -import { existsSync, appendFileSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, appendFileSync, mkdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; const LIFEOS = process.env.LIFEOS_DIR ?? join(process.env.HOME ?? "~", ".claude", "LIFEOS"); const REFLECTIONS = join(LIFEOS, "MEMORY", "LEARNING", "REFLECTIONS", "algorithm-reflections.jsonl"); @@ -139,7 +139,10 @@ export function validate(r: Partial): string[] { for (const f of ["ts", "session_id", "slug"] as const) { if (typeof r[f] !== "string" || !r[f]) errs.push(`${f} must be a non-empty string`); } - if (typeof r.iteration !== "number" || r.iteration < 0) errs.push("iteration must be a non-negative number"); + // Number.isFinite, not just typeof: `Number("x")` is NaN, which is typeof + // "number" and fails every comparison, so `NaN < 0` waved it through — and + // JSON.stringify then wrote it as `null` into the corpus this gate protects. + if (typeof r.iteration !== "number" || !Number.isFinite(r.iteration) || r.iteration < 0) errs.push("iteration must be a non-negative number"); for (const f of ["claims_closed", "evidence_classes", "deploys"] as const) { if (!Array.isArray(r[f])) errs.push(`${f} must be an array`); else if ((r[f] as unknown[]).some((x) => typeof x !== "string")) errs.push(`${f} must contain only strings`); @@ -221,6 +224,7 @@ if (import.meta.main) { console.log(line); process.exit(0); } + mkdirSync(dirname(REFLECTIONS), { recursive: true }); appendFileSync(REFLECTIONS, line + "\n", "utf8"); const wb = record.within_budget === null ? "null (unaudited)" : String(record.within_budget); console.log(`✅ reflection appended · within_budget=${wb} · verdict=${spend.verdict ?? "none"} · dispatches=${spend.dispatches}`);