Skip to content

fix(hooks): ISASync refreshes the ISA body hash on Read - #2130

Open
pai-scaffolde wants to merge 1 commit into
danielmiessler:mainfrom
pai-scaffolde:fix/z-isasync-refresh-body-hash-on-read
Open

pai-scaffolde wants to merge 1 commit into
danielmiessler:mainfrom
pai-scaffolde:fix/z-isasync-refresh-body-hash-on-read

Conversation

@pai-scaffolde

Copy link
Copy Markdown

Reproduced on a fresh LifeOS 7.40.4 install (macOS, Claude Code in the Claude desktop app); the fix was applied to that install and run there before filing. Related: #2079 / open PR #2082 describe the same missing observation from the Bash side; this change is independent (different files) and closes the rewind-specific consequence even where a Bash write is never relayed.

Observed

Finished ISAs (phase: complete) rewound themselves to learn after edits that changed only frontmatter. LIFEOS/MEMORY/OBSERVABILITY/isa-rework.jsonl on the install, 2026-09-14 (fields trimmed):

{"slug":"…port-bpe-audit…","prev_phase":"complete","new_phase":"learn","prev_iteration":1,"new_iteration":2,"trigger_kind":"edit","body_delta_bytes":0,"had_legacy_bodyhash":false}
{"slug":"…install-solid-before-pr","prev_phase":"complete","new_phase":"learn","prev_iteration":2,"new_iteration":3,"trigger_kind":"edit","body_delta_bytes":3,"had_legacy_bodyhash":false}
{"slug":"…install-solid-before-pr","prev_phase":"complete","new_phase":"learn","prev_iteration":3,"new_iteration":4,"trigger_kind":"edit","body_delta_bytes":6820,"had_legacy_bodyhash":false}

body_delta_bytes: 0 is the tell: the file did not change length at all and the auto-rewind still fired, bumped iteration, wrote resumed_at / resumed_from_phase back into the frontmatter and appended a D-auto-… Decisions row to a finished artifact. had_legacy_bodyhash: false rules out the "no hash yet" path — a hash was present and simply did not match.

Root cause

syncToWorkJson() in hooks/lib/isa-utils.ts gates the v6.9.0 Resume After Complete rewind on

const bodyChanged = !existing.bodyHash || existing.bodyHash !== incomingBodyHash;
const shouldResume = completeInRegistry && completeInFrontmatter && bodyChanged && !isFrozen && !!content;

existing.bodyHash is only ever written by that same sync, and the sync only runs from ISASync.hook.ts under the Write, Edit and MultiEdit PostToolUse matchers. Anything that writes an ISA outside those tools — a Bash heredoc, a subagent, a git checkout, or the rewind's own appended Decisions row — leaves the registry's hash describing a body that is no longer on disk.

From then on the hash is stale for good, so the next edit of any kind compares a fresh body hash against a stale one and reads as a body change. That includes a frontmatter-only edit, which hashBody() deliberately excludes from the hash: it strips the frontmatter block before hashing, so bumping an updated: stamp cannot change the body hash and still rewound the ISA, because the stored hash was wrong to begin with.

Fix

Three parts, no change to the rewind rule itself:

  1. hooks/lib/isa-utils.ts gains refreshBodyHashBySlug(slug, isaPath) — reads the ISA from disk, rehashes the body with the same hashBody(), and updates bodyHash / lastBodySize in the registry. It writes nothing if the hash already matches, and it never touches the ISA file or its phase.
  2. hooks/ISASync.hook.ts calls it from the existing Read branch, before the heartbeat bump. The Edit tool requires a Read first, so after a Read the following Edit's bodyChanged measures only that edit's own delta.
  3. hooks/hooks.json registers ISASync.hook.ts under the existing PostToolUse Read matcher, alongside ISAStaleWriteGuard.hook.ts. The hook's header already documented Read as a trigger and the branch already existed; only the registration was missing.

Read never mutates the ISA file. The v6.9.0 contract ("Read NEVER writes back to the file — only Write/Edit/MultiEdit do") is kept exactly. What Read now updates is the registry's observation of the body, which is bookkeeping about the file, not the file.

How tested

Probe in a throwaway tree, HOME and LIFEOS_DIR pointed at a temp dir. Fixture: an ISA with phase: complete plus a work.json row for its slug carrying a deliberately stale bodyHash (all-zeroes), which is what an unobserved write leaves behind. The hook is fed PostToolUse JSON on stdin:

printf '{"session_id":"s1","tool_name":"Read","tool_input":{"file_path":"<abs ISA path>"}}' \
  | HOME=$TMP LIFEOS_DIR=$TMP/.claude/LIFEOS bun hooks/ISASync.hook.ts

Three cases, run against upstream/main's files and against this branch's files:

case sequence upstream/main this branch
A frontmatter-only edit, no prior Read → Edit phase=learn phase=learn
B Read → frontmatter-only edit → Edit phase=learn phase=complete
C Read → real body append → Edit phase=learn phase=learn

B is the defect and is fixed. C shows the rewind contract still holds for a genuine body change. A is unchanged by design: nothing has observed the body, so nothing can tell a real change from a stale hash — in the harness an Edit is always preceded by a Read, which is case B.

Case B's observability log agrees: upstream/main emits prev=complete new=learn trigger=edit body_delta_bytes=171; this branch emits no row at all.

Read-only invariant, same probe:

ISA.md sha256 before Read: 7fb4080401970453a778bee2627b050889a189bfb856e53e8223c185c3514941
ISA.md sha256 after  Read: 7fb4080401970453a778bee2627b050889a189bfb856e53e8223c185c3514941
registry bodyHash after Read: 8a78585b45819299d68a6de2fa454ca2cf6affc06117dee0f67454432948dc04

The file is byte-identical; only the registry moved. git diff shows no writeFileSync on the Read path.

Both changed files transpile: bun build --target=bun --no-bundle hooks/ISASync.hook.ts and … hooks/lib/isa-utils.ts, exit 0. hooks.json parses.

Against open PR #2082, which also edits hooks.json (it appends BashWriteRelay.hook.ts to the Bash matcher at a different block): git apply --check succeeds in both orders — #2082 on top of this branch, and this branch on top of #2082.

Repro on a clean tree: check out upstream/main, create a temp HOME with LIFEOS/MEMORY/WORK/<slug>/ISA.md at phase: complete and LIFEOS/MEMORY/STATE/work.json holding that slug with any bodyHash that does not match the file. Fire the Read event above, then change only the updated: line, then fire the same JSON with "tool_name":"Edit". The ISA comes back as phase: learn with iteration bumped and a D-auto-… row appended. Apply this patch and repeat: it stays complete.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants