Skip to content

fix(hooks): relay Bash-written files to the Write-shaped PostToolUse hooks (#2079) - #2082

Open
Bowlerjim wants to merge 1 commit into
danielmiessler:mainfrom
Bowlerjim:fix/2079-bash-write-relay
Open

Bowlerjim wants to merge 1 commit into
danielmiessler:mainfrom
Bowlerjim:fix/2079-bash-write-relay

Conversation

@Bowlerjim

Copy link
Copy Markdown

Fixes #2079.

The problem

permissions.defaultMode: "auto" tells the model to prefer Bash for file writes, and ISASync, CheckpointPerISC, ConfigEvalFire and SystemChangeSurface are registered on Write/Edit/MultiEdit only. A file written by heredoc or a script therefore fires none of them: an ISA never reaches work.json, the per-claim commit never runs, the eval suite never fires, and the SYSTEM disclosure line stays silent. Reproduced on a live install exactly as the issue describes.

The change

One new hook and one registration line; the four existing hooks are untouched.

hooks/BashWriteRelay.hook.ts (PostToolUse, matcher Bash):

  • finds files changed under a bounded set of watched roots (doctrine, prompt, ALGORITHM, RULES, TOOLS, hooks, skills, settings, USER identity files, MEMORY/WORK ISAs, plus ISA paths already in work.json) since the session's last Bash call
  • mtime narrows the candidates; a content hash against a shared per-path claims ledger decides, so byte-identical rewrites and sibling sessions' writes are never attributed (first claimer wins, exactly once)
  • replays each changed file through the four hooks with a synthesized Write payload carrying file_path, and returns their additionalContext concatenated
  • never parses the command; the first Bash call of a session only records a baseline; change sets over 40 files are logged and skipped; every failure path exits 0 with no output

Registered in hooks/hooks.json on the existing Bash matcher. Documented in HookSystem.md.

Verification

  • Live install: a Bash-only edit to a registered ISA moved its work.json progress on the same call and produced the SYSTEM line, with no Write or Edit involved.
  • A no-change scan costs 0.02 s wall.
  • Four concurrent sessions ran the claims ledger without double attribution.
  • bun build --no-bundle --target=bun passes.

Known limit

A brand-new project ISA outside MEMORY/WORK written by Bash is invisible until it is first touched by Write or registered; MEMORY/WORK ISAs and already-registered paths are covered.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KdVkxUPGxFm91bsNDxyi5M

…hooks (danielmiessler#2079)

defaultMode "auto" steers file writes to Bash, and ISASync, CheckpointPerISC,
ConfigEvalFire and SystemChangeSurface are registered on Write/Edit/MultiEdit
only, so an ISA written by heredoc never reaches work.json, the per-claim
commit never fires, the eval suite never runs and the SYSTEM disclosure line
stays silent.

BashWriteRelay.hook.ts (PostToolUse, matcher Bash) finds files changed under a
bounded set of watched roots since its last run for the session and replays the
four hooks with a synthesized Write payload carrying file_path. mtime narrows
the candidates; a content hash against a shared per-path claims ledger decides,
so byte-identical rewrites and sibling sessions' writes are never attributed
(first claimer wins, exactly once). Never parses the command. The first Bash
call of a session only records a baseline; change sets over 40 files are
logged and skipped; every failure path exits 0 with no output.

Registered in hooks.json on the existing Bash matcher; documented in
HookSystem.md.

Verified on a live install: a Bash-only edit to a registered ISA moved its
work.json progress and produced the SYSTEM line on the same call; a no-change
scan costs 0.02 s wall; four concurrent sessions ran the claims ledger without
double attribution.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KdVkxUPGxFm91bsNDxyi5M
@corobis

corobis commented Sep 21, 2026

Copy link
Copy Markdown

Thanks for this; #2079 bites here too. I ran the 1.1.0 hook from this PR in a throwaway HOME with a stub replay hook, and one case gets through.

claimChanged() treats the first sighting of a path as a baseline and reports nothing. But a path only reaches the claims ledger after its mtime moves, and nothing seeds hashes ahead of time. So the first sighting is the first Bash edit, and it gets swallowed. A file created through Bash is the same: never reported on creation.

Repro, one session, five Bash calls, hooks/existing.ts on disk before the first:

  1. Call 1: baseline only, as designed.
  2. Edit existing.ts via Bash, call 2: no output. Log says no content change this session could claim.
  3. Edit it again, call 3: relayed.
  4. Create hooks/created.ts via Bash, call 4: no output, same log line.
  5. Edit it again, call 5: relayed.

For an ISA written once by heredoc, which is the #2079 case, that one write is the one that's lost. The live check in the PR description passed, I'd guess, because that ISA was already in the ledger from an earlier run.

One way out: on the baseline call, hash every file under the watched roots into the ledger. Then a later unknown path can only be a new file, and it can be claimed and relayed. The over-reporting fix stays intact, since pre-existing state is recorded before anyone edits it. Cost is one full hash pass per session; the ledger is shared, so later sessions mostly hit known rows.

Small thing: the HookSystem.md text in this PR still describes the mtime-only sibling-session limit that 1.1.0 replaced with the claims ledger.

Environment: Linux (WSL2), Bun, Claude Code 2.1.278, LifeOS 7.40.4. Hook run standalone, not registered on a live install.

@cristbc

cristbc commented Sep 21, 2026

Copy link
Copy Markdown

I ran this PR against the case #2079 started with, and it still drops it. A brand-new ISA written by heredoc has no row in claims.json, so it hits the if (!prior) branch, gets baselined, and never gets replayed. It never shows up in work.json.

Clean state file, no prior claim row, BashWriteRelay.hook.ts 1.1.0:

# first Bash call of the session (baseline only, as designed)
work.json BEFORE: False
# heredoc creates MEMORY/WORK/<slug>/ISA.md
# second Bash call, relay runs
work.json AFTER:  False
{"session":"…","candidates":1,"replayed":[],"reason":"no content change this session could claim"}

So detection works (candidates: 1) and the baseline branch eats it.

I think the hash and the shared ledger are right. The problem is that "no prior row" covers two different things: a file that was already there before the ledger saw it (baseline it, fine), and a file that was created during this scan window (that's a change, and it's the whole point of the issue).

Every candidate already passed mtimeMs > sinceMs, so birthtime tells them apart for free:

if (!prior) {
  let bornInWindow = false;
  try { bornInWindow = statSync(p).birthtimeMs > sinceMs; } catch { /* keep false */ }
  claims[p] = { hash: h, at: Date.now(), session: sessionId };
  if (bornInWindow) mine.push(p);
  continue;
}

claimChanged needs sinceMs passed in from main (it's lastScanMs there). Same probe after:

work.json BEFORE: False
work.json AFTER:  True build 0/1
{"session":"…","replayed":["…/ISA.md"],"contexts":1}

Pre-existing files still don't get pinned on whoever runs first. If a filesystem doesn't record birthtime, the catch leaves it false and you get today's behavior, not extra noise.

Two smaller things from my install, which has drifted from the shipped tree:

  • registeredIsaPaths() only handles ~ and absolute paths. If work.json stores ISA paths relative to the LIFEOS dir, every registry row gets dropped. The watched-roots walk still finds ISAs in the default spot, so you don't notice.
  • It'd help to say in the header which hooks are safe to replay. A guard that blocks a write before it lands can't do anything after the fact except complain about a write that already happened. I keep ISAStaleWriteGuard and KnowledgeWriteGuard out of the replay set for that reason. The four in this PR are the right four.

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.

Shipped defaultMode: "auto" steers writes to Bash, silencing four PostToolUse hooks

3 participants