Skip to content

resume: hide sessions that burn started, show them with --all (#74) - #83

Merged
cverorg merged 23 commits into
mainfrom
fix/resume-hide-burn-sessions-74
Sep 14, 2026
Merged

cverorg merged 23 commits into
mainfrom
fix/resume-hide-burn-sessions-74

Conversation

@cverorg

@cverorg cverorg commented Sep 12, 2026

Copy link
Copy Markdown
Member

Closes #74.

What

  • burn records the session it started in a sidecar state/burn-sessions/<engine>/<tank> (<sid>\t<run_id>\t<epoch> per run): claude by the id it launches with (or the id the caller itself supplied), codex / antigravity by a before/after transcript-set diff — the ONE new transcript this run produced, never a guess. Ambiguous or empty runs record nothing.
  • resume's picker AND the home board's Continue list hide sidecar sessions by default; --all (or the a key in the picker) shows them, rows labelled [burn].
  • antigravity adapter: adapter_recent_sids sources candidates from the CLI's own cache/last_conversations.json (anchored key lookup, honours limit), transcript glob as fallback.
  • The sidecar has a lifecycle: clikae rename carries it, clikae remove deletes it, clikae clean prunes stale lines and caps it at 2000 (newest kept).
  • One canonical sid derivation per engine (adapter_sid_canonical), shared by burn's writer and resume's picker, so a burn session can actually be found and hidden.

Round-1 review fixes (this round)

Fixed all 4 P1 + 5 P2 from the round-1 adversarial review, one commit per finding:

  1. P1-1 — codex's picker derived a session id differently than burn wrote it (a hyphen-embedding uuid vs "everything after the last hyphen"); they never matched, so codex burn sessions were never actually hidden. One canonical adapter_sid_canonical per engine now.
  2. P1-2 / P2-3 — sidecar attribution is now PROVEN, not guessed. The old "newest transcript with mtime ≥ attempt start" heuristic attributed a human's own concurrent session to the lane that never touched it (and wrote ghost lines on dry/failed runs). claude uses the id it told the engine to use, confirmed to exist; codex/agy use a before/after transcript snapshot diff, recording nothing when it can't tell its own session apart from someone else's.
  3. P1-3burn claude … -- --resume <sid> (or -- --session-id <sid>) used to fail (rc=1): burn appended --session-id unconditionally, clashing with claude's own rule. It now checks adapter_sid_from_args first, the same gate switch.sh already uses.
  4. P1-4 — antigravity's cache lookup used a greedy sed 's/.*:…' that, on a real (compact, single-line) cache with more than one project's pointer, silently returned a DIFFERENT project's session. Anchored via a new shared json_value_for_key helper.
  5. P2-1 — a cache hit returned immediately regardless of limit, so the board's Continue list (limit 10) got back at most one agy candidate. Now falls through to the disk scan for the rest.
  6. P2-2 — the sidecar had no GC anywhere and the picker's read was O(sessions × sidecar lines) (8170ms measured at 20,000 lines). Now: rename/remove carry or delete it, clean prunes + caps it, and the read is one grep -n -F -x -f pass (214ms measured at 20,000 lines on this shared host, vs the review's 8170ms baseline).
  7. P2-4 — the picker's a key skipped the terminal-leaving cleanup the c key already does, so toggling --all off on an all-burn store printed "No sessions" inside the alt screen and it got wiped on the way out. Same three-step cleanup as c now.
  8. P2-5 — the board's R key already forwarded to clikae resume (one filter), but the board's own Continue list (a separate query) had no filter at all. Now shares the same sid-membership read as the picker.
  9. P3sCLIKAE_RESUME_ALL is a plain assignment, not export (it never needs to reach the resumed engine); docs/usage.md documents --all/a; the CHANGELOG declares agy --json's run_id going from null to a string.

🔴 Stacked branch notice: fix/agy-resume-visible-34 (#34) is built on this branch and independently rewrites adapter_recent_sids/adapter_resume_args in lib/adapters/antigravity.sh (dropping the $PWD cwd filter, a printf option-parsing fix). This round's P1-4/P2-1 commits touch the same function surgically (the cache-lookup anchor and the limit fallthrough) without restructuring it, but #34 will still need a rebase — it forked before these commits existed.

Tests

tests/bats/resume-hide-burn.bats grew from 7 to 28 cases covering every finding above (codex actually hides in resume, not just the sidecar; zero-transcript runs write no line; an unresolvable same-cwd collision records nothing; the claude argv-clash cases; etc.), plus new coverage in adapters/{claude,codex,antigravity}.bats, rename.bats, remove.bats, clean.bats, home.bats, and resume.bats. Full local suite: 1322/1322 across all 84 files, rc=0. shellcheck -S warning clean on every touched file. git merge-tree --write-tree origin/main HEAD — no conflicts.

Round-2 review fixes (this round)

Fixed the 1 P1 + 3 P2 from the round-2 adversarial review, one commit per finding, plus 4 of its P3s:

  1. P1-1 — the multi-candidate cwd tie-break compared each new codex transcript's recorded cwd against $PWD (this shell's cwd), not the cwd codex actually launched in (-C add_dirs[0], default dirname("$artifact")). Any --artifact outside $PWD, or an explicit --add-dir, made burn's OWN session stop matching — leaving a concurrent human session in $PWD as the sole "match", recorded and hidden. Threaded through a single _burn_launch_cwd variable, set where the launch argv is composed; agy's identical tie-break (which never overrides its own cwd) now reads the same variable instead of a bare $PWD.
  2. P2-2 — "proven" read adapter_find_session's exit code, but codex/grok both return 0 on a miss (empty stdout). Both new call sites now read the output.
  3. P2-3 — an explicit --resume <sid> named a transcript that already exists before the run starts, so "the transcript exists" was a tautology — recorded and hidden even when the engine refused to start. Now proven by rc == 0 OR the transcript's (mtime, size) changing across the run.
  4. P2-1 — an unrecognized engine directory under state/burn-sessions/ (a removed adapter, a downgrade) took the entire clikae clean down with it: load_adapter exit()s the process, and || true only catches a nonzero return. Same subshell-probe-then-load shape home.sh:193-201 already uses.
  5. P3s — i18n'd the picker's two remaining hard-coded English strings; fixed json.sh's stale comment (says head -n 1, the only caller uses tail -n 1); unified "valid sidecar line" into one definition shared by home.sh and clean.sh's GC (a hand-corrupted line was dead to the reader but alive to the GC, occupying a cap slot forever); fixed a temp-file cleanup that was structurally scoped to the wrong branch (unreachable today, pinned so it stays that way).

