Skip to content

fix(guard): deny tmux kill-server on a shared socket β€” the command that destroyed 47 live conversations - #1415

Merged
ZacxDev merged 5 commits into
mainfrom
fix/tmux-kill-server-guard-and-oom-protection
Sep 9, 2026
Merged

fix(guard): deny tmux kill-server on a shared socket β€” the command that destroyed 47 live conversations#1415
ZacxDev merged 5 commits into
mainfrom
fix/tmux-kill-server-guard-and-oom-protection

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Sep 8, 2026

Copy link
Copy Markdown
Member

Deny tmux kill-server / kill-session unless an explicitly-named private socket makes it safe β€” and stage the OOM-score protection that was originally asked for.

Why this exists β€” a real incident, tonight

At 21:54:15.802 CDT on 2026-09-07 an agent dispatched by a Claude Code session ran, inside a tmux pane, against the operator's live workspace:

TMUX_TMPDIR=$SCRATCH/run tmux kill-server

42 tmux-spawn-*.scope units tore down 1.2s later. 47 live claude conversations were destroyed. 46 panes were eventually recovered β€” 20 of them only by manual forensic identification against the agent-ledger and restored pane scrollback β€” and one was never recovered.

πŸ”΄ TMUX_TMPDIR does not isolate a tmux client. A process running inside a pane reads $TMUX, and that socket path wins. Only -L <socket> / -S <path> isolates.

πŸ”΄ The agent that ran it had MEASURED the non-isolation 72 seconds earlier β€” an empty TMUX_TMPDIR directory, no socket in it, and yet tmux list-sessions returned the operator's real sessions marked (attached) β€” and did not read its own output as the warning it was. Its dispatch brief warned about this exact hazard, in capitals, citing an earlier instance in the same arc that destroyed 43 panes.

That is the argument for this PR. A written warning had already failed twice. This is the structural version.

The guard

check_tmux_kill_shared_server in scripts/claude-hooks/guard_core.py, wired into bash-guard.py. It denies kill-server / kill-session unless -L or -S names a non-default socket.

  • Matched on the kill-s prefix, because tmux resolves command abbreviations β€” a guard matching only the full spelling is walkable by typing less.
  • Measured blast radius: it denies nothing that exists. Every kill-server call in this repo already passes -L. Re-measured after merging current main, which brought two more such calls in fix(tmux-reply-agent): a launched pane gets a PATH that can find claude (task 524)Β #1379 β€” both carry -L, so the claim still holds.

Tests

The regression test pins the exact command, not a tidied equivalent:

test_the_exact_command_that_killed_47_conversations_is_denied β€” asserts the literal TMUX_TMPDIR=$SCRATCH/run tmux kill-server is denied. Three properties are load-bearing and a cleaned-up fixture would lose all three:

  1. The TMUX_TMPDIR= env prefix is kept β€” that prefix is the whole reason the command looked safe. A guard tested only against a bare tmux kill-server is untested against the form that actually occurred.
  2. $SCRATCH/run stays unexpanded β€” the guard sees the pre-expansion string.
  3. It asserts this guard's OWN reason reaches the caller (evaluate()'s result must equal this check's result), so a neighbouring deny cannot satisfy it.

test_the_isolated_spelling_the_deny_message_prescribes_is_still_allowed β€” the discriminating control. tmux -L my-probe-9182 kill-server must still be ALLOWED. Without it, a guard hardcoded to refuse everything would pass the regression test while breaking every legitimate use.

A third added test passes at base and is labelled an invariant guard, not counted as regression coverage.

Mutation: two sweeps. The blanket-deny mutant is killed by the -L ALLOW control β€” exactly what that control exists for. One mutant was mis-designed (commands(cmd)[:1] does not model env-stripping, since _peel_variants strips VAR= during primary peeling); it was replaced by mutating _ASSIGN itself, and the incident test is among the killers β€” so the env prefix is genuinely load-bearing rather than decorative.

The OOM half β€” staged, not applied, and it does NOT address the measured cause

The dispatching brief hypothesised an OOM kill. That hypothesis is refuted, with instrument-validated negatives rather than bare absences:

  • Kernel OOM killer did not run. Zero oom-kill: / Killed process lines for the incident boot. Positive control: the same grep over the same journal does match real kernel OOM kills from 2026-08-28, so the pattern can fire and didn't.
  • systemd-oomd is not installed at all, so ManagedOOMPreference would have been decoration.
  • tmux did not crash β€” core limit unlimited, core_pattern pipes to systemd-coredump, no tmux core exists.
  • The alarming 57.7G / 38.2G / 29.8G figures are not what they look like. They are systemd's per-scope lifetime memory peak, printed at teardown, over 7–23h wall clocks β€” high-water marks for scopes that mostly no longer overlapped. Not concurrent usage, and not summable.
  • The V8 FatalProcessOutOfMemory was tsserver.js, hitting its own per-process heap cap in a different scope 24ms after the real cause.

nix/system/apply-tmux-oom-protection.sh is therefore staged and honestly labelled. It cannot be home-manager: unprivileged processes cannot lower oom_score_adj (EPERM, measured), and the tmux server is not in an HM unit β€” it lives in a per-login session-N.scope.

πŸ”΄ On what it displaces: raising OOMScoreAdjust on the pane scopes was rejected β€” it reaches the same relative ordering by making the conversations more killable, which buys an empty workspace. It lowers one process only, and to βˆ’500 rather than βˆ’1000, so tmux stays reclaimable. For context on whether it is worth applying at all: tmux currently scores 668 against a box top of 1332–1333.

πŸ”΄ Gate status β€” owed, and stated plainly

Earlier runs on base 5d6fffa7 reported: dev-host node PASS (1449); dev-host pytest 8 failures, 0 attributable to this diff β€” 6 were the age 1.3.2 nixpkgs drift since fixed on main by #1392, and the other two did not reproduce; sandbox nodetests PASS read from nix log.

That is not sufficient and I am not claiming it is. main has moved substantially since, this branch has been merged with it (head 24c77099), and no tier has been run on that merged tree. The box is at load ~62 with two other sessions gating; adding a fourth concurrent suite is the exact condition that preceded tonight's incident, so the run is deliberately deferred rather than rushed.

Do not merge until both tiers are green on the merged tree β€” scripts/gate.sh --tier both, then the two nix build derivations one at a time, with verdicts read from nix log rather than build exit codes.

Blast radius

Reversible β€” 793 insertions, 0 deletions; purely additive. The guard denies a command class that no existing caller uses. The sudo script changes nothing until someone runs it.

ZacxDev and others added 5 commits September 8, 2026 00:14
…lled 47 conversations

## What actually happened (the dispatching hypothesis was wrong)

Dispatched to add OOM protection after the operator's tmux server died at
2026-09-07 21:54:21 CDT, taking 47 live Claude conversations β€” 20 of which
needed manual identification to recover. The first job was to establish the
cause. It was NOT an out-of-memory event.

MEASURED:

- The kernel OOM killer did not run. Zero `oom-kill:` / `Killed process` lines
  in `journalctl -k` for the incident boot. That zero is instrument-validated,
  not a bare absence: the same pattern over the same journal DOES match real
  kernel OOM kills from 2026-08-28, so it can fire and did not. The kernel emits
  that line unconditionally before it kills.
- systemd-oomd is not installed (`systemctl cat systemd-oomd` -> no files), so
  `ManagedOOMPreference` would have been decoration.
- tmux did not crash: core limit `unlimited`, `core_pattern` pipes to
  systemd-coredump, and no tmux core exists for that window.
- The `57.7G / 38.2G / …` scope figures are systemd's per-scope LIFETIME
  `memory peak` printed at teardown, over 7-23h wall clocks. Not concurrent
  usage; not summable.
- The one coredump in the window is `tsserver.js` hitting its own V8 heap cap,
  in a different scope, 24ms after the real cause. Coincidence.

THE CAUSE, recovered from the session transcripts:

  02:54:15.802Z (= 21:54:15.802 CDT)
      TMUX_TMPDIR=$SCRATCH/run tmux kill-server

42 `tmux-spawn-*.scope` units tore down 1.2s later. `TMUX_TMPDIR` does not
isolate a client: one run inside a pane reads `$TMUX`, whose socket path wins.
The same agent had measured that 72s earlier β€” with `$TMUX_TMPDIR` pointing at a
directory holding no socket, `tmux list-sessions` still listed the operator's
real sessions `(attached)` β€” and did not read it as the warning it was.

πŸ”΄ This is the argument for a STRUCTURAL guard over a written one. The agent that
ran it was a subagent of the session that is now shipping this fix, and its brief
warned about this exact hazard in capitals β€” "TMUX_TMPDIR does NOT isolate a
client; only `tmux -L <unique-socket>` does" β€” naming the earlier occurrence that
cost 43 panes. It proceeded anyway. Prose in a prompt does not re-assert itself
inside a long session; a PreToolUse hook fires on every call.

## The fix

