Skip to content

fix(hooks): never pin a non-durable plugin root into global settings.json#438

Merged
SUaDtL merged 4 commits into
mainfrom
fix/statusline-no-ephemeral-pin
Jul 25, 2026
Merged

fix(hooks): never pin a non-durable plugin root into global settings.json#438
SUaDtL merged 4 commits into
mainfrom
fix/statusline-no-ephemeral-pin

Conversation

@SUaDtL

@SUaDtL SUaDtL commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

The defect

heal_statusline_wiring() in core/pysrc/session-start.py runs on every SessionStart and refreshes a ca-OWNED statusLine pin to the current renderer path. The purpose is legitimate: a plugin cannot own a statusLine and ${CLAUDE_PLUGIN_ROOT} is not expanded inside settings.json, so an absolute, version-pinned path must be written — and nothing re-ran it after a plugin update, so an updated install kept invoking the old version's statusline.py until that cache dir was pruned.

What it had no notion of is a non-durable root. A session started inside a git worktree (subagents run in <repo>/.claude/worktrees/<id>/) resolves the plugin root to that worktree, and the heal rewrote the user's global ~/.claude/settings.json to point there. The worktree is then pruned — its entire purpose — and the statusline renders nothing.

Found in-session on 2026-07-25, after it broke the maintainer's statusline three times in one day:

...\.claude\worktrees\wf_58ee3fa6-de1-8\plugins\ca\hooks\statusline.py   (pruned -> broken)
...\.claude\worktrees\wf_b7160646-041-2\plugins\ca\hooks\statusline.py   (recurred)
...\.claude\worktrees\wf_04e3c2f6-f27-1\plugins\ca\hooks\statusline.py   (recurred again)

Not agent-specific: any user running codeArbiter from a worktree hits it. There is no issue number — this was found and fixed in the same session.

How a healthy pin degraded into a doomed one

Worth spelling out, because the entry point is not obvious. is_ours() recognises a legitimate cache pin via _owned_command(). So from a perfectly healthy …/plugins/cache/codearbiter/ca/<ver>/hooks/statusline.py, a worktree session took the heal branch, rewrote the command to the worktree path, and stamped _codearbiterStatuslineOwner with it. From that point the worktree pin is "ours" by owner-key match, so every later session kept re-pointing it at whatever worktree it happened to be in. The bug fed itself.

Required behaviour, and what this implements

When the current root is non-durable: leave the existing pin alone. Do not heal, do not clear, do not error — degrade silently, as the surrounding code already does.

The feature is not disabled. A genuinely stale pin from a real plugin-cache update still heals, because that root is durable. Both directions are proven at both layers (see Tests).

Clearing instead of skipping was considered and rejected: a user whose only session that day is a worktree session would lose a working statusline over a condition that resolves itself the next time they start from a real install.

Placement — argued

The task asked whether the guard belongs in the caller, the producer, or both. Both, sharing one predicate, and the reasoning differs per site:

Site Treatment Why there
wire-statusline.refresh_if_stale silent return False The mutation itself. heal_statusline_wiring calls this function directly and never goes through cmd_refresh, so a guard one level up (in cmd_refresh) would have left the confirmed corruption path wide open. Silence matches the automatic/degrade-silently contract of the SessionStart path.
wire-statusline.cmd_install loud SystemExit Explicit human action — someone ran /ca:statusline, or passed --plugin-root <worktree> by hand. A silent no-op here would leave that human believing the statusline was wired. This is the reachable-by-a-human path the task flagged.
session-start.heal_statusline_wiring early return False Deliberately redundant, and not decoratively so. _load_wire_statusline loads the producer out of plugin itself. A worktree cut from a pre-fix branch therefore supplies a pre-fix, unguarded producer — while session-start.py may have been loaded from somewhere else entirely, since main() honours $CLAUDE_PLUGIN_ROOT independently of where the file came from (exactly the split the pre-existing TestMainHealsBeforeDormantGate harness already exercises). The highest-consequence write on the machine gets to refuse on its own account rather than on the good behaviour of whatever version it happened to load.
cmd_status, cmd_uninstall never blocked Reporting changes nothing, and a user stuck with a dead worktree pin must be able to clear it from wherever they are.

Both call sites invoke the same is_ephemeral_path(), so there is one policy and nothing to drift.

Salvage: what was reused, what was rejected

A prior agent (killed mid-task by a session limit) left an uncommitted _durabilitylib.py plus test drafts. Its branch existed but carried zero commits — nothing to rebase onto. I verified rather than trusted; the judgement calls:

Kept. The two-signal design, the fail-durable direction, the "false positive costs one /ca:statusline run, false negative corrupts global config" asymmetry argument, the loud-install/silent-refresh split, and the observation that fixtures driving the real plugin root become checkout-dependent once the guard exists. All sound, all retained (largely rewritten in wording, same substance).

Rejected — the git rev-parse subprocess. The salvaged in_linked_git_worktree() shelled out to git rev-parse --git-dir --git-common-dir with a 2s timeout and an injectable runner. Its own module header conceded the probe "fails there every single time" for the ordinary plugin-cache install — which is not a git repo at all. But the lexical check short-circuits only on a positive, so the common, negative, everybody-every-session case fell straight through to the subprocess. That is a process spawn on every SessionStart, before the dormant gate, in every repo on the machine, to learn nothing. Plus a timeout hazard and a dependency on git being installed.

Git already writes the answer to disk: a linked worktree's .git is a file reading gitdir: <common>/.git/worktrees/<name>; a normal checkout's .git is a directory. Walking up to the first .git and reading a bounded prefix gives the identical answer from a handful of stat() calls, with no git dependency, no timeout, and no injected-runner seam in the tests. It is also more precise: a submodule also uses a .git file, but its target is modules/, not worktrees/ — so submodules stay durable, which the subprocess version got right only incidentally. test_module_does_not_import_subprocess pins the constraint so it cannot creep back.

Judged, per the task's prompt — is a whole new module over-architecture? I kept it, but only after stripping it to ~2 public predicates. The deciding factor is house style, not speculation: core/pysrc/ already carries small single-concept libs, _gitexec.py among them at 45 lines. "Is this path durable enough to pin?" is a topology question, not a statusline question, and folding it into the 1200-line _hooklib.py junk drawer or inlining it in wire-statusline.py would hide it from the next caller. (One is visible already and deliberately out of scope here: _githooks.install(root) installs git hooks pointing at the current plugin path, and linked worktrees share $GIT_COMMON_DIR/hooks — the same bug class, unreported, not touched by this PR.)

Tests — TDD, red first

Red was captured before any implementation existed, and reproduced the production corruption verbatim in the failure message:

FAIL: test_worktree_root_pin_never_appears_in_settings
AssertionError: 'worktrees' unexpectedly found in '{\n  "statusLine": {\n    "type": "command",
  "command": "\\"python\\" \\"C:\\\\...\\\\repo\\\\.claude\\\\worktrees\\\\wf_58ee3fa6-de1-8\\\\
  plugins\\\\ca\\\\hooks\\\\statusline.py\\"", ...

FAIL: test_refresh_if_stale_declines_an_ephemeral_script_without_mutating
AssertionError: True is not false
FAIL: test_worktree_root_leaves_settings_byte_identical
AssertionError: True is not false
...
Ran 18 tests in 0.150s
FAILED (failures=10)

plus ModuleNotFoundError: No module named '_durabilitylib' for the new unit suite. +39 tests, all three required directions pinned at both layers:

  1. A worktree-rooted heal leaves settings.json byte-identicaltest_worktree_root_leaves_settings_byte_identical (caller), test_refresh_from_worktree_root_leaves_settings_byte_identical (producer), plus test_worktree_root_pin_never_appears_in_settings asserting the literal string worktrees is absent from the file afterward.
  2. A stale-but-durable pin still healstest_durable_stale_pin_still_heals, test_refresh_from_durable_root_still_heals, test_install_from_durable_root_still_wires.
  3. A non-ca-owned statusLine is still never touched — re-proven under the new guard and on the durable path that does write.

Beyond the three: test_worktree_root_does_not_repair_an_already_worktree_pin (the maintainer's observed corrupt state, re-entered from another worktree — leave it, don't swap one doomed pin for another); test_durable_root_repairs_a_worktree_pin_left_by_the_bug (the forward fix — the corruption already on disk self-heals next time a session starts from a real install); test_caller_refuses_even_an_unguarded_producer (injects a pre-fix producer stub and asserts the heal bails before reaching it, which is the whole reason the caller guards); test_refresh_declines_a_linked_worktree_with_no_worktrees_segment (the git signal earning its place — a worktree the lexical rule cannot see); test_uninstall_from_worktree_root_is_never_blocked and test_status_from_worktree_root_still_reports_and_writes_nothing.

Every test passes an explicit temp --settings / settings_path. Nothing touches real home settings.

Fixture change worth reviewing

