diff --git a/LifeOS/install/hooks/ISASync.hook.ts b/LifeOS/install/hooks/ISASync.hook.ts index 79bab2f668..e66af10dce 100755 --- a/LifeOS/install/hooks/ISASync.hook.ts +++ b/LifeOS/install/hooks/ISASync.hook.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun /** - * @version 1.5.0 + * @version 1.5.1 * ISASync.hook.ts — ISA → work.json sync via PostToolUse * * TRIGGER: PostToolUse (Write, Edit, MultiEdit, Read) @@ -29,6 +29,15 @@ * because the strip was the last derived-state line the model self-computed. * No ISA write → no block → no strip: an unregistered run can no longer * claim a state the board doesn't show. + * + * v1.5.1 (Read refreshes the body hash): the Read branch now calls + * refreshBodyHashBySlug() before the heartbeat bump, so the registry's + * bodyHash matches the file the model just read. Read still never writes the + * ISA — only the registry's observation of it. Writes that bypass + * Write/Edit/MultiEdit (Bash heredocs, subagents, git, this hook's own + * Decisions append) used to leave that hash stale, and the next edit — even a + * frontmatter-only one, which hashBody() excludes — read as a body change and + * rewound a complete ISA to `learn`. */ import { readFileSync, existsSync } from 'fs'; @@ -40,6 +49,7 @@ import { syncToWorkJson, readRegistry, bumpLastToolActivityBySlug, + refreshBodyHashBySlug, ARTIFACT_FILENAME, LEGACY_ARTIFACT_FILENAME, } from './lib/isa-utils'; @@ -72,10 +82,18 @@ async function main(): Promise { const isWorkISA = filePath.includes('MEMORY/WORK/'); // v6.9.0: Read trigger — bump heartbeat on the slug, rebind UUID, debounced. - // No file write-back, no rewind. Read alone never mutates the ISA. + // No file write-back, no rewind. Read alone never mutates the ISA. It does + // refresh the registry's bodyHash from disk, so the Edit that follows a Read + // compares against the body as it was just observed, not against whatever + // Write/Edit last saw — Bash heredocs, subagents, git and hook appends all + // write ISAs without firing this hook, and a stale hash turned a + // frontmatter-only edit on a complete ISA into a false rewind (2026-09-14). if (toolName === 'Read') { const slugMatch = filePath.match(/MEMORY\/WORK\/([^/]+)\//); - if (slugMatch) bumpLastToolActivityBySlug(slugMatch[1], input.session_id); + if (slugMatch) { + refreshBodyHashBySlug(slugMatch[1], filePath); + bumpLastToolActivityBySlug(slugMatch[1], input.session_id); + } return null; } diff --git a/LifeOS/install/hooks/hooks.json b/LifeOS/install/hooks/hooks.json index e9fc09f189..1857da7075 100644 --- a/LifeOS/install/hooks/hooks.json +++ b/LifeOS/install/hooks/hooks.json @@ -116,6 +116,10 @@ { "type": "command", "command": "$HOME/.claude/hooks/ISAStaleWriteGuard.hook.ts" + }, + { + "type": "command", + "command": "$HOME/.claude/hooks/ISASync.hook.ts" } ] }, diff --git a/LifeOS/install/hooks/lib/isa-utils.ts b/LifeOS/install/hooks/lib/isa-utils.ts index b13f73039a..5034d3b186 100755 --- a/LifeOS/install/hooks/lib/isa-utils.ts +++ b/LifeOS/install/hooks/lib/isa-utils.ts @@ -17,7 +17,7 @@ // module reads ISA.md first and falls back to PRD.md for sessions created // before the rename. New sessions always write ISA.md. -import { writeFileSync, readdirSync, statSync, existsSync, mkdirSync, appendFileSync } from 'fs'; +import { writeFileSync, readFileSync, readdirSync, statSync, existsSync, mkdirSync, appendFileSync } from 'fs'; import { join, basename } from 'path'; import { createHash } from 'crypto'; import { paiPath } from './paths'; @@ -1077,6 +1077,35 @@ export function bumpLastToolActivity(filePath: string): boolean { return bumpLastToolActivityBySlug(slug); } +/** + * Refresh the registry's view of an ISA body from disk without touching the ISA + * or its phase. Called on Read: the Edit tool requires a Read first, so after + * this the next Edit's `bodyChanged` measures only that edit's own delta. + * Without it, anything that wrote the ISA outside Write/Edit (a Bash heredoc, + * a subagent, git, a hook's own append) left `bodyHash` stale, and the next + * frontmatter-only edit on a complete ISA read as a body change and rewound it + * to `learn` (three false rewinds on one ISA, 2026-09-14). Never debounced, + * never gated on phase — a stale hash is wrong at any age. + */ +export function refreshBodyHashBySlug(slug: string, isaPath: string): boolean { + if (!slug || !isaPath) return false; + try { + if (!existsSync(isaPath)) return false; + const registry = readRegistry(); + const session = registry.sessions[slug]; + if (!session) return false; + const content = readFileSync(isaPath, 'utf-8'); + const bodyHash = hashBody(content); + if (session.bodyHash === bodyHash && session.lastBodySize === content.length) return false; + session.bodyHash = bodyHash; + session.lastBodySize = content.length; + writeRegistry(registry, 'refreshBodyHashBySlug'); + return true; + } catch { + return false; + } +} + /** * v6.9.0: Bump `lastToolActivity` by slug (not by sessionUUID). Used by the * Read-trigger path in ISASync — a fresh session UUID reading an ISA still