`check_tmux_kill_shared_server` in guard_core.py, the 15th claude-code check.
Denies `kill-server`/`kill-session` unless `-L`/`-S` names a non-default socket.
Matched on the `kill-s` prefix because tmux resolves unambiguous command
prefixes, so a guard keyed on the two full words is walked past by `kill-ser`.
`kill-pane`/`kill-window` stay allowed β€” they destroy what the caller named, and
a guard that fires on correct work gets routed around.

The deny message corrects the specific wrong belief, not just the command.

Blast radius measured before landing: every real shell use of a `kill-s…` in
this repo already passes `-L`, so this denies none of them.

Known blind spot, documented rather than papered over: `TB=$(command -v tmux);
$TB kill-server` reaches the binary through a variable and is invisible to a
text guard.

## The OOM protection, staged and honestly labelled

`nix/system/apply-tmux-oom-protection.sh` β€” STAGED, NOT APPLIED. It cannot live
in home-manager, for two measured reasons: an unprivileged process cannot LOWER
`oom_score_adj` (EPERM without CAP_SYS_RESOURCE β€” only raising works), and the
tmux server is not in a home-manager unit at all (per-login `session-N.scope`).
Its header states plainly that it does not address this incident, and a test
pins that it keeps saying so.

Displacement, addressed explicitly: the rejected design raised `OOMScoreAdjust`
on the `tmux-spawn-*.scope` units, reaching the same ordering by making the
conversations more killable. This lowers one process and touches nothing else.
-500 not -1000, so the server stays reclaimable.

Whether it is worth applying: tmux scores 668 while the top of the box is
1332-1333 (kubepods with explicit adj 998-1000), so it is far from the front of
the queue. The operator decides; nothing here applies it.

## Verification

- guard_core: 54 red at base c5e425c (of 1500), 1500 green at HEAD. The literal incident
  command and the `-L` control are red-at-base by name;
  `test_the_incident_and_its_control_differ_ONLY_in_the_socket_selector` is
  labelled an INVARIANT GUARD because it passes at base β€” measured, not assumed.
- Sweep A (7 killing mutants + no-op control): all killed by this guard's OWN
  assertions, incl. the fail-open case (dropping `-f` from the value-flag table,
  which would let `tmux -f conf kill-server` through).
- Sweep B (8 + control) on the staged-script guards: all killed by their named
  test.
- Sweep C (5 + control) on the incident regression pair: the blanket-deny mutant
  is killed by the `-L` ALLOW control, which is the whole reason that control
  exists.
