From e53f407c499c053d5af7a3a6e5dffbcd50389ae9 Mon Sep 17 00:00:00 2001 From: Gary Date: Mon, 14 Sep 2026 13:38:06 -0700 Subject: [PATCH] fix(hooks): ISASync refreshes the ISA body hash on Read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finished ISAs (`phase: complete`) were rewound to `learn` by edits that changed only frontmatter. syncToWorkJson() rewinds when the registry's `bodyHash` differs from the file's body, but that hash is written only by the same sync, which fires on Write/Edit/MultiEdit. Any write that bypasses those tools — a Bash heredoc, a subagent, git, or the hook's own appended Decisions row — leaves the hash stale, so the next edit of any kind reads as a body change, including a frontmatter-only one that hashBody() excludes. ISASync's Read branch now calls the new refreshBodyHashBySlug(), so the registry's view of the body matches the file the model just read and the following Edit measures only its own delta. Read still never writes the ISA, only the registry's observation of it, keeping the v6.9.0 contract. ISASync is now registered under the PostToolUse Read matcher, which its own header already documented as a trigger. Co-Authored-By: Claude Fable 5.1 --- LifeOS/install/hooks/ISASync.hook.ts | 24 ++++++++++++++++++--- LifeOS/install/hooks/hooks.json | 4 ++++ LifeOS/install/hooks/lib/isa-utils.ts | 31 ++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 4 deletions(-) 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