Skip to content
Open
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
24 changes: 21 additions & 3 deletions LifeOS/install/hooks/ISASync.hook.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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';
Expand All @@ -40,6 +49,7 @@ import {
syncToWorkJson,
readRegistry,
bumpLastToolActivityBySlug,
refreshBodyHashBySlug,
ARTIFACT_FILENAME,
LEGACY_ARTIFACT_FILENAME,
} from './lib/isa-utils';
Expand Down Expand Up @@ -72,10 +82,18 @@ async function main(): Promise<string | null> {
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;
}

Expand Down
4 changes: 4 additions & 0 deletions LifeOS/install/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@
{
"type": "command",
"command": "$HOME/.claude/hooks/ISAStaleWriteGuard.hook.ts"
},
{
"type": "command",
"command": "$HOME/.claude/hooks/ISASync.hook.ts"
}
]
},
Expand Down
31 changes: 30 additions & 1 deletion LifeOS/install/hooks/lib/isa-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down