- Sweep D: one sweep-C mutant was MIS-DESIGNED (`commands(cmd)[:1]` does not
  model env-stripping, which happens inside `_peel_variants`' primary peeling).
  Replaced by mutating `_ASSIGN` itself: the incident test IS among the killers,
  so the `TMUX_TMPDIR=` prefix is genuinely load-bearing rather than decorative.
- All sweeps run under PYTHONDONTWRITEBYTECODE=1 and refuse to score a run that
  collected no tests β€” sweep A's first version was a BROKEN HARNESS that reported
  7 must-die mutants as SURVIVED because `nix develop` never ran under its
  stripped env, and "no FAILED lines" was read as "survived".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V1ysK1JBgdNyfqAt6gDKC
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
…guard-and-oom-protection

Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
…guard-and-oom-protection

Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
…guard-and-oom-protection

Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
…, and an unresolved -L was taken as proof

Round-2 fixes for the audit of #1415. Two blockers, three should-fixes, three
nits β€” plus one stale claim of my own found while sweeping.

BLOCKER 1 β€” the OOM unit selected no process and reported success forever.
`pgrep -u 1000 -x tmux` matches NOTHING: `-x` is exact on /proc/<pid>/comm and
the tmux server's comm is `tmux: server`. The loop body never ran, the unit
printed `adjusted 0`, exited 0, and systemd called that success every two
minutes. Measured 2026-09-09: `-x tmux` -> rc=1; `-x 'tmux: server'` -> 1111077.
The selector was only half the defect, so all three are fixed:
  * selector corrected, and the in-loop comm re-read tightened from a `tmux*`
    prefix (which accepts `tmux: client`) to the exact string;
  * an APPLY-TIME positive control β€” the script runs the selector itself under
    sudo and refuses to install when it matches nothing, which is the control
    the every-2-minutes unit structurally cannot perform;
  * RUN-TIME counters split (`found` vs `adjusted`), the written value read back
    out of procfs, and a non-zero exit when servers were found but not lowered.
    `found 0` deliberately does NOT escalate β€” a permanently-red gate is worse
    than no gate.
Verified behaviourally, not by reading: the generated unit body run as the
unprivileged user prints `found 1 ... adjusted 0` and exits 1 (EPERM is the
documented unprivileged-lower failure). The real server's oom_score_adj still
reads 0 afterwards.

BLOCKER 2 β€” the import-wiring awk could not match the only host it targets.
It required `[` on the same line as `imports =`; /etc/nixos/configuration.nix
puts the opener on the next line. Replaced with the logic from
apply-airvpn-host.sh:88-116, INCLUDING the multiple-`imports =` guard this
script had dropped. Verified by running the awk against the real config (insert
at line 25, inside the list, nix-instantiate --parse OK) with the old program as
the negative control (0 insertions).

SHOULD-FIX 3 β€” an unresolved `-L`/`-S` is no longer proof of isolation.
`-S "${TMUX%%,*}"`, `-S "$TMUX_SOCK"`, `-L "$sock"`, `-L ''`, `-S ''` now DENY;
`-L my-probe-$$` still ALLOWs, because the rule is "a literal survives in the
last path component", not "contains no variable". Two further fail-opens found
and closed: leftover metacharacters from an unbalanced substitution, and
`commands()` eliding a substitution so `-L` swallowed the subcommand. The action
is now detected by scanning every token, so a flag-arity miscount can no longer
shift the read. Scope stated in code: this protects exactly one socket NAME,
`default` β€” `tmux -L work kill-server` is ALLOW by design.

SHOULD-FIX 4 β€” `ssh <host> tmux kill-*` is denied, via a targeted arm.
Adding `ssh` to `_WRAPPERS` was measured and rejected: `_peel_variants` reads a
wrapper's first non-flag token as the command, which for ssh is the HOST, so the
check would still miss while every other check began evaluating hostnames. The
arm scans an ssh argv for a `tmux` token at any position, so it needs no ssh
option-arity table.

SHOULD-FIX 5 β€” the prose blast-radius ledger is now derived and enforced.
Two-way over paths (not line numbers), with positive controls on both scanners,
reusing public_ip_scan.repo_files so it works in the git-less sandbox tier. The
docstring now says plainly that the ALLOW measurement is reassurance about the
WRONG POPULATION: those sites are Python argument lists this hook, which gates
Bash-tool text, structurally never sees.

NIT 6 β€” the behavioural assertion moved first, so the red at base is
`evaluate(...) is None` rather than an AttributeError on the import surface.

NIT 7 β€” `cat >` not `mv` (inode + mode), module written after the wiring
succeeds, one fixed backup name instead of one file per run, and restore() no
longer claims to restore a backup that does not exist.

NIT 8 β€” remediation split by what the caller wanted, including the kill-session
branch (kill-pane/kill-window stay allowed) and the escape hatch: this hook
gates the Bash tool, the operator's own terminal is not hooked β€” ask them.

MY OWN PROSE, swept: the comment calling `_TMUX_VALUE_FLAGS` "the only way this
check can fail OPEN" was true of the positional resolution it was written
against and false after the token scan; corrected in both the source and the
test whose docstring repeated it. "This exact command" in a message now shared
by several shapes became "a command of this shape". "The FIFTEENTH check" was
ambiguous (fifteenth added, eleventh in order) and now says which.

MUTATION: 18 mutants, 18 killed, each by its own guard's assertion text;
positive control killed; control run green. The round-1 SURVIVOR (`-x
zzz-no-such-proc`) now dies. One mutant survived the first sweep β€” deleting the
`n_imports` assignment left the token visible in the comparison β€” so that check
now names the count, the comparison and the refusal as three separate claims.

Merged origin/main (base c507d71) to re-check the pytests red on the old head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
@ZacxDev

ZacxDev commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

audit-claims β€” round-2 fix pass on #1415

Per prior finding: fixed / partially / not / made worse. Severity words, never coloured circles.

# finding status
BLOCKER 1 pgrep -u 1000 -x tmux selects nothing; unit reports success forever FIXED
BLOCKER 2 import-wiring awk cannot match this host's imports = shape FIXED
SHOULD-FIX 3 unresolved -L/-S accepted as proof of isolation FIXED (denied; argued below)
SHOULD-FIX 4 ssh <host> tmux kill-server is ALLOW FIXED (targeted arm, blast radius measured)
SHOULD-FIX 5 prose "MEASURED BLAST RADIUS" ledger already wrong FIXED (derived + test-enforced)
NIT 6 red-at-base is an import-surface red FIXED
NIT 7 mv inode, orphan module, backup churn, lying restore() FIXED (all four)
NIT 8 remediation text does not fit kill-session; no stated escape hatch FIXED
CONTEXT tekton/devrc-pytests red on old head RE-BASED; named a concrete candidate cause; awaiting the run on the new head

BLOCKER 1 β€” selector

Selector is now pgrep -u 1000 -x 'tmux: server'. Re-measured on the workbench 2026-09-09:

pgrep -u 1000 -x tmux             -> rc=1, no output
pgrep -u 1000 -x 'tmux: server'   -> 1111077, rc=0
cat /proc/1111077/comm            -> tmux: server

Three things changed, because the selector was only half the defect β€” the other half was that
an empty match set was the silent path:

  1. Selector corrected, and the in-loop /proc/<pid>/comm re-read tightened from a tmux*
    prefix (which also accepts tmux: client) to the exact string.
  2. Apply-time positive control. The script now runs the selector itself, under sudo, and
    refuses to install when it matches nothing. That is the control the every-2-minutes unit
    structurally cannot perform β€” there, found 0 is indistinguishable from "no tmux running".
  3. Run-time counters split. The unit counts found separately from adjusted, reads the
    written value back out of procfs, and exits non-zero when it found servers it could not
    lower. found 0 is deliberately not escalated: after a clean shutdown there genuinely is no
    server, and failing on that would toast four times an hour forever.

Behavioural verification, not just a text check. The generated unit body was extracted and
executed as the unprivileged user:

tmux-oom-protect: found 1 tmux server process(es), adjusted 0 to oom_score_adj=-500
tmux-oom-protect: FAILED to lower 1 of 1 server process(es)
rc=1

found 1 is the corrected selector locating the real server (the old one found 0); adjusted 0
is the documented EPERM for an unprivileged lower; rc=1 is the new loud path firing. Afterwards
cat /proc/1111077/oom_score_adj still reads 0 and the server is alive β€” nothing was mutated.

test_process_selection_never_uses_a_full_commandline_pattern no longer certifies the bug: it now
says in its own docstring that it checks flag SHAPE only, and the selector's behaviour is a
separate test asserting it against the realistic comm (tmux: server), with the shipped-broken
tmux pattern kept as the negative control so a "tidied" fixture goes red.

BLOCKER 2 β€” import wiring

Copied from apply-airvpn-host.sh:88-116 rather than reimplemented, including the
multiple-imports = guard this script had dropped
. Also reordered: the import is wired before
$MODULE is written, so a refused wiring leaves no orphan in /etc/nixos.

Verified by running the awk, not by reading it β€” against the real /etc/nixos/configuration.nix
(copied read-only) and against both list shapes:

new awk on the real config      -> inserts at line 25, inside the list; nix-instantiate --parse OK
OLD awk on the same real config -> 0 insertions   (negative control: the blocker reproduces)

Duplication across two standalone sudo bash scripts is deliberate (neither can import from the
other), so a test pins that both still carry the same load-bearing pieces β€” the [-character
split, the assignment count, the comparison that makes the count a guard, the refusal and its
non-zero exit β€” as five separate claims. That granularity was not gratuitous: see the mutation
table, where a one-fragment version let a mutant through.

SHOULD-FIX 3 β€” unresolved -L/-S: DENY, and here is the argument

I took your lean. _socket_is_proven_isolated now requires the socket's last path component
to retain a literal character after every recognised expansion is deleted, and that component must
not be default.

  • -S "${TMUX%%,*}", -S "$TMUX_SOCK", -L "$sock", -L '', -S '' β€” all now DENY.
    Measured, and it is not theoretical: $TMUX on this box is
    /run/user/1000/tmux-1000/default,1111077,20, so ${TMUX%%,*} is the operator's own socket.
  • -L my-probe-$$ β€” still ALLOW. The rule is not "contains no variable"; that would deny the
    very command the message prescribes.

Argued against the repo's real callers, as asked. All four -L-carrying sites are Python
subprocess.run([...]) argument lists. This hook gates Bash-tool text, so it can never see
them β€” denying the shell spelling -L "$sock" breaks nothing that exists in this tree today, and
the workaround is one word (write the label literally), stated in the deny message.

Two more fail-opens surfaced while building this and are closed:

  • Leftover metacharacters. tmux -L `echo default` kill-server tokenises with an
    unbalanced backtick; the socket became the literal-looking string `echo, which survived
    the expansion strip and read as proven-isolated. Anything the expansion table could not consume
    is now unproven.
  • Elided command substitution. commands() lifts $(…)/backticks into their own argv and
    deletes them from the outer one, so -L swallowed the subcommand and the guard read a
    socket named kill-server. Now dropped as incoherent.

Also: the action is detected by scanning every token, with only the socket read positionally.
Resolving the subcommand positionally is exactly how a token miscount fails open.

The scope is now stated in code: this protects one socket name, default.
tmux -L work kill-server is ALLOW by design.

SHOULD-FIX 4 β€” ssh

Blast radius measured before choosing, as asked β€” and adding ssh to _WRAPPERS is the wrong
fix for a mechanical reason, not a taste one.
_peel_variants treats a wrapper's first non-flag
token as the command; for ssh that token is the host. ssh laptop tmux kill-server peels to
['laptop', 'tmux', 'kill-server'] β€” argv[0] laptop β€” so the check would still miss it while
every other check in the policy started evaluating hostnames as commands. Wider radius, hole open.
That is pinned by a test that fails if ssh ever starts peeling.

So: a targeted arm that scans an ssh argv for a tmux token at any position and reads flags
from there. It needs no ssh option-arity table (getting one wrong is the fail-open mode), and
ssh -L 8080:localhost:80 host tmux -L probe kill-server reads correctly β€” the port-forward -L
is skipped, the tmux -L is the socket. Cost is a bounded over-block on
ssh host echo "tmux kill-server", which is the right side to err on.

Measured on the tree: the only shell-text ssh … tmux … in this repo are reads
(list-panes, is-active) β€” both still ALLOW.

SHOULD-FIX 5 β€” the ledger

The prose enumeration is gone. In its place:

  • test_every_kill_server_call_site_in_the_repo_is_classified β€” a two-way ledger of every
    tracked file mentioning a wide kill, keyed on paths, not line numbers (the line number is
    what rotted last time). A new or removed file fails the suite.
  • test_every_executed_tmux_kill_argv_names_an_isolating_socket β€” every tmux argv list either
    carries -L or is named in an exemption dict with the reason (the reason must be that the
    surrounding assertion is that the call is refused; "it's a test" is not a reason).
  • test_no_tracked_shell_text_writes_a_kill_this_guard_would_deny β€” the population the guard
    can see.
  • Both scanners carry a positive control (test_the_kill_site_scanner_can_see_anything_at_all)
    so a broken regex cannot report a clean tree as a reassuring zero.

The ledger earned its keep on its first run: it flagged a site I had just written (a scanner
fixture in the test file itself) that I had not classified.

And the honesty you asked for is now in the docstring: the ALLOW measurement is reassurance
about the wrong population. Those sites are Python argument lists this hook structurally never
sees; the guard could deny every one of them and nothing here would break. The population that
matters is shell text an agent types, and this repo contains none of it β€” so the measurement is a
true fact about a set that cannot be affected, not a clean bill of health for the set that can.

These scanners reuse public_ip_scan.repo_files rather than growing a third copy of the
git-ls-files/walk logic β€” which also buys the sandbox tier, where a bare git ls-files errors and
a test that caught that error would skip its way to green in exactly the tier the merge is gated
on. Verified in a git-less copy of the tree (1298 tracked files, no .git): 24 passed.

NIT 6

Assertion order swapped: evaluate(_INCIDENT_COMMAND, "claude-code") is not None is now line one,
with the AttributeError reasoning written into the docstring so it does not get "tidied" back.

NIT 7 β€” all four

  • mv "$CFG.new" "$CFG" β†’ cat "$CFG.new" > "$CFG" (inode + 0644 root:root preserved), matching
    apply-airvpn-host.sh:114-115. $MODULE also gets an explicit chown/chmod.
  • $MODULE is written after the wiring succeeds.
  • Backup name is now fixed (.bak.tmux-oom), taken only on the branch that actually edits, so
    a re-run overwrites one file instead of adding a nineteenth to the 18 already in /etc/nixos.
  • restore() checks the backup exists and otherwise prints
    "$CFG was not modified by this run; nothing to restore".

NIT 8 β€” remediation text

Rewritten as three branches by what the caller wanted: (a) experiment with a server β†’ make your own
and kill that; (b) remove one session/window/pane β†’ kill-pane/kill-window are allowed, and
there is deliberately no allowed spelling of kill-session on the default socket, because it
takes panes you did not name; (c) target a real non-default server β†’ allowed, but write the socket
name literally, and here is why -L "$sock" is not.

Plus the sentence you asked for: the escape hatch for a genuinely-needed shared kill-server is
that this hook gates the Bash tool, and the operator's own terminal is not hooked β€” ask them.
Do not go looking for a spelling that slips past the check.


Mutation results β€” 18 mutants, 18 KILLED, each by its own guard's assertion text

Control run GREEN (106 tests) before and between mutants; final full run of both affected
files on the committed tree: 1556 passed, 0 failed (284s, dev-host tier, base c507d71d). PYTHONDONTWRITEBYTECODE=1 throughout.
Node IDs, never -k tmux (which deselects
test_the_exact_command_that_killed_47_conversations_is_denied). Both files restored from cp -a
copies afterwards and confirmed byte-identical.

mutant verdict killed by
M1 selector back to the shipped-broken -x tmux KILLED test_the_pgrep_pattern_matches_a_REALISTIC_comm
M2 selector β†’ -x zzz-no-such-proc (the mutant that SURVIVED in round 1) KILLED same
M3 awk [-split disabled KILLED test_the_wiring_awk_actually_inserts_in_both_config_shapes
M4 multiple-imports = guard deleted KILLEDΒΉ test_the_import_wiring_is_the_SAME_logic_…
M4b comparison neutralised (count taken, never acted on) KILLED same
M4c refusal removed from the guard branch KILLED same
M5 cat β†’ mv KILLED test_the_config_is_overwritten_in_place_not_replaced_by_mv
M6 fixed backup β†’ timestamped KILLED test_the_backup_does_not_accumulate_one_file_per_run
M7 found>0 escalation disabled KILLED test_an_empty_match_set_is_not_allowed_to_be_the_silent_path
M8 apply-time pre-flight removed KILLED same
M9 /proc comm re-check loosened to tmux KILLED test_the_comm_recheck_in_the_loop_uses_the_same_realistic_value
M10 empty-residue check removed KILLED test_an_unresolvable_socket_is_not_an_isolation_claim
M11 leftover-metacharacter check removed KILLED test_socket_isolation_is_proven_not_assumed
M12 elided-substitution guard removed KILLED test_an_unresolvable_socket_is_not_an_isolation_claim
M13 prefix-OF relaxed to bare startswith KILLED test_tmux_kill_near_misses_stay_allowed (kill-server-test)
M14 ssh arm removed KILLED test_tmux_kill_over_ssh_is_denied
M15 default-socket comparison neutralised KILLED test_tmux_kill_shared_server_is_denied (-L default)
PC positive control: drop -f from _TMUX_VALUE_FLAGS (known fatal) KILLED test_tmux_value_flag_table_covers_every_value_taking_server_flag

ΒΉ M4 SURVIVED on the first sweep and that was a real finding, not noise: the test asked only
for the token n_imports, which still appeared in the if that compared it β€” the guard was gone
while the test read as covering it. A guard on a word is walkable by rewording; the check now names
the count, the comparison and the refusal as separate claims, and M4/M4b/M4c all die.

CI

tekton/devrc-pytests was red on 24c77099 on
test_the_SUMMARY_BANNER_names_the_real_selection_source
(scripts/tests/test_run_tests_targets.py), a file this PR does not touch. origin/main merged;
new base c507d71d (was 19 commits behind).

A concrete candidate rather than "base/environment": 9300f234 (#1406) touched that exact test
file and run-tests.sh, and is absent from the old base.
The four most recent merged PRs on
bases that include it (#1432, #1427, #1426, #1425) all have pytests=SUCCESS. That is
correlation and a plausible mechanism, not a proof β€” the measurement is the tekton run on this
new head. If it stays red on a current base I will report it rather than merge through it.

What was NOT run, and why

The box was at load 72-99 throughout (six other sessions gating). Per the standing instruction not
to gate above ~25, I did not run scripts/gate.sh --tier both or the two nix build check
derivations locally.
What was run: the affected test files directly (dev-host tier), plus a
git-less-tree run of the new corpus-scanning tests to cover the sandbox tier's file-listing path.
Naming this rather than implying a full gate.

Split into two PRs?

Recommendation: keep them together β€” but it is your call, and I did not split unilaterally.

The auditor's reasoning was "the guard is sound, both blockers are in the script". Both blockers
are now fixed and gated, so the thing that made splitting attractive has largely gone. And
merging the script applies nothing: it is staged, inert until someone runs it under sudo, so
carrying it costs no operational risk.

The honest asymmetry that remains is verification confidence, not correctness: the guard is
verified behaviourally end to end, while the script's real runtime effect (nixos-rebuild + the
timer firing every 2 minutes) cannot be exercised from here at all. If you would rather ship the
guard now and keep the OOM prophylaxis decision open β€” its own header says it is staged for an
explicit decision, against a mechanism that has never fired on this host β€” splitting is the right
call and I will do it on a word.

@ZacxDev

ZacxDev commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

CI re-check β€” resolved

Both tekton legs are green on the re-based head a8c47b05:

tekton/devrc-nodetests: SUCCESS
tekton/devrc-pytests:   SUCCESS

So the test_the_SUMMARY_BANNER_names_the_real_selection_source failure was base-related, not
this diff
β€” as hypothesised, and now measured rather than argued. That closes the CONTEXT row in
the comment above, which said "awaiting the run on the new head".

⚠ Neither of those checks blocks a merge. main is protected in name only and the block is
DECLARED off in scripts/drift-check.sh β€” a green check here is a claim about this branch, not
about the tree a merge creates. Gating the merged tree is still a manual step.

⚠ And this fix is not live on the workbench. Measured, with a positive control: the deployed
~/.claude/hooks/guard_core.py (/nix/store/y9dwn6x81xk5sdkma2svd5qrkzj9xxan-hm_guard_core.py)
has 3 hits for check_pkill_full_pattern and 0 for check_tmux_kill_shared_server. The guard
only starts protecting anything after merge β†’ pull β†’ home-manager switch / ship.sh.

ZacxDev added a commit that referenced this pull request Sep 9, 2026
…ERGED; #1415 round-1 fixed with

Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d
@ZacxDev

ZacxDev commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Machine-readable restatement of the round-1 fix pass above, in the fenced form
scripts/audit-dispatch.py parses. The prose comment used a ## audit-claims heading,
which the parser does not accept, so round 2 could not be dispatched. Content is unchanged β€”
this is a format fix, not a new claim.

1. BLOCKER 1 β€” the every-2-minutes unit's process selector was changed from `pgrep -u 1000 -x tmux` (matches nothing; `comm` is `tmux: server`) to `-x 'tmux: server'`, the in-loop `/proc/<pid>/comm` re-read was tightened from a `tmux*` prefix to the exact string, an apply-time positive control was added that refuses to install when the selector matches nothing, and run-time counters were split into `found` vs `adjusted` with a readback and a non-zero exit when servers are found but cannot be lowered (`found 0` deliberately not escalated).
2. BLOCKER 1 (cont.) β€” `test_process_selection_never_uses_a_full_commandline_pattern` was claimed to have stopped certifying the bug: its docstring now says it checks flag SHAPE only, and selector behaviour moved to a separate test asserting against the realistic `comm`, with the shipped-broken `tmux` pattern kept as a negative control.
3. BLOCKER 2 β€” the `/etc/nixos` import-wiring awk was replaced with the one copied from `apply-airvpn-host.sh:88-116`, including the multiple-`imports =` guard this script had dropped, and the ordering was changed so the import is wired BEFORE `$MODULE` is written so a refused wiring leaves no orphan.
4. SHOULD-FIX 3 β€” `_socket_is_proven_isolated` now DENIES an unresolved `-L`/`-S` socket: the socket's last path component must retain a literal character after every recognised expansion is deleted and must not be `default`; leftover metacharacters (e.g. an unbalanced backtick) and elided command substitution are both treated as unproven; the action is detected by scanning every token with only the socket read positionally.
5. SHOULD-FIX 4 β€” `ssh <host> tmux kill-server` is denied by a targeted ssh arm that scans the ssh argv for a `tmux` token at any position and reads flags from there, rather than by adding `ssh` to `_WRAPPERS` (which was measured to still miss, because `_peel_variants` reads the wrapper's first non-flag token β€” the HOST β€” as the command); a test pins that `ssh` never starts peeling.
6. SHOULD-FIX 5 β€” the prose "MEASURED BLAST RADIUS" enumeration was replaced by a two-way ledger keyed on paths not line numbers (`test_every_kill_server_call_site_in_the_repo_is_classified`), an argv-level check that every executed tmux kill names an isolating socket or is exempted with a reason, a check that no tracked shell text writes a kill this guard would deny, and a positive control that the scanner can see anything at all; the docstring now states the ALLOW measurement covers a population this hook structurally cannot see.
7. NIT 6 β€” assertion order in the red-at-base test was swapped so `evaluate(_INCIDENT_COMMAND, "claude-code") is not None` is first, with the `AttributeError` reasoning written into the docstring.
8. NIT 7 β€” all four: `mv "$CFG.new" "$CFG"` became `cat "$CFG.new" > "$CFG"` (inode and mode preserved) with explicit `chown`/`chmod` on `$MODULE`; `$MODULE` is written after the wiring succeeds; the backup name is fixed (`.bak.tmux-oom`) and taken only on the branch that edits; `restore()` checks the backup exists and otherwise says the config was not modified.
9. NIT 8 β€” the remediation text was rewritten as three branches by caller intent (make your own server; `kill-pane`/`kill-window` with deliberately no allowed `kill-session` on the default socket; a real non-default server with the socket name written literally), plus a stated escape hatch that the hook gates the Bash tool only and the operator's own terminal is not hooked.
10. Mutation battery β€” 18 mutants claimed KILLED including round 1's survivor (selector `-x zzz-no-such-proc`), each by its own guard's named assertion, with a green control run before and between mutants and `PYTHONDONTWRITEBYTECODE=1` throughout; M4 is claimed to have survived a first sweep because the test asked only for the token `n_imports`, and to have been split into three separate claims (count, comparison, refusal) so M4/M4b/M4c all die.
11. CI β€” the `tekton/devrc-pytests` red on `24c77099` (`test_the_SUMMARY_BANNER_names_the_real_selection_source`) is claimed to be base-related rather than caused by this diff, on the basis that both legs are SUCCESS on the re-based head `a8c47b05`.

⚠ Two things the round-1 pass states it did NOT do, carried forward so round 2 does not
assume them: scripts/gate.sh --tier both and the two nix build check derivations were
not run locally (the box was at load 72–99); and the SHOULD-FIX 3 argument that denying
the shell spelling -L "$sock" breaks no real caller β€” because all four -L-carrying sites
are Python subprocess.run([...]) lists the hook never sees β€” is unaudited.

@ZacxDev

ZacxDev commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

audit round 2 β€” delta 24c77099..a8c47b05 (the round-1 fix pass)

Scope: the delta only. a8c47b05 is the fix commit; the rest of that range is a merge of
origin/main. Verified behaviourally where possible rather than by reading.

What I re-verified rather than took on the claims block

The guard itself: 29/29 adversarial cases correct, with controls. Ran
check_tmux_kill_shared_server from the MERGED tree (origin/main + a8c47b05) against a
battery covering every claim in round 1: the incident command, -S "${TMUX%%,*}",
-S "$TMUX_SOCK", -L "$sock", -L '', -S '', both elided-substitution spellings,
-L default, -S …/default, all four kill abbreviations, the kill-server-test session-name
near-miss, kill-pane/kill-window, the ssh arm (bare, quoted, and with a port-forward -L
that must not be read as the tmux socket), and the two documented blind spots asserted as
STILL blind. 0 mismatches. Controls: the deny path fires, the allow path fires, and the
check is reachable through the real evaluate(cmd, "claude-code") entry point β€” not just
callable directly.

SHOULD-FIX 3's unaudited argument holds. Round 1 argued that denying the shell spelling
-L "$sock" breaks nothing because every real call site is a Python subprocess.run([...])
list the hook never sees, and flagged that reasoning as the thing round 2 should check first.
Re-derived independently over git ls-files: every executable kill-s… site is a Python argv
list, and the only shell-TEXT occurrences in the tree are prose in handoff docs and this
script's own header comment. The one -L-less site
(scripts/tests/test_session_manager.py:114) is classified in the ledger, and its surrounding
assertion genuinely is that the call is refused (pytest.raises(_Forbidden)), which is the
ledger's own stated bar for an exemption. Claim verified.

All 10 spot-checked tests named in the mutation table exist at the paths claimed.


🟑 SHOULD-FIX 1 β€” a re-run whose rebuild fails silently reverts /etc/nixos/configuration.nix, including unrelated operator edits

nix/system/apply-tmux-oom-protection.sh. Introduced by NIT 7's fixed-backup change, and not
present in the timestamped spelling it replaced.

BACKUP="${CFG}.bak.tmux-oom" is a fixed name, but the backup is only taken inside the
else branch that does the wiring. On a re-run the script takes the already-wired path and
creates no backup β€” while restore(), armed by trap restore ERR for the whole script,
still finds the previous run's backup and cp -as it over the live config.

nixos-rebuild switch runs at the end under that trap, so any rebuild failure β€” for a reason
having nothing to do with this module β€” triggers it.

Measured, not reasoned. Ran the real script with only its environment couplings patched
(paths, the $EUID check, the selector pre-flight, and nixos-rebuild); the backup, trap and
restore() logic are untouched:

RUN 1 (rebuild succeeds)  -> import wired, configuration.nix.bak.tmux-oom created, rc=0
operator then hand-edits configuration.nix   (services.openssh.enable β€” an unrelated edit)
RUN 2 (already wired; rebuild FAILS)
  "FAILED β€” restoring …/configuration.nix from …/configuration.nix.bak.tmux-oom"
  import present in CFG:  0     <- the live, correctly-applied import is gone
  operator edit present:  0     <- the unrelated edit is gone
  CFG identical to the pre-wiring backup: YES

So the message says it is unwinding this run, and it is actually reverting the file to a state
from an arbitrarily earlier run. The cp -a also restores the old mtime, which removes the
obvious tell.

Why the existing test cannot see it.
test_restore_does_not_claim_to_restore_a_backup_that_does_not_exist asserts that the literal
-f "$BACKUP" appears inside restore() and that the string nothing to restore is present.
That is a guard on the SPELLING of the source, and the spelling it pins is exactly what makes
the stale restore fire β€” the -f test passes because a stale backup exists. The test reads
as covering restore honesty while providing none of it, which is worse than no test because it
stops anyone looking.

Fix direction: gate restore() on whether this run took the backup (a flag set next to
the cp -a), not on whether the file exists β€” and pin it behaviourally (run 1 succeeds, run 2
fails, assert the config still carries the import) rather than by grepping the source.

🟒 NIT 2 β€” the refusal path leaves a backup behind

On the could not find an 'imports =' list exit, cp -a "$CFG" "$BACKUP" has already run, so a
refusal that modified nothing still deposits configuration.nix.bak.tmux-oom in a directory the
script's own comment notes already holds 18 such files. Cosmetic on its own; it also arms
SHOULD-FIX 1 for the next run.


Ledger

Round 1 claimed 11 items. Verified: the guard behaviour (claims 1–5, 7–9 by battery and by
reading), the blast-radius ledger and its exemption (claim 6), and the existence of every test
named in claim 10. Round 2 attributes SHOULD-FIX 1 and NIT 2 to claim 8 (NIT 7's fixed
backup name + honest restore()) β€” both are defects the round-1 fix introduced, which is the
pattern this arc's handoff already records for #1311, #1317 and #1344.

Not re-run by me: the 18-mutant sweep (re-running it requires mutating files the gate is
currently reading). Claim 11 (the pytests red was base-related) is superseded β€” I am gating the
merged tree directly.

Verdict

The guard half is safe to merge β€” it is the only half that does anything on merge, and it is
now verified behaviourally end to end rather than on the fix pass's own authority.

Both findings are in the staged sudo script, which merging does not run. Its runtime effect
cannot be exercised from here at all, and it has now produced a real defect in each of two
consecutive audit rounds. Merging it changes nothing on disk; the findings are being carried
straight into a follow-up rather than held against the guard, because the guard is currently
NOT DEPLOYED and the failure mode it prevents is live for every agent running right now.

@ZacxDev
ZacxDev merged commit 176f412 into main Sep 9, 2026
2 checks passed
@ZacxDev
ZacxDev deleted the fix/tmux-kill-server-guard-and-oom-protection branch September 9, 2026 16:30
ZacxDev added a commit that referenced this pull request Sep 9, 2026
… ledger

MEASURED as a MERGED-TREE break, not on either branch: #1415 (merged as
176f412) added a two-way ledger of every tracked file mentioning a wide tmux
kill, plus a shell-text scanner with a named-files allowlist. This PR adds
scripts/tests/test_tmux_restore_trigger.py, whose class docstring explains why
the service needs `ConditionPathExists=` and names the wide-kill command in
doing so.

Both PRs are green ALONE. The merge is red β€” two failures:

  test_every_kill_server_call_site_in_the_repo_is_classified
    added: ['scripts/tests/test_tmux_restore_trigger.py']
  test_no_tracked_shell_text_writes_a_kill_this_guard_would_deny
    shell text this guard denies, outside the named files:
    [('scripts/tests/test_tmux_restore_trigger.py', <the wide-kill spelling>)]

This is the ledger working exactly as designed β€” it is two-way precisely so a
new file has to be classified by a human rather than silently joining the set.
Zero file overlap between the two diffs, which is why nothing before the merged
tree could see it. It is also the textbook case from claude/RULES.md: disjoint
files are not safety, and the trigger is the base MOVING.

The mention is PROSE, one line of a class docstring about the event; that file
executes no tmux kill at all. So it is classified in _KILL_MENTION_LEDGER with
that reason, and named in the shell-text scanner's allowlist.

Red-at-base confirmed rather than assumed: 2 failed / 1 passed before this
change, 3 passed after, with the scanner's own positive control green
throughout β€” so the pass is not a scanner wired to nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR
Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675
ZacxDev added a commit that referenced this pull request Sep 9, 2026
…mer (#1376)

* feat(tmux-restore): trigger on the tmux socket appearing, not a 45s timer

The unit destroyed the workspace it existed to restore. Measured 2026-09-06:
43 `claude --resume` conversations, silently. It was started by an
`OnActiveSec=45s` timer; on a cold boot nothing else has started tmux by
second 45, so tmux-session-restore.py's own `tmux new-session -d` created the
server INSIDE the unit's cgroup. The sends were delivered SUCCESSFULLY into
it. Then ExecStart returned and Type=oneshot + RemainAfterExit=no +
KillMode=control-group tore the cgroup down, taking the server and every
claude process with it. The unit reported Result=success.

A duration was never the variable, so this replaces the timer with the
observable the restore actually depends on: a tmux server that this unit did
not create, and therefore cannot destroy.

  * systemd.user.paths.tmux-session-restore watches %t/tmux-%U/default with
    PathChanged=, wanted by default.target.
  * The service is gated on ConditionPathExists= for the SAME socket, because
    PathChanged also fires on deletion (the operator's server exiting).
  * The service pins TMUX_TMPDIR=%t, so the socket the path unit WATCHES and
    the socket `tmux` QUERIES are one declaration rather than two that happen
    to agree. tmux's compiled-in default is /tmp, not %t.
  * No RemainAfterExit=yes: it is measured to work and is the wrong fix, since
    with KillMode=control-group the unit would then own the operator's server.

PathChanged rather than PathExists is not a style choice. systemd re-checks a
path unit's condition the moment the triggered unit terminates, so a STATE
that goes on being true re-fires forever: measured on this host, PathExists=
ran the oneshot 5 times in 8 seconds and left both units
Result=start-limit-hit β€” which under OnFailure=notify-failure@%n is a
DND-bypassing toast on every boot, strictly worse than the bug.

Also updates the operator-facing not-run advice in tmux-restore-observe.sh,
which still told the reader to wait for a 45s timer that no longer exists.

Adds nix_units.section() β€” a nested-section reader β€” because `Unit` is both a
section name and a directive name in a path unit, and directive() correctly
refuses rather than answering about the wrong one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQvw3A9KbFEUXVjtTAk4j
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d

* docs(tmux-restore): re-derive the exit-0 refusal reason against the new trigger

The exit-0 justification named a precondition this branch removes β€” "until the
unit is triggered on the tmux socket appearing rather than a fixed
OnActiveSec=45s, no server is the normal COLD-BOOT state". That is now false,
so the comment and the test docstring are re-derived rather than left asserting
it. The DECISION is unchanged (still exit 0); only the reason is new:
PathChanged fires on socket DELETION too, ConditionPathExists skips the
ordinary case, and what reaches the refusal is a race β€” which must not raise a
DND-bypassing alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApQvw3A9KbFEUXVjtTAk4j
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d

* fix(tmux-restore): close the trigger/precondition gap; correct three claims the audit measured false

Round-2 audit fixes on the socket-activation change.

FINDING 1 (deploy-blocker) β€” the path unit fires when the SOCKET FILE appears;
`cmd_restore` refused unless `tmux has-session` succeeded, which needs a
SESSION. tmux creates the socket in `server_start()` BEFORE sourcing its
config, and the first session is queued behind three blocking `run-shell`
plugin loads plus continuum's replay. Measured 2026-09-07 (continuum EXCLUDED,
so a lower bound): socket at t0+0.009s, first session at t_sock+0.098-0.112s,
path-triggered ExecStart reaching `has-session` at t_sock+0.065s in one run and
+0.288s in another β€” the outcome FLIPPED between runs. When it loses: refusal,
exit 0, Result=success, no OnFailure, and NO retry, because the socket is
created once. Silent no-restore.

`wait_for_tmux_server()` is a bounded poll (30s) between the trigger's
observable and the script's precondition. It bails INSTANTLY when the socket is
absent β€” the free discriminator, and the same shape as
`wait_for_workspace_to_settle`'s `no_server_after` bail, which exists because
#1351 once burned a 120s timeout in the nix sandbox. `sleep` and the socket path
are injected, so no test sleeps. Exceeding the bound falls through to the
refusal, which now names the wait and which of the two faults it was.

FINDING 2 β€” the exit-0 justification understated its own frequency. "A stale
socket from a SIGKILLed server, or a server with zero sessions" framed as
exotic the single most common way the branch was reached: every tmux server has
zero sessions for its first ~100ms, exactly the window the trigger fires in. The
conclusion (exit 0, do not toast) survives and does not depend on frequency; the
post-fix frequency is stated as UNMEASURED rather than re-guessed a third time.
The sweep found the same error in a second place the audit did not name β€”
`no_tmux_server_to_restore_into`'s docstring asserted zero sessions is "a state
the operator's workspace never sits in".

FINDING 3 β€” the claim that `tmux-restore-observe.sh` surfaces the quiet path was
FALSE: the resume verdict is gated on `sends != 0`, and a refused run logs zero
sends, so a boot where nothing was resumed returned RC_CLEAN. Fixed the
instrument rather than softening the claim: `refusals_logged=` is emitted from
the unit's journal and a new RC_REFUSED (6) arm reports it, outside that gate.

FINDING 4 β€” documented that the trigger depends on a `TMUX_TMPDIR` nobody
declares: losing it moves tmux's socket to /tmp and the path unit never fires,
silently. A fallback watch is argued against explicitly β€” `$TRIGGER_PATH` is
documented by systemd as lossy and not to be relied on, so a second watch would
reintroduce the trigger/query seam. `TestTheTriggerAndTheQueryNameOneServer` was
narrower than its description (both operands came from `nix/home.nix`); it now
carries a third side in the SCRIPT, plus its own positive control.

Also: the unit `Description` no longer claims it runs after continuum restores
(measured, it starts before); the `RemainAfterExit` comment is self-contained
instead of pointing at a PR body; `observe.sh`'s header premise, stale twice
over, is corrected; the `"45s"` substring ban is narrowed to phrases that can
only mean a fixed delay; `nix_units.section()`'s two silent preconditions are
documented.

Mutation-tested: 10 mutants, all KILLED by the specific guard's own message,
under PYTHONDONTWRITEBYTECODE=1, with a positive control and a byte-identical
restore check. One initially died for the WRONG reason (a message-less tuple
assertion firing first) and the test was reordered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d

* docs(tmux-restore): drop two unmeasured claims from the round-2 comment

'the single most common way this branch was reached' and 'a toast at 4am' were
both inferences written in the same paragraph that corrects an unmeasured
frequency claim. Which reachable state is most common was never measured; the
argument does not need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d

* fix(tmux-restore-observe): add rc 6 to the usage legend

The new RC_REFUSED arm made the legend in the usage `die` incomplete β€” it
listed 0-5 and the script can now exit 6. Nothing asserts this string, which is
why it was missed; it is the line an operator reads when they mistype the
subcommand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d

* test(guard): classify this PR's new test file in #1415's kill-mention ledger

MEASURED as a MERGED-TREE break, not on either branch: #1415 (merged as
176f412) added a two-way ledger of every tracked file mentioning a wide tmux
kill, plus a shell-text scanner with a named-files allowlist. This PR adds
scripts/tests/test_tmux_restore_trigger.py, whose class docstring explains why
the service needs `ConditionPathExists=` and names the wide-kill command in
doing so.

Both PRs are green ALONE. The merge is red β€” two failures:

  test_every_kill_server_call_site_in_the_repo_is_classified
    added: ['scripts/tests/test_tmux_restore_trigger.py']
  test_no_tracked_shell_text_writes_a_kill_this_guard_would_deny
    shell text this guard denies, outside the named files:
    [('scripts/tests/test_tmux_restore_trigger.py', <the wide-kill spelling>)]

This is the ledger working exactly as designed β€” it is two-way precisely so a
new file has to be classified by a human rather than silently joining the set.
Zero file overlap between the two diffs, which is why nothing before the merged
tree could see it. It is also the textbook case from claude/RULES.md: disjoint
files are not safety, and the trigger is the base MOVING.

The mention is PROSE, one line of a class docstring about the event; that file
executes no tmux kill at all. So it is classified in _KILL_MENTION_LEDGER with
that reason, and named in the shell-text scanner's allowlist.

Red-at-base confirmed rather than assumed: 2 failed / 1 passed before this
change, 3 passed after, with the scanner's own positive control green
throughout β€” so the pass is not a scanner wired to nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR
Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Sep 9, 2026
…socket trigger) are MERGED, SHI

Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675
ZacxDev added a commit that referenced this pull request Sep 10, 2026
…-back could overwrite a generation

Round-1 audit findings, all four, in one commit. The πŸ”΄ is a data loss inside
the mechanism this PR exists to build.

πŸ”΄ 1 β€” THE STAMP IS NOW UTC.

`list_generations` sorts on the NAME and pruning deletes from the older end, so
every ordering guarantee here rests on the stamp being monotonic. Local time is
not: it repeats an hour at every DST fall-back. Measured on this host's zone
(America/Winnipeg), the two instants 2026-11-01 06:00 and 07:00 UTC are BOTH
`20261101T010000` local β€” one stamp, two saves. The anchor then parsed that
ambiguous string with `time.mktime`, `stamp > newest` could never be satisfied,
and the fall-through returned an OCCUPIED stamp: a bound session id and its
cheat-sheet destroyed at rc 0 with nothing printed, while `prune_generations`
reported `0 pruned`. The docstring called that "a bounded, VISIBLE loss"; it was
not visible by any means.

`generation_stamp` uses `time.gmtime`, and the anchor uses `calendar.timegm` to
match. The resurrect-style format is unchanged β€” only the clock. Nothing parses
these names as local time.

πŸ”΄ 1b β€” AN EXHAUSTED SEARCH NOW RAISES instead of returning an occupied stamp.
A save that fails loudly costs one save; a save that clobbers costs the bound
plan it existed to protect.

🟑 2 β€” THE CONCURRENT-SAVE RACE THE DOCSTRING NAMED IS CLOSED.
`scripts/tmux-post-save.sh:21` backgrounds and disowns `save` with no lock, so a
manual save genuinely races the 15-minute hook. What was implemented covered the
SEQUENTIAL same-second case only. Two processes in the same second both saw the
slot free via `exists()`, both took the stamp, then interleaved over FIXED temp
names β€” measured: `FileNotFoundError` out of `os.replace`, a generation holding
one process's bytes under the other's rename, and `FileExistsError` out of
`os.symlink`. Now: the free-check and the claim are one `O_CREAT|O_EXCL`
operation, and `_write_atomic`/`_point_at` use per-process temp names.
`_write_atomic` also removes its own temp on failure β€” a leftover `.tmp` does not
match `_GEN_PLAN_RE`, so pruning would never reap it.

🟑 3 β€” `prune_generations` REPORTS ONLY WHAT IT DELETED.
The unlink `OSError` was swallowed and the stamp appended regardless. Measured:
`2 kept (max 1), 1 pruned` while NOTHING had been pruned. A persistent unlink
failure gives unbounded growth reported as healthy retention on every save.

🟑 4 β€” THE SHRINK REPORT CANNOT NAME A FILE THE SAME SAVE PRUNED.
`protect=(stamp,)` did not cover `previous_gen` β€” the file the recovery command
names. Measured at KEEP_GENERATIONS=1: the report named a path whose `exists()`
was False. This file's own rule is that a warning pointing at the wrong file is
worse than none.

RED AT BASE / GREEN AT HEAD β€” the matrix, per test

Base = this branch merged with main (d732106), source reverted, tests kept.

  RED at base, green at HEAD (regression coverage):
    test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[America/Winnipeg]
    test_an_exhausted_stamp_search_REFUSES_instead_of_overwriting
    test_a_concurrent_save_cannot_take_a_stamp_another_save_claimed
    test_write_atomic_temp_names_are_per_process
    test_prune_reports_only_what_it_actually_deleted
    test_the_shrink_report_never_names_a_file_this_save_just_pruned

  GREEN at base β€” labelled INVARIANT GUARDS in their own docstrings, not
  counted as regression coverage:
    test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[UTC]
      the non-vacuity control: in a zone without DST there is no collision, so
      this is what proves the Winnipeg parametrisation is doing the catching.
    test_a_save_inside_a_repeated_local_hour_does_not_destroy_a_generation[both]
      the end-to-end loss is NOT deterministic from a test: glibc's `mktime`
      tie-break for a repeated hour is unspecified, and in this harness it
      resolves so the base anchor steps forward and no loss occurs. The audit
      measured it going both ways. Kept because it pins the property on the real
      `cmd_save` path; labelled so nobody reads it as evidence the bug is caught.

TWO FIXTURE ERRORS OF MY OWN, FOUND BY WATCHING THE TESTS AT BASE

Both would have shipped as coverage that catches nothing:

  * The DST tests first used epoch `1793440800`, which is 2026-10-31 β€” a day off
    the transition. The fixture never entered the repeated hour and BOTH
    parametrisations passed at base. The constant is now DERIVED, with the
    derivation recorded in the test.
  * `test_an_exhausted_stamp_search…` first occupied a contiguous run of stamps,
    which does not exhaust the search at all: the anchor JUMPS PAST `newest`, so
    the slot after the newest generation is always free. It scored DID NOT RAISE
    β€” the guard was unreachable, not working. It now holds the listing empty
    while the files exist, which is the actual race (another process claimed
    them between this caller's listing and its claim).

Change-scoped: 187 passed across test_tmux_session_restore.py,
test_tmux_restore_observe.py and test_tmux_restore_trigger.py. The #1415
kill-mention ledger still passes with its own positive control (this change adds
no new file). Per CLAUDE.md as of today, no full-tier run: CI is advisory and the
local full-suite ritual is retired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR
Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675
ZacxDev added a commit that referenced this pull request Sep 10, 2026
…r destroys the claude session bindings (#1383)

* fix(tmux-restore): keep TIMESTAMPED plan generations β€” a bad save no longer destroys the bindings

MEASURED data loss, workbench 2026-09-06:

  21:47:17  a good plan β€” 47 entries, 46 carrying a bound session id
  21:54:21  the tmux server died, taking 47 claude conversations
  22:09:40  a continuum autosave fired on the DEGRADED post-crash workspace
            and `cmd_save` overwrote ~/.config/initiatives/restore-plan.json
            with 10 entries. The cheat-sheet went in the same second.
            NO BACKUP EXISTED.

The conversations were recovered only because tmux-resurrect keeps its saves
TIMESTAMPED (`tmux_resurrect_<ts>.txt` + a `last` symlink) and each pane line
happens to carry a full `claude --resume <id>`; 24 were rebuilt from that file.
THAT ASYMMETRY WAS THE BUG: a bad save cost the LAYOUT nothing and the BINDINGS
everything.

So mirror what resurrect already does. Each `save` writes an immutable
generation into `restore-plans/restore-plan_<ts>.json` (+ the cheat-sheet, which
had the identical defect from the identical writer) and repoints
`restore-plan.json` / `restore-cheatsheet.md` at it as relative symlinks. Every
existing reader β€” `cmd_restore`, `plan_staleness_hours`, `cmd_show`, `--plan`,
`tmux-restore-observe.sh` β€” goes on reading the same two paths; `stat()` and
`Path.exists()` follow symlinks, so `basis=layout` and the observe script's
mtime reads are unchanged.

What this deliberately does NOT do: refuse a shrinking save. The operator
closing windows is ordinary use, so a guard on a falling entry count would fire
on ordinary use and train everyone to bypass it. The degraded save is still
written β€” it is simply no longer the only copy. What a shrink gets is a WARNING
keyed on BOUND SESSION IDS (not the entry count: dropping five unbound windows
loses nothing resumable and must stay quiet) naming the previous generation and
the exact `restore --plan <path>` that recovers it.

RETENTION: 192 generations, a COUNT rather than an age. The writer is hook-
driven, so an age bound gives no bound on disk at all; a count bounds disk
whatever the cadence does, at the price of a cadence-dependent span, which is
stated rather than hidden. 192 is 48h at continuum's 15-min interval β€” long
enough to outlast a crash noticed the following evening (~20h) or over a
weekend (~40h). Cost: the live 10-entry plan measures 3,820 B + 3,267 B, so a
47-entry generation pair is ~33 KB and 192 of them ~6.3 MB.

Also fixed, same writer, same shape:
  * `_write_atomic` β€” `write_text` truncates first, so a save killed mid-write
    left a zero-byte plan. Temp file + `os.replace`.
  * `free_generation_stamp` β€” the stamp has one-second resolution and the
    incident was a same-second write. A manual `save` racing the 15-min hook
    would have shared a stamp and clobbered the previous generation from inside
    the mechanism built to stop that. It must also be strictly NEWER than
    everything present, not merely free: a first draft returned the slot pruning
    had just freed, the new generation sorted OLDEST, and the run reported
    `4 kept (max 3), 0 pruned` β€” the retention cap silently unenforced. Caught
    by its own test; a backwards clock reproduces it with no race.
  * `adopt_pre_generation_files` β€” the DEPLOY of this change must not itself be
    the bad save. A host still on the old writer has a regular file at the
    pointer path holding possibly the only good plan; it is copied in as a
    generation before the pointer moves.
  * `prune_generations(protect=…)` β€” pruning is the only code here that deletes,
    so it is the only code that can recreate the defect. It refuses to unlink
    the generation the pointer was just aimed at, whatever the ordering
    argument does.

TESTS β€” 13 new, red/green matrix measured at base c5e425c:
  RED at base on the DATA LOSS (not on an AttributeError; the assertions use the
  test module's own `_bound_ids` and walk the state dir implementation-blind, so
  they measure "is the binding still on disk"):
    a_shrinking_save_does_not_destroy_the_previous_bindings   46 of 46 ids lost
    a_shrinking_save_does_not_destroy_the_previous_cheat_sheet no resume cmd left
    a_pre_generations_plan_is_preserved_by_the_first_new_save  46 of 46 ids lost
    a_save_dropping_bound_ids_names_the_recovery_command       no report at all
  plus 8 more red at base, all 13 green at HEAD (102 in the file).
  GREEN AT BASE BY DESIGN, labelled as invariant guards not regression coverage:
    a_shrinking_save_is_written_not_refused        (the constraint on the fix)
    a_shrink_that_drops_no_bindings_stays_quiet    (control: ids, not counts)
    the_pointer_still_reads_as_the_current_plan    (reader compatibility)

MUTATION SWEEP, 13 mutants under PYTHONDONTWRITEBYTECODE=1, fresh tree each,
every patch verified to have applied (a `str.replace` matching nothing scores
SURVIVED), each kill confirmed to carry THAT guard's own assertion text:
  12 KILLED β€” no-adoption, protect-ignored, prune-wrong-end, prune-never,
  report-on-count, report-silent, stamp-free-not-newest, raw-second-stamp,
  non-atomic-write, empty-plan-spends-a-slot, generations-dir-hardcoded, and
  the full revert to the in-place overwrite.
  1 SURVIVED and is explained, not waved away: removing ONLY the generation
  writes leaves adoption running, and adoption alone then provides a one-deep
  backup β€” the mutant fails to break the property rather than the test failing
  to see it. Reverting both together (M1b) kills both regression tests with
  their own messages, and the base-tree measurement above is the definitive
  form of that same mutant.
  Positive control for retention: 5 saves at keep=3 leave exactly the newest 3,
  with `0 pruned` asserted below the cap and `1 pruned` on the run that first
  exceeds it β€” a pruner wired to nothing cannot pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V1ysK1JBgdNyfqAt6gDKC
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d

* fix(tmux-restore-observe): `stat -L` the plan β€” GNU stat uses lstat, and the plan is now a symlink

Found by verifying the compatibility claim instead of asserting it. The previous
commit's message said the observe script's mtime reads were "unchanged" because
`stat()` follows symlinks. That is true of Python's `Path.stat()` β€” so
`plan_staleness_hours` really was unaffected β€” and FALSE of GNU `stat(1)`, which
uses **lstat** by default.

MEASURED on a link whose target was stamped 12:00:00:
    stat -c '%y' <link>   -> 2026-09-07 22:41:47   (the LINK was repointed then)
    stat -Lc '%y' <link>  -> 2026-09-07 12:00:00   (the PLAN was written then)

Both are timestamps, both render plausibly, and nothing in the output says which
one you are looking at β€” so `plan_mtime=` would have silently become "when the
pointer moved" and `plan_layout_skew_seconds` would have compared the pointer's
repoint time against the layout's write time: two writers that are not the two
that line claims to compare. In practice the link is repointed in the same
second the generation is written, so the numbers would usually have looked
right, which is what makes it worth pinning rather than shrugging at.

Both `$PLAN` sites now dereference. The layout sites are deliberately left
alone: `replayed_layout`/`newest_layout` glob `tmux_resurrect_*.txt`, which are
always real files β€” `<resurrect-dir>/last` is a symlink but is never what those
reach. `[ -f "$PLAN" ]` needs no flag either; `test` dereferences already.

TEST: test_the_plan_mtime_is_the_PLANS_not_the_symlinks β€” a real generations
dir plus a pointer into it, layout mtime 1 and generation mtime 1001.
RED at base c5e425c on the skew: "the plan/layout skew was not computed from
the plan GENERATION's mtime (1001) against the layout's (1) β€” `stat` read the
symlink". Green at HEAD (44 in that file).

The skew is asserted as the exact number 1000 rather than as "present" because
the two candidate answers differ by ~1.8e9 seconds β€” arithmetic separates them
and a presence check cannot. The `plan_mtime=` expectation is DERIVED by running
`stat -c %y` on the generation rather than written as a literal: epoch 1001
renders as 1969-12-31 or 1970-01-01 depending on the host offset, and the first
draft asserted `1970-` and failed at -0600. A test that passes in one timezone
is the config-blind suite, not coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019V1ysK1JBgdNyfqAt6gDKC
Claude-Session-Id: 5542cd95-4967-4463-8fe2-0f0a75194e9d

* fix(tmux-restore): the generation stamp was LOCAL time, so a DST fall-back could overwrite a generation

Round-1 audit findings, all four, in one commit. The πŸ”΄ is a data loss inside
the mechanism this PR exists to build.

πŸ”΄ 1 β€” THE STAMP IS NOW UTC.

`list_generations` sorts on the NAME and pruning deletes from the older end, so
every ordering guarantee here rests on the stamp being monotonic. Local time is
not: it repeats an hour at every DST fall-back. Measured on this host's zone
(America/Winnipeg), the two instants 2026-11-01 06:00 and 07:00 UTC are BOTH
`20261101T010000` local β€” one stamp, two saves. The anchor then parsed that
ambiguous string with `time.mktime`, `stamp > newest` could never be satisfied,
and the fall-through returned an OCCUPIED stamp: a bound session id and its
cheat-sheet destroyed at rc 0 with nothing printed, while `prune_generations`
reported `0 pruned`. The docstring called that "a bounded, VISIBLE loss"; it was
not visible by any means.

`generation_stamp` uses `time.gmtime`, and the anchor uses `calendar.timegm` to
match. The resurrect-style format is unchanged β€” only the clock. Nothing parses
these names as local time.

πŸ”΄ 1b β€” AN EXHAUSTED SEARCH NOW RAISES instead of returning an occupied stamp.
A save that fails loudly costs one save; a save that clobbers costs the bound
plan it existed to protect.

🟑 2 β€” THE CONCURRENT-SAVE RACE THE DOCSTRING NAMED IS CLOSED.
`scripts/tmux-post-save.sh:21` backgrounds and disowns `save` with no lock, so a
manual save genuinely races the 15-minute hook. What was implemented covered the
SEQUENTIAL same-second case only. Two processes in the same second both saw the
slot free via `exists()`, both took the stamp, then interleaved over FIXED temp
names β€” measured: `FileNotFoundError` out of `os.replace`, a generation holding
one process's bytes under the other's rename, and `FileExistsError` out of
`os.symlink`. Now: the free-check and the claim are one `O_CREAT|O_EXCL`
operation, and `_write_atomic`/`_point_at` use per-process temp names.
`_write_atomic` also removes its own temp on failure β€” a leftover `.tmp` does not
match `_GEN_PLAN_RE`, so pruning would never reap it.

🟑 3 β€” `prune_generations` REPORTS ONLY WHAT IT DELETED.
The unlink `OSError` was swallowed and the stamp appended regardless. Measured:
`2 kept (max 1), 1 pruned` while NOTHING had been pruned. A persistent unlink
failure gives unbounded growth reported as healthy retention on every save.

🟑 4 β€” THE SHRINK REPORT CANNOT NAME A FILE THE SAME SAVE PRUNED.
`protect=(stamp,)` did not cover `previous_gen` β€” the file the recovery command
names. Measured at KEEP_GENERATIONS=1: the report named a path whose `exists()`
was False. This file's own rule is that a warning pointing at the wrong file is
worse than none.

RED AT BASE / GREEN AT HEAD β€” the matrix, per test

Base = this branch merged with main (d732106), source reverted, tests kept.

  RED at base, green at HEAD (regression coverage):
    test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[America/Winnipeg]
    test_an_exhausted_stamp_search_REFUSES_instead_of_overwriting
    test_a_concurrent_save_cannot_take_a_stamp_another_save_claimed
    test_write_atomic_temp_names_are_per_process
    test_prune_reports_only_what_it_actually_deleted
    test_the_shrink_report_never_names_a_file_this_save_just_pruned

  GREEN at base β€” labelled INVARIANT GUARDS in their own docstrings, not
  counted as regression coverage:
    test_a_generation_stamp_is_monotonic_across_a_DST_fall_back[UTC]
      the non-vacuity control: in a zone without DST there is no collision, so
      this is what proves the Winnipeg parametrisation is doing the catching.
    test_a_save_inside_a_repeated_local_hour_does_not_destroy_a_generation[both]
      the end-to-end loss is NOT deterministic from a test: glibc's `mktime`
      tie-break for a repeated hour is unspecified, and in this harness it
      resolves so the base anchor steps forward and no loss occurs. The audit
      measured it going both ways. Kept because it pins the property on the real
      `cmd_save` path; labelled so nobody reads it as evidence the bug is caught.

TWO FIXTURE ERRORS OF MY OWN, FOUND BY WATCHING THE TESTS AT BASE

Both would have shipped as coverage that catches nothing:

  * The DST tests first used epoch `1793440800`, which is 2026-10-31 β€” a day off
    the transition. The fixture never entered the repeated hour and BOTH
    parametrisations passed at base. The constant is now DERIVED, with the
    derivation recorded in the test.
  * `test_an_exhausted_stamp_search…` first occupied a contiguous run of stamps,
    which does not exhaust the search at all: the anchor JUMPS PAST `newest`, so
    the slot after the newest generation is always free. It scored DID NOT RAISE
    β€” the guard was unreachable, not working. It now holds the listing empty
    while the files exist, which is the actual race (another process claimed
    them between this caller's listing and its claim).

Change-scoped: 187 passed across test_tmux_session_restore.py,
test_tmux_restore_observe.py and test_tmux_restore_trigger.py. The #1415
kill-mention ledger still passes with its own positive control (this change adds
no new file). Per CLAUDE.md as of today, no full-tier run: CI is advisory and the
local full-suite ritual is retired.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jdbmhCKa6edhTmiADsziR
Claude-Session-Id: 097b404c-db17-4472-bd37-dc90cf8fa675

---------

Co-authored-by: Claude Opus 5 <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.

1 participant