TestHealStatuslineWiring, TestMainHealsBeforeDormantGate, TestMainSkipsHealUnderNoStatuslineHost and HealReloadBeforeSaveTest drove the real plugin root. codeArbiter's own checkout is very often a worktree, so once the guard exists those tests would heal or decline depending on where the suite was run — green in CI, red on a maintainer's machine, for reasons unrelated to what they test. They now use _helpers.durable_plugin_copy(), which copies the top-level hooks/*.py into a temp dir, making "durable" a property of the fixture. (Placed in the existing shared _helpers.py rather than triplicated across three test modules as the salvage draft had it.)

Live-fire verification

Predicate evaluated against the maintainer's real paths, and the CLI driven end-to-end against a temp settings file:

THIS worktree renderer: ...\.claude\worktrees\wf_5892fde9-a3b-4\plugins\ca\hooks\statusline.py
  has_worktree_segment -> True     in_linked_worktree -> True     is_ephemeral_path -> True
MAIN checkout renderer: C:\Users\brenn\projects\codeArbiter\plugins\ca\hooks\statusline.py
  is_ephemeral_path    -> False
REAL plugin cache:      ~\.claude\plugins\cache\codearbiter\ca\2.8.11\hooks\statusline.py
  is_ephemeral_path    -> False
--- refresh from WORKTREE (the defect vector)
    rc=0  stdout=''
    settings byte-identical: True
    command now: "python" "C:\Users\me\.claude\plugins\cache\codearbiter\ca\2.0.1\hooks\statusline.py"

--- refresh from PLUGIN CACHE (durable, must still heal)
    rc=0  stdout='REFRESHED stale codeArbiter statusline path -> ...\ca\2.8.11\hooks\statusline.py'
    settings byte-identical: False

--- install from WORKTREE (explicit -> must refuse LOUDLY)
    rc=1
    REFUSING TO WIRE: ...\worktrees\wf_5892fde9-a3b-4\plugins\ca\hooks\statusline.py is inside a
    git worktree (or another root that will not survive being pruned).
    ~/.claude/settings.json is GLOBAL and holds an ABSOLUTE path, so pinning it here breaks your
    statusline the moment this checkout goes away.
    Re-run from your real install (the plugin cache, or your main checkout), or pass --plugin-root
    pointing at it.
    settings byte-identical: True

Both signals fire on the real defect vector; both the maintainer's main checkout and their actual 2.8.13-era cache install read durable, so the self-heal they depend on is intact.

Gates

Gate Result
python -m unittest discover -s plugins/ca/hooks/tests OK, 1096 tests (baseline before this change: 1057)
python tools/sync-core.py --check OK — 48 core files x 3 plugins, byte-identical
python tools/build-surface.py --check OK — claude, codex, pi in sync
python tools/build-host-packages.py --check --release-guard-base <merge-base> OK — Pi payload, version, changelog, and root metadata advanced together: 0.1.5 -> 0.1.6
python .github/scripts/check-plugin-refs.py ca Reference graph intact
python .github/scripts/check_docs_contract.py rc=0
git diff --check origin/main HEAD exit 0
Line endings LF on every changed file; 0 CRLF
Version bump ca unpublished 2.9.1 (no tag) and ca-codex unpublished 0.3.0 — not required. ca-pi bumped to 0.1.6 — see below.

One correction, caught by CI rather than by me

My first push failed [GATE ] | [PI ] | Payload version. The Pi guard is stricter than ca's: any change under plugins/ca-pi must ship a strictly advancing version plus regenerated root package.json plus a new exact ## [X.Y.Z] changelog heading. Vendoring _durabilitylib.py into plugins/ca-pi/hooks is such a change.

I had run that exact command locally and it printed no Pi payload change - version bump not required. The reason it lied: pi_release_guard diffs base...HEADcommits, not the working tree — and I ran it before committing, when HEAD was still the base. Worth knowing for anyone verifying this gate locally: run it after committing, or it proves nothing.

Fixed in f03d08d: ca-pi 0.1.3 -> 0.1.4, root metadata regenerated via python tools/build-host-packages.py, changelog entry added.

That number then had to move twice more. #430 landed on main taking 0.1.4, and #426 landed taking 0.1.5 — several ca-pi PRs are racing for the next patch. Each time I merged origin/main (never rebased), split the shared changelog heading so both entries survive, and moved this branch up: 0.1.4 -> 0.1.5 -> 0.1.6. No code ever conflicted — #430 is farm TypeScript, #426 is plugins/ca-pi/tools/**, this branch is python hooks — only the version line and its changelog heading. Final verification against the current merge base:

package.json matches plugins/ca-pi/package.json and the Pi descriptor
Pi payload, version, changelog, and root metadata advanced together: 0.1.5 -> 0.1.6
RC=0

CI status

34 checks pass, 0 fail; PR is CLEAN / MERGEABLE. One flake on the way: [CHECK] | [PI ] | Adapter contract <windows-latest · Pi 0.80.5> hit Error: Test timed out in 5000ms at plugins/ca-pi/tools/test/background-jobs.test.ts:513. That file is not in this diff — the branch contains zero TypeScript — and the same job passed on the preceding commit against an identical TS tree. It passed on re-run (6m18s; that matrix cell is the slowest in the run). Not investigated further as unrelated to this change.

Pre-existing, unrelated: .github/scripts has 4 failures in test_pi_benchmark.py, all RuntimeError: Pi benchmark requires the installed pinned esbuild — an environment prerequisite (no node_modules in this fresh worktree), untouched by this change. The other 1061 tests in that suite pass.

farm.js / sandbox.js / codearbiter*.js were not rebuilt: this diff contains no TypeScript.

Files

  • new core/pysrc/_durabilitylib.py — the predicate (vendored to ca, ca-codex, ca-pi via sync-core)
  • core/pysrc/wire-statusline.py — guard in refresh_if_stale (silent) and cmd_install (loud)
  • core/pysrc/session-start.py — guard in heal_statusline_wiring
  • core/surface/commands/statusline.md — documents the refusal and that uninstall is never blocked (built to plugins/ca/commands/)
  • new plugins/ca/hooks/tests/test_durabilitylib.py; test_wire_statusline.py, test_session_start.py, test_atomic_writes.py, _helpers.py

A note on the two merge commits

Both merges tripped the local H-10b secret gate, and both were cleared by actually reviewing rather than bypassing:

This branch's own commits touch no file under plugins/ca/tools/ or plugins/ca-pi/tools/. The gate fires only because a merge commit's staged diff re-presents the other branch's added lines. Both reviewed PASS with no findings and recorded through hooks/security-pass.py; the rationale is written into each merge commit message.

https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L

SUaDtL added 4 commits July 25, 2026 01:05
…json

The SessionStart statusline self-heal rewrote the user's GLOBAL
~/.claude/settings.json to the CURRENT plugin root on every session. A
session started inside a git worktree (subagents run in
<repo>/.claude/worktrees/<id>/) resolves that root to the worktree, so the
heal pinned the global config at a directory whose entire purpose is to be
pruned:

  "...\.claude\worktrees\wf_58ee3fa6-de1-8\plugins\ca\hooks\statusline.py"

Found in-session on 2026-07-25 after it broke the maintainer's statusline
three times in one day (wf_58ee3fa6-de1-8, wf_b7160646-041-2,
wf_04e3c2f6-f27-1). Not agent-specific: any user running codeArbiter from a
worktree hits it.

New core/pysrc/_durabilitylib.py answers one question — may this path be
pinned into a long-lived config — from two independent signals, with no
subprocess (it sits on the SessionStart hot path, before the dormant gate,
in every repo):

  * lexical: a `worktrees` / `*-worktrees` path SEGMENT, zero I/O; covers the
    confirmed .claude/worktrees/<id> shape and the skill's
    .codearbiter-worktrees layout.
  * git: a LINKED worktree's `.git` is a FILE reading
    `gitdir: <common>/.git/worktrees/<name>`, read off disk; catches a
    worktree parked anywhere under any name. A submodule's `.git` file points
    at modules/, not worktrees/, and stays durable.

Anything unreadable, unparseable or absent reads as DURABLE: reading
uncertainty as ephemeral would silently disable the self-heal for users whose
installs are fine.

Guard placement, both sides of the seam sharing the one predicate:

  * wire-statusline.refresh_if_stale (producer, at the mutation) — the heal
    calls it DIRECTLY and never goes through cmd_refresh, so a guard one level
    up would have left the confirmed path open. Silent no-op: automatic path.
  * wire-statusline.cmd_install — LOUD SystemExit. install is explicit (a human
    ran /ca:statusline, or passed --plugin-root <worktree>); a silent no-op
    would leave them believing it worked.
  * session-start.heal_statusline_wiring (caller) — deliberately redundant:
    _load_wire_statusline loads the producer OUT OF `plugin` itself, so a
    worktree cut from a pre-fix branch supplies a pre-fix, unguarded producer
    while this file may have been loaded elsewhere (main() honours
    $CLAUDE_PLUGIN_ROOT independently).

status and uninstall are never blocked — reporting and removal are safe from
anywhere, and a user stuck with a dead pin must be able to clear it.

NOT a kill-switch: a genuinely stale pin from a real plugin-cache update still
heals, proven both directions at both layers. Clearing instead of skipping was
rejected — a user whose only session that day is a worktree session would lose
a working statusline over a condition that resolves itself next start.

Tests (+39): test_durabilitylib.py for both signals and the no-subprocess hot-
path contract; TestNonDurableRootIsNeverPinned (producer) and
TestHealRefusesNonDurableRoot (caller), each pinning byte-identical settings on
a worktree root, a still-healing stale-but-durable pin, and the untouched
third-party line. Fixtures driving the REAL plugin root moved to
_helpers.durable_plugin_copy — codeArbiter's own checkout is often a worktree,
which would otherwise make those tests pass or fail on where they were run.

Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
The Pi payload gate requires any change under plugins/ca-pi to ship a strictly
advancing version with regenerated root metadata and a new changelog heading.
The previous commit vendored _durabilitylib.py and the guarded session-start /
wire-statusline into plugins/ca-pi/hooks, so ca-pi 0.1.3 -> 0.1.4 with the
matching root package.json and CHANGELOG entry.

Caught by CI, not locally: build-host-packages --release-guard-base diffs
base...HEAD (commits), so running it against an uncommitted working tree
reports "no Pi payload change" and proves nothing. Verified here against the
merge-base AFTER committing.

Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
PR #430 (run-scoped farm receipts) landed on main and claimed ca-pi 0.1.4, the
version this branch had also taken. Resolved plugins/ca-pi/CHANGELOG.md by
keeping BOTH entries and moving this branch's durability-guard entry up to
0.1.5 (2026-07-25) above main's 0.1.4; plugins/ca-pi/package.json and the
regenerated root package.json follow to 0.1.5, so the Pi payload gate still
sees a strictly advancing version against the new merge base.

No code conflict: #430 is farm/TypeScript plus prose, this branch is python
hooks. Merged rather than rebased (rebasing a pushed PR branch is prohibited).

H-10b fired on this merge and was cleared honestly, not bypassed: the 14
flagged lines are all `FARM_API_KEY: "test-key"` in plugins/ca/tools/
farm.test.ts and farm.unit.test.ts, already present 40x on origin/main from
#430 — this branch's own commits touch no file under plugins/ca/tools. Product
code reads the value from the approved source (`process.env.FARM_API_KEY`,
farm.ts:102); the literal "test-key" is a non-credential placeholder that
grants nothing and reaches only a spawned test subprocess's env in a temp dir.
Reviewed, PASS with no findings, recorded via hooks/security-pass.py.

Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
PR #426 (isolated Pi child / ADR-0017 credential-blind config projection)
landed on main and claimed ca-pi 0.1.5 — the version this branch had taken in
the previous merge. Same collision as #430 before it: several ca-pi PRs are
racing for the next patch number.

Resolved plugins/ca-pi/CHANGELOG.md by splitting the shared heading and keeping
BOTH sections — this branch's durability-guard entry moves up to 0.1.6, main's
credential-projection Security section keeps 0.1.5. package.json and the
regenerated root metadata follow to 0.1.6, so the Pi payload gate sees a
strictly advancing version against the new merge base.

No code conflict: #426 is ca-pi TypeScript under tools/ and extensions/, this
branch is python hooks. Merged rather than rebased.

H-10b fired again and was cleared honestly: the 13 flagged lines all sit in
plugins/ca-pi/tools/** and plugins/ca-pi/extensions/codearbiter.js, already on
origin/main from #426, and this branch touches none of those paths. They are
synthetic NEGATIVE-test fixtures for #426's own credential-blind projection —
"sk-literal-operator-secret", "sk-foreign-literal", "dummy-*-value" exist so the
tests can assert a literal apiKey FAILS the launch closed; the "$OPENAI_API_KEY"
forms are whole-value environment references, the shape that feature permits
precisely because it carries no secret. Reviewed, PASS with no findings,
recorded via hooks/security-pass.py.

Claude-Session: https://claude.ai/code/session_01Y8UPz5RZwgnda3NbAVfd6L
@SUaDtL
SUaDtL merged commit 26f8edb into main Jul 25, 2026
39 checks passed
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.

1 participant