#3483: a hook that was wired correctly, ran every session, and reclaimed nothing

Branch fix/3483-cache-reclaim · closes #3483

lifecycle/stale-cache-cleanup is registered in both hooks.json and entries/lifecycle.ts, so it fired at every SessionStart. It had reclaimed nothing, ever. Its CACHE_DIR pointed at .../cache/orchestkit/ork; every prerelease install is orchestkit/ork-alpha. readdirSync threw ENOENT, a bare catch {} swallowed it, and the hook returned success.

This is the #959 dead-hook class inverted: fully wired and still dead. No registry-closure test can catch it, because the registration is correct and only the path is wrong.

What it cost, measured

$ ls ~/.claude/plugins/cache/orchestkit/
  ork-alpha                       # the hook looked for "ork"

$ ls ~/.claude/plugins/cache/orchestkit/ork-alpha | wc -l
  15                              # versions, none reclaimable
$ du -sh ~/.claude/plugins/cache/orchestkit/ork-alpha
  184M

The second defect, found while fixing the first

.in_use is not a marker. It is a directory of live pids.

#3483 reads it as a boolean written on use and never cleared. It is not:

$ cat ork-alpha/10.0.0-alpha.35/.in_use/34263
  {"pid":34263,"procStart":"Sun Aug 16 21:34:06 2026"}

Census across all 15 versions: 39 entries, 11 live and 28 stale, with live sessions spread over two different versions (alpha.34 and alpha.35). The old policy, "keep the 2 newest", consulted this not at all.

So the path bug was hiding a second one. Had CACHE_DIR been correct, that policy would have deleted a plugin out from under a running session. The `procStart` field is there precisely to survive pid reuse, which is a strong hint the mechanism was designed to be read.

The fix

beforeafter
one hardcoded channel, orkenumerates channels under the marketplace root
bare catch {}, ENOENT reads as "clean""not installed" and "unreadable" are distinct outcomes
deletes by recency alonea version held by a live pid survives regardless of age
no failure posturefails closed: unreadable .in_use counts as held

Fail-closed is deliberate: over-retaining costs disk, under-retaining breaks a live session.

compareVersions is deliberately unchanged

It looks broken for 10.0.0-alpha.35, since Number('0-alpha') is NaN. But NaN is falsy, so || 0 maps it to 0 on both sides, the comparison falls through, and the alpha ordinal in the next position decides. Verified against alpha.{9,21,30,35}: correct order. It works by accident, so it is now documented in place, because a well-meaning strict-parsing "fix" would break the ordering it currently gets right.

Evidence

The test drives the shipped bundle through run-hook.mjs against a fixture HOME, not the TS source. That is the point: the defect was a constant, and a source-level unit test carrying its own fixture would have mirrored the wrong constant instead of catching it.

--- 1: prunes old versions under the installed channel (ork-alpha) ---
  FAIL: 1.0.0 (oldest, stale) pruned expected present=no, got present=yes
  FAIL: 1.0.1 (stale) pruned expected present=no, got present=yes
--- 2: a live holder survives regardless of age ---
  FAIL: 1.0.1 (stale) pruned expected present=no, got present=yes
--- 3: works for the stable channel as well ---
  FAIL: 8.0.0 pruned expected present=no, got present=yes
RESULT: FAIL                                              exit 1
--- 1: prunes old versions under the installed channel (ork-alpha) ---
  PASS: 1.0.0 (oldest, stale) pruned      PASS: 1.0.2 (recent) kept
  PASS: 1.0.1 (stale) pruned              PASS: 1.0.3 (newest) kept
--- 2: a live holder survives regardless of age ---
  PASS: 1.0.0 (oldest but LIVE) kept      # the one with teeth
  PASS: 1.0.1 (stale) pruned              PASS: 1.0.3 (newest) kept
--- 3: works for the stable channel as well ---
  PASS: 8.0.0 pruned                      PASS: 8.0.3 kept
RESULT: PASS                                              exit 0

A bug in the first draft of this test, kept here because it is the same class

CH=$(make_fixture ...) ran the helper in a subshell, so the global FIX stayed empty in the parent. HOME="" then resolves to /.claude/..., the hook finds nothing, and the test fails in a way that looks exactly like the bug under test. Nothing was lost (the real cache was verified intact at 15 versions afterwards), but a green run under that mistake would have operated on the real $HOME. The test now sets globals directly and refuses to run if the fixture is missing rather than falling back.

typecheck clean · tests/hooks 7/7 · validate-counts PASS · hook count unchanged at 171. Self-contained: no external scripts, styles, or render proxies.