Not fixed in this round (left for a future pass, per the review's own scoping): P3-2 (a sed escape in antigravity.sh's cache-key regex misses + ? ( ) { } | — falls through to a correct disk scan today) and P3-6 (grok sessions never appear in _resume_all_sessions' glob at all, so its sidecar participation is currently moot).

🔴 #93 stacked-branch note: fix/agy-resume-visible-34 (#93) must rebase onto this branch's new HEAD and keep this round's json_value_for_key lookup in adapter_recent_sids#93's own cache-lookup rewrite (grep -oE ... | tail -n 1, no key match) would silently reintroduce round-1's P1-4 (a compact multi-project cache returning the WRONG project's session) the moment a merge conflict resolves toward its side. See the review for the full three-point semantic-conflict analysis.

Round-2 test additions

New coverage in tests/bats/resume-hide-burn.bats, tests/bats/clean.bats (including two direct _clean_burn_sidecar_gc unit tests), and tests/bats/i18n.bats's existing completeness check (now covering the two new T_K_TOGGLE_* keys across all 9 locales). Full local suite: 1330/1330 across all 84 files except one pre-existing, unrelated failure (home.bats's "every key the board binds is listed in the ? help overlay" — reproduced identically on af5729d itself, before any of this round's changes; not touched by this PR). shellcheck -S warning clean on every touched .sh file. git merge-tree --write-tree origin/main HEAD — no conflicts.

Round-3 review fixes (this round)

Fixed the 1 P1 from the round-3 adversarial review, plus its named P3-1/P3-2 tightening, two commits:

  1. P1-1 — round-2's P1-1 fix only patched the --prompt/--prompt-file path. Raw -- <cmd...> mode's _burn_launch_cwd stayed at its $PWD default no matter what the user's own argv said (the only two resets are both gated on prompt_set==1, which raw mode never sets) — but codex's own -C can be given directly after -- (burn --help's own raw example, burn.sh:118, is exactly -- exec -C /tmp …), so a caller who did that from a $PWD other than -C's target reproduced round-2's exact bug on this path: burn's own session stopped matching the cwd tie-break and a concurrent human session in $PWD got recorded and hidden instead. Added adapter_cwd_from_args next to adapter_sid_from_args (codex scans cmd[@] for -C <dir> / -C<dir> / --cd <dir>; claude/antigravity/grok define it too but always return empty — none of the three has a cwd-override flag). Raw mode now derives _burn_launch_cwd from the adapter, defaulting to EMPTY (never $PWD) when the adapter has no such hook or the flag isn't present, so an unresolved raw launch matches nothing and records nothing — never hide what is not proven. Also deleted the wrong comment this bug's own fix had introduced ("raw mode never overrides the engine's cwd" — it does).
  2. P3-1/P3-2 tightened — a --resume <sid> of an EXISTING transcript was recorded on "rc == 0 OR (mtime,size) changed" (an OR): a FAILED engine whose target transcript changed anyway (a concurrent human still typing into the SAME sid, untouched by burn's own attempt) still got recorded and hidden — a (mtime,size)-changed check is a "someone wrote it" detector, not an "the engine ran" detector. Now requires all three: rc == 0 (no longer an alternative), the transcript's byte size actually GREW, and no OTHER live process still has it open (fuser/lsof if present, else the one check is skipped with a log_warn saying so — conditions 1 and 2 still apply either way). R3-P3-1 itself (an explicit --resume of someone else's sid that the caller typed themselves, engine succeeds) is left as-is per the review's own scoping — a deliberate narrowing, not a defect.
  3. P3-3 (report correction, no code change) — the round-2 test-additions note above says home.bats's "every key the board binds is listed in the ? help overlay" test "reproduced identically on af5729d itself." Re-investigated this round: the test's own for key in $labels word-splits an UNQUOTED variable containing a literal ? — which bash then glob-expands against the invoking shell's cwd. On a cwd that happens to contain a single-character-named file (this machine runs many lanes out of a shared ~/lanes/, and one left a stray h), ? silently expands to that filename and the test misreports a bound-but-undocumented key that doesn't exist. It is not a real difference between any two commits — running from a clean mktemp -d cwd, the six-file targeted set is a real 443/443 ok, rc=0 (six files: resume-hide-burn grew from 20 to 23 cases with this round's additions), and the full 84-file suite is 1334/1334 ok across 4 chunks, all rc=0 (roam.bats's own previously-reported flake also did not reproduce this run). Filed as a finding, not fixed (it's a pre-existing bug in the test itself, outside this PR's touched files).

R3 review: 1 P1 (closed), the named P3-1/P3-2 tightening (closed), P3-3 (report/PR-body correction above), P3-4 (roam.bats's pre-existing red — confirmed unrelated to this PR, and did not reproduce at all this run: full suite is clean). The rest of P3-1's scope, P3-5 (agy want_esc escaping) and P3-6 (grok's non-participation in the sidecar) remain deliberately out of scope, per the review's own scoping — unchanged from round-2.

Round-3 test additions

New coverage in tests/bats/resume-hide-burn.bats: raw -- <cmd...> codex burn with an explicit -C attributes to its own launch dir and never hides a concurrent human session in $PWD (the round-3 review's probe B shape); the same raw mode without -C and two ambiguous candidates records nothing; burn --help's own raw -C example still records cleanly; a --resume <existing sid> whose engine fails (rc=1) while a concurrent human keeps typing into the SAME transcript (file grows) records nothing, where round-2's OR would have recorded and hidden it. shellcheck -S warning -x clean on every touched .sh file and on the full lib/** + bin/clikae. bash 3.2 GNU-ism scan: zero new hits (same two pre-existing comments as every prior round). docker run bash:3.2 bash -n clean on every touched file. git merge-tree --write-tree origin/main HEAD — no conflicts.

Round-4 review fixes (this round)

Fixed the 1 P2 and three of the six P3s from the round-4 adversarial review (0 P1), two commits:

  1. P2-1burn.sh:2778's multi-candidate cwd tie-break compared each new codex transcript's recorded cwd against _burn_launch_cwd with a plain string equality. In raw -- <cmd...> mode with no -C found, _burn_launch_cwd is empty by design (round-3 P1-1: "never hide what is not proven"). But adapter_session_cwd also returns empty when a candidate's session_meta header hasn't flushed its cwd field yet (a narrow but real race between codex creat()-ing the rollout and writing that line) — empty == empty made that candidate the sole "match", and its session got recorded into the sidecar and hidden from the default resume view. Exactly the defect this PR exists to fix, reintroduced via a different path. Fixed by requiring both sides non-empty before comparing.
  2. P3-1codex.sh's adapter_cwd_from_args didn't recognise the clap =-joined forms real codex 0.154.0 (measured) accepts: --cd=<dir> fell through to "no match" (an empty launch cwd, now correctly treated as unknown by the P2-1 fix), and -C=<dir> matched the looser -C?* case and returned =<dir> verbatim (direction-safe but silently dead — could never equal a real cwd). Both forms now handled explicitly.
  3. P3-3-equivalent (review's own numbering collides with an earlier round's P3-3; this is the review's "single candidate recorded unconditionally" finding) — once the --resume triple gate (rc==0 AND grew AND not open elsewhere) rejected an existing sid, control fell through to the before/after snapshot diff further down, whose own single-new-candidate branch has no rc check of its own — a DIFFERENT concurrent session minted during the same failed attempt was the sole "new" transcript and got recorded (and hidden) despite having nothing to do with the gate's verdict. A _burn_resume_gate_rejected flag now makes that rejection final.
  4. P3-5tests/bats/home.bats:949's for key in $labels word-split an unquoted variable containing a literal ?, which bash pathname-expands against the bats process's cwd — a stray single-character-named file silently swaps the ? token for that filename, and the ? guard right below never fires. Switched to a quoted while IFS= read -r key; do … done <<< "$labels". Fixed even though home.sh itself isn't in this PR's touched-file list, because the review's finding was specifically requested in this round's brief.

Left for a future pass, per the review's own scoping: R4-P3-2 (the third gate check has no positive-path test in the repo — only my own throwaway probes exercised it), R4-P3-4 (codex's app-server daemon may hold the rollout fd after the engine exits, making the third gate check a permanent no-op on codex — direction-safe, unverified), and R4-P3-6 (three items the review explicitly says are pre-existing and deliberately kept: an explicit --resume <sid> a caller typed themselves; antigravity's cache-key regex escape gap; grok's non-participation in the sidecar).

Round-4 test additions

New coverage: tests/bats/resume-hide-burn.bats — a raw -- <cmd...> codex burn WITHOUT -C, where one of two new candidates' recorded cwd is unreadable (id-only session_meta line), records nothing and the human stays visible (P2-1); a --resume <existing sid> whose engine fails the triple gate while a DIFFERENT concurrent session is minted records nothing (the gate-rejected fix). tests/bats/adapters/codex.bats — six direct unit tests for adapter_cwd_from_args covering every real codex argv shape (-C <dir>, -C<dir>, --cd <dir>, --cd=<dir>, -C=<dir>, and no flag at all). tests/bats/home.bats — the existing key-legend-parity test's loop is now quoted, plus a new test that plants stray single-char files (h, x) in cwd and confirms the scan is unaffected.

shellcheck -S warning -x clean on every touched .sh file and on the full lib/** + bin/clikae. bash 3.2 GNU-ism scan: zero new hits (same two pre-existing comments as every prior round). docker run bash:3.2 bash -n clean on every touched file. git merge-tree --write-tree origin/main HEAD — no conflicts. Specified 11 files (resume-hide-burn/burn/resume/home/clean/adapters/*): 452/452 ok, rc=0 (443 baseline + 9 new this round). Full suite (84 files, 6 chunks): 1343/1343 ok, 0 not ok (6 chunks, reverse-checked coverage against the exact 84-file list).

Round-2 fixes by KITT.

Built by antigravity (tank g); tests by codex (tank crazy). Round-1 fixes by KITT.

Round-3 fixes by KITT.

Round-4 fixes by KITT.

🤖 Generated with Claude Code

@cverorg
cverorg marked this pull request as ready for review September 12, 2026 17:48
`clikae resume` and the home board's picker listed every session on
disk; on the operator's Mac 478 of 499 antigravity sessions were
headless `clikae burn` one-shots nobody will resume. burn now records
the session it started in a sidecar (state/burn-sessions/<engine>/<tank>,
one `<sid>\t<run_id>\t<epoch>` line per run — claude by the id it
launches with, codex and antigravity by the newest transcript after the
run); the picker hides those by default, `--all` or the `a` key shows
them labelled [burn]. The antigravity adapter sources its recent list
from the CLI's own cache/last_conversations.json with the transcript
glob as fallback.

Built by antigravity (agy) on tank g; the title lookup it also touched
is dropped here because PR #80 carries it. bats to follow in a
separate commit — the build lane's harness forbids editing tests.
…sed (#74)

tests/bats/resume-hide-burn.bats (7 cases): the sidecar line carries the
sid claude was launched with; the non-tty listing hides it and --all
shows it labelled [burn]; codex and antigravity record the transcript
created after the run, not an older one with a later name; an agy sid
missing from the CLI's cache still resolves; `clikae clean` leaves the
sidecar directory byte-identical; malformed sidecar lines are ignored.

Writing them against the previous commit went 0/7: the claude launch
argv did not carry the session id, the sidecar was written under the
wrong root, and one construct was not Bash 3.2. All three fixed
minimally; the three files then pass together with burn.bats and
resume.bats (112/112).

Tests by codex (gpt-6-astra) on tank crazy; report in the lane worktree.
KITT added 9 commits September 13, 2026 15:40
adapter_sid_canonical <path> is now the single place claude/codex/antigravity
compute a transcript's session id. codex's uuid embeds hyphens of its own
(8-4-4-4-12), so resume's picker (${sid##*-}) kept only the uuid's OWN last
segment while burn's sidecar recorded the full id read from the file body -
the two never matched, so a codex burn session's sidecar line was byte-correct
and the row stayed visible anyway. clean.sh's _clean_session_is_live already
trusted "a codex uuid is always the trailing 36 characters" for its
live-session guard; apply the same fact in resume.sh's _resume_session_fields
(inline, not routed through load_adapter - this runs in clean.sh's per-session
hot loop and must not pay for a re-source on every mixed-engine row).

bats: adapters/{claude,codex,antigravity}.bats assert adapter_sid_canonical
directly and (codex) that it matches adapter_recent_sids's own derivation
byte-for-byte; resume-hide-burn.bats adds the codex hide A/B the review's
own fixture already exercised but never asserted.
… P1-3)

switch.sh:222-228's own gate, mirrored here. burn's extra args (after --, or
a raw --session-id) could already carry resume/session identity; appending
--session-id unconditionally fought claude's own rule (--session-id needs
--fork-session alongside --continue/--resume, verified live 2.1.267) and
turned a previously-working launch into rc=1 with no artifact at all.

adapter_sid_from_args (already used by switch.sh) is asked first; only mint
+ append when it says the launch is genuinely fresh. A caller-supplied sid
also becomes the one burn records - it is exactly as proven as a minted one.

bats: burn claude T -- -p ... --resume <sid> and ... --session-id <sid> both
rc=0 (were 1), sidecar holds the caller's own sid. burn.bats 142/142 green.
The cache extraction grep -E'd the whole MATCHING line, then let sed's greedy
`.*:` walk to whichever `: "` came LAST in that line. Every real agy install
writes last_conversations.json compact/single-line, so once it holds more
than one project's pointer, that walk silently returned a different
project's session - the board's Continue row (and Enter) resumed the wrong
conversation, not just a wrong preview string.

New shared helper json_value_for_key (lib/core/json.sh) isolates just the
"<key>":"<value>" pair via grep -oE before sed ever sees it - anchored to the
matched pair, not the whole line. Generalizes the same escape-aware pattern
grok.sh's _grok_json_str already used for its own single-key lookups.

bats: a compact two-entry cache (both key orderings) returns THIS cwd's sid,
never the other project's. antigravity.bats/resume-hide-burn.bats/burn.bats
all still green.
…P2-1)

adapter_recent_sids's cache path returned immediately on a hit, so any
limit greater than 1 still got back exactly one row - the board's Continue
list (limit 10) and home.sh's exclusion-pass retries (also 10) silently
collapsed to at most one agy candidate no matter how many real sessions the
directory actually had, and a second bare row on the same tank fell back to
the unfiltered guess R3-P2-1 had already fixed once.

limit<=1 keeps the original single-stat fast path (burn's own use) exactly
as it was. limit>1 keeps the cache hit and falls through to the disk scan
for the rest, deduped so it is never listed twice, then both sources are
ranked together by sessions_by_mtime as before.

bats: limit 10 with a cached hit + 3 disk-only sessions returns all 4, no
duplicate; limit 1 still short-circuits on the cache alone; a bigger ask
still caps at the given limit. home.bats (62/62), resume-hide-burn.bats
(14/14) and burn.bats (142/142) all still green.
… read (#74 P2-2)

The burn sidecar (state/burn-sessions/<engine>/<tank>) had no GC anywhere:
rename_tank_state carried the burn-order entry and dry marker across but not
this; remove.sh's rm -rf never touched it (out-of-dir state, keyed by name);
clikae clean deliberately left it alone. Every burn attempt (including infra
retries) appends a line forever.

- rename_tank_state / remove_tank_burn_sidecar (profile_store.sh): move it on
  rename, delete it on remove. Both translate antigravity -> "agy" for the
  sidecar path specifically - burn.sh has always stored agy's sidecar under
  that literal directory name, unlike every other piece of out-of-dir state.
- clean.sh's new _clean_burn_sidecar_gc: drops lines whose sid no longer has
  a transcript, caps what's left at CLIKAE_BURN_SIDECAR_CAP (default 2000,
  newest kept - the file is append-only, so newest = the tail). A file
  needing neither prune is left byte-identical (untouched mtime, no rewrite).
- resume.sh's picker: the O(sessions x sidecar-lines) `case` substring
  compare against the WHOLE accumulated sidecar per candidate is now ONE
  `grep -n -F -x -f` pass over every candidate at once. New shared
  _burn_sids_file (home.sh) is the one store read, reused by P2-5's board
  fix next.

Measured (this host, other lanes running concurrently, uptime posted
alongside): 5000-line sidecar 82ms (review's baseline: 3044ms), 20000-line
214ms (review's baseline: 8170ms) - ~38x at the reviewer's own case.

bats: rename/remove tests for the sidecar; clean.bats direct-calls
_clean_burn_sidecar_gc for the stale-drop, the cap, dry-run, and the
byte-identical no-op case. resume-hide-burn/burn/home/antigravity.bats all
still green (243/243 across this run).
#74 P2-4)

`c` (open clean, come back) does three things before rescanning: closes fd 3,
_home_tty_leave + drops the EXIT/INT/TERM trap, unsets _handle_key. `a`
(toggle --all) did none of them - it went straight back to _resume_picker's
rescan while still in the alt screen with stty -echo in effect.

Not theoretical: toggling --all OFF on a store that is now all-burn makes
`sessions` empty, which takes the "No sessions to resume yet" + exit 0 path
further down - printed INSIDE the alt screen, then wiped by the EXIT trap's
_home_tty_leave on the way out. The user pressed `a` and saw nothing happen.

bats: resume.bats + resume-hide-burn.bats stay green (31/31) - this class of
bug needs a real pty to observe directly (tests/tools/pty-smoke.py
territory); noted in the round's report as unverified by a live pty run.
The board's `R` key already forwards to `clikae resume` (one filter, not
two - the PR body's "two surfaces" framing was right about that half). But
_home_recent_rows (the Continue list: this dir's newest across engines) is a
SEPARATE query and had no burn filter at all - a lane's one-shot session,
being by definition the newest thing in the dir it just ran in, kept showing
up on the board's very first screen even with resume's own hiding in place.

Reuses _burn_sids_file (added in the P2-2 commit) so both surfaces read the
sidecar the same way. Filtered before the rank+cut, so a hidden row leaves
its slot for a real session instead of just shrinking the list.

bats: a burn session is absent from the Continue list by default and a real
session takes its slot; CLIKAE_RESUME_ALL=1 shows it. home.bats (63/63) and
resume-hide-burn.bats (14/14) still green.
- CLIKAE_RESUME_ALL is a plain assignment, not `export` (P3-2): it is only
  ever read inside this same clikae process (the picker's filter, the
  non-interactive list) and never needed by the resumed engine - exporting
  it let it leak into the engine's environment, the same class of incident
  claude.sh's CLIKAE_LAUNCH_SID comment already records.
- docs/usage.md:58's resume row now mentions --all / `a` alongside the other
  picker keys it already documented.
- CHANGELOG's #74 entry now describes both surfaces the fix actually covers
  (resume's picker AND the board's Continue list, P2-5) and the sidecar's
  proven-attribution + lifecycle (P1-2/P2-2), and declares agy --json's
  run_id going from null to a string (P3-4, already shipping in this PR,
  previously unannounced).

bats: a new resume.bats test drives `clikae resume --all <sid>` through a
stub engine that dumps its own env and asserts CLIKAE_RESUME_ALL is absent.
resume.bats (18/18) green. shellcheck -S warning clean across every file
this round touched.
@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P1-1 fixed (35bbdce) — codex's picker derived a session id via ${sid##*-} (last hyphen segment only), while burn wrote the full uuid read from the file body; they never matched, so codex burn sessions were recorded correctly but never actually hidden. Added adapter_sid_canonical <path> per engine (claude/codex/antigravity) as the one derivation both sides use. codex's version reuses the "a uuid is always the trailing 36 characters" fact clean.sh's _clean_session_is_live already trusted, applied inline in resume.sh for the hot path (no extra load_adapter). New bats: adapter_sid_canonical asserted directly per engine, plus a codex-specific "actually hides in resume" test (the previous test only asserted the sidecar's own content).

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P1-2 / P2-3 fixed (83c2d82) — sidecar attribution is now PROVEN, not guessed. The old "newest transcript with mtime ≥ attempt start" had nothing tying it to this run's own process, so a human's concurrent session in the same tank got attributed to the lane (and then hidden) — the inverse of what #74 asked for. It also wrote a ghost sid on every dry/failed run.

claude: sid_to_record is launch_sid only if adapter_find_session confirms a transcript for that exact id exists. codex/agy: new adapter_all_transcripts hook + before/after snapshot diff — candidates are transcripts that exist after launch and didn't before. Exactly one → record; zero → nothing; more than one → narrow by recorded cwd, and if still ambiguous, record nothing and print burn: could not attribute session (N candidates).

New bats: zero-transcript runs (agy + codex) write no line; an unresolvable same-cwd collision (agy) records nothing rather than guessing wrong; codex picks the cwd-matching rollout among several. burn.bats 142/142 still green.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P1-3 fixed (1d1265e) — burn claude … -- --resume <sid> (or a hand-typed -- --session-id <sid>) used to fail rc=1: burn appended --session-id unconditionally, clashing with claude's "--session-id needs --fork-session alongside --continue/--resume" rule. Now calls adapter_sid_from_args first — the same gate switch.sh:222-228 already trusts — and only mints+appends when the launch is genuinely fresh. A caller-supplied sid also becomes the one burn records (ties into P1-2: it's exactly as proven as a minted one). New bats: both argv shapes now rc=0 and the sidecar holds the caller's own sid.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P1-4 fixed (871ffaa) — the cache extraction grep -E'd the whole matching LINE, then let sed's greedy .*: walk to whichever : " came last in that line. Every real agy install writes last_conversations.json compact/single-line, so once it holds more than one project's pointer, that walk silently returned a different project's session. New shared json_value_for_key (lib/core/json.sh) isolates just the matched "<key>":"<value>" pair via grep -oE before sed ever sees it — generalizes the same escape-aware pattern grok.sh's _grok_json_str already used. New bats: a compact two-entry cache (both key orderings) returns this cwd's sid, never the other project's.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P2-1 fixed (0859dbf) — a cache hit returned immediately regardless of limit, so the board's Continue list (limit 10) and home.sh's exclusion-pass retries (also 10) got back at most one agy candidate no matter how many real sessions the directory had. limit<=1 keeps the original single-stat fast path unchanged (burn's own use); limit>1 keeps the cache hit and falls through to the disk scan for the rest, deduped, then both sources are ranked together as before. New bats: limit 10 with a cached hit + 3 disk-only sessions returns all 4 with no duplicate; limit 1 still short-circuits; a bigger ask still caps at the given limit.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P2-2 fixed (fc0b0bb) — the sidecar had no GC anywhere (rename didn't carry it, remove didn't delete it, clean deliberately left it alone) and the picker's read was O(sessions × sidecar lines) — 8170ms measured at 20,000 lines. Now: rename_tank_state/new remove_tank_burn_sidecar move/delete it (both translate antigravity → the literal agy directory burn.sh has always used for this one path); clean.sh's new _clean_burn_sidecar_gc drops lines whose transcript is gone and caps at CLIKAE_BURN_SIDECAR_CAP (default 2000, newest kept), leaving a file with nothing to prune byte-identical; the picker's read is one grep -n -F -x -f pass via new shared _burn_sids_file (home.sh), reused by P2-5 below.

Re-measured on this shared host (other lanes running concurrently, uptime posted in the commit): 5000-line sidecar 82ms (was 3044ms), 20000-line 214ms (was 8170ms) — ~38x at the reviewer's own case.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P2-4 fixed (d395d8a) — the picker's a key skipped the terminal-leaving cleanup the c key already does (exec 3>&-, _home_tty_leave + drop the EXIT/INT/TERM trap, unset -f _handle_key), going straight back into the alt screen. Concretely: toggling --all off on a store that's now all-burn makes sessions empty, taking the "No sessions to resume yet" + exit 0 path — printed inside the alt screen, then wiped by the EXIT trap on the way out. a now does the identical three-step cleanup before rescanning. Note: this class of bug needs a real pty to observe directly; I validated the fix structurally (an exact mirror of the already-pty-verified c path) and via bats/shellcheck, not a fresh pty run — flagged in the round's report as unverified by a live pty session.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P2-5 fixed (3521223) — confirmed the brief's "two implementations" framing didn't hold (the board's R key already forwards to clikae resume), but the board's own Continue list (_home_recent_rows — this dir's newest across engines, a separate query) had no burn filter at all. A lane's one-shot session, being by definition the newest thing in the dir it just ran in, kept showing up on the board's first screen even with resume's own hiding in place. Now reuses _burn_sids_file (from P2-2) so both surfaces read the sidecar the same way, filtered before the rank+cut so a hidden row leaves its slot for a real session. New bats: a burn session is absent from the Continue list by default and a real session takes its slot; --all shows it. PR body updated to describe both surfaces accurately.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P3s fixed (af5729d) — CLIKAE_RESUME_ALL is now a plain assignment, not export (it's only ever read inside this same process; exporting it let it leak into the resumed engine's environment — the same class of incident claude.sh's CLIKAE_LAUNCH_SID comment records). New bats drives clikae resume --all <sid> through a stub engine that dumps its own env and asserts the var is absent. docs/usage.md:58's resume row now documents --all/a. CHANGELOG's #74 entry now describes both surfaces the fix covers and declares agy --json's run_id going from null to a string (P3-4 — already shipping pre-round-1, just undocumented).

Not carried in this round: P3-3 (regex-escaping want_esc for cwd paths with + ? ( ) { } |) and P3-5 (sub-second attempt-epoch races) — noted as out of scope for this pass, not forgotten; they're small and independent of what shipped here.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

🔴 Notice for #34 (fix/agy-resume-visible-34, stacked on this branch): this round's P1-4 and P2-1 commits (871ffaa, 0859dbf) touch adapter_recent_sids in lib/adapters/antigravity.sh — the same function #34 rewrites (dropping the $PWD cwd filter for tank-scoped rows, plus a separate adapter_resume_args printf fix). I kept this round's changes surgical (the cache-lookup anchor via json_value_for_key, and the limit>1 fallthrough) rather than restructuring the function, but #34 forked before these commits existed and will conflict on the same hunks. #34 will need to rebase onto this branch's new HEAD once merged — its own analysis of the cache (limit==1-only fast path, tail -n 1 on grep -oE) is a reasonable design and doesn't need to change, just re-applied on top.

cverorg pushed a commit that referenced this pull request Sep 13, 2026
adapter_recent_sids filtered agy sessions by the session's recorded
history.jsonl "workspace" field matching $PWD. But every real agy
install records the same workspace ($HOME) for every conversation,
never the directory it actually ran in — measured on #34/#83:
607/607 indexed conversations, one distinct workspace value. So that
filter could never match outside $HOME, and the home board's Resume
rows for agy were permanently empty in any real project directory.

Fix (issue #34's "Option 1"): drop the $PWD filter. agy's Resume rows
are now every session in the active tank, newest first by transcript
mtime, relying on the caller's own cap ($limit / the board's
CLIKAE_HOME_RECENT_MAX, default 10) rather than a cwd match to keep
the list from flooding. Burn one-shots stay hidden through #83's
sidecar, unaffected by this.

The existing "last used" cache (antigravity-cli/cache/last_conversations.json)
is a single pointer, not an ordered multi-item list, so it can now only
answer a limit-1 lookup (burn's hot path) correctly; a multi-row request
(the board's continue list) always falls through to the mtime-ranked
disk scan, which is the only source that can rank multiple sessions
against each other — so a cache file being present no longer silently
truncates the board to one row.

Documented the trade-off in the adapter's docstring and in
docs/EXPECTATIONS.md ("Engines on one board"): agy's Resume rows are
tank-scoped, unlike claude/codex/grok, which do match $PWD.

Tests: replaced the bats test asserting the OLD (buggy) cwd-exclusion
behavior with one asserting inclusion regardless of cwd, plus new
coverage for newest-first + limit-cap ordering and a stale cache
pointer (deleted brain dir) being skipped without error — all at the
adapter level (tests/bats/adapters/antigravity.bats). Added two new
end-to-end board-level tests in tests/bats/home.bats using the verify
report's exact fixture (workspace=cwd, workspace=$HOME, no history
entry): from a non-$HOME cwd, all three now show on the board's Resume
rows, newest first, and a fourth test confirms CLIKAE_HOME_RECENT_MAX
still caps the list. Confirmed all six new/changed assertions red on
the old $PWD filter and green after the fix.

by KITT

Rebased onto #83's af5729d (#74's anchored, limit-aware cache path via
json_value_for_key). That preserved #83's cache-path code as-is (the
$want-keyed cache lookup + limit<=1/limit>1 split are burn's own
per-directory pointer, a different mechanism from the dropped disk-scan
$PWD filter) and applied this commit's tank-scoping only to the
disk-scan loop's cwd check, which it removes. Two of #83's own P1-4
regression tests ("cache lookup returns THIS cwd's sid ... from a
compact multi-entry cache", and its order-reversed sibling) asserted
the OTHER project's sid never appears in adapter_recent_sids' output
at limit=5 — true under the old cwd-scoped disk scan, no longer true
under tank-scoping, where that sid's own session now legitimately
surfaces via the disk scan like any other tank session. Narrowed both
to limit=1, the same isolation P2-1's own tests already use to test
the cache extraction alone without the disk scan folded in; the
anchoring assertion itself (this cwd's sid, not a later key's) is
unchanged and still passes.

by KITT
KITT added 4 commits September 13, 2026 18:53
…$PWD (#74 P1-1)

The multi-candidate attribution tie-break compared each new transcript's
recorded cwd against $PWD — this SHELL's working directory, not the cwd
codex actually ran in. codex always launches with -C add_dirs[0] (default:
dirname("$artifact")), so any --artifact outside $PWD, or an explicit
--add-dir, made burn's OWN session stop matching here, leaving a concurrent
human session in $PWD as the sole "match" — exactly what 83c2d82 was meant
to prevent, reintroduced by comparing against the wrong cwd.

Thread the cwd the engine was actually launched in through a single
variable (_burn_launch_cwd), set where the launch argv is composed
(add_dirs[0] once _burn_compose has run; $PWD is only correct when nothing
overrides the engine's own cwd). agy's identical tie-break at what's now
:707 never overrides its own process cwd via anything add_dirs-shaped, so
$PWD stays right there — threaded through as an explicit parameter instead
of a bare $PWD so both branches read from the same source of truth.
…t code (#74 P2-2)

Both new #74 call points into adapter_find_session read the EXIT CODE to
decide "proven" — but codex's and grok's implementations both return 0
(success) on a miss: no matching transcript on disk, empty stdout. Every
other caller in the repo already does the right thing (capture stdout,
check for empty), so these two were the only readers of a signal the
adapters never promise.

burn.sh: a caller-supplied --resume/--session-id sid (P2-3's "as proven as
a minted one" path) was recorded and hidden even when codex/grok had no
transcript for it at all — a ghost line, for real this time (codex accepts
an unknown resume target without erroring on it).

clean.sh: the sidecar GC's stale-prune read the same exit code, so it never
fired for a codex or grok line — the line stayed "live" forever regardless
of whether its transcript still existed.
…y ran (#74 P2-3)

A caller-supplied --resume/--session-id names a sid whose transcript
already exists BEFORE this run starts — "the transcript exists" (the
mint path's proof) is a tautology for it, true whether or not the engine
ever touched it. The old code recorded and hid that sid the moment the
engine was TOLD to resume it, even when the engine refused to start and
never read or wrote a single byte of the file (case 2 in the round-2
review: rc=1, "engine refused to start", sidecar written anyway).

Snapshot the resume target's (mtime,size) before launch — only when it
already existed pre-run, so the common mint-a-fresh-sid path (no pre-stamp)
is untouched — and only enforce the "did it change" gate when the engine
itself reports failure. A successful run (rc == 0) is proven the way it
always was; a failed one is proven only if the transcript moved.
…r kills the whole command (#74 P2-1)

load_adapter exit()s the WHOLE PROCESS on an adapter it can't find
(lib/core/adapter_loader.sh) — `|| true` only catches a nonzero return,
never an exit. This sidecar GC's own `load_adapter ... || true` sat
directly in clean's shell, not a subshell, so a removed custom adapter
(docs/adding-an-adapter.md), a downgrade, or any hand-placed directory
under state/burn-sessions/ took down `clikae clean` entirely: rc=1, zero
output, no GC, no Trash scan, no report — for a command that has nothing
to do with the broken adapter.

home.sh:193-201 already has the fix for this exact hazard: probe in a
subshell first (safe to let it exit there — only the subshell dies), and
only load for real in this shell once the probe has proven it won't. Same
shape here, plus a warning line naming the skipped engine instead of
silence.
KITT added 3 commits September 13, 2026 19:08
… P3-3)

The comment described "head -n 1" for a "want the first" caller, but the
only caller (antigravity.sh's cache lookup) pipes into "tail -n 1" and
wants the LAST match -- a repeated key there means an appended, not merged,
write, so the last line is the current value. Comment-only; behavior is
unchanged.
home.sh's _burn_sids_file (the picker's read side) and clean.sh's GC used
TWO DIFFERENT definitions of "valid burn-sidecar line": home.sh required
NF==3 with a numeric 3rd field; clean.sh's GC took whatever preceded the
first tab as a sid and asked nothing else. A hand-corrupted line (trailing
tab, non-numeric epoch, missing field) was dead to the READER (never hid a
session — correct) but ALIVE to the GC whenever its first field happened to
match a real transcript — kept forever, occupying one of
CLIKAE_BURN_SIDECAR_CAP's slots.

_BURN_SIDECAR_VALID_AWK is now the one definition (home.sh), used directly
by _burn_sids_file's awk pass and, via the new _burn_sidecar_line_valid
wrapper, by clean.sh's per-line GC loop.
 P3-5)

_burn_sids_file's temp file has its own lifetime (created once per picker
pass) but the rm -f that cleans it up sat inside the SAME if that gated the
match loop on ${#_rf_sid[@]} -gt 0 -- a zero-candidate pass would never
clean it up. Unreachable today (files empty exits earlier, at :743), so
this is pinned structurally rather than end-to-end: the shape is what was
wrong, not an observed leak.
@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P1-1 fixed in 578394d. The cwd tie-break compared each candidate's recorded cwd against $PWD (this shell's cwd) instead of the cwd codex actually launched in (-C add_dirs[0], default dirname("$artifact")). Threaded a single _burn_launch_cwd variable from the launch composition into the attribution step; agy's identical tie-break now reads the same variable instead of a bare $PWD. New bats coverage: burn with --artifact outside $PWD plus a concurrent human session in $PWD now correctly hides burn's own sid and leaves the human's visible; the --add-dir "$PWD" two-candidate tie still records nothing.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P2-2 fixed in 39d9f9c. Both new call sites read adapter_find_session's exit code, but codex/grok both return 0 on a miss (empty stdout). Now both read the output instead. New coverage: a codex run with an unmatched --resume-like sid writes no ghost sidecar line; _clean_burn_sidecar_gc now prunes a stale codex line.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P2-3 fixed in 657508a. An explicit --resume <sid> names a transcript that already exists before the run starts, so "the transcript exists" was a tautology — recorded and hidden even when the engine refused to start. Now proven by rc == 0 OR the transcript's (mtime, size) changing across the run (snapshotted before launch, only for a sid that already had a transcript). New coverage: burn claude --resume <existing sid> writes no sidecar line when the engine refuses to start.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P2-1 fixed in eb90f4e. load_adapter exit()s the whole process on an adapter it can't find; the sidecar GC's load_adapter ... || true sat directly in clean's own shell, not a subshell, so an unrecognized engine dir under state/burn-sessions/ took the entire clikae clean down (rc=1, zero output). Same subshell-probe-then-load shape home.sh:193-201 already uses, plus a warning line naming the skipped engine. New coverage: a state/burn-sessions/unknown-engine/ dir now runs to completion, rc 0, with one warning line.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P3-1 fixed in 4598cef. i18n'd resume.sh:343's keybar ("Toggle --all") and :638's help overlay ("Toggle burn sessions") via two new keys (T_K_TOGGLE_ALL, T_K_TOGGLE_BURN — mirroring the existing T_K_CLEANUP/T_K_CLEAN split), translated into all 9 supported locales.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P3-3 fixed in 96f50a7. json.sh:38's comment said head -n 1 for "callers that only want the first", but the only caller (antigravity.sh's cache lookup) uses tail -n 1 and wants the LAST match. Comment-only; behavior unchanged.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P3-4 fixed in 60e24a0. home.sh's reader and clean.sh's GC used two different definitions of "valid sidecar line" — a hand-corrupted line was dead to the reader (never hid a session, correctly) but alive to the GC whenever its first field happened to match a real transcript, occupying a CLIKAE_BURN_SIDECAR_CAP slot forever. _BURN_SIDECAR_VALID_AWK (home.sh) is now the one definition, used by both directly and via a new _burn_sidecar_line_valid wrapper.

@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

P3-5 fixed in 85c64a9. _burn_sids_file's temp file has its own lifetime, but the rm -f that cleans it up sat inside the same if that gated the match loop on ${#_rf_sid[@]} -gt 0 — a zero-candidate pass would never clean it up. Unreachable today (files empty exits earlier, at :743), so pinned structurally rather than end-to-end.

cverorg pushed a commit that referenced this pull request Sep 13, 2026
adapter_recent_sids filtered agy sessions by the session's recorded
history.jsonl "workspace" field matching $PWD. But every real agy
install records the same workspace ($HOME) for every conversation,
never the directory it actually ran in — measured on #34/#83:
607/607 indexed conversations, one distinct workspace value. So that
filter could never match outside $HOME, and the home board's Resume
rows for agy were permanently empty in any real project directory.

Fix (issue #34's "Option 1"): drop the $PWD filter. agy's Resume rows
are now every session in the active tank, newest first by transcript
mtime, relying on the caller's own cap ($limit / the board's
CLIKAE_HOME_RECENT_MAX, default 10) rather than a cwd match to keep
the list from flooding. Burn one-shots stay hidden through #83's
sidecar, unaffected by this.

The existing "last used" cache (antigravity-cli/cache/last_conversations.json)
is a single pointer, not an ordered multi-item list, so it can now only
answer a limit-1 lookup (burn's hot path) correctly; a multi-row request
(the board's continue list) always falls through to the mtime-ranked
disk scan, which is the only source that can rank multiple sessions
against each other — so a cache file being present no longer silently
truncates the board to one row.

Documented the trade-off in the adapter's docstring and in
docs/EXPECTATIONS.md ("Engines on one board"): agy's Resume rows are
tank-scoped, unlike claude/codex/grok, which do match $PWD.

Tests: replaced the bats test asserting the OLD (buggy) cwd-exclusion
behavior with one asserting inclusion regardless of cwd, plus new
coverage for newest-first + limit-cap ordering and a stale cache
pointer (deleted brain dir) being skipped without error — all at the
adapter level (tests/bats/adapters/antigravity.bats). Added two new
end-to-end board-level tests in tests/bats/home.bats using the verify
report's exact fixture (workspace=cwd, workspace=$HOME, no history
entry): from a non-$HOME cwd, all three now show on the board's Resume
rows, newest first, and a fourth test confirms CLIKAE_HOME_RECENT_MAX
still caps the list. Confirmed all six new/changed assertions red on
the old $PWD filter and green after the fix.

by KITT

Rebased onto #83's af5729d (#74's anchored, limit-aware cache path via
json_value_for_key). That preserved #83's cache-path code as-is (the
$want-keyed cache lookup + limit<=1/limit>1 split are burn's own
per-directory pointer, a different mechanism from the dropped disk-scan
$PWD filter) and applied this commit's tank-scoping only to the
disk-scan loop's cwd check, which it removes. Two of #83's own P1-4
regression tests ("cache lookup returns THIS cwd's sid ... from a
compact multi-entry cache", and its order-reversed sibling) asserted
the OTHER project's sid never appears in adapter_recent_sids' output
at limit=5 — true under the old cwd-scoped disk scan, no longer true
under tank-scoping, where that sid's own session now legitimately
surfaces via the disk scan like any other tank session. Narrowed both
to limit=1, the same isolation P2-1's own tests already use to test
the cache extraction alone without the disk scan folded in; the
anchoring assertion itself (this cwd's sid, not a later key's) is
unchanged and still passes.

by KITT
KITT added 2 commits September 13, 2026 21:36
… not $PWD (#74 R3 P1-1)

_burn_launch_cwd stayed at its "$PWD" default for the raw '-- <cmd...>' form
no matter what the user's own argv said: the only two places that ever reset
it (both _burn_compose call sites) are gated on prompt_set==1, which raw mode
never sets. codex's own -C can be given directly after `--` — burn --help's
own raw example (burn.sh:118) is exactly `-- exec -C /tmp …` — so a caller
who did that from a $PWD other than -C's target reproduced round-2's P1-1 on
this path verbatim: burn's own session stopped matching the multi-candidate
cwd tie-break and a concurrent human session in $PWD got recorded and hidden
instead.

Add adapter_cwd_from_args next to adapter_sid_from_args, one per adapter that
defines the latter: codex actually scans cmd[@] for `-C <dir>`, `-C<dir>`,
and `--cd <dir>`; claude/antigravity/grok define it too but always return 1
(none of the three has a cwd-override flag — codex is the only one whose
launch cwd can diverge from $PWD).

burn.sh's raw-mode branch (both the entry-point compose and the cross-engine
reroute) now asks the loaded adapter for the cwd out of cmd[@], defaulting to
EMPTY when the adapter has no such hook or the flag isn't present — an empty
launch cwd matches no candidate's recorded cwd, so an unresolved raw launch
records nothing rather than falling back to $PWD and misattributing a
concurrent session (never hide what is not proven). Also deletes the wrong
comment at the old burn.sh:2035 ("raw '-- <cmd...>' mode never overrides the
engine's cwd") — it does, that's the whole bug.

bats: codex burn with -C attributes to its own launch dir and never hides a
concurrent human session in $PWD (the reviewer's probe B shape); raw mode
without -C and two candidates records nothing; burn --help's own raw -C
example still records cleanly.

R3 review: 1 P1 (this).
…urrent reader (#74 R3 P3-1/P3-2)

Round-2's own judgment for a caller-supplied `--resume <sid>` of an EXISTING
transcript was "rc == 0 OR (mtime,size) changed" — an OR. A FAILED engine
whose target transcript changed anyway (a concurrent human still typing into
the SAME sid, while burn's own attempt never touched it) still got recorded
and hidden: a (mtime,size)-changed check is a "someone wrote it" detector,
not an "the engine ran" detector (R3 review P3-2).

Tightens cheaply to three conditions, all required:
  1. rc == 0 — no longer an alternative to the stamp check, required outright.
  2. the transcript's byte size actually GREW — the one direction a resumed
     transcript's own append-only log moves in; a bare (mtime,size)-changed
     check can't tell the engine's own write from someone else's.
  3. no OTHER live process still has the transcript open — a human's own
     concurrent `codex resume <sid>` / `claude --resume <sid>` on the SAME
     sid holds the file open the whole time burn's attempt runs, so it can
     grow and rc can land 0 purely from burn's side while none of the growth
     is burn's. Checked with fuser (preferred) or lsof; neither on PATH just
     skips this one check and says so via log_warn — conditions 1 and 2 still
     have to hold either way.

Any of the three false ⇒ nothing recorded. R3-P3-1 (an explicit --resume of
someone else's sid that the caller typed themselves, engine succeeds) is left
as-is — the R3 review names it a deliberate scope narrowing, not a defect.

bats: a --resume of an existing sid whose engine fails (rc=1) while a
concurrent human keeps typing into the SAME transcript (file grows) records
nothing — round-2's OR would have recorded and hidden it.

R3 review: this closes the P3-1/P3-2 tightening named in the round-3 fix
brief (not full closure of P3-1, which the review scopes out).
@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Round-3 fixes pushed (85c64a9..3bcc7af, 2 commits):

  • P1-1 fixed in 5d7fe32. Round-2's P1-1 only patched --prompt/--prompt-file; raw -- <cmd...> mode's _burn_launch_cwd never left its $PWD default, so codex's own -C (burn --help's own raw example) reproduced the exact same bug on that path. Added adapter_cwd_from_args per adapter; raw mode now derives the launch cwd from the argv, defaulting to EMPTY (not $PWD) when unresolved.
  • P3-1/P3-2 tightened in 3bcc7af. A --resume <sid> of an existing transcript now requires rc==0 AND the transcript growing AND no other live process holding it open (all three) instead of round-2's rc==0 OR (mtime,size) changed.
  • P3-3: re-investigated — the "existing home.bats failure reproduced on af5729d" claim in the round-2 report was an artifact of the invoking shell's cwd containing a single-character file (the test's for key in $labels glob-expands an unquoted ?), not a real commit difference. From a clean mktemp -d cwd: six-file targeted set 443/443 ok rc=0, full suite 1334/1334 ok rc=0 across 4 chunks. PR body corrected.

Receipts (red on 85c64a9 → green on HEAD, clean cwd, mktemp -d HOME/CLIKAE_HOME, stub engines): P1-1 raw-mode probe not okok; P3-2 human-still-typing probe not okok. shellcheck -S warning -x clean (touched files + full lib/**+bin/clikae). bash 3.2 scan: no new hits. git merge-tree --write-tree origin/main HEAD rc=0, no conflicts. CI: all platforms green on the new head (see checks).

KITT and others added 2 commits September 14, 2026 01:06
…s a match (#74 R4 P2-1)

R4 review P2-1: burn.sh:2778's multi-candidate cwd tie-break compared
each new codex transcript's recorded cwd against _burn_launch_cwd with a
plain string equality. In raw '-- <cmd...>' mode with no -C found,
_burn_launch_cwd is empty by design (round-3 P1-1: 'never hide what is
not proven'). But adapter_session_cwd also returns empty when a
candidate's session_meta header hasn't flushed its cwd field yet (a
narrow but real race between codex creat()-ing the rollout and writing
that line) — empty == empty made that candidate the sole 'match', and
its session got recorded into the sidecar and hidden from the default
resume view. Exactly the defect this PR exists to fix, reintroduced via
a different path (5d7fe32's empty default, unguarded at the compare
site).

Fix: require both sides non-empty before comparing. An unknown launch
cwd (or an unreadable candidate cwd) now correctly contributes zero
matches, same as the already-tested 'both readable but neither equal'
case just above it in the file.

Adds a red-on-3bcc7af regression test to resume-hide-burn.bats: two new
codex transcripts appear during a raw '-- <cmd...>' run with no -C
(burn's own, cwd readable; a concurrent human's, cwd unreadable) — the
human's session must stay unattributed and visible in 'clikae resume'.

Co-Authored-By: Claude <noreply@anthropic.com>
…verdict is final, quoted board-legend key labels (#74 R4 P3-1/P3-3/P3-5)

Three independent findings from the round-4 adversarial review, each
<=10 lines of code, bundled in one commit:

P3-1 — lib/adapters/codex.sh's adapter_cwd_from_args didn't recognise
the clap '='-joined forms real codex 0.154.0 accepts: '--cd=<dir>' fell
through to 'no match found' (an empty launch cwd — which the P2-1 fix
above now correctly treats as unknown rather than misattributing), and
'-C=<dir>' matched the looser -C?* case and returned '=<dir>' verbatim
(a value that can never equal a real cwd, so it silently never matched
anything either — direction-safe, but silently dead). Both '='-joined
forms are now recognised explicitly, ahead of the looser glob. Adds six
direct unit tests to tests/bats/adapters/codex.bats covering all argv
shapes (spaced, attached, both '=' forms, and the empty case).

P3-3 — once the --resume triple gate (rc==0 AND grew AND not open
elsewhere) rejected an existing sid, control fell through to the
before/after snapshot diff further down, whose own single-new-candidate
branch has no rc check of its own — a DIFFERENT concurrent session
minted during the same failed attempt was the sole 'new' transcript and
got recorded (and hidden) despite having nothing to do with the gate's
verdict. A new _burn_resume_gate_rejected flag now makes that rejection
final: the snapshot-diff fallback only runs when sid_to_record is empty
for a reason the gate never had an opinion on. Adds a regression test
to resume-hide-burn.bats: a failed --resume of an existing sid, with a
concurrent human's brand-new session as the only other new transcript.

P3-5 — tests/bats/home.bats:949's 'for key in $labels' word-split an
unquoted variable containing a literal '?' — bash pathname-expands it
against the bats process's cwd. On a clean cwd '?' just expands to
itself (a false red), but with any single-character-named file present
it silently expands to that filename instead, and the '?' guard right
below it never fires — the real '?' key quietly stops being checked.
Switched to a quoted 'while IFS= read -r key; do ... done <<< "$labels"',
which line-splits instead of word-splitting and never globs. Adds a
regression test that plants stray single-char files ('h', 'x') in cwd
and confirms the scan is unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
@cverorg

cverorg commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

Round 4 fixes for the round-4 adversarial review (0 P1, 1 P2, 3 of 6 P3 per this round's scope): 53901d9 (P2-1: empty-cwd tie-break match), 2dd80ff (P3-1: codex --cd=/-C= argv forms; the resume gate's rejection is now final; quoted home.bats key-legend loop + stray-file test).

Red-on-3bcc7af confirmed for both code findings via dedicated bats regression tests; unit-level red/green also captured directly for P3-1's argv parsing and P3-5's word-splitting divergence. Specified 11 files: 452/452 ok (443 baseline + 9 new). Full suite (84 files, 6 chunks, reverse-checked coverage): 1343/1343 ok, 0 not ok. shellcheck clean, bash 3.2 clean, merge-tree clean. CI: all 9 checks pass on 2dd80ff (run 34798830773) — shellcheck, signet, smoke test x2, pty smoke x2, pester, bats (ubuntu 11m56s), bats (macos 16m11s). PR is MERGEABLE/CLEAN.

@cverorg
cverorg merged commit 7051027 into main Sep 14, 2026
9 checks passed
@cverorg
cverorg deleted the fix/resume-hide-burn-sessions-74 branch September 14, 2026 09:03
cverorg pushed a commit that referenced this pull request Sep 14, 2026
adapter_recent_sids filtered agy sessions by the session's recorded
history.jsonl "workspace" field matching $PWD. But every real agy
install records the same workspace ($HOME) for every conversation,
never the directory it actually ran in — measured on #34/#83:
607/607 indexed conversations, one distinct workspace value. So that
filter could never match outside $HOME, and the home board's Resume
rows for agy were permanently empty in any real project directory.

Fix (issue #34's "Option 1"): drop the $PWD filter. agy's Resume rows
are now every session in the active tank, newest first by transcript
mtime, relying on the caller's own cap ($limit / the board's
CLIKAE_HOME_RECENT_MAX, default 10) rather than a cwd match to keep
the list from flooding. Burn one-shots stay hidden through #83's
sidecar, unaffected by this.

The existing "last used" cache (antigravity-cli/cache/last_conversations.json)
is a single pointer, not an ordered multi-item list, so it can now only
answer a limit-1 lookup (burn's hot path) correctly; a multi-row request
(the board's continue list) always falls through to the mtime-ranked
disk scan, which is the only source that can rank multiple sessions
against each other — so a cache file being present no longer silently
truncates the board to one row.

Documented the trade-off in the adapter's docstring and in
docs/EXPECTATIONS.md ("Engines on one board"): agy's Resume rows are
tank-scoped, unlike claude/codex/grok, which do match $PWD.

Tests: replaced the bats test asserting the OLD (buggy) cwd-exclusion
behavior with one asserting inclusion regardless of cwd, plus new
coverage for newest-first + limit-cap ordering and a stale cache
pointer (deleted brain dir) being skipped without error — all at the
adapter level (tests/bats/adapters/antigravity.bats). Added two new
end-to-end board-level tests in tests/bats/home.bats using the verify
report's exact fixture (workspace=cwd, workspace=$HOME, no history
entry): from a non-$HOME cwd, all three now show on the board's Resume
rows, newest first, and a fourth test confirms CLIKAE_HOME_RECENT_MAX
still caps the list. Confirmed all six new/changed assertions red on
the old $PWD filter and green after the fix.

by KITT

Rebased onto #83's af5729d (#74's anchored, limit-aware cache path via
json_value_for_key). That preserved #83's cache-path code as-is (the
$want-keyed cache lookup + limit<=1/limit>1 split are burn's own
per-directory pointer, a different mechanism from the dropped disk-scan
$PWD filter) and applied this commit's tank-scoping only to the
disk-scan loop's cwd check, which it removes. Two of #83's own P1-4
regression tests ("cache lookup returns THIS cwd's sid ... from a
compact multi-entry cache", and its order-reversed sibling) asserted
the OTHER project's sid never appears in adapter_recent_sids' output
at limit=5 — true under the old cwd-scoped disk scan, no longer true
under tank-scoping, where that sid's own session now legitimately
surfaces via the disk scan like any other tank session. Narrowed both
to limit=1, the same isolation P2-1's own tests already use to test
the cache extraction alone without the disk scan folded in; the
anchoring assertion itself (this cwd's sid, not a later key's) is
unchanged and still passes.

by KITT
cverorg pushed a commit that referenced this pull request Sep 14, 2026
…7 follow-up, round-2 review of #102)

With main's #74/#83 merged, the bare `clikae resume` picker hides sessions
that burn started, and `--all` shows them. The prefix candidate list does
include burn-started sessions, so the previous hint ("run `clikae resume`
to browse") could send the operator to a picker that hides the very
candidate that was cut. The hint now names `clikae resume --all`.

Receipt: the P3-7 test updated to the new wording; resume tests in
tmux-status.bats + resume.bats (including main's own --all test) 27/27 ok
on the merged tree.
cverorg pushed a commit that referenced this pull request Sep 14, 2026
…es (round-4 review P2-2)

Rebasing this PR onto current main pulls in #83 (`resume: hide sessions
that burn started`), which gave `cmd_burn` its own pre/post
`adapter_all_transcripts` snapshot calls -- two more `find` invocations
(codex.sh's `find "$1/sessions" ...`) that run inside the same timed
window as the engine attempt, before `_burn_result` pins `elapsed_s`. This
test's shim slept 1s on *every* `find` call, so after the merge those two
unrelated calls legitimately push `elapsed_s` to 2-3s -- the semantics
`elapsed_s` measures (engine time, not `_burn_left_behind` scan time) are
still correct, but the test's own ruler stopped being narrow enough to
isolate them. Limiting the sleep to the scan's own find shapes (discovery's
`-print0`, the file-list find's `-newer` sentinel) restores the original
invariant under #83's semantics instead of weakening the assertion.
cverorg pushed a commit that referenced this pull request Sep 15, 2026
…#34)

_home_recent_rows asked each adapter for exactly CLIKAE_HOME_RECENT_MAX
rows and only then filtered the burn-started sids out of them. Its own
comment claimed the filter ran "BEFORE the rank+cut ... or a hidden row
would just leave a gap instead of letting a real session take its slot" —
true of the rank+cut in this function, but every adapter had already cut
its answer to N on the way here. So N burn sessions newer than the human
ones handed the filter N rows it had to drop and left it nothing to
promote: the board's Resume block disappeared entirely.

That is the exact symptom #34 exists to fix, and this PR's tank-scoping
makes it reachable from ANY directory on an agy tank (before, agy's rows
were cwd-scoped and therefore empty out there anyway). clikae's own
dispatch doctrine runs long burn relays on agy tanks, so "more than ten
burn sessions newer than every human one" is the normal shape of such a
tank, not a corner case. claude and codex tanks have always had the same
hole (#74's filter, #83's sidecar) — it just took a burn-heavy tank to
reach it.

Fix, once, at the root rather than in three adapters: read the sidecar
BEFORE the tank walk, and ask each adapter for N + <hidden count> rows.
At most <hidden count> rows can be dropped, so N survivors are guaranteed
whenever N exist. The ask is bounded by the new CLIKAE_HOME_RECENT_SCAN_MAX
(default 200) because the sidecar itself is capped at 2000
(CLIKAE_BURN_SIDECAR_CAP) and no board wants 2010 rows per tank flowing
through a shell accumulator. With no sidecar, or under CLIKAE_RESUME_ALL=1,
the ask stays exactly CLIKAE_HOME_RECENT_MAX — the common path is unchanged.

Cost, measured on a 1,000-session agy tank (this host, two runs):
adapter_recent_sids is 4.7-5.0 s per call at limit=10, at limit=200 and at
limit=1000 alike — every adapter stats its whole tank before it
`head -n <limit>`, so a wider ask lengthens the sort's tail and nothing
else. Full board render 4.8-5.4 s with no sidecar, with a 190-line one
(ask=200) and with a 2000-line one (ask clamped to 200): all within the
host's own noise. The widened ask is free; the 5 s itself is the tank
scan, pre-existing and out of scope here.

Tests (tests/bats/home.bats): three new board-level cases, one per engine
with a session adapter — agy, claude and codex — each with 3 human
sessions (mtime 2020) and 12 burn sessions (mtime 2021, all recorded in
state/burn-sessions), CLIKAE_HOME_RECENT_MAX=10, run from a non-$HOME
directory. The board must show HUMAN-3/2/1 newest-first and no BURN row.
A fourth asserts CLIKAE_RESUME_ALL=1 still shows the burns. Confirmed red
on this branch's tip (all three engines: the Resume block absent entirely)
and green after the fix.

by KITT
cverorg pushed a commit that referenced this pull request Sep 15, 2026
… and five negations now assert

Round 9 (d396968) taught scripts/test.sh and ci.yml to shellcheck
`*.bats`. Main's newer test files (#83, #92, #98) were written before
that gate existed, so merging main turned it red on 12 findings. Two
of them are cosmetic; one is a test that did not test:

  * clean.bats (#83): five `! grep -qF … "$f"` lines in the middle of
    a test (SC2314). In bats a leading `!` does not fail the test —
    `set -e` ignores a negated command — so "the dead sid is gone",
    "the oldest three are dropped" were never asserted. Now
    `run ! grep …`, with `bats_require_minimum_version 1.5.0` as
    cockpit.bats already declares it. All 11 sidecar/burn tests in
    the file still pass, now with the assertions live.
  * wait.bats: `_mkstatus … done true` passes the word `done` as an
    argument (SC1010); quoted.
  * cockpit.bats: an unused loop variable (SC2034).

No production code changes.
cverorg pushed a commit that referenced this pull request Sep 15, 2026
Round-4 narrowed the `find` shim in the `elapsed_s` test to the scan's
own two shapes (`-print0` for discovery, `-newer` for the file list) so
#83's transcript-snapshot `find` calls would stop polluting the number.
That was right, but it bound the shim to the implementation's flags with
nothing asserting the binding still holds. The day discovery stops
passing `-print0`, the shim fires zero times, the scan costs ~0s, and
`[ "$json_elapsed" -lt 2 ]` passes unconditionally — the test goes
quietly empty instead of going red.

The shim now appends a line per shape it matches, and the test requires
both. Receipt: with only the shim's two patterns mutated so neither can
match (the implementation untouched), every pre-existing assertion in
that test still passes and the new one is the only thing that fails:

    not ok 1 burn #84 P2-3: elapsed_s agrees …
    # the scan find shim never fired — elapsed_s < 2 proved nothing

which is exactly the silence this asserts against.
cverorg pushed a commit that referenced this pull request Sep 15, 2026
…eview P3-6)

The P3s rounds 3, 4 and 5 deliberately did not fix existed only in the
review lanes' own report files. The PR body stopped at round 2, there was
no follow-up issue, and the CHANGELOG addendum said what was fixed and
never what was not — so merging #87 would have dropped all of them, and
the next round would have rediscovered them from scratch. This repo has
the convention already (#94 -> #110, #98 -> #103/#109, #83 -> #105).

Filed as #112 — ten items with file:line, what each is,
and which review round raised it: per-repo git timeouts that leave a
wedged repo reading `dirty 0`, `left_behind_truncated` mixing units,
newline-in-filename splitting, the `dirty`/`files` scope difference, the
silent no-git path, the untested single-pid fallback, two missing bats
coverages, the watchdog's inherited fds 5/9, and — first among equals —
that not one line of this bound has ever run on stock macOS, the
platform whose bash 3.2 the whole thing was written for.

CHANGELOG now carries the round-5 entry and points at that issue.
cverorg added a commit that referenced this pull request Sep 15, 2026
)

* home: bounded board reads — session-boundary indexes and a per-file reading cache (#62)

The board no longer walks transcript trees on every render. Session
discovery goes through per-tank boundary indexes (claude, codex, agy,
grok), recent candidates are capped per cwd and tank
(CLIKAE_HOME_RECENT_MAX, default 10), and every per-file parse (title,
recap, limit, success, agy email) is cached by kind+path+size+mtime,
including negative readings. Snapshots are published through unique
mktemp files so concurrent renders never read a half-written index.

Built by codex (gpt-6-astra) on tank crazy in two runs; report in the
lane worktree (REPORT-board62-build.md, not committed).

by KITT

* board: self-heal a stale or missing per-tank snapshot inline at render time

Round-1 fix review, P1-2/P1-3: a board snapshot only got (re)built at a
session boundary (clikae run/burn/agy's own switch), so it went stale the
moment anything reached an engine without passing through one of those three
call sites (clikae alias, clikae env, a .app bundle, relay, switch.sh's
ephemeral path) - permanently, for that tank, with no way to self-correct.
It also froze fuel (dry/limit) readings behind an arbitrary "snapshot
published within N seconds" age gate that was never a proxy for "the
underlying data is still accurate".

board_generation now does one cheap freshness check per read, per tank (a
directory mtime plus a bounded handful of file mtimes, both compared for
equality against what board_state_refresh recorded at publish time - never
">" against a wall-clock stamp, which would read a future-dated fixture or a
clock-skewed file as permanently stale) and rebuilds inline, only for the one
tank actually being read, the instant that check disagrees with what was last
published. board_read/board_recent/board_find all route through it, so
limit.sh's age-gate heuristic is no longer needed and is removed.

Also verifies board_key (a 32-bit cksum) lookups against the raw sid/scope
recorded at publish time, so a hash collision reads as a miss rather than
silently answering with a different session's data - the same guard
reading_cache_run already uses for its own key.

board_read gains an explicit <engine> argument (board_generation needs it to
know which scan root/scope rules apply); its handful of callers in limit.sh
and scan.sh are updated accordingly.

* home/adapters: restore live-row guessing and cross-project resume lookup

Round-1 fix review, P1-1: board mode disabled the whole tank-scoped title
guess for an unstamped live row, and the all-projects glob fallback for a
stamped row's sid outside the current PWD's project slug - not narrowing
either one, turning both off outright. Live rows are bounded by how many
tmux sessions exist, not by transcript count, so neither one was the O(all
transcripts) cost issue #62 was written to kill.

Each adapter's adapter_recent_sids/adapter_find_session (and codex/grok's
_codex_find_rollout/_grok_find_summary) now try the snapshot first - cheap,
and self-healing as of the previous commit - and only fall through to the
original live scan when the snapshot itself has nothing to say for this
exact query, never disabling the scan wholesale. home.sh's own _CLIKAE_BOARD
gates around the guess passes are removed to match: the adapter functions
already make the cost-vs-cost call internally now.

* run/antigravity: drop the post-launch board refresh, restore exec

Round-1 fix review, P2-1/P2-2: cmd_run and the agy switch used to wrap
adapter_run/exec agy in a subshell so a board_state_refresh could run before
and after the engine - which meant clikae stayed a resident parent for the
whole session instead of exec'ing away (different signal delivery, $PPID,
and a 128+N exit code instead of WIFSIGNALED when the engine is killed), and
paid a full tank scan synchronously on every interactive launch and every
exit.

Neither refresh is needed anymore: board_generation (previous commit)
rebuilds a stale tank inline the moment a render actually reads it, so there
is nothing left for a boundary call here to buy. Both call sites go back to
a bare tail call/exec, matching main.

* clean/reading_cache: GC board snapshots and cache entries, sub-second precision

Round-1 fix review, P2-3: state/board generations and state/readings entries
grew unbounded and clikae clean never touched either. board_state_refresh
already GC's its own tank's generations on every publish now
(board_gc_generations, keeping the newest few by mtime instead of an
mtime+1-day floor); clikae clean additionally sweeps state/board for tanks
that have not been launched (and so never re-published) in a while, and
state/readings for entries whose own source file no longer exists.

P3-1/P3-5: reading_cache_run's key only had whole-second mtime precision, so
a same-size overwrite within the same wall-clock second could read as a
cache hit. Its stat call now asks for GNU's fractional-seconds modifier / BSD's
F sub-format, detected once via the same _clikae_statv this repo already
uses elsewhere (never `stat -c ... || stat -f ...`: on a GNU machine `-f`
means --file-system and prints something else entirely, which is exactly
what _clikae_statv's own comment warns against).

* tests: cover the board self-heal, live-row, and exec-semantics fixes

- home-bounded.bats: the "missing state never triggers discovery" test
  encoded the P1-2 bug (an unpublished tank stays empty forever) as the
  expected behaviour; rewritten to assert the self-heal instead (discovers
  once, then reads bounded again). The "live rows read only the stamped
  transcript" test likewise encoded the P1-1 bug (an unstamped row never
  guesses in board mode); rewritten to assert the guess reads at most the one
  candidate it settles on. The run.sh test updated to assert cmd_run no
  longer calls board_state_refresh at all (exec semantics restored).
- home.bats: "bare clikae changes nothing on disk" now excludes state/ from
  the before/after comparison - the board cache's own inline self-heal is a
  deliberate write to clikae's OWN derived cache, not to session/profile
  source data, which is what the invariant actually protects.

* P1-A: files_mtime_size reports nanosecond precision, not whole seconds

board_stale (board_state.sh) compares this against what it recorded at
publish time to decide staleness. Whole-second precision made any write
landing in the same wall-clock second as the last publish invisible in
either direction: a limit landing mid-second read as fresh, and a
resolved limit stuck reading stale. _reading_cache_keyv already made
this exact fix for its own cache key; this brings files_mtime_size in
line with it.

* P1-C: add _codex_today_scan_dir, computed not discovered

Rollouts live three levels under sessions/ (sessions/YYYY/MM/DD/), so
sessions/ itself never took a new session's mtime as its own dirent
count didn't change. This adds a helper that resolves the deepest
EXISTING directory on today's date path (walking up from DD to MM to
YYYY to sessions/ itself) so board_state.sh's freshness check has a
directory whose mtime genuinely moves when a new rollout appears
today, whether it's the first session of an existing day (bumps the
DD dir) or the first of a new day (bumps whichever ancestor already
exists). No `find`: this runs on every render via board_stale, and
this repo's own test suite hard-fails any find call on a warm render.

* board62 round-2: close P1-A/P1-B/P1-C, tidy P3-1/P3-4

P1-A: recent/<key> rows now carry a size column alongside the
nanosecond-precision mtime (files_mtime_size), and board_stale compares
both. Previously it kept only a whole-second mtime and threw away the
size files_mtime_size already handed back, so a write landing in the
same wall-clock second as the last publish was invisible either way.
board_recent still hands callers the older, public whole-second
"<mtime>\037<sid>" contract (a separate display-mtime field, never the
staleness one) so home.sh's _human_age arithmetic keeps working.

P1-B: claude's fuel reading (claude-usage) scans projects/ in full —
it's account-level — but the only freshness signals were PWD-scoped, so
a limit landing in a different project directory never invalidated
this one's board. board_state_refresh now also records projects/'s own
mtime (catches a brand new project directory) and the (mtime, size) of
every file that was inside the -mmin -300 window at publish time
(catches an append to a session in a project that already existed) —
both O(bounded), never a tree walk. _claude_usage_stale checks these
BEFORE board_stale's per-scope early return, so a scope with no
recorded recent sessions still answers for an account-level limit.

P1-C: _board_scan_root's codex case now points at
_codex_today_scan_dir (see its own commit) instead of sessions/ itself.

P3-1: board_gc_generations' sort now breaks ties on directory name.
Several publishes inside the same wall-clock second all get
whole-second mtimes, which made the sort's outcome non-deterministic
(mktemp's XXXXXX suffix is random, so this doesn't guarantee `current`
survives a prune, but it does make the same disk state sort the same
way every time).

P3-4: load_adapter now runs before mktemp -d, not after — a load
failure no longer leaves an orphan generation directory behind.

* P2-A: adapter_session_cwd caches by its OWN transcript, not history.jsonl

The reading_cache identity used to be the shared history.jsonl, not
the one session's own transcript file — so any write to history.jsonl
(one new agy session anywhere) invalidated every OTHER session's
cached cwd at once. On a synthetic 500-session tank, adding one new
session turned into a 500-entry cache stampede: board_state_refresh
went from ~5.2s (history.jsonl unchanged) to ~9.9-10.1s (one new
line), because 499 unrelated sessions each re-paid a grep over
history.jsonl they didn't need to. Keying on the session's own
transcript file means an unrelated session's cache entry survives a
write elsewhere; only the session whose own transcript actually
changed re-derives its cwd. Measured before/after with
board_state_refresh timed directly on the same 500-session store:
refresh cost after adding one session drops from ~9.9s back to the
~5.2s unchanged baseline.

* P2-B: remove burn's two remaining full board_state_refresh calls

run.sh and antigravity.sh already dropped their boundary-call
board_state_refresh invocations in round-1, for the reason recorded in
run.sh's own comment: board_generation now rebuilds a stale tank
inline, right when a render actually reads it, so a boundary call buys
nothing but a synchronous full tank scan. burn.sh's two call sites
(one per attempt, before launch and again before classifying the
reply) were left in place and the round-1 fix report never mentioned
them. Measured directly: one claude board_state_refresh on a 50-session
store costs ~800ms; the agy equivalent on a 500-session store costs
4.7-10s (see the antigravity.sh commit). burn was paying that twice,
unconditionally, per attempt.

* tests: raise the bounded-IO ceiling for P1-B's two extra signals

claude's account-level fuel freshness check now costs two more bounded
stat calls per render section (projects/'s own mtime, plus one batched
files_mtime_size over the -mmin -300 file set) — still O(one freshness
check per render section), never O(transcripts), but board_generation's
per-process cache does not survive a command-substitution subshell
boundary, so a real render pays this per section. Measured: 165 lines
on the existing fixture, up from a ceiling tuned to 160 before this
signal existed. The equal-cost-at-100-vs-1000-transcripts assertion
(the actual boundedness claim) is unchanged and still passes.

* P1-1: rebase onto main, adapt to PR 79's dry rc=2 contract and the read-only boundary

Rebased onto origin/main (ab6fb52): PR 80's antigravity title lookup now
wraps in the cached-title path instead of being replaced by it, and the
two textual home.bats conflicts kept main's idiom.

PR 79 (dry-reset-expiry) landed a third return code on _limit_codex_dry:
rc=2 is positive recovery evidence, distinct from rc=1 (nothing found).
This PR's own codex-snapshot test predates that change and still
expected rc=1 for a later success; update it to assert rc=2, matching
the contract PR 79 established.

Ruling: a bare render MAY write its own derived cache under
CLIKAE_HOME/state/ - never under any tank's profile dir. The
read-only test now asserts exactly that boundary: profiles/ (the
tank's source data) is byte-for-byte untouched by a bare render;
state/ is excluded from the comparison because it is allowed, not
required, to change.

* P1-2: claude account-level fuel sees a file that appears after a publish

_claude_usage_stale had two signals: projects/'s own mtime (a brand new
project directory) and the recorded (mtime, size) of each file that was
inside the -mmin -300 window at the last publish. Neither sees a brand
new FILE landing inside an EXISTING project directory: creating a file
does not move projects/'s own mtime, and the file was never in the
-mmin -300 set at the last publish (it did not exist yet), so there was
no per-file record to re-stat either. Once that happened the account's
claude tank stayed green forever, with no timeout to recover it.

Record each existing projects/<slug> directory's own mtime too, bounded
by the project count, folded into the same files_mtime_size call as the
tracked files (one fork, not two) so a new file always moves a signal
this check is watching.

Separately, an empty tracked-file set at publish time used to read as
"nothing to compare against, call it fresh" - permanently, since an
append to an OLD file (already excluded from the -mmin -300 window)
does not move any directory's mtime either. Zero evidence is not
evidence of freshness: that exit now rebuilds instead.

* P2-1: codex's scan-root chain is read off disk, not guessed from the clock

_codex_today_scan_dir computed "today" from the OBSERVER's own `date`,
then walked up to the deepest existing ancestor of that path. This is
only correct when the observer's local "today" agrees with whatever
clock actually wrote the newest rollout's date directory - true by
default (codex uses the machine's own local time), false the moment
clikae runs under an explicit TZ, across a timezone during travel, or
under a CI/cron invocation pinned to a different zone than the
interactive session that wrote the rollout. When the two disagree, the
old code settled on a directory that could be a SIBLING of where the
real newest rollout landed; a new rollout inside an existing sibling
day directory only bumps that day directory's own mtime, never its
parent's, so the freshness check watched the wrong thing and the board
never rebuilt.

Replace it with _codex_newest_chain, which reads the actual newest
YYYY/MM/DD chain off disk (bounded glob at each of three levels, no
`find`) instead of computing a path from the wall clock. board_state.sh
generalizes _board_scan_root and its scanroot-mtime bookkeeping from a
single path to a list, batched through one files_mtime_size call, so
codex's multi-level chain and the other three engines' single path
share the same publish/check code.

Receipt (probe-p21.sh): a rollout in an existing sessions/2026/09/12
directory, rendered with TZ=Asia/Tokyo throughout (observer's own
"today" is already 2026/09/13) - the old code left the tank frozen
fresh; the new code sees it on the next render, same as TZ=UTC (where
the fixture's date agrees with the observer's).

* P2-2: antigravity's cwd lookup is a bulk index, not one fork per session

antigravity records cwd IN the file (history.jsonl), not in the path,
so board_state_refresh's rebuild loop scans the WHOLE account's
sessions on a genuine miss, never just this PWD's. Deriving each
session's cwd used to cost one reading_cache_run call - a stat, a
cksum, and a subshell read even on a cache HIT - per session. On a
synthetic 500-session tank that measured as a ~5s fixed cost on top of
whatever a real rebuild needs, standing between the round-2 fix (the
cache-key stampede is gone) and beating main's per-render cost on the
very first render after any change on the tank.

Add adapter_session_cwd_index: one awk pass over history.jsonl
building the whole sid -> workspace map at once. board_state_refresh's
rebuild loop and adapter_recent_sids's discovery fallback both use it
when available, falling back to the old per-file adapter_session_cwd
otherwise (a test's minimal stub adapter, or a session missing from
the index). Same source of truth, same "first occurrence per sid
wins" semantics as the per-file lookup - this removes the per-session
forks around an unchanged read, not the read itself.

Receipt (perf-p22.sh, the round-3 review's synthetic shape: 500 agy +
50 claude sessions, ~103KB each, one cwd): first render after adding
one agy session with a limit line - before this fix, ~7.1s; after,
~3.7-3.9s, matching main's own ~3.6-3.9s baseline on the same store.

* P3-1/P3-4: nanosecond directory mtimes for claude's fuel signal, align GC sorts

P3-1: P1-A upgraded every FILE mtime comparison in this file to
nanosecond precision, but the one DIRECTORY comparison left over
(projects/'s own mtime, _claude_usage_stale's signal 1) still used
file_mtime's whole-second precision. A project directory created in
the SAME wall-clock second as the last publish was invisible to that
signal in either direction. Both the publish and the check side now
read it via files_mtime_size, the same nanosecond stat this file's
other two signals already use.

P3-4: board_gc_generations gained a directory-name tie-break for its
sort (several publishes in the same wall-clock second get the same
whole-second mtime), but clean.sh's _clean_board_gc runs the
equivalent sweep over the same directories with the old two-key sort.
Align it so the two GC paths rank the same set of generations the same
way and never disagree on which one keep-N protects.

* round-4 fix: P1-1/P1-2/P2-1/P3-4 in board_state.sh

Round-4 adversarial review, 2 P1s + 2 P2s + P3-4:

- P1-1: an idle claude account (no transcript touched in the last 300
  minutes) paid a full rebuild on EVERY frame, forever. Round-3's fix for
  "the -mmin -300 window is empty at publish" made an empty signal-3 set
  rebuild instead of trusting it fresh, but rebuilding never changes
  whether the window is still empty five hours later, so it never stopped.
  Signal 3 now records the newest K files of EACH project directory
  instead of the -mmin -300 hit set (never empty unless the account has no
  claude session at all), and the empty-set exit goes back to fresh.

- P1-2: a codex tank with no sessions/ directory yet at its first render
  (right after `clikae init codex`, before it has ever run) froze its
  snapshot permanently — no limit it hit afterward ever reached the
  board, and `clikae clean` could not recover it. board_state_refresh
  silently dropped a scan-root level that did not exist yet at publish
  time. Every level _board_scan_root names is now recorded, existing or
  not: a missing one gets a MISSING sentinel, and board_stale reading it
  checks existence (has it appeared since?) instead of a mtime that was
  never there.

- P2-1: board_stale used to pay up to five separate stat forks per claude
  tank (scanroot, claude-usage's root/projdirs/files, recent files) on
  every single warm read. It now gathers every path all of its signals
  need first (plain reads, no fork) and pays ONE batched stat call per
  tank for the union.

- P3-4: files_mtime_size's positional zip misattributes a row the moment
  a path vanishes between building the argument list and the stat call
  landing — GNU/BSD stat both print one fewer line for a missing arg,
  silently shifting every zip index after it. Added
  _board_mtime_size_map, keyed off the path stat itself echoes back
  instead of argument position, and routed every zip in this file through
  it (scanroot-mtime, claude-usage's projdirs/files, recent-files, the
  publish-time file-mtime map).

* round-4 fix P2-2: codex chain records every DD under the newest MM

A new rollout landing in an existing day directory that is not the
lexically-newest one was permanently invisible — the same shape as
round-3's P2-1, one level down (needs a clock set back, a cross-timezone
local-time rewrite, or a synced/restored CODEX_HOME, not the default
path, but the consequence is just as permanent once hit).

_codex_newest_chain now records every DD directory under the newest MM
(bounded: at most 31 per month) instead of only the newest one, so a
write to ANY of them bumps a directory board_stale already watches.

* round-4 fix P1-1/P2-1: share one board_generation across a whole render

board_generation's per-process memo does not survive a `$( )` command
substitution — it can only be read by a forked subshell, never written
back to. All four render sections (_home_items' live/tanks/recent, plus
_home_refresh's own dry-set and board_total) each fork at least one of
their own, so a naive per-section call paid board_stale's freshness
check, and on a genuine miss a full per-tank rebuild, once PER SECTION
PER TANK, every single render (measured: 3 claude tanks, idle fuel
window, 24 find calls a frame — 3 tanks x 2 finds x 4 sections).

_home_refresh now primes board_generation for every tank once, before
any of those subshells exist, so each of them inherits an already-warm
cache instead of paying for its own. This also makes board_total ride
the same cache as the other three sections (P2-1), instead of walking
every tank's generation a fourth time.

* round-5 fix P1/P2-2: one whole-tank stat fingerprint replaces every bounded staleness signal

Round 1-4 each shipped a bounded, per-signal freshness approximation
(a scan-root directory mtime, an account-level top-K of recorded files,
a codex day/month/year chain) meant to avoid re-listing a tank's whole
transcript tree on every render. Every round found one more write shape
the approximation could not see: a codex limit landing via an append to
the current session's rollout was invisible to a board opened from any
other cwd (P1); claude's per-project top-K missed an append to the
11th-newest session in its project, and any append to an agent-*.jsonl
subagent transcript, both reading as a false green fuel dot until the
account's next unrelated write rescued them (P2-2).

Design decision: stop approximating. board_stale now re-lists every
transcript file under the tank (the exact find board_state_refresh
already runs) and re-stats every one of them in a single batched call,
reducing the whole set to one opaque fingerprint (count + a CRC over
each file's own path/mtime_ns/size, sorted). Equality between that,
recomputed fresh on every read, and what board_state_refresh recorded
at publish is now the entire staleness signal, for every engine. This
is real O(files) work again, but find+stat only, never a parsed line -
measured at a few ms per fork for 500 files on this host, well under
the parsing costs #62 was written to kill.

Deleted outright rather than left beside the new signal: the
per-project top-K bookkeeping, _claude_usage_stale and its three
sub-signals, every per-engine scan-root mtime/chain (_board_scan_root,
_codex_newest_chain), the scanroot-mtime/claude-usage-* on-disk
records, and the MISSING-sentinel handling that existed only to keep
that catalogue alive across a not-yet-existing directory.
board_recent's on-disk row also shrank from 4 fields to 2 - the two
staleness-only fields had no other reader once the per-file comparison
they backed was gone.

* round-5 fix P2-1: _home_refresh clears its generation memo before priming

_home_refresh primes _BOARD_GEN_CACHE once per render so every
subshell inherits an already-warm freshness check instead of paying
for its own - but it never cleared that memo first. In a long-lived
process (the interactive TUI's _home_pick, which calls _home_refresh
again after every c/m/n/a/d/l), the second priming pass just re-read
the first call's already-populated memo and skipped board_generation
entirely, so a session or limit that landed between two refreshes
stayed invisible until the process exited.

_home_refresh now resets _BOARD_GEN_CACHE=() immediately before the
priming loop, so every refresh in the same process asks fresh.

* round-5 fix: bats receipts for the P1/P2-1/P2-2 findings and the new staleness signal

Adds one bats case per round-5 finding, each verified red against
a578aa0 and green on the fixed tree: a codex limit appended to a
running rollout is visible from a different cwd (P1); a claude limit
appended to the 11th-newest session in its project is visible (P2-2);
a claude limit landing only in an already-existing agent-*.jsonl is
visible (P2-2); _home_refresh called twice in one process picks up a
session added between the two calls (P2-1).

_board_shims no longer hard-fails find - the new staleness signal
expects one bounded find+stat per tank on every read, which is the
correct new invariant this round's design replaces the old
'never discover transcripts' one with. Assertions across the file are
updated from 'no find call at all' to 'find/stat are bounded, but
head/tail never touch a transcript's content on a warm read.'

* round-6 fix P1-1/P2-1/P1-2: batch the fingerprint through find -exec, make rebuilds incremental

Round-6 review found the fix5 fingerprint pushed every transcript path
into one stat argv: past ARG_MAX, stat died E2BIG under 2>/dev/null and
the fingerprint silently collapsed to a file count, so an append to an
existing file went permanently invisible (P1-1). Building that argv also
cost a bash while-read loop over find's output through a process
substitution -- 500-575ms at 5,000 files even when stat succeeded (P2-1).

_board_stat_rows replaces both: one `find ... -exec stat ... {} +` piped
straight into `sort | cksum`. find batches its own argv per the real
ARG_MAX, so E2BIG is now structurally impossible, and no path is ever
pulled into a bash variable.

Separately, any write anywhere in a tank forced board_state_refresh to
rebuild every file's sid/scope/resume-row and re-run every file's
rate-limit reading through reading_cache_run, even though only one file
changed -- 39.8s for one append in a 5,000-session store (P1-2), exactly
the class of regression #62 exists to fix. board_state_refresh now diffs
the fresh stat rows against the previous generation's own manifest via
one awk pass instead of a bash loop; sids/ and recent/ start as a cp -al
(hard link) of the previous generation; only a changed, new, or removed
file pays a fresh parse. A cold build (no previous generation to diff
against) keeps the old bulk shape.

The same argv exposure the review flagged at sessions_by_mtime's call
site is also gone: the incremental path never calls it, and the cold
path sorts from the stat rows it already has instead of re-stating via
argv.

board_stale's own equality check against the single fingerprint is
unchanged, and no bounded per-signal approximation (top-K, -mmin
windows, scanroot sentinels, day-dir chains) was reintroduced.

* round-6 fix: bats receipts for the ARG_MAX fingerprint and the incremental rebuild

Three new cases in home-bounded.bats:

- a 20,000-transcript fixture (built in the test, under a throwaway
  HOME/CLIKAE_HOME) proves the fingerprint still sees a one-line append
  at a scale well past the ARG_MAX threshold an argv-based stat would
  hit, and separately re-runs the fingerprint's own find+stat pipeline
  without swallowing stderr, asserting rc=0 and an empty stderr capture
  -- proving stat never fails, not just that the fingerprint happens to
  still work.
- stubbing the per-file rate-limit parser and appending to 1 of 50
  sessions asserts the stub runs exactly once on the next
  board_state_refresh, not 50 times -- a unit-level proof that a rebuild
  only pays for the files that changed.
- deleting one of two sessions and appending to the other (forcing a
  real rebuild) asserts the deleted session's resume row is gone and the
  kept session's is still there -- coverage for the one new failure mode
  the carry-forward design introduces that a pure performance receipt
  would not catch.

* tests(compat): gate declare -g / -A / -n before porting board_state.sh off bash 4

PR #78 CI (run 34754505915) is red on all three macOS jobs since this
branch's first commit: `declare -gA _BOARD_GEN_CACHE` in
lib/core/board_state.sh runs at SOURCE time, and macOS bash 3.2 has
neither `declare -g` (4.2+) nor associative arrays (`-A`, 4.0+) —
`lib/core/board_state.sh: line 205: declare: -g: invalid option`,
exit 2 before a single bats runs.

compat.bats already source-scans for mapfile/${var,,}/readlink -f/&>
and missed this. Extend it: `declare -[gAn]`, `local -[An]`,
`typeset -[An]` (deliberately NOT `[[ … =~ …]]` — 3.2 has that), with
the same whole-line-comment exclusion the file already uses, plus a
negative control planting `local -A x=()` in a temp copy (same shape
as the existing readlink -f control).

Red on this commit (4 real sites: board_state.sh:205,437,457,
adapters/antigravity.sh:230) — the port lands next commit.

Co-authored-by: Claude <noreply@anthropic.com>

* fix(board_state): port off declare -gA / local -A for macOS bash 3.2

CI (gh run view 34754505915): `declare -gA _BOARD_GEN_CACHE` runs at
SOURCE time, and macOS bash 3.2 has neither `declare -g` (4.2+) nor
associative arrays (`-A`, 4.0+) — the file failed to source at all,
`clikae version` exited 2, ~1,000 bats red on every macOS job since
this branch's first commit. Three more `local -A` sites (added by
round-3's P2-2 bulk-index fix and round-6's incremental
classifier, both correct in design, bash-4-only in implementation)
had the same problem, just not yet exercised on the job that would
have caught them first.

PORTED, not shimmed — no version check, no bash-4-only fast path, one
code path for 3.2 and 5.x:

- board_generation's memo -> plain globals keyed by a sanitized
  (engine,dir) name, read back through `eval` indirection; a
  sanitize-collision is caught by writing the raw cachekey back
  alongside the value and comparing on read (same guard
  board_recent/board_find already use for their own hashed keys).
  `_board_gen_cache_clear` replaces the old single-assignment
  `_BOARD_GEN_CACHE=()` reset _home_refresh needs every redraw
  (home.sh updated to call it).
- `_agy_ws`/`_ws` (antigravity's bulk cwd index) -> `_agy_ws_load`/
  `_agy_ws_lookup`/`_agy_ws_varname`, one shared naming scheme so
  board_state.sh and antigravity.sh can never drift onto two
  different variables for the same sid. Still one fork per RENDER
  (`adapter_session_cwd_index`), zero forks per session — round-3
  P2-2's own invariant, re-verified.
- the cold-build classifier's five per-path maps (cur_mtime,
  cur_size, sid_of, scope_of, reading_of) -> parallel INDEXED arrays
  keyed by the file's position in stat_rows, not by path — bash 3.2
  has always had indexed arrays, nothing here needed a hash. The
  INCREMENTAL classifier already used one awk join with no bash maps
  (round-6) and needed no change.

tests/bats/compat.bats (previous commit) now source-scans for
`declare -[gAn]|local -[An]|typeset -[An]`; this commit takes it from
red to green. Receipts (full detail in
/home/kitt/lanes/REPORT-board62-fix7.md): compat.bats green,
home-bounded.bats 17/17 (incl. the round-5 P2-1 cache-reuse
regression test, which exercises _board_gen_cache_clear directly),
antigravity/agy-email/board-width 42/42, shellcheck -S warning clean
on all three files, no bash-4+ construct left per a full repo grep.

Co-authored-by: Claude <noreply@anthropic.com>

* fix(board_state): move _agy_ws_* helpers into antigravity.sh, guard call sites

CI (run 34760883667, both bats jobs) caught what my targeted local
testing missed: `_agy_ws_load`/`_agy_ws_lookup`/`_agy_ws_varname`
lived in board_state.sh, but antigravity.sh's `adapter_recent_sids`
called them unconditionally. `tests/bats/adapters/antigravity.bats`
sources lib/adapters/antigravity.sh directly, WITHOUT
board_state.sh (a real, tested, previously self-contained
configuration — the original `local -A _ws` block had zero
dependency on board_state.sh) -> "_agy_ws_load: command not found",
one test failure (an exact `[ -z "$output" ]` check; two sibling
tests using substring matches papered over the same stderr noise).

Fix: the three functions move to lib/adapters/antigravity.sh, next
to `adapter_session_cwd_index` (their natural neighbour — this is
antigravity-specific bookkeeping, not board-generic). board_state.sh
keeps calling them but every call site is now guarded with
`declare -F`, the same pattern this file already used for
`adapter_session_cwd_index` itself before this port (and that the
original `local -A _agy_ws` population block had too, and my first
draft of this port dropped). board_state.sh must stay usable when an
adapter has not been loaded — not just when antigravity specifically
has not.

Re-verified: tests/bats/adapters/ (95/95, including the regression
test), tests/bats/compat.bats, home-bounded.bats (17/17),
antigravity.bats + agy-email.bats + board-width.bats (42/42),
shellcheck -S warning clean on both files.

Co-authored-by: Claude <noreply@anthropic.com>

* fix(board_state): generations chain instead of sharing inodes (P1-1/P2-1)

Round-7 fix review, P1-1 and P2-1, which are the same defect seen from two
sides: `cp -al` carried `sids/`/`recent/` forward as HARD LINKS, and the two
in-place writes (`> "$base"` in _board_merge_recent_row, `> "$gen/sids/$key"`
in the changed loop) then wrote through those links into the generation
`current` was still pointing at. The reviewer's three-way probe caught it
directly: gen1's recent row changed the moment gen2 published. The same copy
cost one `link()` per transcript PRESENT — 5,001 at 5,000 files, measured by
strace — which is the O(tank) bookkeeping #62 exists to remove.

Both go away by not copying:

- A generation holds ONLY the entries this refresh changed, plus `parent`
  (the generation it was built from) and `depth`.
- `_board_gen_entry` resolves one entry by walking gen -> parent -> …, first
  hit wins. No fork, no `$( )`: an out-variable, like `_agy_ws_lookup`.
- The walk is bounded by `_BOARD_GEN_MAX_DEPTH` (8). The publish that would
  reach it MATERIALISES instead — `cp -a`, no `-l`, oldest ancestor first —
  and starts a fresh chain. Reads are <= 8 lookups; writes are proportional
  to CHANGED files, amortised against one copy every 8 publishes.
- Removal writes a zero-byte TOMBSTONE, because `rm -f` on this generation's
  own name would leave the ancestor answering for a deleted transcript.
- Every entry write is temp + `mv -f` (`_board_gen_put`), the pattern
  `_board_purge_recent_row` already used: `mv` replaces a NAME and never
  opens an ancestor's inode.
- `board_gc_generations` learned about the chain: keep-N alone would have
  unlinked an ancestor `current` still resolves through, which is a silently
  empty Resume list, not a dangling pointer a rebuild heals.
  `_clean_board_gc` now calls the same `_board_gc_candidates` rather than
  keeping a second copy of a rule that just grew a second clause.
- Generations carry a `format` stamp; an older layout is treated as stale and
  as no previous generation at all, so an upgrade rebuilds once instead of
  reading entries under a naming scheme that moved.

Receipts (tests/bats/home-bounded.bats): the superseded generation is
byte-identical after the next publish; no entry has a link count above 1; the
chain materialises and never exceeds the depth bound over 20 publishes; GC
(and `clikae clean`'s sweep) never unlinks a live chain link.

* fix(board_state): one canonical fingerprint, so an empty tank can be fresh (P1-2)

Round-7 fix review P1-2. The staleness fingerprint had two spellings. The
reader (`board_stale`) piped `_board_stat_rows` straight into `cksum`; the
publisher captured the same rows in `$( )` — which strips trailing newlines —
and re-emitted them with `printf '%s\n'`. With at least one transcript the two
byte streams happen to agree. With NONE they cannot: the pipeline sends zero
bytes, `printf '%s\n' ""` sends one newline, so the saved value was
cksum("\n") = "3515105045 1" and the live one cksum("") = "4294967295 0".

Every read therefore said STALE. A freshly `clikae init`'d tank — the first
screen a new user sees, and the state every user is in right after install —
rebuilt and published a whole new generation on EVERY frame, forever, never
self-healing: `mktemp -d`, four files, a pointer swap and a GC per frame, plus
`agy_email` per frame on an agy tank and `_limit_codex_rate_limits_cached` per
frame on a codex one. Measured at 2.2x main's time to do nothing, with the
generation count climbing by two per render.

`:612` made it worse: a zero-file tank wrote no manifest, so the publisher's
own `[ -f "$oldgen/manifest" ]` gate then treated the generation it had just
published as unusable and cold-built again next frame.

Fixes both:

- `_board_fingerprint_rows` is the ONE function, called by the reader and the
  publisher, so "the empty set" has one canonical value by construction
  rather than by two authors agreeing.
- `board_state_refresh` spools the stat rows to a file instead of `$( )`, so
  the bytes hashed at publish are exactly the bytes `_board_stat_rows`
  produced. The classifier awk reads that file directly too, which drops a
  process substitution.
- An empty tank writes an empty manifest. That is a fact about the tank, not
  a missing file, and it lets the next refresh take the incremental path.

Receipts: two freshly-init'd tanks (claude + codex) with zero transcripts hold
exactly ONE generation after five renders, both sides report `cksum("")`, and
`board_stale` says fresh; publish and `board_stale` agree at 0, 1 and 2 files.

* perf(board_state): cold build under 1 s at 5,000 files (P2-2)

Round-7 fix review P2-2. #62's acceptance text is "Board render on the machine
above under 1 s cold and near-instant warm". Rounds 5-7 measured 3.4 s at 500
transcripts and 35.6 s at 5,000 on an IDLE host and framed cold as "not a
target". It is the target, and the reviewer was right that noise explains the
upper end of the old spread, not the lower one.

The cost was never the tree walk (`find … -exec stat … {} +` over 5,000 files
is ~30 ms). It was FORKS PER FILE: two `board_key`s (`printf | cksum`, two
processes each) to name the `sids/` and `recent/` entries, plus a
`reading_cache_run` + parser pipeline for every file inside the engine's
rate-limit window. 5,000 x ~4 forks x ~1.8 ms is the 35 s, to the second.

The cold path now has no per-file fork at all:

- `_board_entry_key` names an entry with parameter expansion in bash and one
  `gsub` in awk — the same rule, written twice and pinned against itself by a
  test. It is a sanitisation, not a hash, and collisions are handled exactly
  as before (the raw sid/scope is written into the entry and compared back, so
  a collision is a MISS, never another session's transcript). It also collides
  far less than what it replaces: a uuid survives it intact, while a 32-bit
  cksum has ~0.3% odds of a colliding pair in a 5,000-session tank.
  `board_key` still names the per-tank root, where it runs once per tank.
- claude keeps a session's id in its FILENAME and its scope in its parent
  directory, so the whole sid/scope table is one awk pass over the stat rows
  this function already has — no file is opened.
- codex and grok keep the id IN the file, so the table comes from ONE batched
  bounded read: `head -c 512` with `/dev/null` first (so the `==>` banners are
  unconditional even for a one-file batch) over `xargs -0` batches, parsed by
  one awk. Any file the bound does not resolve falls back to the real
  `_board_engine_sidscope`, so 512 bytes costs speed on a pathological file,
  never an answer.
- antigravity's id is in its path and its scope comes from the bulk
  `_agy_ws_load` index, wired straight into the table with no `$( )`.
- The rate-limit scan is bounded to the newest `CLIKAE_HOME_RECENT_MAX` files
  per PROJECT DIRECTORY inside the window (by directory, not by scope, so
  claude's `agent-*.jsonl` subagent transcripts stay in the set — round-5
  P2-2). The list is published as `readings-bounded`, so what was read is
  inspectable rather than inferred.

DEVIATION from the round-8 brief, with the measurement that forced it: the
brief asked for those readings to be computed lazily by the incremental path
on the NEXT render. Built that way first, and it turns four receipts this
branch has carried since round 5 RED — "warm reads zero transcript bytes",
"self-heals inline once, then reads are bounded (list+stat, never content)
again", "an incremental rebuild re-reads only the ONE file that changed", and
"live rows … at most ONE candidate" — because the render that consumes a
pending list is, from outside, a warm render running `tail -c 524288` over ten
transcripts. The bound is the brief's; it is consumed before the generation
publishes instead of one render later, which keeps the property #62 actually
asks for: a render that changes nothing reads nothing.

Receipts: bash and awk agree on the entry name over nine adversarial inputs
(including `..`, empty, 140 chars, and a path with slashes, which must not
escape the entry directory); a cold build over 40 in-window transcripts parses
at most 10 and still resolves all 40; the batched read agrees with the
per-file parser on codex and grok fixtures, including a codex meta line past
the 512-byte bound that only the fallback can resolve.

* fix(board_state): purge removed entries BEFORE applying changed ones (P2-3)

Round-7 fix review P2-3. A transcript that moves to a new path while keeping
its session id is classified as BOTH removed (at the old path) and changed (at
the new one). The removed loop ran last, so the changed loop wrote
`sids/<key>` and the removed loop then deleted the very entry it had just
written. The file was still on disk and its `recent/` row was still there, but
`board_find` could no longer resolve it — the session vanished from
`clikae resume` with nothing to explain it.

Ordering is the whole fix: purge what the previous generation knew, then apply
this generation's facts on top. A moved file's entry is tombstoned and
immediately rewritten with its new path; a genuinely deleted file's entry
stays tombstoned.

The trigger surface is not narrow. claude encodes the sid in the filename, so
only a whole-file move between project directories hits it there — but codex,
grok and antigravity read the sid out of the file's CONTENT, and a rollout
moved, a grok session directory renamed or an agy brain directory renamed all
keep the sid and change the path.

Receipt: a session moved between project directories resolves to its NEW path
after the rebuild, and a genuinely deleted one still stops resolving (the
tombstone works through the chain, where `rm -f` on this generation's own name
would not).

* fix(board_state,antigravity,compat): the four round-7 P3s

P3-1 — the bash-4 compat gate's negative control was a COPY of the ruler, not
the ruler. `tests/bats/compat.bats` re-spelled `scan()`'s greps and the
`declare -[gAn]` pattern inline against a probe tree, so it proved "this regex
fires", not "THIS RULER fires": the moment `scan` changed its roots or its
comment exemption, the control would have gone on vouching for a ruler that
no longer existed. The pattern is written once now and the control points the
REAL `scan` at the probe tree.

P3-2 — `_agy_ws_load` populates plain globals (bash 3.2 has no associative
arrays), and the `local -A _ws=()` it replaced was FUNCTION-scoped, so every
call started from an empty map for free. Globals do not. Two consequences: a
long-lived TUI accumulated one global per session id it had ever seen and kept
answering for ids that had since left `history.jsonl`, and a lookup made after
loading a SECOND tank could be answered by the first tank's index — the sid is
written back and checked, but the same sid genuinely existing in two tanks is
not a collision that write-back can catch. `_agy_ws_clear` is the twin of
`_board_gen_cache_clear`, and the variable name is namespaced by tank.

P3-3 — `_board_transcript_find` ended in `2>/dev/null`, documented as "only
ever hides 'no such directory' for an engine this tank has never used". A
redirection on `find` covers the stderr of whatever it `-exec`s, so a `stat`
that failed on an INDIVIDUAL file dropped that file's row silently, and its
absence is indistinguishable to the fingerprint from the file not existing.
Nothing reads wrong (both sides run the same function, so a dropped row is
dropped symmetrically), but it should not be invisible. The case the redirect
existed for is answered directly (`[ -d "$root" ]`); everything else reaches
the caller.

P3-4 — grok's fingerprint file is `summary.json` and grok REWRITES it, where
the other three engines append to a `.jsonl` and always move its size. On a
filesystem with whole-second mtime granularity (not this host's ext4/tmpfs,
measured at 2-3 ms, but HFS+, SMB and some NFS are), a same-size rewrite
inside one second changes neither mtime nor size and the fingerprint cannot
see it. Recorded in `_board_engine_name`'s own docstring, where the file that
has the problem is named, with the bound on the consequence: Resume ORDER for
that session goes one edit stale, no answer is wrong.

Receipts: the agy index stops answering for a sid removed from
`history.jsonl` and never lets one tank answer for another's identical sid;
an engine root this tank never created stays silent at rc=0 while a directory
the walk cannot descend into is now audible.

* fix(board_state): one entry-name implementation, byte-wise (macOS CI, non-ASCII)

Found by this PR's own macOS job, run 34769949650 `bats (macos-latest)`,
within hours of pushing round 8 — and not reproducible on Linux at any
locale, so nothing local would ever have shown it.

`_board_entry_key` names a snapshot entry and the cold build's awk names the
same entry, and they were TWO SPELLINGS of one rule — the exact shape that
let this file's own staleness fingerprint drift (see _board_fingerprint_rows).
On a non-ASCII sid they disagreed:

  bash (macOS 3.2)  "unicode-<2 non-ASCII chars>"   bracket matched nothing
  awk               "unicode-__"                     matched both bytes

A bracket expression over multibyte input is not portable: bash 3.2's and each
awk's notion of "one character" differ, and the locale changes the answer
again. The consequence is invisible rather than loud — the cold build writes
`sids/unicode-__`, a reader looks up `sids/unicode-<chars>`, the lookup MISSES,
and the session drops out of `clikae resume` with its transcript still on
disk. A tank whose project paths are Chinese or accented — normal, not
exotic — would lose its whole Resume list on macOS.

The rule is now written once, as awk source in `_BOARD_EKEY_AWK`, and both
callers run it under `LC_ALL=C` so it is byte-oriented on every platform and
every locale. The reader pays one fork per lookup, which is exactly what
`board_key`'s `printf | cksum` cost before it; the cold path, the one that
runs per file, still pays none — it runs that same source inline in the awk
it was already running.

The receipt is replaced too, because the old one was the defect in miniature:
it compared bash's answer against a re-spelling of the awk. It now writes real
transcripts whose ids include Chinese and accented text, cold-builds, and
asserts the name ON DISK is the name the reader COMPUTES and that `board_find`
resolves each one — the real coupling, through the real path, on whatever
platform and locale the suite runs under. Green here at LC_ALL=C, POSIX and
en_US.UTF-8.

* perf(board_state): a rebuilding render walks the tank once, not twice

Round-8 timing, idle host, 5,000 transcripts: one-append came out at 2.15x
main, over the 2x bar this round set for itself. Breaking the 127 ms
incremental rebuild down rather than guessing at it: 26 ms of it is the tree
walk (`find` + one batched `stat`), and `board_generation` was paying that
twice back to back — once in `board_stale` to decide whether to rebuild, then
again inside `board_state_refresh` to do it.

`board_stale` now takes an optional output path and leaves its rows there;
`board_state_refresh` takes an optional rows file and uses it instead of
walking. Direct callers (tests, adapters) pass neither and are unchanged.

This is the safe direction, not a shortcut. The published fingerprint then
describes a disk state a few milliseconds OLDER than the parse, so anything
that changed in between makes the NEXT board_stale disagree and rebuild. The
failure mode it cannot produce is the dangerous one — a fingerprint NEWER than
the data it vouches for, which would read as fresh forever.

Two smaller forks went with it: `_board_entry_key` uses a here-string instead
of `printf | awk` (a pipeline forks both sides, and this runs once per lookup
on a render's path), and the two single-`printf` entry writes no longer go
through `_board_gen_put`'s `cat`.

Receipt: a render that DOES rebuild invokes `find` exactly once (shim-counted
through the real front door), and the generation it publishes still matches
`_board_transcript_fingerprint` computed fresh, still reads not-stale, and
still resolves both the changed session and an untouched one.

* fix(board_state,limit): the rate-limit scan is bounded by the WINDOW, not by a count

Round-8 fix review P1-1 / P2-1.

The cold build recorded, per PROJECT DIRECTORY, the newest
CLIKAE_HOME_RECENT_MAX transcripts that fell inside the engine's
rate-limit window, and scanned only those. A count and a window are
different DIMENSIONS, so no count is ever "provably >= the window" — and
the reviewer measured the consequence on the real front door, not in a
unit: a limit sitting in a session that had gone quiet behind twelve
newer neighbours in the same project directory was invisible, this
branch drew a FULL fuel dot where main drew "○ … resets 11pm", and the
control (the same fixture with five neighbours instead of twelve) drew
the red dot again. It is not only a picture: claude's
_limit_tank_dry_raw deliberately never falls back to dry_store, so
`clikae burn` dispatched into a tank that was dry. It did not self-heal
— only a further write to that abandoned file would have fixed it —
and the cold build is exactly the render every user gets once, on the
first launch after upgrading.

Twelve `agent-*.jsonl` in one project directory is ordinary (this
repo's own fleet writes one per subagent), and codex's "project
directory" is a DATE directory with a seven-day window.

So the bound is the window: every transcript whose mtime is inside it
is scanned, however many share a directory. CLIKAE_HOME_RECENT_MAX goes
back to bounding only the Resume rows a scope SHOWS.

The cost that made a count tempting is paid off a different way. The
old scan forked `reading_cache_run` + `tail` + `awk` per file (~10-11
ms each — the whole slope the reviewer measured), so the set had to be
small. `_limit_batched_readings` reads all of them with one `tail` per
`xargs` batch and folds them in one `awk`: same per-file bound
($CLIKAE_TX_TAIL_BYTES, i.e. exactly the bytes limit.sh's own per-file
parser reads), same matching rules, one spelling of those rules
(_LIMIT_READING_AWK, now shared by the per-file parsers and the batched
one — two spellings of one rule is how this file's twin drifted).

Receipts, each RED on 26cac7b:
  * a claude limit in a session that is NOT the newest in its project
    (the file is never written again — what an abandoned dry session
    leaves on disk), behind RECENT_MAX+2 newer neighbours: visible, and
    the board agrees with the non-board scan it approximates.
  * the codex twin, twelve newer rollouts in one day directory.
  * a cold build scans all 40 in-window transcripts, none of the 5
    outside the window, in <= 5 tail processes (not one per file).
  * the batched scan agrees with the per-file parser, claude and codex,
    including a transcript whose last byte is not a newline.

The round-5 receipt at home-bounded.bats:345 is replaced by the first
of those: it APPENDED the limit to the "11th-newest" session, and an
append makes that file the newest, so it ranked 1st by the time
anything read it and could not tell a count bound from a window bound.
A ruler that could not reach its own specimen — green on a branch whose
cold build dropped exactly the shape it was named for.

Also removes the comment block describing `readings-pending` and a
"the fuel dots land on the NEXT render" mechanism that has not existed
since round 8 (round-8 P3-1): `grep -rn readings-pending lib bin tests`
is now empty.

* fix(board_state): entry names are an injective escape, so a non-ASCII path cannot answer for its neighbour

Round-8 fix review P1-2.

`_board_scope_raw` hands the raw `$PWD` to every engine but claude, and
round 8 named each `recent/` entry by folding every byte outside
[A-Za-z0-9._-] to `_`. Two sibling directories of the same BYTE LENGTH
therefore got the SAME entry name — and two CJK characters are six
bytes, so `~/Developer/專案` and `~/Developer/文件` are one key. This
machine's owner works in exactly such paths.

The consequence was not a miss. The cold build grouped by the entry
name, so both scopes' sessions went into ONE file under whichever
`#scope` header was written first: that scope's Resume list answered
with its neighbour's sessions (4 rows where main showed 2) and the
neighbour's list went silently empty (0 rows). The file's own header
promises "a collision reads as a MISS, never as another session's
transcript", and for `recent/` that was false.

Two changes, because there were two defects:

  * the name is now an ESCAPE (`%XX` per unsafe byte), not a fold. It
    is injective for every input short enough not to hit the
    100-character rule, so the collision class is removed rather than
    made rare. Still ONE implementation, still awk, still run by both
    the cold build and every reader under LC_ALL=C — the property
    round 8 paid for with a macOS CI job, kept.
  * the cold build groups `recent/` by the RAW SCOPE, not by the name,
    and the incremental path's merge/purge verify the resolved entry's
    `#scope` header before rewriting it. So even where a name IS shared
    (the >100 truncation, the one case left) the loser reads as a miss,
    which is what the header claims — never a merged list.

DEVIATION from the brief, measured. The brief asked for `cksum` of the
raw bytes. The reader can call `board_key` outright, but the cold build
names every entry inside one awk pass, so awk needs its own CRC-32. I
wrote one (table-driven POSIX cksum, byte-exact against `cksum` on
ASCII and CJK — verified against the coreutils output) and timed it:
177 ms for 5,000 sids on this host's gawk, three runs, against a cold
build that has to fit in #62's one second and costs ~500 ms without it;
macOS's one-true-awk is slower still. The escape costs 3 ms for the
same input, needs no second implementation anywhere, and kills the
class instead of shrinking it. The receipts and the reasoning are in
lib/core/board_state.sh's own header.

Receipt (RED on 26cac7b, where 專案一 listed s1-0/s1-1 — its
neighbour's sessions — and 專案二 listed none): two CJK siblings and
two accented siblings, each with two codex sessions; every scope's
`board_recent` returns its own two rows and nobody else's, cold AND
after an incremental rebuild; and the two scopes' entry names differ.

* fix(board_state): GC protects the chain of every live generation, not only current's

Round-8 fix review P2-2.

`board_gc_generations` protected the ancestors `current` resolves
through, and applied keep-N to everything else. But `current` is not
the only generation someone is still reading: `board_generation`
memoizes a generation PATH for the life of the process, so a TUI frame,
a `clikae burn`, or any second shell holds one while another publishes.

The reviewer measured what one further publish does to such a holder.
Hold a generation at depth 7; publish once more — that publish is the
MATERIALISING one, so the new chain starts fresh and the held chain is
no longer reachable from `current`; keep-5 then unlinks its three
oldest links. The held generation still exists, `current` is valid,
`board_stale` still says "fresh", and 49 of its 50 entries stop
resolving. That is exactly the consequence _board_gc_candidates' own
header names ("a silently EMPTY Resume list … with `current` still
valid") — the defence was simply one generation too narrow. Round 7's
design had five publishes of slack here (every generation was
self-contained); the chain took that to zero.

keep-N now counts CHAINS rather than directories: the roots are what
`current` points at plus the newest <keep> generations, and every
ancestor any root still resolves through is protected with it. Cost is
at most keep x _BOARD_GEN_MAX_DEPTH reads of a one-line `parent` file,
all builtins; the chains overlap almost entirely, so the number of
directories on disk barely moves.

Receipt (RED on 26cac7b, which removed a held chain link on the first
materialising publish): 50 sessions written once and never touched
again, the chain driven to depth _BOARD_GEN_MAX_DEPTH-1 by appending to
ONE unrelated file, then one more publish — every link of the held
chain is still on disk, all 50 entries resolve FROM THE HELD
GENERATION, and `clikae clean`'s own sweep (same rule, one
implementation) does not undo it.

* chore(board_state,test): the round-8 P3s — readings-bounded on both paths, and a gate that can see .bats

Round-8 fix review P3-2 and P3-3. (P3-1 — the comment describing
`readings-pending` and a "the fuel dots land on the NEXT render"
mechanism that no longer existed — went with the commit that replaced
the mechanism it described; `grep -rn readings-pending lib bin tests`
is empty. P3-4 — the round-5 receipt that could no longer reach its own
specimen — went with the same commit, rewritten as the window receipt.)

P3-2 · `readings-bounded` was published by the cold build and by
nothing else, so the presence of that file meant two different things
("this is what cold read" vs "this generation is incremental") and
nobody auditing a frame could tell which. Both paths write it now, with
one meaning on both: the transcripts THIS refresh opened for a
rate-limit reading. Receipt (RED on 26cac7b, where the file does not
exist after an incremental rebuild): after a 50-file tank's one
changed file, `readings-bounded` is exactly that one path.

P3-3 · `scripts/test.sh` read `find lib tests scripts -name '*.sh'`,
which LOOKS like it covers tests/ and does not, and CI's shellcheck
action does not scan `.bats` either. So two findings this branch
introduced in test files — home-bounded.bats SC2155, antigravity.bats
SC2154 — sat under a green light on both sides, as did compat.bats'
SC1087, which came in from main.

The gate now scans `.bats` too, CI grew the matching step (so the two
agree by construction, not by remembering), and everything it found is
fixed: 73 findings across 20 files, all of them either real (24 SC2155
`local x="$(…)"` splits, dead assignments in gate-stamp.bats and
limit-codex-status.bats, `done` as an unquoted argument in wait.bats,
`VAR= cmd` in suite-lock.bats, `$n[` read as an array in compat.bats)
or a named, reasoned `# shellcheck disable=` where the idiom is
deliberate (a destructuring `read`, a name the code under test reads
out of its caller's scope, an out-variable).

Two of them were dead assertions, not style: `! declare -F …` and
`! grep -q …` in burn.bats. In bats, a `!`-negated command never fails
the test — SC2314 is an ERROR for that reason — so both had been
asserting nothing. They are `… || false` now, and burn.bats is green
with them live.

* fix(board_state): same-scope is byte equality, so macOS's collation cannot merge sibling directories

Round 9b. PR #78's macOS job (run 34804107952, `bats (macos-latest)`,
test 720) went red on round 9's own receipt: the 專案一 scope's Resume
list held s0-0, s0-1, s1-0, s1-1 — its neighbour 專案二's two sessions
among them. Linux was green.

It was not the entry name: Apple's awk computes round 9's escape
byte-exactly (the golden-name asserts added below check that on the
runner itself). The merge happened one step later, in
the awk that groups the cold build's `recent/` rows by scope:

    if (sc != cur) { ... start a new entry ... }

POSIX awk compares strings in the collation order of the caller's
locale, and macOS's awk does exactly that —
apple-oss-distributions/awk `src/run.c` relop() is
`strcoll(getsval(x), getsval(y))`, for `!=` and `==` as well as `<`.
That awk ran without `LC_ALL=C`, the runner's collation is UTF-8, and
UTF-8 collation on macOS has no weights for most CJK ideographs, so
`專案一 != 專案二` was false: the scope never "changed", 專案二's rows
went into 專案一's entry, and 專案二 had no entry at all.

No Linux awk can show this, and no glibc locale can either: glibc's
strcoll never returns 0 for two distinct strings (measured on C.utf8 and
en_US.utf8 for the CJK pair, NFC/NFD café, soft hyphen, ZWSP, U+2060),
and gawk/mawk/onetrue-awk use a byte compare for `!=` whatever the
locale. So it was reproduced with the real thing instead: Apple's awk
(awk-40, "version 20200816") built from source in a Debian container,
run under a compiled locale whose collation gives 二 the weight of 一.
The unfixed branch then fails test 720 with the IDENTICAL four rows
macOS printed; the same build under C.UTF-8 is green, so the collation
is the one variable.

Fix, twice over:

  * both awks that ask "is this the same scope / sid?" — the `recent/`
    grouping and `_board_purge_recent_row`'s filter — run under
    `LC_ALL=C`, where collation is byte order by definition;
  * and they no longer ask with `==`/`!=` at all: `bsame(a, b)`
    (length + index, which never consult LC_COLLATE) is defined once
    next to `ekey` in `_BOARD_EKEY_AWK` and used by both.

The test grows the shapes the brief asked for and one it did not:
café in NFC AND NFD as sibling scopes (two directories on Linux, one on
APFS, two `$PWD` byte strings either way — nothing may normalize either
side); golden escapes for 專案一 and both cafés, asserted on whatever
awk and locale the suite runs under; every scope's `recent/` entry on
disk; and a PURGE pass (remove one NFD session: only that scope loses a
row), which is the second awk this changes.

Receipts (in the PR body): Apple-awk + hostile collation, test 720 on
fb91dcd RED with macOS's four rows, on this commit GREEN; control arm
green on both.

* fix(board_state): GC ranks generations in publish order, not by a same-second coin toss

Round 9b, found while re-running the r8 review's P2-2 receipt on HEAD
as the brief asked (r8/p3b-hold.sh, a reader holding a depth-7
generation while publishing continues).

Round 9 protected the chain of every generation in "the newest
<keep>" — and "newest" was whole-second mtime with the directory
name as the tie-break (round-2 P3-1). The name is mktemp's random
suffix. Rebuilds inside one wall-clock second are the normal case
(every tight refresh loop, every bats run), so which generations
counted as the newest five was a coin toss, and the generation a
reader still HOLDS could fall out of the roots and be unlinked on the
very next publish — chain protection and all.

Measured, same probe, four runs each (no code change between the two
trees on this path; the merge did not cause it):

  d396968 (round 9 tip)  first publish: held gone in 2 of 4 runs
                          (chain-alive 6/8 and 7/8, 50/50 unresolvable)
  3c1b4f2 (after merge)  same probe: held survived 4/4 on the first
                          publish, died early in 2 of 4 by publish 7

The round-9 bats receipt for P2-2 was therefore passing on the toss.

Fix: every generation records `seq` at publish — the previous
`current`'s seq + 1 — and `_board_gc_candidates` (which `clikae
clean`'s sweep also calls) ranks by seq, then mtime, then name. Order
no longer depends on the clock; mtime and name only break a tie
between two processes that published from the same `current`, or rank
a generation written before `seq` existed (seq 0, swept first).
Materialising copies only sids/ and recent/, so a fresh chain never
inherits a stale seq.

The receipt stops relying on luck: before the materialising publish it
gives the held (newest) generation the OLDEST mtime of all, so the
clock disagrees with publish order on every run. RED on 3c1b4f2 (the
held generation itself is unlinked), GREEN here.

* test: the bats files merged from main pass the bats shellcheck gate — and five negations now assert

Round 9 (d396968) taught scripts/test.sh and ci.yml to shellcheck
`*.bats`. Main's newer test files (#83, #92, #98) were written before
that gate existed, so merging main turned it red on 12 findings. Two
of them are cosmetic; one is a test that did not test:

  * cle…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant