Skip to content

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

Merged
cverorg merged 60 commits into
mainfrom
perf/home-board-bounded-reads-62
Sep 15, 2026
Merged

cverorg merged 60 commits into
mainfrom
perf/home-board-bounded-reads-62

Conversation

@cverorg

@cverorg cverorg commented Sep 12, 2026

Copy link
Copy Markdown
Member

Closes #62.

What

The board render no longer scales with transcript count or size:

  • lib/core/board_state.sh — session-boundary discovery for claude / codex / antigravity / grok; SID→path indexes; bounded recent lists per scope (CLIKAE_HOME_RECENT_MAX, default 10); session counts and usage readings published as immutable generations.
  • lib/core/reading_cache.sh — per-file parser cache keyed by reading kind + path + size + sub-second mtime; caches empty/negative readings; follows log symlinks; publishes through unique mktemp files.
  • Adapters (claude, codex, antigravity, grok): titles/recaps/metadata read from the cache; board lookups use snapshots or the exact known path instead of tree walks.
  • lib/core/limit.sh / scan.sh: cached per-file limit/success readings and agy email extraction; the board consumes snapshots.
  • lib/commands/home.sh: bounded board mode on; opt-in millisecond timings for live/tanks/recent.

Staleness model (as of round 5): one batch stat of every transcript;
parse only what changed. See "Fix round 5" below for why rounds 1-4's
per-signal approximations were replaced outright rather than patched again.

Fix round 1 (this push)

A round-1 adversarial review found 3 P1s and 3 P2s on top of a confirmed performance win. All are addressed:

  • P1-1 (live rows regressed to the tank's name in board mode). home.sh's live-row title guess and the all-projects glob fallback used to be disabled outright in board mode. Each adapter's adapter_recent_sids/adapter_find_session (and codex/grok's twins) now try the snapshot first and only fall through to the original live scan on a genuine miss — bounded by tmux session count, never by transcript count.
  • P1-2 (Resume permanently empty for a tank that never hits a session boundary) / P1-3 (fuel/dry readings frozen behind an age gate). board_generation now does one cheap per-tank freshness check at render time (a directory mtime plus a bounded handful of file mtimes, compared for equality against what was recorded at publish — never > against a wall-clock stamp) and rebuilds inline, only for the one tank being read, the instant it disagrees with what was last published. limit.sh's arbitrary snapshot-age gate is removed; freshness is now judged directly instead of guessed at.
  • P2-1/P2-2 (clikae became a resident parent for the whole session; launch paid a full scan twice). cmd_run/_agy_switch no longer wrap the engine in a subshell to run a post-launch refresh — that refresh is unnecessary now that render-time self-heal exists. Both call sites are back to a bare tail call / exec.
  • P2-3 (board snapshots and reading-cache entries grew unbounded). board_state_refresh GCs its own tank's generations on every publish (newest N, no age floor); clikae clean additionally sweeps state/board for tanks that haven't relaunched in a while and state/readings for entries whose source file is gone.
  • P3-1/P3-2/P3-5/P3-6 also fixed: sub-second mtime precision in the reading cache (a same-size overwrite within one wall-clock second no longer reads as a cache hit); board_key/sids/recent lookups now verify the raw sid/scope they were keyed by, so a 32-bit cksum collision reads as a miss instead of another session's data; the new stat probes reuse the existing GNU/BSD detection instead of a stat -c … || stat -f … fallback; mktemp failures in limit.sh no longer redirect to an empty path.
  • P3-3/P3-4/P3-7/P3-8 left as-is — see the round-1 fix report for why each is low-priority/self-resolved.

Rebased onto current main (only CHANGELOG.md conflicted, resolved additively).

Measurements (round 1)

Same synthetic store as round 1 (500 agy + 50 claude transcripts, ~56 MB, one cwd), through the real bin/clikae front door, CLIKAE_NO_INTERACTIVE=1:

run 1 run 2 run 3
main 3.63 s 3.78 s 3.70 s
branch · cold (no snapshot yet) 12.10 s (one-time, self-heals both tanks)
branch · warm 0.63 s 0.61 s 0.62 s

main → branch/warm output is byte-identical (diff rc=0). clikae run claude x's own board-refresh overhead (engine stubbed): 955 ms → 1 ms. clikae agy x -- -p …'s own overhead: 14.6 s → 20 ms.

Full bats tests/bats on this branch: rc=0, all tests green. shellcheck -S warning rc=0 on every changed file; git diff --check clean.

Full report: /home/kitt/lanes/REPORT-board62-fix1.md (not part of this repo).

🤖 Generated with Claude Code

Fix round 2 (this push)

