Roasbeef
## Summary `substrate watch` locates its advisory lock through `os.UserHomeDir()` and nothing else, so the whole watcher-persistence handshake assumes one `$HOME` shared by every participant. A harness that deliberately runs imported hooks and jailed tools under different `HOME`s breaks that assumption: the arming side and the checking side write and read *different lock directories*, so `watch --check` can never observe a watcher that was armed. This was found running Loom against an unmodified `~/.claude/settings.json` (cross-reference: Loom #364). ## The mechanism `watchLockDir()` (`cmd/substrate/commands/watch.go:120`) resolves the lease directory as: ```go home, err := os.UserHomeDir() dir := filepath.Join(home, ".subtrate", "watch") ``` and `watchLockPath` puts the lease at `agent-<id>.lock` beneath it. `watcherArmed` probes that same path with a non-blocking `flock`. There is **no override anywhere in the repo** — the only environment knobs are `SUBSTRATE_GRPC_ADDR` (`cmd/substrate/commands/client.go:31`) and `SUBSTRATE_CLI` (hook scripts). Under Loom: - the **imported hook** process runs with `HOME` = the operator's home, so `~/.claude/hooks/...` resolves when the shell expands `~`; - the **jailed tool** (the agent's `Bash`) runs with `HOME` = `<workspace>/.codemode/home`, so nothing a toolchain writes lands in the operator's checkout. The model follows the Stop hook's instruction and arms the watcher from its jailed Bash tool, so the lease lands under the jail home. The next `Stop` hook runs `watch --check` from the operator home, finds a different directory, and answers `not armed` — so it blocks with the arming instruction again, and the cycle repeats indefinitely. Measured directly, with disposable `HOME`s: arm a watcher under `HOME=A`; `watch --check` from `A` reports `armed`; from `B` it reports `not armed`. On the live host the split is visible as 320 leases under the operator home versus one or two under the jailed home, with no `subtrate.db` under the jailed home at all. **The hook cannot arm it either.** Loom runs the hook under the session jail, whose writable root is the workspace, so `~/.subtrate` is readable but not writable from the hook's own process: ``` failed to open lease: open $HOME/.subtrate/watch/agent-1053.lock: operation not permitted ``` There is therefore no single `HOME` from which both sides can agree. ## The stamp file has the same problem `cffb566` (*"harden watcher persistence after adversarial review"*) replaced the Stop hook's `stop_hook_active` gate with a per-cycle stamp, at: ```sh stamp_dir="$HOME/.subtrate/watch" nudge_stamp="$stamp_dir/nudged-${session_id:-default}" ``` `$HOME` is still the only escape hatch, so this gate fails the same way — and under a jail it fails *earlier*, because the hook cannot create the stamp file at all (`touch $HOME/.subtrate/watch/... → operation not permitted`). The stamp never persists, every stop reads it absent, and the arming nudge re-fires every cycle. Same cycle, different mechanism: the hardening does not fix this, so it should not be assumed to. Separately, that commit never reached the deployed scripts — the installed `~/.claude/hooks/substrate/stop.sh` is byte-identical to `beefstack`'s (formerly `claude-files`) copy, which predates it. That is a deploy-hygiene issue tracked on the consumer side rather than here. ## Why this is worth fixing rather than documenting The single-`HOME` assumption is invisible until a harness splits it, and when it does the failure mode is an unbounded loop rather than a clean error: the hook keeps asking for something the harness cannot let it observe. Both sides of the handshake also degrade differently (one cannot write, the other cannot see), so neither side can diagnose it alone. ## Possible directions - **A state-root override** (e.g. `SUBSTRATE_STATE_DIR`) honoured by `watchLockDir` and the hook scripts' stamp path, so a harness can point both participants at one directory. This is the smallest change that makes the handshake work under a split `HOME`, and it is what Loom #364 identifies as load-bearing. - **Split the two concerns.** The lease is a liveness probe between a *tool* and a *hook*; deriving it from `$HOME` conflates "where a user keeps state" with "where two processes rendezvous". If the lease instead lived under a path the session's owner designates, the `db`/state location could stay `$HOME`-relative without breaking the handshake. - **Fail loudly.** If the lease directory is not writable, `watch` currently surfaces `operation not permitted` but the *hook* still blocks with an instruction that cannot succeed. Having the arming path report unsatisfiable would at least bound the loop. ## What is not in question The lease design itself — flock-based, kernel-released on death, no stale-lock or PID-reuse bookkeeping — is sound, and the arm-once watcher pattern is a reasonable persistence model. The issue is only that the rendezvous point is derived from a variable the participants may not share. ## Cross-references - Loom #364 — the consuming-side report, with the full diagnosis and the harness's own `HOME` split.