A round-2 adversarial review found 3 P1s and 2 P2s on top of round-1's
fixes — 4 of round-1's 6 findings were fully fixed, 3 were half-fixed
(same shape, tighter window). All are addressed:

  • P1-A (board_stale dropped size, whole-second mtime only). recent/<key>
    rows now carry a size column alongside the nanosecond-precision mtime
    (files_mtime_size's %.9Y, the same fix _reading_cache_keyv
    already made), and board_stale compares both. Previously a write
    landing in the same wall-clock second as the last publish was
    invisible in either direction — a limit landing read as fresh, a
    resolved limit stuck reading stale.
  • P1-B (claude's fuel is account-level; its freshness signals were not).
    limit_profile_dry reads claude-usage, which scans projects/ in
    full — every project, not just the current one — but board_stale's
    signals were all 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 (a brand new project
    directory) and the (mtime, size) of every file inside the -mmin -300
    window at publish time (an append to a session in a project that
    already existed) — both bounded, never a tree walk — and this check
    runs before the per-scope early return, so a scope with no recorded
    sessions still answers for an account-level limit.
  • P1-C (codex rollouts live 3 levels under sessions/; the scan root was
    sessions/ itself).
    A new rollout never touched sessions/'s own
    mtime, so a new codex session never appeared in Resume and its dry
    reading never updated. The scan root is now the deepest EXISTING
    directory on today's sessions/YYYY/MM/DD path — computed from the
    wall clock, not discovered via find (this repo's own test suite
    hard-fails any find call on a warm render).
  • P2-A (agy's session-cwd cache was keyed on the shared history.jsonl).
    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 this turned "one new session" into a 500-entry
    cache stampede: board_state_refresh measured ~9.9s with the extra
    write vs ~5.2s unchanged. The cache is now keyed on the session's own
    transcript file; the same measurement now shows ~5.2s either way.
  • P2-B (burn paid two full board rebuilds per attempt). Round-1
    removed the boundary-call board_state_refresh in run.sh and
    antigravity.sh for the reason recorded in run.sh's own comment
    (render-time self-heal already covers it); burn.sh's two call
    sites were missed. Removed.
  • P3-1/P3-4 also tidied: board_gc_generations' sort now breaks
    ties on directory name (several publishes inside one wall-clock
    second no longer sort non-deterministically); load_adapter now
    runs before mktemp -d, so a load failure can't leave an orphan
    generation directory.

Every fix verified with a deterministic before/after receipt (pre-fix
code reproduces the bug, post-fix code doesn't) — see the round-2 fix
report for the full methodology and numbers.

Re-measured headline (round 2)

Same synthetic store shape as round 1, through the real bin/clikae
front door, CLIKAE_NO_INTERACTIVE=1, quiet host:

cold warm
main ~0.86 s ~0.86 s (no caching either way)
branch ~11.4 s (one-time self-heal) ~0.74-0.75 s

Output diff rc=0 between main and branch on this store. Full
bats tests/bats: rc=0, 1175/1175. shellcheck -S warning rc=0 on
every changed file; git diff --check clean.

Full report: /home/kitt/lanes/REPORT-board62-fix2.md (not part of this repo).

Fix round 3 (this push)

A round-3 adversarial review found the branch had not been rebased (merge-base
still at 941bb49, three commits behind main) plus 2 P1s and 2 P2s on top of
a re-confirmed performance win. All are addressed:

  • P1-1 (branch was never rebased; the merge conflicts didn't resolve
    cleanly).
    Rebased onto current main (ab6fb52, bringing in board: a dry verdict expires when its own reset instant has passed (#75) #79/antigravity: session title from conversation_summaries.db, not the opening prompt (#73) #80/burn: guard headless claude runs against sub-agent delegation (--disallowedTools Agent,Task + bg-wait ceiling 0) #82).
    Two textual conflicts: lib/adapters/antigravity.sh (#80's
    conversation-summary title lookup now stays intact, wrapped by this PR's
    per-file cache instead of being replaced by it) and tests/bats/home.bats
    (two same-behavior style conflicts, kept main's idiom). #79's
    dry-reset-expiry rewrite also changed _limit_codex_dry's contract (a
    real recovery after a limit is now rc=2, distinct from rc=1 "nothing
    found"); this PR's own codex-snapshot test predated that and expected
    rc=1 — updated to rc=2, matching #79's contract.

    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 ("bare clikae changes nothing on disk") 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's account-level fuel never saw a file that appeared after a
    publish, in an already-existing project directory).
    _claude_usage_stale
    had two signals — projects/'s own mtime (a brand new project directory)
    and the recorded (mtime, size) of files that were in the -mmin -300 window
    at the last publish — and neither moves when a brand new FILE lands inside
    an EXISTING project directory. Now also records each existing project
    directory's own mtime (bounded by project count, one more batch onto the
    same files_mtime_size call). Separately, an EMPTY tracked-file set at
    publish used to read as "nothing to compare, call it fresh" — permanently,
    since an append to an old file re-entering the window moves no directory's
    mtime either. That exit now rebuilds instead of trusting silence.

  • P2-1 (codex's freshness signal computed "today" from the OBSERVER's
    clock, not from what codex actually wrote).
    _codex_today_scan_dir used
    date to guess a day directory and walked up to the nearest existing
    ancestor — correct only when the observer's local "today" agrees with
    whatever clock wrote the newest rollout's date directory (true by default,
    false under an explicit TZ, travel, or a CI/cron zone mismatch).
    Replaced with _codex_newest_chain, which reads the actual newest
    YYYY/MM/DD chain off disk (bounded glob per level, no find, no clock).

  • P2-2 (a change on the agy tank still cost 1.8x main on the very first
    render after it).
    Round-2 killed the cache-key stampede, but antigravity's
    per-session cwd lookup (adapter_session_cwd, one reading_cache_run call
    per session) still cost a stat + a cksum + a subshell read PER SESSION even
    on a warm cache hit — ~5s fixed on a synthetic 500-session tank. Added
    adapter_session_cwd_index: one awk pass over history.jsonl building
    the whole sid → workspace map at once; the rebuild loop and the discovery
    fallback both use it (falling back to the per-file lookup when unavailable),
    turning 500 forked lookups into 500 in-memory array reads.

  • P3-1/P3-4 also tidied: the one remaining whole-second directory-mtime
    comparison in _claude_usage_stale (projects/'s own mtime) is now
    nanosecond precision, matching P1-A's file-level fix; clean.sh's
    _clean_board_gc sort now carries the same directory-name tie-break
    board_gc_generations already had, so the two GC sweeps rank the same
    generations the same way.

Every fix verified with a deterministic before/after receipt (pre-fix code
reproduces the bug, post-fix code doesn't) on a throwaway HOME+CLIKAE_HOME
under mktemp -d — the real ~/.clikae was never touched.

Re-measured headline (round 3)

Same synthetic store shape as rounds 1-2 (500 agy + 50 claude sessions,
~103 KB each, one cwd, 62 MB / 551 files), through the real bin/clikae
front door, CLIKAE_NO_INTERACTIVE=1:

  • First render after a change (the frame a burn-and-check workflow
    actually sees) — an agy tank getting one new session: ~3.7-3.9 s, at or
    below main's own ~3.6-3.9 s baseline on the same store (round-2 had this
    at ~6.4 s, 1.8x main).
  • Steady-state disk footprint: state/ stays bounded relative to the
    source store regardless of session count — a few MB of reading-cache
    entries plus a handful of board generations per tank, not one entry per
    source transcript.
  • Warm render: ~0.6 s, not round-1's original ~0.3 s. Round-1's very
    first number (316 ms) predates round-1's OWN P1-2/P1-3 fixes for a stale
    Resume list / frozen fuel reading — those fixes added board_stale's
    per-render freshness check (a handful of directory/file mtime comparisons,
    still O(1)-ish, never a tree walk) that must run on every read before
    trusting a published snapshot. That correctness cost is what moved the warm
    floor from ~0.3 s to ~0.6 s, and it has stayed there since (round 2, round
    3) — it is the price of the snapshot never going silently stale, not a
    regression introduced later.

Full bats tests/bats: rc=0, 1203/1203. shellcheck -S warning rc=0 on every
changed file; git diff --check clean.

Full report: /home/kitt/lanes/REPORT-board62-fix3.md (not part of this repo).

Fix round 4 (this push)

A round-4 adversarial review found 2 P1s and 2 P2s on top of a re-confirmed
performance win (round-3's 2 P1s + 2 P2s, plus P3-1/P3-4, all fixed and
re-verified). All are addressed:

  • P1-1 (a claude account with no transcript touched in the last 300
    minutes paid a full rebuild EVERY frame, forever — and once per render
    SECTION, not once per render).
    Round-3's fix for "the publish-time
    -mmin -300 window is empty" (P1-2 there) made that empty-set case
    rebuild instead of trusting it fresh — but rebuilding never changes
    whether the window is still empty, so it never stopped rebuilding. This
    is not an edge case: it is every account's first clikae of the day.
    Signal 3 (board_state.sh's _claude_usage_stale) now records the
    newest K files of EACH project directory instead of whatever the -mmin -300 window happened to catch — a set that is empty only when the
    account has never written a claude session at all — and the empty-set
    exit goes back to "fresh". Separately, board_generation's per-process
    memo does not survive a $( ) subshell, so all four render sections
    (_home_items's live/tanks/recent plus _home_refresh's own fuel scan)
    each forked their own rebuild for the same tank; _home_refresh now
    primes every tank's generation once, before any of those subshells
    exist, so a $( ) fork inherits an already-warm cache instead of paying
    for its own.
  • P1-2 (a codex tank with no sessions/ directory yet at its first
    render — i.e. right after clikae init codex, before the tank has ever
    actually 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, so nothing was ever watching for it to
    appear. Every level _board_scan_root names is now recorded, existing
    or not — a missing one gets a MISSING sentinel instead of being
    dropped, and board_stale reading that sentinel checks EXISTENCE (has
    it appeared since?) instead of comparing a mtime that was never there.
  • P2-1 (branch's warm, per-frame cost was ~260ms MORE per claude tank
    than main, on an otherwise-identical small/idle store — 6 tanks: main
    409ms vs branch 1906ms, 4.7x).
    Same root cause as P1-1's second half
    (duplicated per-section board_generation calls) plus each tank's own
    freshness check paying up to five separate stat forks (scanroot,
    claude-usage's root/projdirs/files, recent files). board_total now
    rides the same primed cache as the other three sections, and
    board_stale gathers every path any of its signals need before stat-ing
    anything, paying ONE batched stat call per tank instead of up to five.
  • P2-2 (a new codex rollout landing in an existing day directory that is
    not the lexically-newest one was permanently invisible — 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).
    _codex_newest_chain now records every DD directory under the
    newest MM (bounded: at most 31), not only the newest one — a write to
    ANY of them bumps a directory the check already watches.
  • 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 actually running — GNU/BSD stat both print one fewer line for a
    missing arg, silently shifting every zip index after it).
    Added
    _board_mtime_size_map (board_state.sh, local to this file — the
    shared files_mtime_size primitive's " " contract has other
    callers that already parse it positionally): keys off the path stat
    itself echoes back, so a vanished path is a missing map entry, never a
    value recorded against the wrong path. Every zip in board_state.sh
    (scanroot-mtime, claude-usage's projdirs/files, recent-files, the
    publish-time _fmap) now goes through it.

Every fix verified with a deterministic before/after receipt (pre-fix
2a24e8c reproduces the bug, post-fix doesn't) on a throwaway
HOME+CLIKAE_HOME under mktemp -d — the real ~/.clikae was never
touched. P3-4's receipt deletes a file between building the stat argument
list and the stat call landing (a stat-wrapper race) and shows the old
positional zip misattributing the next file's data, then shows the fixed
path-keyed lookup reporting a missing key instead.

Re-measured headline (round 4)

Through the real bin/clikae front door, CLIKAE_NO_INTERACTIVE=1, warm
best-of-3, one throwaway store per tree:

Idle claude account (the shape P1-1 fixed — no transcript touched in 300
min), 3 claude tanks × 20 sessions each:

frame 1 (cold) frame 2 frame 3 frame 4
branch, before this fix (2a24e8c) 2.4s, 24 finds 2.6s, 24 finds 2.6s, 24 finds 2.6s, 24 finds
branch, after this fix 0.67s, 6 finds 0.25s, 0 finds 0.25s, 0 finds 0.25s, 0 finds

24 finds/frame → 0 after the first render, forever — not "less often", gone.

Claude-only, single tank, 400×100KB transcripts (round-4 review's
scenario C):

window fresh window idle (6h)
main 584ms 111ms
branch 161ms (3.6x faster) 167ms (was 10.3s / 25x SLOWER before this fix)

Multi-tank, small store, fully warm (round-4 review's scenario D — 1/3/6
claude tanks × 10 sessions):

tanks main branch (before this fix) branch (after this fix)
1 112ms 175ms 164ms
3 110ms 262ms 241ms
6 172ms 375ms 363ms

The per-tank marginal cost this fix targets (board_generation/board_stale
itself, isolated from the rest of the render) dropped from ~260ms/tank to
~14ms/tank (measured directly, bypassing home.sh's other per-tank work).
Honest disclosure: the WHOLE render's multi-tank number above is still
measurably slower than main (not the "must match main" bar this round
aimed for) — the residual ~40ms/tank lives in per-tank work OUTSIDE
board_state.sh (label lookups, dry-set scans, per-call scope/key
resolution) that this round's review did not flag and this fix did not
touch. Filed as follow-up, not silently dropped.

State size (steady-state state/ after a publish, independent of
session count): measured 1.7–3.3 MB across this round's fixtures
(single claude tank, 400×100KB transcripts) — consistent with round-3's
"a few MB regardless of source size" claim; round-1/round-3's own
5.5MB/2.6MB/12MB figures were from different fixture shapes and sizes, not
a discrepancy in the mechanism.

Ruling, restated: a bare clikae render may write its own derived
cache under $CLIKAE_HOME/state/never under any tank's profiles/
dir. That boundary is enforced by tests/bats/home.bats's "bare clikae
changes nothing on disk" test (compares profiles/ byte-for-byte,
excludes state/), not just documented in a comment.

Full bats tests/bats: rc=0, 1203/1203 (three ≤600s segments, 514+427+262).
shellcheck -S warning rc=0 on every changed file, with and without -x.
git diff --check origin/main...HEAD clean.

Full report: /home/kitt/lanes/REPORT-board62-fix4.md (not part of this repo).

Fix round 5 (this push)

A round-5 adversarial review found 1 P1 and 2 P2s, and made an observation
that changed the plan: every round so far (1-4) 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, …),
and every round found one more write shape that approximation could not
see — an append to the 11th-newest file in a project, a limit landing in a
subagent's agent-*.jsonl, a rollout appended to after publish from a
different cwd. A bounded guess about which paths to watch is always one
write shape behind reality.

Design decision: stop guessing. board_stale no longer keeps a
per-engine catalogue of "the paths that might have changed" at all. It
re-lists EVERY transcript file under the tank (the exact find per engine
board_state_refresh already ran to build the generation) and re-stats
every one of them in a single batched call, on every read, reducing the
whole set to one opaque fingerprint (count + a CRC over each file's own
path\037mtime_ns\037size, sorted). Equality between that, recomputed
fresh, and what publish last recorded is now the ENTIRE staleness signal,
for every engine. This is real O(files) work again — but it is find +
stat only, never a file open or a parsed line: #62's actual cost was
PARSING (a title/recap read, a rate-limit scan) on every frame, and stat
never did that. Measured on this round's host: the raw find+stat forks
over 500 transcripts run in a few milliseconds each; the full freshness
check (which also pays a bash-level loop building the file-list array)
costs about 35ms for the same 500 files — comfortably under the 50ms bar,
though most of that 35ms is bash's own per-line loop overhead, not the
stat syscall (see the fix report for the breakdown).

Deleted outright rather than left beside the new signal (all superseded,
none of it partially — see the diff, not a summary of it): the per-project
top-K recorded-file set, _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-root-mtime /
claude-usage-files / claude-usage-projdirs on-disk records, and the
MISSING-sentinel handling that existed only to make that catalogue
survive a not-yet-existing directory. The bounded per-file READING cache
(reading_cache.sh) is untouched — that is where the 12x speedup came
from, and nothing about staleness detection changes what gets parsed once
a rebuild actually triggers.

Findings fixed:

  • P1 (a codex limit landing via an APPEND to a rollout that is already
    running was invisible to a board opened from any OTHER cwd, indefinitely
    — codex's limit can run for weeks, so this could hide a dry tank for the
    rest of that time).
    codex's only per-render signal used to be a
    day/month/year directory chain keyed to the OBSERVER's $PWD; an append
    bumps no directory's mtime at all. The new whole-tank fingerprint covers
    every rollout under sessions/, regardless of which cwd is rendering.
  • P2-1 (priming left the board_generation memo in the PARENT process,
    so an interactive TUI's later refreshes — after c/m/n/a/d/l
    reused the FIRST refresh's generation forever, until the process exited).

    Round-4 primed _BOARD_GEN_CACHE once per render for good reason (one
    batched check per tank instead of one per render SECTION), but never
    cleared it first, so priming again on the SAME long-lived process just
    re-confirmed the stale memo. _home_refresh now clears
    _BOARD_GEN_CACHE before priming, so every refresh asks fresh.
  • P2-2 (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 read as a false green fuel dot until the account's
    next unrelated write rescued them).
    Superseded by construction: the new
    fingerprint covers every .jsonl under projects/, including
    agent-*, not just each project's newest 10.

Every round-5 receipt re-run and added as its own bats case in
tests/bats/home-bounded.bats (all four failed against round-4's
a578aa0 before this fix, confirmed by temporarily reverting just the
three changed files and re-running; all four pass now):

  • cross-cwd codex limit append (P1)
  • claude limit appended to the 11th-newest session in its project (P2-2)
  • claude limit landing only in an already-existing agent-*.jsonl (P2-2)
  • _home_refresh called twice in one process picks up a session added
    between the two calls (P2-1)

home-bounded.bats's own hard-failing find shim (asserting a warm
render never lists the tree at all) is retired along with the mechanism it
was guarding — replaced with a logging shim asserting the NEW invariant:
find/stat are expected and bounded (once per tank), but head/tail
never touch a transcript's content on a warm read.

Re-measured headline (round 5)

Through the real bin/clikae front door, CLIKAE_NO_INTERACTIVE=1, one
throwaway store per tree, mktemp -d HOME+CLIKAE_HOME:

#62's own shape (3 claude sessions × 60MB + 970 agent-*.jsonl, 187MB,
one tank):

main branch cold (first publish) branch warm
~1.03s (every render — no caching on main at all) ~10.7s (one-time) ~216ms

~4.8x over main once warm, consistent with round-5 review's own 4.4x
finding on the same shape. Round-3's PR-body headline number (0.61s warm,
on a 500-agy + 50-claude / ~56MB store) is the number this round is held
to: 216ms is well within 1.5x of it (0.915s ceiling).

Full bats tests/bats: rc=0, 1207/1207 (78 files).
shellcheck -S warning rc=0 on every changed file. git diff --check clean.

Full report: /home/kitt/lanes/REPORT-board62-fix5.md (not part of this repo).

Fix round 6 (this push)

A round-6 adversarial review found 2 P1s and 1 P2, all in fix5's whole-tank
batch-stat fingerprint. Round-5's design decision — one (path, mtime, size)
fingerprint over every transcript is the entire staleness signal — was
correct and stays unchanged; these were bugs in how that fingerprint was
computed and how a rebuild acted on it.

  • P1-1 (the fingerprint pushed every transcript path into ONE stat argv;
    past ARG_MAX stat died E2BIG, the error was swallowed by 2>/dev/null,
    and the fingerprint silently collapsed to a file count — an append to an
    existing file then changed nothing a render could see).
    Linux's default
    ARG_MAX hits this around 14k transcripts at typical path lengths; macOS's
    smaller 1 MiB argv hits it around 7k. _board_stat_rows is now a single
    find … -exec stat … {} + piped straight into sort | cksumfind
    batches its own argv per the real ARG_MAX, so this failure mode is now
    structurally impossible, not just less likely. The same argv exposure at
    sessions_by_mtime's call site (building the cold-build sid/scope list)
    is also removed: the incremental path never calls it, and the cold-build
    path now sorts from stat data it already has instead of re-stating via argv.
  • P2 (building that argv cost a bash while read loop pulling find's
    output through a process substitution, one byte at a time — 500-575ms at
    5,000 files even when stat succeeded).
    Gone along with the argv itself:
    _board_stat_rows never pulls a path into a bash variable.
  • P1-2 (any transcript write anywhere in the tank forced a FULL rebuild —
    every file's sid/scope/resume-row recomputed, every file's rate-limit
    reading re-scanned, even though only one file changed — 39.8s for one
    append in a 5,000-session store, 5.5s on home: board render time must not scale with transcript count or size #62's own shape).
    This is
    exactly the regression class home: board render time must not scale with transcript count or size #62 exists to fix: the board is designed to
    be opened mid-session, so a tank actively being used almost never saw the
    "near-instant warm" render home: board render time must not scale with transcript count or size #62 promised. board_state_refresh now diffs
    the fresh stat rows against the previous generation's own manifest via
    ONE awk pass (not a bash loop) instead of rebuilding everything; sids/
    and recent/ start as a cp -al (hard link, no data copy) of the
    previous generation; only a changed, new, or removed file pays a fresh
    parse. A cold build (nothing to diff against yet) keeps the prior bulk
    shape.

No new staleness signal was introduced — board_stale's equality check
against the single fingerprint is untouched, and no per-project top-K,
-mmin window, scanroot sentinel, or day-dir chain was reintroduced.

Three new bats receipts in tests/bats/home-bounded.bats: a 20,000-transcript
fixture (built in the test) proving the fingerprint still sees a one-line
append at that scale, with the underlying find+stat pipeline asserted to
return rc=0 and produce no stderr (no more 2>/dev/null hiding a failure);
a stubbed-parser test proving an incremental rebuild re-reads exactly the one
file that changed, not the other 49; and a deletion test proving a removed
transcript's resume row disappears on the next rebuild (the one new failure
mode the carry-forward design introduces).

Re-measured headline (round 6)

Through the real bin/clikae, CLIKAE_NO_INTERACTIVE=1, one throwaway store
per tree, warm best-of-3. Stores: 500 and 5,000 small claude sessions (all
but one backdated 30 days outside the rate-limit window — a realistic
"mostly old, one active" tank), and #62's own shape (3×60MB + 970 agent
files).

No-write warm:

store main branch-before (bf5078c) branch-after (this fix)
500 files 378 ms 388-395 ms 395-419 ms
5,000 files 378-383 ms 880-895 ms 396-441 ms
#62 shape 1,108-1,124 ms 319-328 ms 234-239 ms

One file appended, then rendered (the shape P1-2 is about):

store main branch-before branch-after after ÷ main
500 files 352-390 ms 3,166 ms 535 ms ~1.4-1.5×
5,000 files 379-390 ms 39,845 ms 705-708 ms ~1.8-1.9×
#62 shape 1,142 ms 5,437 ms 362 ms 0.32× (faster than main)

The 5,000-file append case — the worst of the three — went from 39.8s to
0.7s, a ~57× improvement, landing within 2× of main instead of 100×+ over
it. strace -f -e trace=openat around the render confirms exactly one
.jsonl file is opened after the append, in both the 500- and 5,000-file
stores, regardless of the other files present.

Full bats tests/bats: 1209 ok / 1 not ok / 1210 total. The one failure
(roam.bats, a tmux second-attach terminal-width assertion, unrelated to
board_state.sh) reproduces identically on the pristine pre-round-6
baseline — pre-existing and environment-specific, not a regression from
this round. shellcheck -S warning rc=0 on the changed file, with and
without -x; git diff --check clean.

Full report: /home/kitt/lanes/REPORT-board62-fix6.md (not part of this repo).

Fix round 8 (this push)

A round-7 adversarial review found 2 P1s, 3 P2s and 4 P3s. All nine are fixed,
one commit each, plus two more that this PR's own CI and its own timing found
while fixing them.

#62's acceptance condition is now MET, not reframed. Rounds 6 and 7 called
cold "not a target" while the issue says "Board render on the machine above
under 1 s cold and near-instant warm"
. On an idle host, 5,000 transcripts:
cold 415 ms (was 35,631 ms), no-write warm 1.22x main, one-append 1.93x
main. Table below.

  • P1-1 + P2-1 — generations shared inodes (f797845). cp -al carried
    sids//recent/ forward as HARD LINKS and two in-place writes went through
    them, so publishing gen2 rewrote gen1 while current still pointed at it;
    the same copy cost one link() per transcript PRESENT (5,001 at 5,000
    files). A generation now holds only what changed in it plus a parent
    pointer; readers walk that chain, bounded — it materialises a real cp -a
    before depth 8 — so reads are <= 8 lookups and writes are proportional to
    CHANGED files. Removal writes a zero-byte tombstone; every entry write is
    temp + mv -f; GC (here and in clikae clean, now sharing one rule) never
    unlinks a link of the live chain.
  • P1-2 — a tank with zero transcripts was permanently stale (5e9cfb8).
    The publisher and the freshness check hashed two different byte streams that
    could only agree when the tank held at least one file, so every freshly
    clikae init-ed tank — the first screen a new user sees — published a new
    generation on every frame, forever, 2.2x slower than main at doing nothing.
    One fingerprint function now, called by both sides.
  • P2-2 — cold build (7acb51c). The cost was never the tree walk; it was
    ~4 forks per FILE. The entry name is computed without a process, claude's sid
    comes from the filename and codex's/grok's from ONE batched bounded read
    (head -c 512 over xargs -0 batches, with a per-file fallback so the bound
    costs speed and never an answer), and the rate-limit scan is bounded to the
    newest CLIKAE_HOME_RECENT_MAX files per project directory.
  • P2-3 — a moved transcript lost its entry (94cb3f1). Removals are
    applied before changes; a file seen at a new path was being written and then
    deleted under the same key.
  • P3-1/2/3/4 (d50cb1c): the compat gate's negative control now calls the
    real ruler instead of a copy of it; _agy_ws_load clears what it loaded and
    is namespaced by tank; _board_transcript_find no longer swallows a per-file
    stat failure; grok's same-second same-size summary.json rewrite is
    documented where the file that has the problem is named.
  • Found by this PR's own macOS job (a590006). _board_entry_key (bash)
    and the cold build's gsub (awk) were two spellings of one rule and
    disagreed on a non-ASCII sid — a MISS, i.e. a session silently absent from
    clikae resume with its transcript still on disk. A tank with Chinese or
    accented project paths would have lost its whole Resume list on macOS. One
    implementation now, run byte-wise (LC_ALL=C) on both sides.
  • Found by this round's own timing (26cac7b). One-append came out at
    2.15x, over this round's 2x bar. Breaking the rebuild down rather than
    guessing: 26 ms of 127 ms was the tree walk, paid twice — once by
    board_stale to decide, once by board_state_refresh to do it. The check
    hands its rows over now.

Measurements (round 8)

Idle host (16 cores; busy_cores sampled from /proc/stat — this box's
uptime load average is container-scoped and read 0.59 while vmstat showed
8 of 16 cores busy, so it is not a usable idleness signal on its own). Same
fixture and harness shape as the round-7 reviewer's. Wall clock, min of 3
(cold is one sample); main = origin/main 4496a6d.

store main branch (r7) branch (r8) r8 vs main
500 cold 125 ms 3,438 ms 227 ms 1.82x
500 no-write warm 122 ms 165 ms 161 ms 1.32x
500 one-append 122 ms 251 ms 234 ms 1.92x
5,000 cold 159 ms 35,631 ms 415 ms 2.61x
5,000 no-write warm 142 ms 178 ms 173 ms 1.22x
5,000 one-append 138 ms 416 ms 267 ms 1.93x

Marginal cost of one changed file (append - warm, immune to a load offset,
which is the gate the round-7 reviewer asked for instead of a ratio):
94 ms at 5,000 (round 7: 238 ms) and 73 ms at 500 (round 7: 86 ms).
Second idle run reproduces within noise: 5,000 cold 403 ms, no-write 1.18x,
one-append 1.90x.

strace -f -e trace=link,linkat over a one-append render at 5,000 files:
0 link() calls (round 7 measured 5,001), and exactly one .jsonl opened.

Full bats tests/bats locally: 1,319 ok / 1 not ok, the one being
roam.bats's tmux width negotiation, which reproduces 2/2 on origin/main
in the same window (and passes on both CI platforms) — a host flake, not a
regression. shellcheck -S warning rc=0 on every changed file (-x too);
git diff --check clean; git merge-tree against main clean.

Full report: /home/kitt/lanes/REPORT-board62-fix8.md (not part of this repo).

Fix round 9 (this push)

A round-8 adversarial review found 2 P1s, 2 P2s and 4 P3s. All eight are
fixed, one commit each.

The headline correction first. Round 8's "cold 415 ms at 5,000 files" was
measured on a tank with ONE project directory and one in-window transcript,
and the acceptance it claimed depended on a bound that could not hold: the
rate-limit scan was bounded by a COUNT (the newest CLIKAE_HOME_RECENT_MAX
files per project directory) while the thing that count approximates — the
rolling limit window — is a TIME. Different dimensions, so the reviewer was
right that no count can ever be "provably >= the window", and they measured
both halves of the consequence: a dry tank drawn as full on the real front
door, and cold rising to 1,453 ms as soon as the in-window files were spread
over ten project directories.

Window semantics, stated plainly. Every transcript whose mtime is inside
the engine's window (claude 300 min, codex 7 days) is scanned for a limit
signal, no matter how many share a project directory.
CLIKAE_HOME_RECENT_MAX bounds the Resume rows a scope SHOWS and nothing
else. The cost is bought back by not forking per file rather than by looking
at fewer files: one tail per xargs batch plus one awk, over exactly the
bytes ($CLIKAE_TX_TAIL_BYTES) the per-file parser would have read.

  • P1-1 + P2-1 — the fuel dot could draw a dry tank as full, and cold rose
    with project-directory count
    (3d442ab). The bound is the window now.
    _LIMIT_READING_AWK is one spelling of the matching rules, shared by the
    per-file parsers and the new batched _limit_batched_readings. Four
    receipts, all red on 26cac7b: a claude limit in a session that is not the
    newest in its project (behind RECENT_MAX+2 newer neighbours, never written
    again — what an abandoned dry session leaves on disk); the codex twin in a
    day directory; a cold build that scans all 40 in-window files, none of the 5
    outside, in <= 5 tail processes; and the batched scan agreeing with the
    per-file parser on both engines, including a transcript with no trailing
    newline. home-bounded.bats:345 — the round-5 receipt that APPENDED the
    limit, which made that file the newest and so could not tell a count bound
    from a window bound — is replaced by the first of those (round-8 P3-4).
  • P1-2 — a non-ASCII project path answered with its neighbour's sessions
    (3de35fc). Round 8 folded every byte outside [A-Za-z0-9._-] to _, and
    _board_scope_raw hands every engine but claude the raw $PWD, so two
    sibling directories of equal BYTE LENGTH shared one recent/ entry —
    專案一 listed 專案二's sessions and 專案二 listed none. The name is an
    injective %XX escape now, so the class is gone rather than rare; and the
    cold build groups recent/ by the RAW SCOPE (the incremental merge/purge
    verify the #scope header) so that even a truncation collision degrades to
    a MISS, which is what this file's header has always promised.
    Deviation from the round-9 brief, measured: the brief asked for cksum of
    the raw bytes. The reader can call board_key, but the cold build names
    every entry inside one awk pass, so awk needs its own CRC-32 — written,
    verified byte-exact against cksum on ASCII and CJK, and timed at 177 ms
    for 5,000 sids
    (gawk, three runs) against a cold build that must fit in
    one second and costs ~400 ms without it. The escape costs 3 ms on the same
    input and needs no second implementation.
  • P2-2 — GC unlinked ancestors a live generation still resolved through
    (0fe0112). Protection was drawn around current only, and current is
    not the only generation being read: board_generation memoizes a generation
    path for the life of the process. keep-N counts CHAINS now — the newest
    keep generations plus whatever current points at are roots, and every
    ancestor any root resolves through is protected with it. Receipt (red on
    26cac7b): hold a depth-7 generation, publish once more, all 50 entries
    still resolve from the held generation and every link of its chain is on
    disk — clikae clean's sweep included, since it shares the rule.
  • P3-1/P3-2/P3-3/P3-4 (3d442ab, d396968): the comment describing a
    readings-pending mechanism that had not existed since round 8 is gone
    (grep -rn readings-pending lib bin tests is empty); readings-bounded is
    published by BOTH the cold and the incremental path, with one meaning on
    both; and scripts/test.sh — which read find lib tests scripts -name '*.sh' and so LOOKED like it covered tests/ — now scans .bats too, with
    a matching CI step, because the action does not scan .bats either. That
    gate found 73 findings across 20 files and all of them are fixed. Two were
    dead assertions rather than style: ! declare -F … and ! grep -q … in
    burn.bats never fail a bats test (SC2314), so both had been asserting
    nothing.

Measurements (round 9)

Idle host, 16-core Linux; busy_cores sampled from /proc/stat immediately
before and after every row (this box's uptime load average is
container-scoped and is not a usable idleness signal on its own). Every row's
busy_cores was between 0.08 and 1.44. Front door (bin/clikae), wall clock.
main = origin/main 9cf7a29. Fixture: <files> transcripts of two JSON
lines spread over <dirs> project directories, 200 of them inside the
window
, the rest backdated 30 days.

Every branch row was verified to have actually scanned what it claims: the
published readings-bounded list was counted immediately after the cold
render and equals the in-window count exactly (200/200 in this table). A first
pass of this same harness quietly put only ONE file inside the window — the
numbers looked fine and meant nothing, so the count is now part of the
measurement.

files project dirs branch cold main cold branch no-write main no-write branch one-append main one-append
500 1 253 ms 292 ms 175 ms 288 ms 248 ms 285 ms
500 5 235 ms 283 ms 176 ms 279 ms 247 ms 289 ms
500 10 256 ms 287 ms 178 ms 285 ms 245 ms 288 ms
5,000 1 437 ms 263 ms 197 ms 268 ms 327 ms 266 ms
5,000 5 408 ms 298 ms 169 ms 297 ms 278 ms 284 ms
5,000 10 434 ms 293 ms 197 ms 308 ms 315 ms 295 ms

#62's "under 1 s cold" holds — and now it holds on the shape that broke it.
The reviewer's own P2-1 fixture (5,000 files, 10 in-window per project
directory), same harness, same host:

in-window dirs readings taken round 8 (measured by the reviewer) round 9 main
1 10 504 ms 399 ms 149 ms
5 50 978 ms 388 ms 141 ms
10 100 1,453 ms 392 ms 183 ms

Cold is flat in the number of project directories now, because the cost is no
longer ~10 ms per reading. Pushing it further on purpose: 20 project
directories and 1,000 in-window readings — 452 ms cold
(all 1,000 scanned,
verified). The old mechanism's own slope puts that at roughly ten seconds.

Full bats tests/bats locally: 1,323 ok / 1 not ok, run in five chunks,
each chunk taking the same lock scripts/test.sh takes (never
CLIKAE_ALLOW_CONCURRENT_SUITE); the chunk lists were reverse-checked against
the full 84-file list (byte-identical union, no file in two chunks). The one
red is roam.bats's tmux width negotiation (SECOND_WIDTH 100 where 60 is
expected), and this round has the interleaved control round 8 could not get:
the SAME chunk on 26cac7b and on this HEAD, alternating, in one window —
26cac7b red 2/2, this HEAD red 1/2. Load-sensitive host flake, not a
regression, and green on both CI platforms.

shellcheck -S warning rc=0 over bin/clikae, install.sh, scripts/test.sh
and every .sh AND .bats under lib/ tests/ scripts/ (-x clean on the
changed library files). bash 3.2 (docker run bash:3.2): bash -n over
bin/clikae + lib/** + scripts/*.sh, a real source of the board/limit
stack, and _board_entry_key over an ASCII/CJK/accented/edge corpus — all
green. git diff --check clean.

Correction to what this section said when it was written. It carried a ⚠️
saying git merge-tree against origin/main was NOT clean because #86
conflicted in CHANGELOG.md and lib/commands/burn.sh. That was resolved in
this branch by two merges — fb91dcd (9cf7a29, #86) and 3c1b4f2
(90bf75b, #92) — and by the end of round 9b merge-tree --write-tree origin/main 2f51d15 returned rc=0 with mergeable_state = clean and
HEAD..origin/main empty. The warning is removed rather than left standing:
a merger who reads "this does not merge" stops, or does work that is already
done. For the state as of THIS push, see the round-10 section below.

Full report: /home/kitt/lanes/REPORT-board62-fix9.md (not part of this repo).

Fix round 9b (pushed after round 9's review)

Round 9 went green on Linux and red on macOS, and the red was not a flake.
Four commits.

  • 02d8f89 — same-scope is BYTE equality, because Apple's awk answers !=
    with strcoll().
    bats (macos-latest) test 720 failed on round 9's own
    receipt: the 專案一 scope's Resume list held 專案二's two sessions as
    well. The escape (round 9's P1-2 fix) was innocent — Apple's awk computes it
    byte-exactly, and golden-name asserts on the runner now pin that. 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 makes awk's
    string comparison follow the caller's COLLATION, and macOS' awk really does
    (apple-oss-distributions/awk, src/run.c, relop() is strcoll(), for
    ==/!= as much as for <). That awk ran without LC_ALL=C, the runner's
    collation is UTF-8, and macOS' UTF-8 collation has no weights for most CJK
    ideographs — so 專案一 != 專案二 was FALSE, the scope never "changed",
    and one directory swallowed the other's rows.
    No Linux awk can show this and no glibc locale can either (glibc's strcoll
    never returns 0 for two distinct strings; gawk/mawk/onetrue-awk byte-compare
    regardless of locale), so it was reproduced with the real thing: Apple's awk
    built from source in a container, under a compiled locale that gives the
    weight of . Both sites (the cold build's grouping awk, and
    _board_purge_recent_row's filter) now carry LC_ALL=C and compare with
    bsame(a,b)length(a)==length(b) && index(a,b)==1, which consults no
    collation at all. Either alone would fix it; both are there because this
    class is invisible on the platform the work is done on.
  • 6a4aa49 — the GC ranks generations in PUBLISH order, not by a same-second
    coin toss.
    _board_gc_candidates sorted by whole-second mtime with the
    directory name (mktemp's random suffix) as the tie-break, so when several
    publishes share a second — every rebuild in a tight loop, every bats run —
    "the newest keep" was random, and a generation a reader still HELD could
    fall out of the roots and be unlinked on the very next publish. Round 8's own
    depth-7 probe, re-run on round 9's tip, lost the held generation on the FIRST
    publish in 2 of 4 runs. seq is written at publish time (the previous
    current's seq + 1), so the ranking no longer depends on the clock; mtime
    and name only break a tie between two processes that published from the same
    current.
  • 3bd9189 — the .bats files merged in from main pass the .bats
    shellcheck gate, and five negations now assert.
    Round 9 added that gate;
    the merge brought in files written before it. Five ! grep … lines in
    clean.bats were SC2314 — a bats test cannot fail on ! alone, so those
    five had been asserting nothing. They are run ! grep … now, with
    bats_require_minimum_version 1.5.0.
  • 2f51d15 — among same-second rows, the session that JUST changed stays on
    Resume.
    The new row was appended and sort was not stable, so a render
    immediately after a write could drop it. The row goes first and the sort is
    sort -s. (_board_merge_recent_row already skips an existing row with the
    same sid, so leading with the new one cannot duplicate it.) This commit also
    hoisted _board_gc_rows out of a $( … ) — see round 10's P3-2 below for
    what that was really fixing.

Result: bats (macos-latest) 1719/1719, bats (ubuntu-latest) 1719/1719, all
nine checks green on 2f51d15.

Fix round 10 (this push)

A round-9 adversarial review returned 🟢 mergeable with 0 P1, 2 P2 and 3 P3.
Three commits.

  • P2-1 — a ==> … <== line INSIDE a transcript was read as a tail banner
    (dbe8f04). tail's framing is in-band: its ==> name <== separators
    travel in the same stream as the file CONTENT that _limit_batched_readings
    folds, and the scan matched them by PATTERN. One physical line shaped like
    one — a torn or partial write, anything third-party writing into the tank
    tree — therefore ended that file's section early. The reviewer measured both
    halves on the real front door: the limit sitting after the line was
    attributed to a path that was never in the scan list, the transcript's own
    reading came back EMPTY, and a dry tank drew where the control drew
    ○ … resets 11pm. Since claude's _limit_tank_dry_raw deliberately never
    falls back to dry_store, burn would dispatch into it — round-8 P1-1's
    exact failure through a second door — and it broke this function's own
    promise, asserted in its header and in bats, that it is a SPEED path and
    never a narrower answer than _limit_claude_reading.

    A banner is reconciled against the scan list now instead of pattern matched.
    The list is read first (NR == FNR), so the scan knows which paths are
    expected and in which ORDER they were handed to tail, and a banner is
    accepted only when it names exactly the next unconsumed path — where the
    batch's own framing can put one. The comparison is bsame, never awk's ==,
    for the reason 02d8f89 had to learn. Refusing a line is not enough on its
    own, though, because the batch stays mis-framed from there on, so the batched
    result is TRUSTED only when three signals agree: awk saw no banner-shaped
    line it did not accept (/dev/null's own banners excepted), the pipeline
    exited 0, and one row came back per listed path. Otherwise the per-file
    parser answers the whole list. A fabricated banner naming the very NEXT path
    is invisible to a row count alone — it consumes that file's real banner later
    and the totals still balance — which is why the first signal exists.

    The same check covers a listed path that vanished between the scan and the
    tail: tail then prints no banner for it at all and exits 1, which xargs
    turns into 123 — under bin/clikae's set -eo pipefail that used to abort
    the whole refresh.

    Receipts, on a fixture of four transcripts (one carrying ==> /etc/passwd <==, one carrying an EARLIER sibling's path, one carrying the NEXT sibling's
    path) plus a listed path that does not exist:

    before   batched != per-file; a row for /etc/passwd that was never scanned;
             front door drew ● on the dry tank, claude-usage = [||]
    after    batched == per-file on all four; no row outside the scan list;
             front door drew ○ … resets 11pm, and the published manifest is
             byte-for-byte the control's
    

    Cost: the process count is unchanged and still flat in the number of files —
    clone/execve/tail counts are byte-identical at N = 100 / 500 / 1,000
    (1062 / 518 / 78), two grep -c more per batched read than before.

  • P3-2 — the board GC now has a guard that EXECUTES under bash 3.2
    (0e5f545). 2f51d15 fixed a case pattern inside $( … ) in
    _board_gc_candidates, and nothing in this repo could have caught it. bash
    3.2 — what macOS ships and what bats (macos-latest) really runs clikae
    under — does not parse a command substitution's body when it parses the file;
    its scanner stops at the first unbalanced ), which is exactly what a case
    pattern has. bash -n, shellcheck -S warning, the CI syntax gate and every
    source-scanning guard in compat.bats were all silent, while on 3.2 the
    substitution failed at RUNTIME on stderr, yielded the empty string, and the
    function returned 0 — the GC swept nothing on macOS and reported success:

    docker run --rm bash:3.2 …    board_state.sh @ 6a4aa49
      bash -n                                            rc=0        <- blind
      _board_gc_candidates <fixture> 2   stdout          (empty)  rc=0
                                         stderr  command substitution: line 730:
                                                 syntax error near unexpected
                                                 token `newline'
    
    docker run --rm bash:3.2 …    board_state.sh @ HEAD
      _board_gc_candidates <fixture> 2   stdout  generation.badseq
                                                 generation.noseq   rc=0
                                         stderr  (empty)
    

    The new compat.bats test runs that on a real generation chain. It skips
    with the reason when docker or the bash:3.2 image is absent (it never
    pulls, so CI's macOS runners simply skip it) and it carries a control — a
    known-bad $( … ) in its own file that must fail inside the container — so
    green means "fixed", not "quietly ran on bash 5". tests/README.md gains the
    section naming the class, the rule that follows from it (hoist the case
    into a helper; a guard for it must execute), and the trap that cost the
    review an experiment: bash:3.2 is an Alpine image, so busybox's
    stat -f '%m' answers with FILESYSTEM information and file_mtime returns
    garbage there — the container's userland, not this code.

  • P3-3 — scripts/doc-names-exist.sh's own "I found no source" insurance
    could never fire
    (2b53f98). ${#SRC[@]:-0} is not a legal expansion, so
    both call sites printed bad substitution on every run and the tests they
    guard evaluated to failure — which is how a gate that exists to refuse a
    green light meaning "I did not look" gave exactly that:

    before, in a tree with docs/ but no lib/ or bin/:
       doc-names-exist.sh: line 57: ${#SRC[@]:-0}: bad substitution
       doc-names-exist.sh: line 61: ${#SRC[@]:-0}: bad substitution
         docs name nothing the code lacks (functions, call sites, tmux options)
       rc=0                                    <- zero files checked, gate green
    after, same tree:
       no source/docs found
       rc=1
    

    This file is not this PR's — e226308 introduced it on main and it was
    byte-for-byte identical to origin/main until this commit. It is fixed here
    because the branch's own review ran the gate and the noise was in the
    receipt.

  • P2-2 — this PR body. Rounds 9b and 10 are written down above, and the
    round-9 section's ⚠️ claiming the branch does not merge is corrected in
    place. Four commits (02d8f89, 6a4aa49, 3bd9189, 2f51d15) had no
    record here at all, including the one root cause that only macOS can produce.

P3-1 · the honest cost statement (no code change)

Round 9's tables measure cold against the NUMBER of in-window transcripts, and
by that measure #62's "under 1 s cold" holds with room to spare (5,000 files /
40 project directories / 400 readings = 439 ms; all 5,000 in-window = 471 ms).
That is not the whole cost function. Every in-window file is read up to
CLIKAE_TX_TAIL_BYTES (512 KB), so cold is linear in in-window BYTES, and on
large transcripts it is seconds, not milliseconds. Same host, same harness,
1,000 × ~620 KB transcripts (605 MB), all in-window, 10 project directories:

cold warm (no write)
this branch 4,321 ms (reviewer measured 4,430 ms on 2f51d15) 349 ms
main 90bf75b 4,299 ms 4,268 ms — every frame

This is not a regression — cold is within 1% of main, and main pays it
on EVERY render while the branch pays it once and is then ~12× faster. But the
acceptance sentence should be read with its unit: under 1 s cold for a tank
whose in-window transcripts are a few tens of MB
, not "under 1 s cold" without
qualification. No code change; stated here so the number in the PR is the one
that is true.

Full report: /home/kitt/lanes/REPORT-board62-fix10.md (not part of this repo).

Gates and merge state (this push, 2b53f98)

bats tests/bats/{home-bounded,home,board-width,limit,limit-codex-status,
     limit-reset,compat}.bats                       195 ok / 0 not ok
shellcheck -S warning  bin/clikae install.sh scripts/test.sh          rc=0
shellcheck -S warning  every *.sh and *.bats under lib/ tests/ scripts/  rc=0
scripts/doc-names-exist.sh                          rc=0, and now silent
git diff --check  (origin/main..HEAD, and the worktree)               rc=0
docker run --rm bash:3.2   bash -n over 104 shell files, 0 failures;
     board/limit stack sources clean; _board_gc_rows / _board_gc_candidates /
     _limit_batched_readings all produce correct output under 3.2 + busybox awk

(Per this branch's test policy, the full suite is not run locally — six lanes
share one suite lock — so the 1719/1719 figure comes from CI, not from here.)

⚠️ Merge state and CI, measured at this push — read this before merging.

main moved twice while this round was running: b030edf (#93, antigravity
resume --conversation + tank-scoped Resume rows, issue #34) and 6190f86
(#87, burn reports work left behind). git merge-tree --write-tree origin/main HEAD conflicts, the API reports mergeable_state = dirty, and that is why
this push has no CI run
: ci fires on pull_request, GitHub builds those
runs from refs/pull/78/merge, and that ref is still the stale
Merge 2f51d15 into 90bf75b — no merge ref, no run. 2f51d15's 9/9 green is
the last CI this branch has.

The merge was attempted in this lane's worktree and then aborted on purpose.
The textual conflicts are small — tests/bats/clean.bats (both sides fixed the
same SC2314 lines, different spelling), tests/bats/compat.bats (both sides
appended a test), lib/adapters/antigravity.sh (#93 deletes
adapter_recent_sids' cwd filter; this branch adds a board_recent fast path
above it) — and they resolve in minutes. What does not resolve in minutes is
what main's own new tests then say. With the merge resolved and
shellcheck -S warning clean over everything:

bats compat.bats adapters/antigravity.bats antigravity.bats resume.bats
                                                       84 ok / 0 not ok
bats home.bats home-bounded.bats board-width.bats      108 ok / 6 not ok
  not ok  agy board Resume rows show sessions regardless of recorded
          workspace, newest first (#34)
  not ok  12 burn sessions newer than 3 human ones do not empty the continue
          list (claude tank, #93 P2-1)   — and the codex twin
  not ok  195 burns + 50 humans still fill the Continue list — claude
          (#93 round-2 P2-1)  rows=0     — and the codex twin
  not ok  a sidecar past CLIKAE_BURN_SIDECAR_CAP truncates, and says so, at
          the DEFAULT ceiling (#93 round-2 P2-1)

Both failures are this branch's board, not the merge:

  1. agy sessions never enter tmux, so they are invisible to the board and unreachable remotely #34#93's point is that agy's workspace field is a constant on
    real installs, so cwd-scoping hides every row. This branch keys the board's
    recent/ entries for antigravity by exactly that cwd scope
    (board_state.sh :1087-1126), so the board answers with the one
    cwd-matching session and returns before the adapter's now-unscoped disk scan
    can run. Teaching the board tank-scoped agy rows is a design change to this
    PR, not a conflict resolution.
  2. antigravity: fix agy resume --conversation bug and tank-scope the board's Resume rows (#34) #93 P2-1 — this branch bounds the recent/ rows it keeps per scope by
    CLIKAE_HOME_RECENT_MAX before #83's burn-sidecar exclusion removes
    burn sessions, so a tank where burns outnumber the cap (195 burns + 50
    humans) publishes a Resume list that is entirely burns and renders 0
    human rows. main has a test that says that must not happen. Where the cap
    is applied relative to the exclusion is a real fix, with its own receipts.

Neither is visible in this PR today, because the tests that catch them arrive
with the merge. They are stated here so that the merge is planned rather than
discovered: this branch needs one more round — merge main, then make the
board tank-scoped for agy and cap Resume rows after the burn exclusion, not
before.
Resolving the conflicts blind and pushing would have traded a red
local run for a silent regression in the merged result.


Fix round 11 — the merge, and #93's semantics adopted into the index

Round 10 stopped one step short on purpose: main had moved to b030edf (#93)
and 6190f86 (#87) mid-round, and six of main's tests went red against this
branch's board. That was never a bad conflict resolution — it was a genuine
semantic mismatch, so round 10 measured it, wrote it down, and aborted the
merge rather than push a silent regression. This round lands it.

974b2a3 merge, cae6ef4 agy scope, 966dcb8 the widened cap.

1 · The merge (974b2a3), three textual conflicts

file both sides did resolution
tests/bats/clean.bats the same five SC2314 fixes, spelled differently kept this branch's run ! grep … (3bd9189, mutation-tested in round 9). Same assertion as main's ! grep … || false.
tests/bats/compat.bats each appended a new guard at the same place both kept: this branch's "board GC still RUNS under a real bash 3.2" and main's three GNU-ism scans (touch -d, date -d, stat -c).
lib/adapters/antigravity.sh #93 deleted adapter_recent_sids' cwd filter; this branch added the board fast path above it main's semantics AND the fast path: $n naming, no $want filter, tank-scoped scan, with board_recent in front.

git merge-tree --write-tree origin/main HEAD is clean, git diff --check
clean both for the worktree and for origin/main..HEAD.

2 · main's six red tests, and what changed for each

The merge commit is the mechanical resolution only. The six are two design
alignments, not conflicts, so they are two separate commits with their own
receipts.

cae6ef4 — agy's Resume index is keyed by TANK, not by cwd (#34).
A real agy install writes one workspace value for every session (the CLI's
own root, not the user's cwd), which is why #93 deleted the cwd filter from the
adapter's disk scan. This branch's index reproduced the same bug one layer
down, and would have hidden #93's fix: board_recent answers from
recent/<key(scope)> and returns first, so on a warm board the adapter's
now-correct scan never runs, and a cwd-keyed index had no rows to answer with.
Both sides of the index move together now — _board_scope_raw (read) and
_board_engine_sidscope (write) — onto one scope per tank,
_BOARD_TANK_SCOPE = #tank. Not a path, so it cannot collide with a real
cwd, and _board_entry_key percent-encodes it to the same %23tank on both
sides (verified under real bash 3.2, below).

Two things fall out, neither of them the reason: _board_engine_sidscope no
longer opens a file for agy at all (the adapter_session_cwd fallback fork per
session is gone), and neither board_state_refresh nor adapter_recent_sids
calls _agy_ws_load any more — that bulk workspace index existed to serve the
cwd keying and the cwd filter, and with both gone it was one awk pass over the
tank's metadata per refresh for a value nothing reads. The helpers stay where
they are with their receipts in tests/bats/adapters/antigravity.bats.

966dcb8 — the index is built at the same widened per-tank cap clikae home
asks with (#93 P2-1).

The cut and the burn-sidecar exclusion are not commutative.
_home_recent_rows drops burn one-shots after the adapter answers, so an
answer already cut to N hands the filter N rows to drop and nothing to promote.
#93 fixed that by widening the ASK per tank (N + _burn_tank_hidden, ceiling
CLIKAE_HOME_RECENT_SCAN_MAX = CLIKAE_BURN_SIDECAR_CAP = 2000, with
T_RESUME_TRUNCATED when the ceiling really bites). On a warm board this index
is what answers that ask, and an entry cut to 10 at BUILD time cannot hand back
205 rows however wide the ask is. So board_state_refresh now computes the
same widened cap, per tank (never per store — a row this tank returns can only
be dropped by a sid recorded for this tank), under the same ceiling, behind
declare -F _burn_tank_hidden because that helper lives in home.sh. A tank
with no sidecar keeps N exactly: the common entry is byte-for-byte what rounds
5–10 wrote. CLIKAE_RESUME_ALL=1 skips the widening on both sides.

The READ side is deliberately unchanged — the entry's own cap bounds the
answer. See "what is not verified" for the draft that was rejected and why.

Red → green, one bats run per step (home.bats, home-bounded.bats,
board-width.bats):

merge head  974b2a3                108 ok /  6 not ok
  not ok 24  agy board Resume rows show sessions regardless of recorded workspace, newest first (#34)
  not ok 31  12 burn sessions newer than 3 human ones do not empty the continue list (claude tank, #93 P2-1)
  not ok 32  … (codex tank)
  not ok 73  195 burns + 50 humans still fill the Continue list — claude (#93 round-2 P2-1)
  not ok 74  … (codex)
  not ok 78  a sidecar past CLIKAE_BURN_SIDECAR_CAP truncates, and says so, at the DEFAULT ceiling

after cae6ef4 (tank scope)         104 ok / 10 not ok
  #24 GREEN. The agy rows became reachable FOR THE FIRST TIME, so the agy twins
  of every #93 P2-1 test appear and go red with the claude/codex ones:
  #29 (12 burns, agy), #72 (195+50, agy), #75 (250 burns + 3 humans),
  #77 (the ceiling note), #79 (no note on an ordinary board).

after 966dcb8 (widened cap)        114 ok /  0 not ok

So the six named in round 10 are green, and four more that only became
reachable once agy stopped being cwd-scoped are green with them. No test was
loosened, edited or skipped — the only test files this round touches are the
two conflict resolutions.

3 · This branch's own guarantees, still green

bats home-bounded.bats home.bats board-width.bats limit.bats \
     limit-codex-status.bats limit-reset.bats                    201 ok / 0 not ok
bats adapters/antigravity.bats resume.bats resume-hide-burn.bats \
     clean.bats compat.bats                                      183 ok / 0 not ok
     (9 skips, all "sqlite3 required for fixture database" — this host, not the code)
                                                          TOTAL  384 ok / 0 not ok

Including, by name: the window-bounded scan (board … scans the whole WINDOW, nothing outside it, and does not fork per file), the batched-vs-per-file
agreement on both the reading parser and the sid/scope table, the injective
escape and byte-equal scope receipts (the entry name the cold build WRITES is the one a reader LOOKS UP (incl. non-ASCII), non-ASCII sibling scopes keep their OWN Resume list), GC chain protection (GC never unlinks a generation the current one still resolves through, GC keeps the chain of every LIVE generation), publish-order GC (publishing a generation leaves the PREVIOUS one byte-identical), the same-second row (does not reuse a stale generation on its next refresh), and round 9's ==> path <== banner receipt.

Other gates on 966dcb8:

shellcheck -S warning  bin/clikae install.sh scripts/test.sh          rc=0
shellcheck -S warning  195 *.sh/*.bats under lib/ tests/ scripts/     rc=0  (0.9.0)
scripts/doc-names-exist.sh                                            rc=0
git diff --check (worktree, and origin/main..HEAD)                    rc=0 / rc=0
git merge-tree --write-tree origin/main HEAD                          clean
git status --porcelain                                                empty

Real bash 3.2 (docker run --rm -v "$PWD":/w bash:3.2, image present
locally — the compat guard ran rather than skipping, ok 175 board GC still RUNS under a real bash 3.2), plus a direct probe of everything this round
added:

BASH_VERSION=3.2.57(1)-release
agy=[#tank]      _board_scope_raw antigravity INSIDE $( ) — the shape 3.2 re-parses
direct=[#tank]   and assigned directly
claudekind=[/w]  the other engines still answer with the cwd
ss=[ag-1|#tank]  _board_engine_sidscope's new antigravity branch
key=[%23tank]    _board_entry_key of the constant — the name both sides look up
guard=skipped    declare -F _burn_tank_hidden with home.sh unsourced: no widening
ask=[205]        …and with it defined: 10 + 195, under the 2000 ceiling
bash -n over every shell file in the tree: 0 failures

4 · Cost on the merged head

Same fixture and same script as rounds 9/10 (1000 × 634,800 B = 605 MB across 10 project directories, every file inside the window), A/B against the
pre-merge tip 2b53f98 interleaved on the same host under the same load,
because this host is not idle and absolute numbers here are not comparable
across rounds:

                     cold            warm (nothing written)
2b53f98  run 1       5,099 ms        418 ms      busy 3.03/3.42
966dcb8  run 1       4,742 ms        416 ms      busy 2.97/2.84
2b53f98  run 2       4,724 ms        409 ms      busy 2.19/1.67
966dcb8  run 2       4,691 ms        418 ms      busy 2.39/3.48

Warm is 416–418 ms, unchanged against the pre-merge tip and well under the
~500 ms line; cold is within noise of it (and both runs of the merged head came
out below the pre-merge run they were paired with). readings-bounded = 1000
in every run: the window bound is still the window, not a count. For scale,
round 9 measured main at the time as ~4.3 s cold and 4.27 s on every
frame
; this branch pays the cold cost once and then renders ~11× faster.

5 · What this round did NOT verify

  • A read-side guard was drafted and rejected, deliberately. The first
    version of 966dcb8 also recorded each entry's cap in its #scope header and
    made board_recent fall through to the adapter's disk scan whenever the ask
    exceeded that cap — "a speed path, never a narrower answer". It is a real
    guard, and it turned two of this branch's own receipts red (snapshot selects newest main sessions, does not reuse a stale generation on its next refresh), both of which define the index cap AS the bound on the answer.
    Rather than loosen them, the guard was dropped and the two caps are kept in
    step at BUILD time instead. The residue that leaves: if a tank's burn
    sidecar grows without any transcript in that tank changing, the entry stays
    at the older, narrower cap until the next rebuild, and the Continue list is
    short by that difference. In practice a recorded burn sid comes with a
    transcript in the same tank, so the two move together and the drift is ~1 row
    and self-heals on the next change — but that is an argument, not a
    measurement. I did not build a fixture for it.
  • _agy_ws_load/_agy_ws_lookup/adapter_session_cwd_index now have no
    caller on the render path
    — only tests/bats/adapters/antigravity.bats.
    They were kept (deleting them means deleting green receipts for a documented
    adapter hook, which is not this round's business), but they are dead weight
    until something asks for a bulk cwd index again. Naming it rather than
    quietly leaving it.
  • Nothing ran on macOS or any BSD. The bash 3.2 receipt is docker bash:3.2 — Alpine/busybox userland, not BSD. The strcoll() awk root cause
    from 02d8f89 is still carried forward from the commit message, not
    re-verified on Apple's awk.
  • The compat guard only fires where the bash:3.2 image is already present
    (it never pulls). It ran here; on CI's runners it will skip.
  • No full bats -r locally (six lanes share one suite lock). The receipts
    above are 11 targeted files under flock; CI is the arbiter for
    Windows/pester, the pty smoke and the macOS jobs.
  • No real engine and no real TTY. Every front-door check is a
    non-interactive single render of bin/clikae.
  • The cost numbers come from one busy 16-core Linux host, two samples per
    side. They support "warm did not regress" and "the order of magnitude is
    unchanged"; they are not absolute figures for the Mac in home: board render time must not scale with transcript count or size #62.
  • The PR comment thread was not read (public repo; body only).

Fix round 11, continued — three more merges, and two reds that were main's

The section above was written against main = 6190f86. main moved three
more times while this round was in flight, so the round ends with four
merge commits, not one. HEAD is c05b557, CI 9/9 green, PR mergeable: clean.

974b2a3  merge main (b030edf #93, 6190f86 #87)     ← the section above
cae6ef4  board: agy Resume index keyed by TANK      (#34)
966dcb8  board: index built at the widened per-tank cap (#93 P2-1)
4ee4186  merge main (eb58aab #89 — vendor usage)
9f1ad57  merge main (459c981 #102 — tmux status row)
acff289  home: guard #89's _human_age call site, and give the test its lib
3d022aa  merge main (b6e8dc1 #91 — tank marker + per-process tank cache)
c05b557  tests: six shellcheck findings in main's files, under this PR's gate

The three later merges

Two reds that were main's, fixed here rather than carried

acff289 — main is red at 459c981/b6e8dc1, on bats both platforms.
#89 (the vendor usage block in _home_fuel_dotv_compute) and #102 (which moved
_human_age into lib/core/duration.sh and guarded home.sh's two call sites)
landed in the same window and crossed. Reproduced on a clean origin/main
worktree with nothing of this branch in it:

bats -f "_human_age call in home.sh" tests/bats/home.bats
  not ok 1  unguarded: 1265:  [ "$age" -lt "$ttl" ] || _FNOTE="… $(_human_age …)"

bats -f "stale-but-recent vendor reading" tests/bats/usage.bats
  _home_fuel_dotv_compute '' claude work "$now"  failed with status 127

and confirmed against main's own CI log for b6e8dc1 (bats (ubuntu-latest),
run 34939130397): those two, and only those two, are its failures. Fixed in
two halves — #89's call site now carries the same declare -F _human_age guard
as the other two, on the same LINE (home.bats asserts the guard per code line);
and the three usage.bats tests that source home.sh STANDALONE now also source
lib/core/duration.sh, which bin/clikae loads globally and they were relying
on home.sh to define until #102 moved it. Assertions untouched, byte for byte.
home.bats' call-site count goes 2 → 3 because #89 added a third site; the guard
assertion — the substance — is unchanged.

c05b557 — six shellcheck findings in main's test files, surfaced by THIS
PR's own gate.
Round 8 added a CI step here (find tests -name '*.bats' -print0 | xargs -0 shellcheck -S warning) because ludeeus/action-shellcheck
does not look at .bats at all. main has no such step, which is why main's
shellcheck job is green and this branch's went red: the gate is this PR's,
the findings are main's (limit-codex-status.bats SC2034 ts ×3;
usage.bats SC2154 $stderr; tmux-status.bats SC2034 mt, SC1010 done,
SC2034 CLIKAE_BURN_LOG_RETENTION_DAYS, SC2034 t27). Every fix keeps the
behaviour and every assertion byte for byte — the gate was not weakened.

Final state

CI on c05b557                                          9/9 success
  shellcheck · signet · smoke ubuntu+macos · pty smoke ubuntu+macos
  · bats ubuntu · bats macos · pester (windows)
PR                                       mergeable: true, state: clean
git merge-tree --write-tree origin/main HEAD                   clean

Local receipts on the final tree (targeted files under the suite lock — no full
bats -r; six lanes share one lock, and CI is the arbiter):

home / home-bounded / board-width / usage                157 ok / 0 not ok
limit* / adapters+antigravity / resume* / clean / compat  273 ok / 0 not ok
burn / init / cockpit / doc-contract / tmux-label / antigravity
                                                          337 ok / 0 not ok
tmux-status / limit-codex-status / usage                  139 ok / 0 not ok
shellcheck -S warning  bin/clikae install.sh scripts/test.sh    rc=0
find tests -name '*.bats' | xargs shellcheck -S warning         rc=0
199 *.sh/*.bats under lib/ tests/ scripts/                      rc=0
scripts/doc-names-exist.sh                                      rc=0
git diff --check                                                rc=0

Cost, re-measured on the final head

Same 605 MB fixture and script, all three trees interleaved on the same host
under the same load
(this host is not idle; absolute numbers are not
comparable across rounds):

                                   cold        warm (nothing written)
origin/main  b6e8dc1  run 1      5,359 ms      4,453 ms
             run 2             4,655 ms      4,782 ms
pre-merge tip 966dcb8  run 1     4,841 ms        408 ms
             run 2             4,777 ms        398 ms
HEAD 3d022aa/c05b557   run 1     4,830 ms        451 ms
             run 2             4,921 ms        452 ms

Warm is 451–452 ms — under the ~500 ms line, and ~10.5× faster than
main, which pays 4.4–4.8 s on EVERY frame. It rose ~45 ms from this branch's
own pre-merge tip (398–408 ms); that is the cost of what the three merges
brought in, most visibly #89's per-tank jq on the usage cache, which main's
own home.sh comment measures and names. Cold is unchanged within noise.
readings-bounded = 1000 in every run of every board tree: the bound is still
the window, not a count.

Also not verified (in addition to the list above)

  • roam.bats "a second client attaches to the running tank instead of
    starting it again" went red once
    , on bats (ubuntu-latest) for 3d022aa,
    and green on the very next run with only shellcheck-comment changes between
    them. It is a tmux client-resize race the test's own comment already names
    ("a timing guess, not a defect… intermittently on ubuntu CI, never on
    macOS"). I did not fix it and did not investigate further than reading it.
  • The cost table's main column is one tree, two runs. Enough for "the
    branch is an order of magnitude faster warm"; not a benchmark.

Fix round 12 (pre-merge) — 0d7c246, 8373ca9, 3d4a2f3

Round 10's adversarial review said 不可合併 on the CI gate (0 P1, 2 P2, 3 P3)
and it was right about the residue, which was worse than this body claimed.

P2-1 — the burn sidecar is now part of a tank's freshness signal (0d7c246)

This body used to say the widened-cap residue was "about one row and it
self-heals on the next change". It flagged that as an argument rather than a
measurement, and the measurement disagrees: it does not self-heal.

shape (front door, CLIKAE_HOME_RECENT_MAX=10) before after
10 human sessions + 1 burn, in burn's own order (transcript → render → sid) 9 human rows, on that frame and every later one 10
an entry built at cap 10 out of 50 burn transcripts, then 50 sids appended, no transcript touched 0 rows, no Resume block, no truncation line 10
a sidecar write, nothing else never stale → no rebuild, wrong rows forever exactly 1 rebuild, then warm again

Root cause: board_stale fingerprinted transcripts only, and the sidecar is
an input to the cap, not to the transcripts. burn records a session's id
after the engine process exits (burn.sh:1106, :4288), so "transcript
appears, board renders, sid lands" is burn's own ordering, not a contrived one.

Fixed in two halves, because either alone is inert:

  1. _board_sidecar_rows stats this tank's sidecar file(s) and
    _board_tank_fingerprint (renamed from _board_transcript_fingerprint)
    hashes them after the transcript rows, so a sidecar write makes that tank,
    and only that tank,
    stale. Both engine spellings — burn writes agy's
    under the literal agy. The generation's fingerprint file is tank-fp, not
    transcripts-fp, so neither side ever reads the other's as vouching for the
    same thing; _BOARD_GEN_FORMAT is deliberately not bumped, because a format
    mismatch forces a cold rebuild while a missing fingerprint takes the cheap
    incremental one.
  2. A generation records the cap its rows were cut at (recent-cap), and an
    incremental rebuild re-cuts every entry when it moves — from the manifest
    it just published, so no transcript is opened and no sid/scope is re-parsed,
    through the same one function the cold build uses. Without this, the
    fingerprint half changes nothing: an incremental rebuild only rewrites an
    entry for a file that changed, and a sidecar write changes no file.

Inside the rebuild the sidecar is stat'd before _burn_tank_hidden counts
it, so the published fingerprint is never newer than the state the cap was
computed from — the other order manufactures the same bug in miniature.

Four tests, red on 03d4884 and green here (tests/bats/home-bounded.bats).

P3-1 — the dead bulk cwd index is deleted (8373ca9), and four hooks were leaking (3d4a2f3)

adapter_session_cwd_index and the _agy_ws_* cache built on it had no caller
in lib/ or bin/; deleted, with the two tests that only exercised themselves,
and every comment that named them rewritten.

Checking the loader's unset list mechanically — the review flagged one missing
name — found four more optional hooks leaking across a two-adapter load
(adapter_cwd_from_args, adapter_ephemeral_flags, adapter_mcp_config_file,
adapter_tank_fingerprint). Same shape as #60's and #81's earlier drifts. Fixed,
plus a gate that compares every adapter_* definition against the list, so the
next one cannot go missing quietly. Negative control run both ways.

已知限制 (documented, not fixed)

  • P3-2 — cold build is bytes-bound, not file-count-bound. 605 MB inside the
    window costs ~4.5 s cold, on branch and on main alike (−0.1 % / −1.2 %).
    Warm is the number this PR is about (~455 ms vs main's ~4.9 s per frame),
    and readings-bounded is the window, not a count. Unchanged by this round.
  • P3-3 — claude merges two equal-length CJK sibling directories into one
    Resume list.
    _claude_project_slug maps every non-[A-Za-z0-9] byte to
    -, so ~/專案一 and ~/專案二 produce the same slug — but that is Claude
    Code's own layout (the transcripts really do share one projects/ directory)
    and the function is byte-for-byte main's. codex/grok/agy are unaffected.
    Filed as claude: two equal-length CJK sibling directories share one Resume list #116; not this PR's to fix.

Receipts

bats home-bounded + home + board-width + resume-hide-burn + adapters/antigravity
177 ok / 0 not ok; burn 217 ok; handoff + adapters/{claude,codex,grok} + ephemeral 97 ok; adapters/extra 20 ok. shellcheck -S warning over
bin/clikae install.sh scripts/test.sh, every *.sh under lib tests scripts,
and CI's own find tests -name '*.bats' | xargs shellcheck line: rc=0.
scripts/doc-names-exist.sh rc=0. git diff --check rc=0.
git merge-tree --write-tree origin/main HEAD rc=0.

沒驗證什麼 (this round)

  • No full bats -r locally (lane test policy) — CI is the arbiter.
  • Nothing run on macOS or any BSD, and no docker bash:3.2 run this round.
    The new code's only new external call is stat, through the existing
    _clikae_statv fork that already handles both spellings.
  • The 605 MB perf harness was not re-run. The warm path gains at most one
    stat per tank that has ever run a burn, and home-bounded's own bounded-IO
    receipts are green, but the number in the table above is round 10's, not a
    fresh measurement.
  • No real engine, no real TTY; every front-door check is a non-interactive
    single render.
  • The "block goes empty" shape's real-world reachability is still unproven,
    exactly as the review said — the mechanism is now fixed and gated either way.

@cverorg
cverorg force-pushed the perf/home-board-bounded-reads-62 branch from a53bd7f to ec783a5 Compare September 12, 2026 14:48
@cverorg

cverorg commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Pushed a fix round addressing round-1 review (3 P1, 3 P2, plus P3-1/P3-2/P3-5/P3-6): live-row guessing and cross-project resume lookup restored (snapshot-first, live-scan fallback on a genuine miss, never disabled outright), a render-time self-heal for a missing/stale per-tank snapshot (fixes the permanently-empty Resume section and frozen fuel readings for tanks that never hit a session boundary), exec semantics restored on the interactive launch path (no more resident parent, no more double scan on launch), and GC for state/board/state/readings wired into clikae clean.

Rebased onto current main (CHANGELOG.md only, resolved additively). Full bats tests/bats is green; shellcheck -S warning and git diff --check are clean. Re-measured the headline number on the same synthetic store: main 3.7 s → branch warm 0.62 s, output byte-identical. Details in the updated PR description.

cverorg added a commit that referenced this pull request Sep 12, 2026
A lane that reaches for the Agent tool hands the whole task to a
background sub-agent and ends its own turn; `claude -p` then terminates
the run — sub-agent included — after its background wait ceiling, and
nothing reaches disk. Measured twice (2026-09-10; 2026-09-13 on the #78
fix lane: 651 s, empty worktree). A brief saying "no sub-agents" is a
reminder; this is the guard.

burn now appends `--disallowedTools Agent,Task` to every print-mode
claude argv that carries no tools flag of its own — composed recipe,
raw `--` form, and again after a cross-engine reroute lands on claude —
and exports CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 unless the operator
set it, so a run that still spawns background work is waited for rather
than killed. Other engines are untouched.

Co-authored-by: cverorg <292680828+cverorg@users.noreply.github.com>
@cverorg

cverorg commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Fix round 2 pushed, addressing the round-2 adversarial review's 3 P1s and 2 P2s (P1-A/P1-B/P1-C, P2-A/P2-B), plus P3-1/P3-4.

  • P1-A: board_stale now compares size + nanosecond-precision mtime, not whole-second mtime only.
  • P1-B: claude's account-level fuel reading now has an account-level (not PWD-scoped) freshness signal.
  • P1-C: codex's scan root is now today's date directory, not sessions/ itself.
  • P2-A: agy's adapter_session_cwd cache no longer hangs off the shared history.jsonl.
  • P2-B: removed burn.sh's two remaining full board rebuilds per attempt.
  • P3-1/P3-4: GC sort tie-break + mktemp after load_adapter.

Every fix has a deterministic before/after receipt. Full bats tests/bats: rc=0, 1175/1175. shellcheck -S warning rc=0 on all changed files; git diff --check clean.

cverorg and others added 17 commits September 12, 2026 20:13
…eading 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
…r 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.
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.
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.
… 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).
- 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.
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.
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.
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.
…sonl

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.
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.
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.
…ad-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.
_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.
…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).
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.
…n 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.
@cverorg
cverorg force-pushed the perf/home-board-bounded-reads-62 branch from d1f5e35 to 2a24e8c Compare September 12, 2026 19:06
@cverorg

cverorg commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Pushed fix round 3, addressing the round-3 review's 2 P1s and 2 P2s (rebase + merge conflicts, claude fuel visibility for a new file in an existing project dir, codex's clock-independent scan root, antigravity's bulk cwd index) plus the P3-1/P3-4 cleanups. Branch is now rebased onto main (ab6fb52).

  • First render after an agy-tank change: ~3.7-3.9 s, at/below main's own baseline on the same synthetic store (was ~6.4 s).
  • Full bats tests/bats: rc=0, 1203/1203. shellcheck -S warning rc=0. git diff --check clean.

Details and receipts in the updated PR description.

KITT added 3 commits September 13, 2026 08:33
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).
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.
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.
@cverorg

cverorg commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Round-4 fix pushed (a578aa0): 2 P1s (idle claude account rebuilding every frame forever; a fresh codex tank with no sessions/ yet at first render freezing its snapshot permanently) + 2 P2s (multi-tank warm cost, codex rollout landing in a non-newest day dir) + P3-4 (a positional stat-zip that could misattribute a row when a path vanishes mid-batch), all with red→green receipts against 2a24e8c. Full bats tests/bats 1203/1203, shellcheck -S warning rc=0, git diff --check clean. PR body updated with the round-4 section (numbers, state-size range, and the bare-render/state/ ruling restated).

…ounded 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.
@cverorg

cverorg commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

P2-2 — GC unlinked an ancestor a live generation still resolved through — fixed in 0fe0112. Reproduced exactly as described. keep-N counts CHAINS now, not directories: the newest keep generations plus whatever current points at are roots, and every ancestor any root resolves through is protected with it. Cost is at most keep x depth reads of a one-line parent file, all builtins, and the chains overlap almost entirely so the directory count barely moves. Receipt, red on 26cac7b: hold a depth-7 generation, publish once more, all 50 entries resolve FROM THE HELD GENERATION and every link of its chain is on disk — clikae clean's sweep included, since it shares the rule.

@cverorg

cverorg commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

P3-1 — the readings-pending comment pointed at code that no longer existed — fixed in 3d442ab (it went with the commit that replaced the mechanism it described, rather than being deleted separately). grep -rn readings-pending lib bin tests is now empty.

@cverorg

cverorg commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

P3-2 — readings-bounded was written by the cold path only — fixed in d396968. You were right that its presence meant two different things. Both paths publish it now with one meaning: the transcripts THIS refresh opened for a rate-limit reading. Receipt, red on 26cac7b (the file does not exist after an incremental rebuild): after one changed file in a 50-file tank, readings-bounded is exactly that one path.

@cverorg

cverorg commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

P3-3 — the shellcheck gate could not see .bats — fixed in d396968. scripts/test.sh scans .bats now and CI grew the matching step, so the two agree by construction rather than by remembering. It found 73 findings across 20 files, not three, and all of them are fixed. Two were dead assertions rather than style: ! declare -F … and ! grep -q … in burn.bats never fail a bats test (SC2314), so both had been asserting nothing; they are … || false now and burn.bats is green with them live.

@cverorg

cverorg commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

P3-4 — the round-5 receipt could no longer reach its own specimen — fixed in 3d442ab. Exactly right: >> made the "11th-newest" file the newest, so it could not tell a count bound from a window bound. It is replaced by the P1-1 receipt — the limit is already in the file, the file is never written again, and there are RECENT_MAX+2 newer neighbours in the same project directory. It is red on 26cac7b.

KITT added 17 commits September 14, 2026 03:35
#86 landed on main after round 8 and the PR went CONFLICTING, which is
why GitHub stopped running its `pull_request` workflows against this
branch at all — no checks, on any platform, rather than red ones.

Three files conflicted:
  * lib/commands/burn.sh — took main's. It supersedes this branch's
    round-2 comment at the same line and adds the `|| true` its own
    round-3 review asked for.
  * tests/bats/burn.bats — took main's. #81's round-1 review found the
    SAME defect this round's shellcheck work found (a `!`-negated
    command never fails a bats test, SC2314) and fixed both sites with
    `run` + `[ "$status" -ne 0 ]`, which is this repo's idiom; the
    `|| false` spelling from d396968 is dropped in its favour.
  * CHANGELOG.md — both sides kept, and the round-8 bullet claiming the
    rate-limit scan is "bounded to the newest CLIKAE_HOME_RECENT_MAX
    files per project directory" corrected: that bound is exactly what
    round 9 removed.

Round 9's three user-visible fixes are added to the Unreleased section.
…annot 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.
# Conflicts:
#	lib/adapters/antigravity.sh
#	lib/commands/clean.sh
#	tests/bats/adapters/antigravity.bats
…e-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.
… 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.
…ed stays on Resume

Round 9b, found by re-running the targeted suites on HEAD: the round-5
P2-1 receipt ("_home_refresh in one long-lived process does not reuse a
stale generation") went red once. Repeated under the suite lock it is a
FLAKE that predates this round — 2/12 red on 3c1b4f2, 1/12 on the
pushed tip fb91dcd, 1/6 on 6a4aa49 — and a real one.

Captured on a red run: the refresh DID rebuild (the new generation's
fingerprint equals the live tree's), and `recent/` still listed the
ten fixture sessions without `session-new`. All eleven rows carried the
same whole-second mtime. `_board_merge_recent_row` appended the fresh
row LAST and ran `sort -t␟ -k1,1rn | head -n 10`; `-k1,1rn`'s `r` does
not reverse sort's last-resort whole-line compare, so among equal
seconds rows ranked by the sid's spelling — `session-new` after
`session-9` — and the fresh row was the one `head` cut. The test only
passed when the new file happened to land in a later second.

Fix: the fresh row goes FIRST and the sort is stable (`sort -s`, GNU
and BSD): among equal seconds, what just changed is the newest.

The receipt now gives the new session the SAME mtime as the fixture
(`touch -r`) instead of hoping the clock ticks, so it tests the tie on
every run: RED on 6a4aa49's lib, GREEN here (8/8 repeated).

Also in this commit, a bash 3.2 break in the previous one (6a4aa49),
caught by running `_board_gc_candidates` under docker `bash:3.2`
before pushing: its new `case "$gseq" in ''|*[!0-9]*)` sat inside
`sorted="$( … )"`, and bash 3.2 closes the command substitution at the
pattern's `)` — "syntax error near unexpected token `newline'", an
EMPTY candidate list, and on macOS's /bin/bash GC would have swept
nothing, ever. The loop moves to a helper, `_board_gc_rows`, called
from the substitution. Same run under bash:3.2 afterwards: GC keeps
seq 7 and 6 of seven generations whose mtimes run the other way.
…a tail banner

Round-9 review P2-1. `tail`'s `==> name <==` framing is in-band: it travels
in the same stream as the file CONTENT that `_limit_batched_readings` folds.
The batched window scan matched those separators by PATTERN, so one physical
line shaped like one — a torn or partial write, anything third-party writing
into the tank tree — ended that file's section early. The reviewer measured
both consequences on the real front door: the limit sitting after the line
was attributed to a path that was never in the scan list, the transcript's
own reading came back EMPTY, and a dry tank drew `●` where the control drew
`○ … resets 11pm`. Since claude's `_limit_tank_dry_raw` deliberately never
falls back to dry_store, `burn` would dispatch into it — round-8 P1-1's exact
failure walking back in through a second door. It also broke this function's
own promise, asserted in its header and in bats, that it is a SPEED path and
never a narrower answer than `_limit_claude_reading`.

A banner is reconciled against the scan list now instead of pattern matched,
and a batch whose framing did not come out as predicted is answered per file:

- The list is read first (`NR == FNR`), so the scan knows which paths are
  expected and in which ORDER they were handed to `tail`. A banner is accepted
  only when it names exactly the next unconsumed path — where this batch's own
  framing can put one. Everything else is content. The comparison is `bsame`
  (byte equality), never awk's `==`: on Apple's awk `==` is answered by
  `strcoll()` and macOS' UTF-8 collation gives most CJK no weight, the same
  trap 02d8f89 had to fix in board_state.sh, and paths are where it would bite.
- Refusing the wrong line still leaves the batch mis-framed from there on, so
  the batch is TRUSTED only when three signals agree: awk saw no banner-shaped
  line it did not accept (`/dev/null`'s own banners excepted), the pipeline
  exited 0, and one row came back per listed path. A fabricated banner naming
  the very next path is invisible to a row count alone — it consumes that
  file's real banner later and the totals still balance — which is why the
  first signal exists. Otherwise the per-file parser answers the whole list.

The same check covers a listed path that vanished between the scan and the
`tail`: `tail` then prints no banner for it at all and exits 1, which `xargs`
turns into 123 — under bin/clikae's `set -eo pipefail` that used to abort the
whole refresh, and now takes the per-file path instead.

Receipts (fixture: a transcript carrying `==> /etc/passwd <==`, one carrying
an earlier sibling's path, one carrying the NEXT sibling's path, plus a listed
path that does not exist):

  before: batched != per-file; a row for /etc/passwd that was never scanned;
          front door drew `●` on the dry tank, claude-usage = [||]
  after:  batched == per-file on all four; no row outside the scan list;
          front door drew `○ … resets 11pm`, and the published manifest is
          byte-for-byte the control's

tests/bats/home-bounded.bats pins all four shapes; it fails on the parent
commit and passes here.
…e class bash -n cannot see

Round-9 review P3-2. `2f51d15` fixed a `case` pattern inside `$( … )` in
`_board_gc_candidates`, and nothing in this repo could have caught it: bash 3.2
— what macOS ships and what `bats (macos-latest)` really runs clikae under —
does not parse a command substitution's body when it parses the file. Its
scanner stops at the first unbalanced `)`, which is exactly what a `case`
pattern has, and defers the rest to expansion time. So `bash -n`, `shellcheck
-S warning`, the CI syntax gate and every source-scanning guard in compat.bats
were all silent, while on 3.2 the substitution failed AT RUNTIME on stderr,
yielded the empty string, and the function returned 0 — the board GC swept
nothing on macOS and said so to no one.

A guard for this class has to EXECUTE, not read. The new test runs
`_board_gc_rows` and `_board_gc_candidates` against a real generation chain
under `docker run --rm bash:3.2`:

  HEAD          ROWS=5  CAND=1  ERR=(empty)
  6a4aa49       ROWS=0  CAND=0  ERR=command substitution: line 730: syntax
                                error near unexpected token `newline'

It `skip`s with the reason when docker or the image is absent rather than
passing (CI's macOS runners have no docker, and it never pulls), and it carries
a control: a known-bad `$( … )` in its own file that must fail inside that
container, so green means "fixed" and not "quietly ran on bash 5".

tests/README.md gains the section naming the class, the two rules that follow
from it (hoist the `case` into a helper; a guard for it must execute), and the
trap that cost the review an experiment — `bash:3.2` is Alpine, so busybox's
`stat -f '%m'` answers with FILESYSTEM information and `file_mtime` returns
garbage there. That is the image, not the code; the probe stubs it out.
…ld never fire

Round-9 review P3-3. `${#SRC[@]:-0}` is not a legal expansion — `${#name[@]}`
is already a count, and `:-` cannot be appended to it. Both call sites printed
`bad substitution` on every run and the tests they guard evaluated to failure,
which is how the gate that exists to refuse a green light meaning "I did not
look" gave exactly that:

  before, run in a tree with docs/ but no lib/ or bin/:
      doc-names-exist.sh: line 57: ${#SRC[@]:-0}: bad substitution
      doc-names-exist.sh: line 61: ${#SRC[@]:-0}: bad substitution
        docs name nothing the code lacks (functions, call sites, tmux options)
      rc=0                                   <- zero files checked, gate green
  after, same tree:
      no source/docs found
      rc=1

The line-57 copy also meant the fallback it guards (a checkout sitting inside
some other repo, where `git ls-files` returns nothing) could never run, so that
tree would have taken the same silent pass.

This file is not this PR's — `e226308` introduced it on `main`, and it was
byte-for-byte identical to `origin/main` until this commit. It is fixed here
because the branch's own review ran the gate and the noise was in the receipt.
main moved twice while round 10 was in flight: b030edf (#93 - agy
`resume --conversation` plus TANK-scoped Resume rows, issue #34) and
6190f86 (#87 - burn reports the work a run left behind). Round 10
measured the collision and deliberately aborted rather than push a
silent regression (REPORT-board62-fix10.md section 4); this merge lands it.

Three textual conflicts, all resolved keeping both sides' intent:

* tests/bats/clean.bats - both sides fixed the same five SC2314 lines,
  differently. Kept this branch's `run ! grep ...` (3bd9189, mutation-
  tested in round 9) over main's `! grep ... || false`. Same assertion.
* tests/bats/compat.bats - both sides appended a new guard at the same
  place. Both kept: this branch's "board GC still RUNS under a real
  bash 3.2" and main's three GNU-ism scans (touch -d, date -d, stat -c).
* lib/adapters/antigravity.sh - #93 deleted `adapter_recent_sids`' cwd
  filter (workspace is a constant on a real install, so the filter hid
  every row); this branch added the board's bounded fast path on top of
  it. Kept main's semantics AND the fast path: `$n` naming, no `$want`
  filter, tank-scoped scan, with `board_recent` in front of it.

This commit is the MECHANICAL resolution only. main's six now-red tests
are a semantic mismatch between this branch's index and #93, not a
conflict; the two commits that follow adopt those semantics.
#93 (b030edf) deleted the cwd filter from agy's `adapter_recent_sids`: a
real agy install writes ONE `workspace` value for every session (the CLI's
own root, not the user's cwd), so filtering by `$PWD` answered "no
sessions" from every project directory and the board's Resume block for
agy was permanently empty.

This branch's bounded index reproduced the same bug one layer down, and
would have hidden #93's fix completely: `board_recent` answers from
`recent/<key(scope)>` and RETURNS FIRST, so the adapter's now-correct disk
scan never runs on a warm board. The index keyed agy by cwd like every
other engine, so it had no rows to answer with. That is what made main's
"agy board Resume rows show sessions regardless of recorded workspace,
newest first (#34)" red against this branch.

The index now uses one scope per tank for agy (`_BOARD_TANK_SCOPE`,
`#tank` — not a path, so it cannot collide with a real cwd, and
`_board_entry_key` percent-encodes it identically on the write and the
read side). Both sides move together: `_board_scope_raw` (read) and
`_board_engine_sidscope` (write).

Two things fall out, neither of them the reason:

* `_board_engine_sidscope` no longer opens a file for agy at all — the
  `adapter_session_cwd` fallback fork per session is gone.
* `board_state_refresh` no longer calls `_agy_ws_load`, and neither does
  `adapter_recent_sids`. That bulk workspace index existed to serve the
  cwd keying and the cwd filter; with both gone it was one awk pass over
  the tank's metadata per refresh for a value nothing reads. The helpers
  stay where they are with their receipts in
  tests/bats/adapters/antigravity.bats — `adapter_session_cwd_index` is
  this adapter's documented bulk hook and burn.sh/resume.sh still use the
  single-session form.

Receipt (tests/bats/home.bats, tests/bats/home-bounded.bats,
tests/bats/board-width.bats, one bats run each side):
  before  108 ok / 6 not ok  (test 24 among them)
  after   104 ok / 10 not ok (test 24 GREEN; the agy #93 P2-1 rows became
          reachable for the first time and go red with the claude/codex
          ones — the next commit is what those need)
…2-1)

THE CUT AND THE BURN-SIDECAR EXCLUSION ARE NOT COMMUTATIVE.
`_home_recent_rows` drops burn one-shots from the Resume rows AFTER the
adapter has answered, so an answer already cut to N gives the filter N
rows to throw away and nothing to promote. #93 fixed that by widening
what it ASKS each adapter for, per tank, by that tank's own hidden count
(`N + _burn_tank_hidden`, ceiling `CLIKAE_HOME_RECENT_SCAN_MAX`, which
defaults to `CLIKAE_BURN_SIDECAR_CAP` = 2000), with a T_RESUME_TRUNCATED
line when the ceiling really bites.

On a warm board this index is what answers that ask, and a `recent/`
entry cut to 10 at BUILD time cannot hand back 205 rows however wide the
ask is — `board_recent` RETURNS FIRST, so the adapter's own scan (which
main fixed) never runs. That is why main's five remaining tests were red
here: 195 burns + 50 humans rendered zero human rows, and a 250-burn tank
lost its Resume block entirely, silently.

So `board_state_refresh` computes the same widened cap, with the same
granularity and the same ceiling, and builds the entry at it. Per TANK,
never per store (#93 round-2's own finding): a row this tank returns can
only be dropped by a sid recorded for THIS tank, so another engine's
burn-heavy tank must not push this one's cap into the ceiling. A tank
with no sidecar keeps N exactly, so the common entry is byte-for-byte
what rounds 5-10 wrote.

`_burn_tank_hidden` lives in lib/commands/home.sh, so the call is behind
`declare -F`, the same rule every adapter hook in board_state.sh already
follows: unsourced home.sh => no widening => the pre-#93 cap, which is
the answer this file gave for ten rounds. `CLIKAE_RESUME_ALL=1` skips the
widening on both sides, so the two stay in step there too.

The READ side is deliberately unchanged: the entry's own cap bounds the
answer (an entry built at cap 2 answers an ask of 10 with 2 rows). An
earlier draft of this commit made board_recent fall through to the disk
scan whenever the ask exceeded the cap; that is a real guard, but it
contradicts two of this branch's own receipts ("snapshot selects newest
main sessions", "does not reuse a stale generation on its next refresh"),
which define the index cap AS the bound. Keeping the two caps in step at
BUILD time is where this belongs. The residue is named in the PR body.

Receipt (tests/bats/home.bats, home-bounded.bats, board-width.bats):
  merge head           108 ok /  6 not ok
  after the tank-scope commit  104 ok / 10 not ok
  after this commit    114 ok /  0 not ok
The ten now green: #24 (#34 tank-scoped rows), #29/#31/#32 (12 burns over
3 humans — agy/claude/codex), #72/#73/#74 (195 burns + 50 humans —
agy/claude/codex), #75 (250 burns + 3 humans), #77/#78 (the ceiling
truncation note), #79 (no note on an ordinary board).
main moved again while this round was in flight: eb58aab (#89 - per-tank
fuel from the vendor usage endpoint, read by the board dots and by burn).
Three textual conflicts, all mechanical:

* bin/clikae - both sides added a `source` line to the same block. Both
  kept: this branch's reading_cache.sh + board_state.sh, then main's
  usage.sh, in main's own position relative to history.sh.
* lib/commands/home.sh - a COMMENT block both sides rewrote (the header
  that used to promise "fork-free"). Took main's: it is the superset,
  it documents main's own new per-tank `jq` cost, and it keeps the codex
  paragraph this branch's version was narrowing to.
* tests/bats/compat.bats - both sides appended a guard at the same place.
  Both kept: this branch's "no declare/local/typeset -g/-A/-n" pair and
  main's real-bash-3.2 `bash -n` parse gate with its negative control.

Nothing in this branch's own design needed to move for #89: the board
index and the usage cache do not share a code path.

Receipts on the merged tree (targeted, under the suite lock):
  compat/home/home-bounded/board-width                130 ok / 0 not ok
  usage/burn/limit-codex-status/limit/limit-reset     336 ok / 0 not ok
  adapters+antigravity/resume/resume-hide-burn/clean  191 ok / 0 not ok
  shellcheck -S warning bin/clikae install.sh scripts/test.sh   rc=0
  scripts/doc-names-exist.sh                                    rc=0
  git diff --check                                              rc=0
main moved again: 459c981 (#102 - the tmux status row; it also moved
`_human_age` out of home.sh into lib/core/duration.sh). One textual
conflict:

* scripts/doc-names-exist.sh - BOTH sides made the same fix, this
  branch's 2b53f98 and main's #102 round-2 P3-6: `${#SRC[@]:-0}` is a
  bad substitution on bash 4+/5, so the "no source/docs found" insurance
  could never fire. Took main's version, which is the same one-character
  change plus a comment naming the round it came from.

Everything else auto-merged. Gate on the merged tree:
`scripts/doc-names-exist.sh` rc=0, `git diff --check` rc=0.

main is RED on this tree, at 459c981, in a way this merge inherits
rather than causes - fixed in the commit that follows, not here.
…b it needs

main is red at 459c981, and this is not a merge artefact: #89 (eb58aab,
the vendor usage block in `_home_fuel_dotv_compute`) and #102 (459c981,
which moved `_human_age` into lib/core/duration.sh and guarded home.sh's
two call sites) landed in the same window and crossed.

Reproduced on a clean `origin/main` worktree, nothing of this branch in
it:

  bats -f "_human_age call in home.sh" tests/bats/home.bats
    not ok 1  unguarded: 1265:  [ "$age" -lt "$ttl" ] || _FNOTE="$_FNOTE · $(_human_age …)"

  bats -f "stale-but-recent vendor reading" tests/bats/usage.bats
    _home_fuel_dotv_compute '' claude work "$now"  failed with status 127

Two halves, both of them main's, fixed here rather than carried:

1. lib/commands/home.sh - #89's call site now carries the same
   `declare -F _human_age` guard as the other two, on the same LINE,
   because tests/bats/home.bats asserts the guard per code line (reaching
   this one needs a real usage cache, so nothing else catches it). Same
   `&&`-list shape as the existing sites: errexit exempts every command
   in an `&&` list but the last, so a missing function is a no-op.
2. tests/bats/usage.bats - the three tests that source home.sh STANDALONE
   now also source lib/core/duration.sh, which `bin/clikae` loads globally
   and they were relying on home.sh to define until #102 moved it. Without
   it the age can never render and the assertion cannot pass at all. The
   assertions themselves are untouched, byte for byte.

tests/bats/home.bats' call-site count goes 2 -> 3 for the same reason:
#89 added a third site. The guard assertion - the substance - is
unchanged; the count is there so a NEW site cannot appear unguarded, and
it has to name the number that exists.

Receipts: usage.bats 40 ok / 0 not ok; home.bats + home-bounded.bats +
board-width.bats + compat.bats + doc-contract.bats + tmux-label.bats
148 ok / 0 not ok.
main moved a third time inside this round: b6e8dc1 (#91 - burn reroute
never targets a lock file, sidecar or dotdir; it also routes the board's
own tank walk through the `tanks_for_engine` marker and adds a
per-process tank cache). Two conflicts:

* lib/commands/home.sh, `_home_refresh` - both sides rewrote the whole
  function. #62 replaced main's parallel `mktemp` + background
  `_home_dry_set`/`_home_total_sessions_scan` machinery with the board
  index (`board_generation` priming + `board_total`); #91 added a
  `profiles_cache_reset`/`profiles_cache_warm` prologue on top of the OLD
  body. Kept THIS branch's body - the replacement is what #62 is - and
  adopted #91's prologue into it, placed FIRST: the board priming walks
  the same tank enumeration, so warming after it would pay the uncached
  store walk the warm exists to avoid.
* CHANGELOG.md - both sides added entries at the top of Unreleased/Fixed.
  Both kept, this branch's two first.

`_home_recent_rows` auto-merged and needed no help: main had already
reconciled #61's `tanks_for_engine` enumeration with #93's per-tank
`$_ask` inside that walk (its own "#61 round-6 P2-1 (merge with #93)"
comment), and the bounded reader is fed by exactly that marker-filtered
list - `tanks_for_engine` is `list_all_profiles | awk`, which is also
what this branch's board priming loop iterates, so the two cannot drift
onto different tank sets.

Receipts on the merged tree (targeted, under the suite lock):
  home/home-bounded/board-width/usage                     157 ok / 0 not ok
  limit*/adapters+antigravity/resume*/clean/compat        273 ok / 0 not ok
  burn/init/cockpit/doc-contract/tmux-label/antigravity   337 ok / 0 not ok
  shellcheck -S warning bin/clikae install.sh scripts/test.sh   rc=0
  scripts/doc-names-exist.sh                                    rc=0
  git diff --check                                              rc=0
…PR's own gate

This branch added a CI step in round 8 (P3-3): `find tests -name '*.bats'
-print0 | xargs -0 shellcheck -S warning`. The `ludeeus/action-shellcheck`
step above it does not look at `.bats` at all, so before that step a
finding in a test file was invisible on both sides. main has none of that
step, which is why main's `shellcheck` job is green while this branch's
went red on 3d022aa - the gate is this PR's, the findings are main's:

  tests/bats/limit-codex-status.bats  SC2034 ts (x3 call sites)
  tests/bats/usage.bats               SC2154 stderr
  tests/bats/tmux-status.bats         SC2034 mt, SC1010 done,
                                      SC2034 CLIKAE_BURN_LOG_RETENTION_DAYS,
                                      SC2034 t27

Every fix keeps the behaviour and every assertion byte for byte:

* `ts` -> `_`. Its whole job (the file says so) is to absorb the 7th field
  so `read`'s "extra fields glom onto the last variable" rule does not
  append it onto `$sr`. `_` absorbs it identically and is the one name
  shellcheck exempts. Only the three sites that never READ it changed.
* `$stderr` is set by bats' own `run --separate-stderr`, which shellcheck
  cannot see: a `# shellcheck disable=SC2154` with the reason, the same
  way burn.bats already documents cmd_burn's dynamically scoped locals.
* `local v mt age suffix` -> `local v age suffix`: `mt` is declared by the
  `local "$v"=""` inside the loop, like `d` and `now`, which were never
  named out there either.
* `_burn_status burn-2 done …` -> `"done"`: bare, it reads as the keyword.
* `CLIKAE_BURN_LOG_RETENTION_DAYS=30` -> `export`. `tmux_status_render`
  runs in this shell so either form reaches it; only the export says the
  value is read somewhere.
* `t27` was built and never used: dropped, `t24`/`t42` are the two widths
  the rungs actually exercise.

Receipts: `find tests -name '*.bats' -print0 | xargs -0 shellcheck -S
warning` rc=0 (the CI step, verbatim); the same over all 199 *.sh/*.bats
under lib/ tests/ scripts/ rc=0; `shellcheck -S warning bin/clikae
install.sh scripts/test.sh` rc=0. tmux-status.bats + limit-codex-status.bats
+ usage.bats: 139 ok / 0 not ok.
cverorg added a commit that referenced this pull request Sep 15, 2026
…tion lib (#115)

main went red on both bats platforms after #89 (a new _human_age call site) and #102 (which moved _human_age into lib/core/duration.sh and guarded only the call sites it knew) landed in the same window. Cherry-pick of the repair the #78 integration lane proved on its merged head: declare -F guard at the call site; usage.bats sources duration.sh.
KITT added 4 commits September 15, 2026 08:58
…0 P2-1)

The Resume index's per-tank cap is widened by that tank's hidden burn count
(#93's fix, adopted into the index in round 11), but `board_stale`
fingerprinted TRANSCRIPTS only — and the sidecar is an input to the cap, not
to the transcripts. burn.sh records a burn's sid AFTER the engine process
exits (burn.sh:1106, :4288), so "transcript appears, board renders, sid
lands" is burn's own ordering, not a contrived one: the entry got built at
the old, narrower cap and nothing afterwards ever disagreed with the
fingerprint again.

Round 10 measured it, on this branch, through the front door:

  10 human sessions + 1 burn, in burn's real order -> 9 human rows, on that
  frame and on all three later ones. It does NOT self-heal; only deleting
  state/board brought the tenth back. main renders 10.

  An entry built at cap 10 out of 50 burn transcripts, then 50 sids appended
  with no transcript touched -> HUMAN rows 0, BURN rows 0, no truncation
  line, no Resume block at all. main renders 10.

The PR body's claim that this "self-heals on the next change" was an
argument, and it was wrong.

Fixed at the root, in two halves, because one is not enough:

* `_board_sidecar_rows` stats this tank's sidecar file(s) and
  `_board_tank_fingerprint` (was `_board_transcript_fingerprint`) hashes
  them after the transcript rows, so a sidecar write makes THAT TANK, and
  only that tank, stale. Both engine spellings: burn.sh writes agy's under
  the literal "agy" while this file calls the engine "antigravity" — the
  same translation `_burn_tank_hidden` carries. One stat per tank, on a file
  that exists only where burns have run; tanks without one hash exactly what
  they hashed before.

* A generation now records the cap its `recent/` rows were cut at
  (`recent-cap`), and an incremental rebuild re-cuts every entry when it
  moves. Without this the first half is inert: an incremental rebuild
  rewrites an entry only for a file that CHANGED, and a sidecar write
  changes no file, so the rebuild carried the narrow entry forward verbatim
  (verified: with only the fingerprint half in place, both shapes above stay
  red). The re-cut reads the manifest this refresh just published — every
  file's sid and scope are already in it — so it opens no transcript and
  re-parses nothing, and it goes through the same one function the cold
  build uses (`_board_write_recent_entries`, factored out here) so the two
  produce byte-identical entries.

Ordering inside the rebuild is part of the fix: the sidecar is stat'd BEFORE
`_burn_tank_hidden` counts it, so the published fingerprint describes a
sidecar state no NEWER than the one the cap was computed from. The other
order manufactures the same bug in miniature — a fingerprint vouching for a
sidecar the cap never saw, fresh forever.

The generation's fingerprint file is `tank-fp`, not `transcripts-fp`: it
covers something the old name does not, and the rename makes each side read
the other's generation as stale (one incremental rebuild) instead of as a
fingerprint vouching for the same thing. `_BOARD_GEN_FORMAT` is deliberately
NOT bumped — a format mismatch forces a COLD rebuild, while a missing
fingerprint takes the cheap incremental path, and the upgrade needs the
cheap one.

Tests (red on 03d4884, green here):

  a sid recorded AFTER the render still leaves ten human rows on the next
    frame                                              9  -> 10
  sids appended to an entry already built at the old cap never empty the
    Continue block                                     0  -> 10
  a sidecar change rebuilds that tank ONCE, not on every frame
                                     (never stale)     -> 1 rebuild, then 0
  an agy tank's sidecar is fingerprinted under the 'agy' alias

Receipts: home-bounded + board-width 39 ok / 0 not ok; home +
resume-hide-burn + adapters/antigravity 140 ok / 0 not ok; shellcheck -S
warning rc=0; scripts/doc-names-exist.sh rc=0.
`adapter_session_cwd_index` and the `_agy_ws_*` plain-global cache built on
it (`_agy_ws_varname`, `_agy_ws_clear`, `_agy_ws_load`, `_agy_ws_lookup`,
plus `_AGY_WS_NS`/`_AGY_WS_KEYS`) existed for exactly ONE reader: the `$want`
cwd filter in antigravity's own recent-sid scan. #34 deleted that filter
(`workspace` is a constant on a real install, so cwd-scoping hid every row),
and fix round 11 dropped board_state.sh's bulk read along with the cwd keying
it fed (`_BOARD_TANK_SCOPE`). What was left was a documented adapter hook, a
three-function cache and a namespacing scheme with no caller in `lib/` or
`bin/` at all — the round-10 review confirmed it mechanically, and the only
users left were the two bats tests that exercise the cache itself.

Deleted, with those two tests, and every comment that pointed at them
rewritten to say what the code does now instead of naming a function the
tree no longer has. `adapter_session_cwd` — the single-session form burn.sh
and resume.sh actually call — is untouched: same file read, same
"first occurrence wins", one session at a time.

adapter_loader's `unset` list, which the review flagged for not carrying
`adapter_session_cwd_index` (a hook that would therefore survive across a
two-adapter load), needs no entry for it now: there is no such hook. The
list's remaining gaps are a separate finding, fixed in the next commit.

Both gates stay green either way, which is why this was a P3 and not a
blocker: `shellcheck -S warning` has no rule for an unused function, and
`docs/` never named any of them. The reason to delete is that a documented
hook with no caller is a claim the code does not keep.

Receipts: adapters/antigravity + home-bounded + resume-hide-burn 93 ok /
0 not ok; shellcheck -S warning (lib + every .bats, CI's own line) rc=0;
scripts/doc-names-exist.sh rc=0; no `_agy_ws`/`adapter_session_cwd_index`
reference survives anywhere outside two past-tense history notes.
Found while closing round 10's P3-1, which flagged ONE name missing from
adapter_loader's `unset` list (`adapter_session_cwd_index`, deleted in the
previous commit). Checking the list mechanically instead of by eye — every
`^adapter_*()` under lib/adapters/ against the list — found four more:

  adapter_cwd_from_args      codex, claude, antigravity, grok
  adapter_ephemeral_flags    claude
  adapter_mcp_config_file    claude
  adapter_tank_fingerprint   claude, antigravity, codex

The list exists because `clikae handoff <a> --to <b>` loads two adapters in
one process, and an optional hook `b` does not define must not be inherited
from `a`. Leaked, it does not read as a bug: `declare -F` says the hook is
there, so a caller gating on it believes the NEW engine supports whatever it
gates. That is #60's exact defect (`adapter_meta_permission_modes`, round-2
P1-1 of that PR) and #81's before it (`adapter_burn_flags` /
`adapter_audit_flags`). Three drifts, three times caught by a person reading
the list, each time fixed only for the hook that person was thinking about.

So the fix is the list AND a gate: "every adapter hook is in
adapter_loader's unset list" (tests/bats/adapters/extra.bats) compares the
two sets and prints what is missing, and a second test proves the same thing
in a live shell (load claude, load grok, the three hooks grok does not define
must be gone). Negative control run both ways: with
`adapter_tank_fingerprint` taken back out of the list, both tests go red and
name it; with it in, both are green.

Receipts: adapters/extra 20 ok; handoff + adapters/{claude,codex,grok} +
ephemeral 97 ok; burn 217 ok; all 0 not ok. shellcheck -S warning rc=0.
@cverorg
cverorg merged commit 9da6cec into main Sep 15, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

home: board render time must not scale with transcript count or size

1 participant