Skip to content

fix(usage): refresh an expired Claude login instead of waiting for Claude to run - #134

Merged
nplez1 merged 1 commit into
nplez1/mainfrom
nplez1/Claude-Usage
Oct 19, 2026
Merged

nplez1 merged 1 commit into
nplez1/mainfrom
nplez1/Claude-Usage

Conversation

@nplez1

@nplez1 nplez1 commented Oct 18, 2026

Copy link
Copy Markdown
Owner

ELI5

Orca reads the Claude login stored on this machine and asks Claude's usage endpoint how much of the plan is left. When that login has expired, Orca used to just say "usage updates the next time Claude runs" — which never happens if you only ever run Claude on remote hosts. Orca now renews the login itself, using the renewal token that was already sitting beside it.

What Changed

Before: once the stored Claude access token expired, the status-bar entry showed "Updates when Claude runs" / "Claude usage updates the next time Claude runs." and stayed there for good. Every poll re-sent the same expired token, so the entry retried forever. The only cures were running claude once on the Orca machine, or using Sign in in the account menu.

After: the first time the usage endpoint rejects the token, Orca exchanges the stored refresh token, writes the rotated pair back to the place it was read from, and retries the usage call once. The bars come back on their own and keep themselves current — one exchange per token lifetime, not one per poll.

The mechanism:

  • claude-oauth-token-refresh.ts (new) posts grant_type=refresh_token to https://platform.claude.com/v1/oauth/token with the public Claude Code client id, and merges the answer into the stored blob without dropping what Claude Code reads (expiresAt, refreshTokenExpiresAt, scopes, account metadata, any other top-level key).
  • claude-oauth-credential-store.ts (new) owns reading and writing that blob. A macOS Keychain item is written compact over security -X; ~/.claude/.credentials.json is written atomically and only if the file still holds the login the exchange started from.
  • claude-oauth-credentials.ts now reports where the login lives, so a refresh has an address to write back to; claude-active-usage-fetch.ts drives it — on a 401/403 from usage, and on a login that has a refresh token but no access token. Never on a timer.
  • claude-usage-error-classification.ts loses the two flags nothing read (shouldAttemptCliFallback, shouldAttemptDelegatedRefresh, plus the unused terminal); the one that survives is shouldAttemptCredentialRefresh, which now has a consumer. The refreshable-credentials-without-token copy — previously dead, claiming a refresh that did not exist — is now the real state for a refresh that failed transiently.

Why

The issue is a recoverable-auth failure whose recovery had been deleted. What makes this delicate is that a Claude refresh token is single-use: refreshing on a timer would race the CLI's own rotation and invalidate one of the two logins. So the exchange is bounded to an actual refusal, re-reads the store immediately before spending the token so a claude /login wins, coalesces concurrent callers, and remembers a refused grant instead of replaying it every poll.

Four more guards exist because the first version of this change could destroy the login it was trying to save, and each one is pinned by a test:

  • A rotation the store will not take — a failed write, or a store that cannot be read, which is not the same thing as one that was signed out of — is held in memory as the token fields the endpoint returned and re-applied on the next exchange, because the token it replaced is dead server-side. An answer that rotates nothing is composed over that pending rotation rather than writing the spent token back over it.
  • A write backs off when the store's refresh token is no longer the one that was spent, and re-reads the Keychain item immediately before writing it.
  • A store that already replaced the token the poll was made with is used as it stands, without spending a second rotation a running Claude may be holding.
  • A service that refuses the token Orca just minted is not handed another rotation for fifteen minutes — a token minted that recently cannot have expired, so the refusal is about the login.

The Keychain detail is not cosmetic. security -w answers any item whose bytes are not entirely printable in hex, and Claude Code parses that answer directly as JSON, so a pretty-printed login written with -w would make the item look like no login at all — to Orca and to Claude Code — from the first rotation on. Orca therefore writes compact JSON over -X (the form Claude Code uses for its own item) and decodes that form when reading. I verified all four directions against a throwaway Keychain and the installed CLI (2.1.302).

Relaxing the credential-persistence ratchet is the one place this PR touches a guard rather than adding to it. The ratchet exists to keep credential replay out of the tree — copying one account's login onto another. Writing a rotated refresh token back to the item it came from is not replay. So the exemptions are named and narrow: the file that may start an exchange, the file that may write the result, the two Keychain write names, and the security verb in the runner that already owns keychain argv. A new census test walks the whole src tree to prove no other file can start an exchange or write a rotation.

Linked Issue

Fixes #133

Visual Proof

N/A — no layout or copy change. The only user-visible difference is that the Claude usage bars stop getting stuck in the "Updates when Claude runs" state; every string involved already existed.

Testing

Automated, on this revision (orca-queue serialises the heavy runs):

  • pnpm tc — green across all four projects.
  • pnpm test src/main/claude-accounts src/main/macos-keychain src/main/rate-limits src/renderer/src/components/status-bar — 1962 passed, 2 skipped, 0 failed.
  • pnpm lint — three failures, none of them ours, each verified against the base:
    • check:focus-read-ratchet (its baseline still allows a focus read that workspace-tab-close-commands.ts no longer has) and check:path-literal-ratchet (.github/workflows/macos-updater-tests.yml names src/main/window/dashboard-popout-window, which this fork's tree does not carry). Both fail identically with this change stashed.
    • oxlint on src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts (303 lines against the 300 max-lines rule), which arrived on nplez1/main in 46f640a501 while this branch was open. This PR does not touch that file.
  • check:code-quality:changed — passed.
  • Full suite: 12,776 files / 128,526 tests pass, with the failures classified at the end of this section. The touched suites were then re-run on the final revision (one doc comment differs from the full-suite run): 1962 passed, 2 skipped, 0 failed.

New and rewritten tests, and what makes each non-vacuous:

  • claude-oauth-token-refresh.test.ts (23 cases). Rotation written back with unrelated fields intact; the fresher stored credential is the one exchanged; a store that already moved on is used without spending anything; a re-login mid-exchange is kept; a refused grant is not replayed; a 4xx that is not invalid_grant stays retryable; concurrent callers share one exchange; a rotation the store refuses survives across further exchanges and lands when the store accepts writes again; the same holds across a store that cannot be read; a pending rotation survives an answer that rotates nothing, and its stale expiry is dropped (removing that branch makes the test fail); a service refusing Orca's own minted token stops the rotation and resumes past the window.
  • claude-profile-usage.test.ts — rewritten where it asserted the old policy ("…and never refreshes it"): exchange-then-retry, refresh-token-only login, refused grant for a profile vs. System Default, transport-failure copy, and no second exchange when the minted token is refused too.
  • generic-password.test.ts — the hex encode/decode pair round-trips a multi-line login, and the write sends -X with hex and never the plaintext.
  • claude-credential-persistence-ratchet.test.ts — the exemptions above, plus a census over src that fails if any other file starts an exchange or writes a rotation.

Manual: not done against a live Keychain rotation — the exchange is covered by unit tests, and the Keychain round trip was verified against a throwaway Keychain plus the installed CLI's own refresh path (endpoint, client id, request shape, -X hex, compact payload). The remaining check is a Mac whose token has actually expired; I could not age one on demand here.

Full suite: 12,776 files / 128,526 tests pass; 39 assertions fail across 14 files, none of them in claude-accounts, rate-limits, macos-keychain or status-bar. Every one is pre-existing or a load artefact, and it was proved rather than assumed: all 14 were re-run against this change stashed, and 13 fail identically there (config/scripts/*, orcad, sqlite, persistence, workspace-path-index, cli, ai-vault-search, the Jira editor panel, and the relay-region test that needs the uninstalled cloud/ workspace). The fourteenth, useFileExplorerVisibleRowProjection.test.ts, fails only inside the full run — it passes on its own, 24/24, exactly like the cross-version-wire e2e files that flaked in an earlier run of this branch.

Review

Reviewed by Opus 5.5 (claude/opus, thinking high) as a subagent, over the final diff, in ten passes: each earlier finding was fixed and the corrected revision re-reviewed, and the last pass returned SHIP-READY, no blockers, no should-fix on 1ab87242ea. Two nits were deliberately declined and are recorded here for the reviewer: a 429 from the token endpoint is not backed off with Retry-After (nothing is spent on that path, and the poll cadence already gates retries), and the translated "Start Claude in this account to refresh it" copy stays as-is for a login with no refresh token (changing it means touching every locale and the localization verifiers; starting Claude in that account does take the user to a sign-in).

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Security: the rotated blob goes back only to the item or file it was read from; no cross-account copy, no new credential store. The Keychain write passes the login hex-encoded on security's argv — the form Claude Code uses for its own item, and the only input form that runner takes; a same-user ps can see it for the few milliseconds the command runs. -U updates the existing item rather than replacing its ACL.
  • A rotation that fails to persist is memory-only. If the store rejects the write and Orca quits before the next poll retries it, the store keeps a refresh token the server has already spent, and the user has to sign in again. The alternative would be a second credential store, which is worse.
  • Compatibility: a host that already holds a refresh token starts recovering on the next poll after updating; a host with only an access token behaves exactly as before. No wire or IPC change, so paired clients and hosts are unaffected.
  • SSH/remote: usage is read on the host that owns the login, so a remote Claude login stays that host's business; this fixes the machine running Orca.
  • Windows/Linux: the Keychain path is macOS-only by construction and fails closed elsewhere; the file path (also used by WSL profiles) writes atomically and only after re-reading.
  • Failure modes left visible, not hidden: a grant the endpoint calls dead becomes "Sign in again to use this account."; a throttled or offline exchange keeps the login and retries on the next poll.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…aude to run

The status bar read the stored Claude access token and never exchanged the refresh token sitting
beside it, so on a machine where Claude only ever runs on remote hosts the entry sat at "Updates
when Claude runs" for good: every poll re-sent the same expired token.

Orca now exchanges the stored refresh token the first time the usage endpoint refuses the token,
persists the rotated pair back to the item or file it was read from, and retries the usage call
once. Nothing refreshes on a timer: a Claude refresh token is single-use, so the exchange is
bounded to a refused token, re-reads the store immediately before spending it, coalesces
concurrent callers, and remembers a refused grant instead of replaying it every poll.

Keeping the login it was meant to save took five guards. A rotation the store will not take — a
write that failed, or a store that could not be read, which is not the same thing as one that was
signed out of — is held in memory as the token fields the endpoint returned and re-applied on the
next exchange, because the token it replaced is dead server-side; an answer that rotates nothing
is composed over that pending rotation instead of writing the spent token back over it. A write
backs off when the store's refresh token is no longer the one that was spent, and re-reads the
Keychain item immediately before writing it, since a `claude /login` inside that window owns the
newer login. A store that already replaced the token the poll was made with is used as it stands,
without spending a second rotation a running Claude may be holding. A service that refuses the
token Orca just minted is not handed another rotation for the next fifteen minutes, because each
one spends a single-use token for the same answer. And only an answer that actually says
`invalid_grant` is written off as a dead login; any other 4xx stays retryable.

The Keychain item is written the way Claude Code writes its own: compact JSON over `security -X`,
hex-encoded transport. Compact is the load-bearing part — `security -w` answers any value that is
not entirely printable as hex, and Claude Code parses that answer directly as JSON, so a
pretty-printed login written there would read back as hex and look like no login at all. The reader
decodes that form, so an item either tool wrote is readable.

The credential-persistence ratchet gains named exemptions for that exchange — the file that may
run it, the file that may write the result, and the `security` verb — because writing the rotation
back is not the credential replay the ratchet exists to keep out.

Also removes the two classification flags nothing read and wires the one that survives, which
points the previously dead "Claude sign-in is being refreshed" copy at the state it describes.

Fixes #133
@nplez1
nplez1 merged commit 0d815f5 into nplez1/main Oct 19, 2026
3 checks passed
@nplez1
nplez1 deleted the nplez1/Claude-Usage branch October 24, 2026 03:23
nplez1 added a commit that referenced this pull request Oct 27, 2026
…nest (#187)

* docs(fork): record the measured sync cost and the mergeability plan

Seven analyses of the 233-path conflict set, the 33-commit patch series, the
product line, the gates and the sync tooling, with the figures each rest on.
The plan is the deliverable; nothing in it is implemented here.

Read-only inputs, no suites run to produce it.

* ci(nightly): stop the relay job deadlocking the fork's unit run

The relay integration job in unit-tests.yml hard-codes a blacksmith runner label
this fork has no runner for. The 2026-10-26 scheduled run sat queued on it for 17
hours and held the run open, and concurrency cancel-in-progress: false then
blocked every later night. The fork ships no cloud relay.

* chore(lint): drop the max-lines budget

Upstream's 300/400/600/800 line budget is what made fork additions evict upstream
code into fork files, which is a permanent conflict and a silent-drift risk. The
budget is upstream's own code-management construct and the fork does not need it.

Proven off with a 320-line probe: passes under this config, errors under upstream's.

* fix(gate): an empty branch diff is unknown, never a waiver

A sync merges upstream onto nplez1/main, so merge-base(nplez1/main, HEAD) is HEAD
itself and the diff came back empty. flakeWaiver reads an empty diff as 'nothing
coupled was touched' and waives, so the release gate waived every flaky pass on a
line a 556-commit merge had just built.

An empty diff is now unresolved: fall back to the merge's first parent, which is
what that merge added, and to unknown when there is nothing to compare against.

* fix(gate): an allowlist entry can name the failure it covers

Entries matched on file and test name only, so an allowlisted test that started
failing for a new reason stayed 'known' and was waived. An optional match regex is
now tested against the failure's first line, the way lint-gate already works.

* fix(quality): resolve the changed-file base from origin/HEAD

origin/main is upstream's mirror on this fork, so the pre-handoff gate diffed the
fork's whole 2,846-file delta instead of the branch's own. The sibling
check-changed-code-quality.mjs already resolves origin/HEAD first.

* chore(gate): pin 27 known-red entries to the failure they cover

Each of these fills a test name with a red that only its host can produce: bash 3.2
missing mapfile, bash 3.2 refusing an empty array under set -u, macOS /private/var
symlinks, and the profile-state SQLite binding. Pinned to the signature the failure
text actually carried in the last full run, so the entry covers that reason and not
whatever happens to fail under the same title next.

The other 13 keep matching on title alone: their observed failure text is a generic
deep-equality diff, and guessing a signature there would turn a known red unexpected.

* docs(fork): record the two policies, the cap removal and the gate fixes

Local-patch rules gain the two policies the analysis argues for: no fork comment in
a file upstream owns, and no deleting a file upstream still owns. Both are recorded
with the measurement behind them, next to the patches they constrain.

Three new patch entries cover what changed here: the max-lines budget is off, the
gate now judges a merge by the merge's own diff, and the nightly's relay job is
skipped. Each names its anchors and what it does not fix.

The ledger's Identity Paths was src/**, which matched 227 of 233 pending conflicts
and made 'this path has a decision' true everywhere: now 33, the files that carry an
identity literal. The ledger also states the Paths-precision and Resolution rules.

* chore(fork): remove fork annotations that sat on unchanged upstream code

Applying the no-comments policy to what it actually reaches: of the 18 upstream-owned
files whose whole fork diff is comment lines, 12 are the fork rewording upstream
comments to match its own reality (pop-out to Agent Dashboard, orca to orca-np,
42 to 49 images). Reverting those would make the comments false, so they stay.

The six that were pure annotation of unchanged upstream code are gone: two timeout
notes in the win32 PTY test, a doc comment on an upstream ADF builder, a param comment
on a watch-path index, a split note in a file the fork did not split, and a paragraph
explaining upstream chunk accounting. The lock-path rationale was the one piece worth
keeping, so it moved to LOCAL-PATCHES.md rather than being deleted.

* feat(nightly): report a stale or wedged backstop instead of leaving it silent

The backstop went eighteen days without running and nothing said so. The 2026-10-26
scheduled run queued on a runner label this fork does not provision for seventeen
hours, and cancel-in-progress: false blocked every later night behind it.

local/check-nightly-liveness.mjs exits 0 on a fresh green run, 1 when the newest run
is stale (past two schedule slots) or wedged (queued/in-progress beyond four hours),
and 2 when it cannot tell — a missing gh must not read as a green nightly. Six cases
in tests/tools/nightly-liveness.test.mjs drive the judgement through an injected
clock, and the live run today correctly reports the cancelled run as attention.

* docs(plan): record that the gate preflight was dropped on evidence

Two of the three proposed checks would have failed a healthy tree. mobile/node_modules
does not exist here and the suite is green (pnpm hoists the mobile deps into the root
install), and the bun-on-PATH version mismatch 1.4.3 vs pin 1.4.4 is not a hazard
because run-vitest.mjs prefers resolvePinnedBunExecutable, which resolves the pin from a
devDependency. A check on either would have guarded a path that is not used.

The nightly liveness check shipped, as a standalone script rather than a gate step.

* feat(sync): give the ledger an executable Resolution field

The ledger recorded decisions as prose, so every sync re-derived what to DO with a
conflicted path. Local commits in the affected set: zero occurrences of "Resolution:".

A section may now carry **Resolution:** with one of keep-ours, take-theirs, delete,
rerere, manual, plus a note. parseLedger captures it, ledgerResolution normalises it,
and conflicts.mjs prints it above the decision. An unrecognised verb is reported as a
ledger defect rather than read as "no instruction" — which is how a malformed entry
would otherwise hide.

Two sections carry one today: SSH relay removal (take-theirs, upstream retired it) and
Identity (keep-ours on the literal, take upstream's surrounding change). 20 fixture
tests pass, and the resolver renders "resolve keep-ours" for the nine identity paths in
today's merge tree.

* chore(fork): restore 13 files upstream still owns instead of deleting them

All 39 files the fork deleted between the last merge base and the tip are still alive
upstream, so each was a modify/delete conflict on the next sync and a place where
upstream fixes were dropped silently rather than surfacing. Thirteen outside the
dashboard subsystem come back byte-identical to upstream/main and are recorded as
present-but-dead in LOCAL-PATCHES.md.

Four stay deleted, with the reason recorded instead of implied: the computer-use Swift
pair hardcodes upstream bundle id where this fork trusts its own, and the PR comment
resolver needs an ack model #169 deliberately removed.

Three restored tests pass (11 + 7 + 9 cases), the restored node-side files typecheck,
and nothing in the fork imports any restored module.

* fix(test): two orca-runtime failures were test defects, and leave the allowlist

Both were recorded as macos-path-semantics environment reds. Both were defects in the
tests, so the three allowlist entries go in the same commit rather than being widened.

The /private/var mismatch is upstream #30657 missing two cases; upstream fixed it in
#33540 and this takes that hunk byte-for-byte, comment included, so the next sync has
no hunk to resolve. The unhandled selector_not_found is the test attaching .rejects
after awaiting a different promise, so the rejection settles unobserved and Node
reports it while the assertion still passes; the allowlist evidence blamed the
coalescing map, which the stack trace contradicts.

pnpm test src/main/runtime/orca-runtime.test.ts: before 2 failed + 1 unhandled error,
after 1520 passed | 3 skipped, green on three consecutive runs.

* fix(test): the four orcad forked-child cases were the same /private/var defect

They set process.argv[1] to an unresolved mkdtempSync(tmpdir()) path while the resolver
reports the resolved deploy root, so all four compared /var against /private/var. Each
resolves its temp dir first now, and all five cases in the file pass.

Upstream has not fixed this one, so it is a fork-local test fix that will conflict once,
benignly, when upstream makes the same change. Four entries leave the allowlist with it:
38 entries becomes 34, and the macos-path-semantics class drops from 7 to 3.

* fix(test): the three remaining macos-path-semantics reds were the same bug

Each created a temp dir with mkdtemp(tmpdir()) and compared it against a path the
subject had resolved: the darwin launcher's `cd -P`, and a spawned child's reported
cwd. Each now resolves its temp dir first.

All three files are otherwise byte-identical to upstream/main, so this is a fork-local
test fix that will conflict once, benignly, when upstream makes the same change.

With these, the macos-path-semantics class is empty: allowlist 40 entries down to 31,
and the remaining 22 + 9 are host-toolchain and sqlite-binding, which are this
machine's bun and SQLite rather than anything a test can fix.

* fix(daemon): the sync merge rewrote two fixtures' imports to file-relative paths

Both fixtures bundle an inline script with resolveDir = process.cwd(), the repo root,
so their imports must be root-relative. Merge 281cccf5a4 rewrote them to file-relative
paths that exist in neither merge parent, so esbuild failed with "Could not resolve"
and the affected electron suites reported a failed file with every test skipped.

The daemon-launcher-driver fixture is byte-identical to upstream/main again. The orcad
adoption test is latent because CI skips it for missing inputs; its three rewritten
imports are the same regression and are fixed the same way.

* fix(test): the cooldown 401 expectation is per-platform, not macOS-only

#134 removed the fixture's refresh token and changed upstream's stale-token expectation
to the value macOS produces. On darwin the exchange cannot re-read the Keychain item, so
the host reports refreshable-credentials-without-token; elsewhere it reads the
credentials file, finds no refresh token, and reports stale-token. Both are correct for
their credential store, so the expectation now depends on the platform. 12 cases pass on
macOS, which takes the darwin branch unchanged.

* fix(ephemeral-vm): observe stdin errors so a fast child exit cannot crash the host

A recipe that exits without reading stdin, as the context-menu test's `exit 9` does,
makes the payload write fail EPIPE. Nothing observed the error on child.stdin, so it
became an uncaught exception: an unhandled-error headline in the Linux nightly shard 3,
and in production a crash of the Electron main process. The exit code already reports
the outcome, so the listener is deliberately silent.

Upstream has the same line, so this is fork-local until it goes upstream. src/shared is
a global input, so a PR carrying it needs the release gate.

* feat(ci): name the file a shard verdict could not blame

A file whose import or beforeAll throws reports every test skipped, so no failing case
names it and the shard reads as red with no culprit. The verdict now adds a line for a
failed file that has no failing test. Replaying the five shard artifacts from run
40308443374 produces the expected output, and 14 tests pass across the two suites.

* docs(sync): check what the merge resolution itself invented

Every post-sync check asks whether the tree is coherent; none asks whether the resolver
wrote something neither parent said. The 2026-10-25 merge turned
'./src/main/daemon/daemon-spawner' into './daemon-spawner' in two esbuild fixtures, and
because the shorter path still resolves relative to the changed file, no import check
complained. Inside an inline script bundled with resolveDir: process.cwd() only the
root-relative form exists, so the electron suites died in beforeAll and the shard
verdict named no culprit.

The runbook now points at git show --remerge-diff for exactly those hunks, and at the
sites where resolveDir makes the difference matter. Both are verified against the merge
that caused it.

* feat(sync): a manifest that answers a modify/delete conflict

modify/delete is the one conflict class where restoring the file is the tempting wrong
answer, and the resolver printed it as a bare conflict with no answer, so a human
re-decided it every sync. local/sync/expected-deletions.json now holds the 24 paths this
fork deletes that upstream still owns, each with the commit that deleted it and why, under
one of three statuses: expected-deletion (re-apply it), restore (the file comes back), or
pending-decision (nobody has chosen).

20 of the 24 are the dashboard/pop-out set, which is a pending product decision, and the
triage note names those paths so they cannot be resolved by accident. Four are settled
with reasons recorded. The manifest is checked against HEAD on read, so one that still
calls a restored file deleted is reported as stale rather than trusted.

Seven fixture tests cover the reader; the live triage prints the status, the reason and
the ledger entry for a conflicted path.

* fix(quality): a restored upstream file is not the fork's to fix

Restoring use-jira-issue-workspace-data.ts byte-identical to upstream tripped three
no-adjust-state-on-prop-change findings, because a restored file counts as all-new lines
and the rule is the fork's own. Fixing upstream's code here would be divergence the next
sync re-resolves, so the file joins the existing suppression map with that reason.

This is the same class a sync hits at scale: every upstream file a merge brings in is
"new" to this gate while being byte-identical to upstream.

* feat(sync): propose and apply the resolutions the fork has already settled

The ledger recorded decisions as prose and the deletion manifest recorded statuses, but
nothing turned either into an action, so a sync still asked a human about every path.
--propose now prints what is settled twice over, and --apply runs the git steps and
stages them without committing.

A verb is a whole-file decision, which exposed a real hazard in my own ledger entry: the
Identity resolution said "keep-ours on the literal, take upstream's surroundings", and a
whole-file keep-ours would revert upstream's work wholesale across 33 paths. That entry
is now manual, with the reason, and the propose engine returns nothing for it — 3 of 231
conflicted paths carry a settled resolution today, all from the deletion manifest, and
the rest are honestly reported as manual.

Verbs written as prose now name their verb (manual. is manual). 42 fixture tests cover
the readers; --apply refuses a --merge-tree run because there is no index to resolve.

* docs(plan): record what landed, what moved and what is still open

The plan was written as analysis and said so. A session reading it now would re-derive
decisions already made and re-litigate items already dropped, so it opens with a status
section instead: the fourteen changes that shipped, the six corrections to its own
premises (preflight dropped, comment sweep narrower than claimed, exit-2-is-not-a-pass),
and the four items still open with the dashboard decision named as the blocker.

Baselines that moved are marked rather than rewritten, so the measurement in section 1
stays comparable.

* fix(sync): two deletions the manifest missed, because a merge deleted them

agent-dashboard-open-mode.ts and its test are gone from HEAD and alive upstream, but the
manifest was generated from git log --diff-filter=D, which cannot see a file a merge
resolution removed without a commit of its own. Generated from git diff --diff-filter=D
instead: 26 deleted paths, 26 manifest entries, nothing uncovered.

Both are dashboard-delivery, so they join the pending decision rather than settling it.

* refactor(fork): put six upstream blocks back where upstream has them

The max-lines cap made the fork carve cohesive units out of upstream-owned files into
fork-owned siblings. Each carve is a permanent conflict at that point plus a copy that
drifts, and with the cap off the split buys nothing. Six blocks that were upstream's code
and nothing else go back verbatim from the merge base, and the siblings are deleted:

  tooltip.tsx                     <- provider-rate-limit-window-sections.tsx
  CheckRunDetailsPanel.tsx        <- check-run-details-status.ts
  web-filesystem-api.ts           <- web-file-mutation-session.ts
  hook-settings.ts + hook-service  <- claude-status-line-slot.ts
  accounts-search.ts              <- accounts-search-extra-providers.ts
  filesystem-watcher-wsl.ts       <- filesystem-watcher-wsl-snapshot.ts

Verified: tc:web and tc:node clean, and the suites covering each — provider-panel-rendering
34, hook-service 21, web-filesystem-api-connection-pin 2, CheckRunDetailsPanel 37,
accounts-search 15, filesystem-watcher-wsl 16.

Thirty-one other splits were measured and left alone: the block carries fork code, was
rewritten rather than moved, or upstream has changed it since. The ledger's accounts-search
decision is updated because this reverses the reason it recorded, and a dated addendum
lists the six so the older chronicles are not rewritten.

* docs(plan): six of the 37 measured cap splits are undone

* fix(test): install bash 4+, which clears fourteen allowlisted failures

Every one of these was a host-toolchain entry whose cause was Apple's /bin/bash 3.2: six cases
across ci-managed-ssh-execution and dependency-security-release die on `mapfile: command not
found`, and eight pr-auxiliary-preflight-admission cases hit `MOBILE_CI_ARGS[@]: unbound variable`
because bash 3.2 rejects an empty array under `set -u`. The ubuntu runners ship bash 5, so these
never failed for upstream. Naming the cause rather than the symptom is what made it a one-command
fix: homebrew bash 5.3.20 now wins resolution, because /opt/homebrew/bin precedes /bin on this
machine's PATH. No repository change, no wrapper. The three files run 233 passed.

Allowlist 31 entries / 13 files -> 17 / 10. What remains is bun and SQLite behaviour (host-toolchain
8, sqlite-binding 9), which is a different machine's problem to fix, not this fork's.

Pruning moves the entry count without a new full run, so the manifest's arithmetic stopped closing:
observedRun describes the ef2897da2b run, which had 40 allowlisted. Rather than repointing that
number at a run nobody did, every entry pruned since it is recorded in prunedSince with its class,
reason and the revision that fixed it — 23, the earlier nine macos-path-semantics ones included —
and the guard now closes over entries + prunedSince, requiring both fields on each.

* fix(sync): restore the popout plumbing, assert the part that cannot be inert

The dashboard/popout subsystem was 22 modify/delete conflicts waiting on every sync, because the fork
deleted files upstream still owns and still edits. The restore now splits where inertness actually
stops, and the split is recorded rather than implied.

Restored, byte-identical to upstream/main (7): src/main/ipc/dashboard-popout.ts, its test,
src/main/window/dashboard-popout-window.ts, its test, clipboard-dashboard-popout-access.test.ts,
src/preload/api/dashboard-api.ts and dashboard-bridge.ts. Nothing registers the IPC or opens the
window, so they are dead — but they typecheck and all 33 of their cases pass, which makes holding
upstream's version free. Restoring them needed `dashboard: DashboardApi` back in PreloadApi and
upstream's isDashboardPopoutRenderer admission in assertTrustedClipboardTextSender — text authority
only, never images, files or remote operations.

Left deleted, asserted in expected-deletions.json (15): the drawer, the sidebar host, the popout window
shell and the open-mode/bridge hooks. They cannot be restored inertly. Upstream's drawer needs
AppState.agentDashboardDrawerOpen, which #44 removed, and upstream's DashboardPopoutRoot needs the
onAckAgent/onRevealAgent props upstream added to AgentKanbanBoard — the component the fork's live page
view renders. Restoring them means two live dashboards or a behaviour change to the shipped one, which
is a product decision, not a merge one. Restoring all 22 was measured before deciding: 11 of 57 cases
failed and tc:web reported 29 errors, because upstream's renderer half has moved past the fork's.

electron.vite.config.ts keeps upstream's popout entry deleted along with popout.html: entry that
resolves nothing does not build. The three restored suites trip the changed-lines gate on their own
`as` casts, which read as entirely added lines until this branch merges, so the suppression map —
renamed to say what it now holds — covers them for the same reason it already covers a restored Jira
file.

tc:node and tc:web clean; 33 restored cases pass; allowlist untouched at 17.

* docs(sync): the dashboard view is the delivery, so stop offering a restore

The owner rejected upstream's drawer and pop-out window outright: the fork's content-area view is the
dashboard, and the overlay/pop-out delivery is a different feature, not a better version of the same
one. That settles what was still an open question in the ledger, the manifest and the plan — and it
makes three of the seven files the last commit restored a liability rather than ballast.

Dropped from the restore (3): src/preload/api/dashboard-api.ts, src/preload/api/dashboard-bridge.ts and
clipboard-dashboard-popout-access.test.ts. The preload pair exists only to publish window.api.dashboard,
which the fork's board stopped calling when it dropped the pop-out relay; dashboard-bridge.ts
type-checks itself against PreloadApi['dashboard'], so holding it forces a `dashboard` member onto
PreloadApi for a runtime API nothing registers — the type lie would be load-bearing. The clipboard test
went with them because it asserts that assertTrustedClipboardTextSender admits upstream's pop-out
renderer, and the last commit had wired exactly that admission in: dead code adding a branch to a
clipboard trust check is a live behaviour change for a feature nobody can reach. All three wiring edits
are reverted, so the 4 held files now import nothing of the fork's and nothing imports them.

Recorded in three places instead of one, because the resolutions differ and a Resolution: has to be
executable: the 18 renderer/preload deletions are `delete`, the 4 inert main-process files are
`take-theirs` (byte-identical to upstream, so a sync should not read them), and AgentKanbanBoard.tsx is
`manual` — 14 code hunks of fork behaviour that keep-ours would revert upstream's unrelated board work
on and take-theirs would revert your click behaviour on. The resolver now proposes 14 deletions that all
agree with the manifest, with no ledger entry unmatched and no manifest entry unledgered. Also adds the
missing entry for pr-comment-snapshotted-thread-resolver, which was a settled deletion with no ledger
record.

Comments that name the pop-out stay, in eleven files: each one exists because the surface it described
no longer exists, so restoring upstream's text would reintroduce a false statement. That is checked
against the merge base, not against upstream/main — diffing the latter reports upstream's own unmerged
progress as if it were fork divergence, which is how the same scan first reported 1741 comment hunks.

Plan § 5.4 is reversed in place with the measurement that supports it: restoring all 22 leaves 11 of 57
cases failing and tc:web at 29 errors. 49 cases pass across the board and the two held suites; tc:node
and tc:web clean; allowlist untouched at 17.

* fix(tools): a cancelled nightly is not a red one, and must not hide the last verdict

The liveness check reported "nightly red (cancelled) at 19.1h old" — about a run I cancelled by hand.
Two defects in one reading: a cancelled run carries no verdict at all, so calling it red is a false
alarm; and because the judge sorted every run by recency, that cancelled run also masked the last run
that actually passed. A backstop checker that cries wolf on a deliberate cancel is exactly the nightly
nobody reads.

It now keeps two clocks. The schedule's clock asks "does any run exist recently", because a cancelled
run still proves the cron fires. The backstop's clock asks "has a completed run produced a verdict
recently", because only that proves the suite runs. Cancelled, skipped and neutral runs advance the
first and are named in the summary rather than counted as a verdict, so the two ways the backstop can
be down — not firing, and firing without ever finishing — read differently instead of both reading
"red".

What it says now, which is the reason this mattered: the newest verdict is 450.1h old and green. The
fork has had no passing nightly since 2026-10-08; every run since is either the wedged one or my
cancel of it, because the relay fix on this branch is not pushed. That is the honest state of #13, and
the old judge could not report it.

Six cases added (10 total): cancelled-only, cancelled-over-green, days of cancellations, and a
schedule that fires while the last verdict is stale.

* docs(plan): §0 said seven files restored; it is four

Today's decision changed the dashboard split and the liveness checker, and §0 is the section a later
session reads to avoid re-deriving both, so it has to be true. Four files held inert, eighteen asserted
deleted, the two reasons the preload pair did not survive, and the measurement behind the reversal.

The baselines that moved were stale in the same way: 26 deletions rather than 24, and 15 conflicted
paths with a settled answer rather than 3 — with the new row that matters most, that the last passing
nightly is still 2026-10-08 at 450h, because the fix is unpushed.

* refactor(fork): delete four fork-only modules nothing imports

§4.9 of the plan, open since the ledger named three of these dead on 2026-10-15. All four are
fork-created (absent from upstream/main, so the no-delete policy does not apply) and all four have
zero importers:

  src/renderer/src/components/task-page-jira-board-page-state.ts       36
  src/shared/quick-open-path-inventory-limits.ts                       26
  src/shared/workspace-path-search-request-budget.ts                   23
  src/shared/skill-freshness.ts (+ its test)                          205

skill-freshness is the one that needed a second look: the names SkillBundleFileIdentity and
SkillUpdateRun appear in other files, but those declare their own copies — a textual scan for the
names says "used", a scan for `from '.../skill-freshness'` says the module's own test is its only
importer. Its both-owner lock-source rule had stopped being on a live path after the merge, so the
ledger's "wire it in or delete it" resolves as delete rather than as a policy the app no longer
applies.

check:test-file-runners failed with ENOENT until the deletions were staged, because it enumerates
git ls-files rather than the working tree — worth knowing before believing that failure on any other
delete.

tc:node and tc:web clean, test-file-runners and max-lines ratchet pass, and the ledger plus
LOCAL-PATCHES now record the deletion instead of the module.

* chore(sync): retire curate.mjs and stop describing a patch series that cannot exist

§6.9. Three things the docs promised and the repo did not have.

curate.mjs is deleted rather than finished. It planned a curated, linear patch series for the rebase
era, counting from `git log merge-base..HEAD` without --first-parent — 332 commits including merges —
so the per-group byte-identical promise never held on non-linear history, and it was never applied.
The series it planned cannot be cut at all: no patch ref exists, the product line imports the identity
modules across ~50 commits, and 35 files are shared. Finishing the tool would have bought a plan for a
branch nobody can build.

The "keep rebase for the patch series" wording in the runbook, LOCAL-PATCHES.md and local/sync/README.md
now says what is true: the release line merges, feature branches rebase, and there is no patch series.
The 2026-10-05 decision itself stands — only its object was imaginary.

.husky/commit-msg is scoped to lineage instead of to a branch name. It refused `local(` everywhere but
nplez1/main, fork PR branches included, and the measured result was worse than the mistake it guarded:
policy work committed there without the local( label at least five times (#146, #130, #142, #177, #52),
so an unlabelled fork-only change rode a fork PR rather than being refused. It now allows local( on any
branch carrying nplez1/main in its history and still refuses anything cut from origin/main — verified
both ways: this branch accepts a local( subject (exit 0), and `git merge-base --is-ancestor nplez1/main
origin/main` is false (exit 1), so an upstream-bound branch still trips it. open-pr.mjs --upstream stays
the second stop.

* fix(gate): key the verdict cache to the repository, so two worktrees run the suite once

§6.8's first item. Both readers — `gate-unit.mjs` writing the verdict, `open-pr.mjs` refusing a PR
without one — resolved the cache from `git rev-parse --absolute-git-dir`, which answers with the
*worktree's* git dir (`.git/worktrees/<name>`). So a revision gated in one worktree was invisible to
the other, and the same SHA paid the full suite twice; at 25 minutes a release gate that is the most
expensive duplicate in the workflow.

`resolvedGateCacheDir` now prefers `--git-common-dir` (absolute, with a relative answer resolved
against the repo root), falls back to the worktree git dir, and only then to `<root>/.git`. The cache
entries were already keyed by revision, allowlist fingerprint and gate inputs, so the shared directory
cannot hand back a verdict that belongs to another revision.

`unitGateVerdict` infers the git dirs only when the caller supplies neither: an explicit `gitDir` is
an answer, and inventing a common dir over it would read a cache that caller did not choose — which is
exactly what the existing open-pr tests do.

Three cases added: the worktree and the main tree resolve to one directory, the fallback when no common
dir is reported, and the relative-path case. 30 pass in that file, 49 across both gate files.

* docs(plan): the node-runtime pool caveat is wrong, and §6.8 records what changed

The caveat said the fork's custom pool "never consults maxWorkers, so that project is not covered by
the cap". It never had to: vitest 5.0.5 wraps any custom poolRunner in the same PoolRunner whose
schedule() returns early at activeTasks.length >= this.maxWorkers, setMaxWorkers is applied per task
group, and VITEST_MAX_WORKERS becomes resolved.maxWorkers. The fork's pool spawns exactly one child per
createPoolWorker with no internal fan-out, so capping workers does cap processes.

Recorded rather than corrected everywhere: the sentence a reader acts on lives in the machine-level
AGENTS.md, which is outside this repository and is the user's directive file. One empirical
confirmation on an idle machine should precede changing it.

Also records the verdict-cache item as done, in the same commit that implemented it.

* feat(sync): report fork-only modules nothing imports

The ledger named three dead modules on 2026-10-15 and they were still there on 2026-10-27. That is the
failure this closes: the sync cannot see a file upstream does not have, so a fork module whose last
caller an upstream change removed simply stops being read, and nothing anywhere says so.

`unreferenced-fork-modules` scans every fork-only path under src/ (no tests, no fixtures) against the
specifiers that every tracked source file carries — including the `@/` and `@renderer/` aliases — and
reports the ones nothing resolves to. Report-only: deleting product code is a judgement, and the
check's job is to make the judgement cheap. 1.5 s on this tree.

Two limits are stated rather than hidden. A cycle of two files that import only each other reads as
live; distinguishing that from a genuine pair needs reachability from an entry point, which the repo
does not have. And a file loaded by path — `workspace-path-index-benchmark-worker-entry.ts` is the
example — is invisible to an import scan, which is what the allowlist argument is for.

Its first run, mid-flight for five agents, named eight candidates; several are their in-progress edits,
so the triage belongs after the tree settles. 53 sync-lib tests pass, 11 of them new.

* refactor(config): give the two count ratchets a fork overlay

§4.8. `focus-setting-read-baseline.txt` and `flaky-test-pattern-baseline.txt` are per-file sorted
monotonic counters, so both sides bump them and they conflict on adjacent lines every sync. Each now
has a fork-owned overlay that records only the rows whose count differs, including a count of 0 for
"the fork fixed this while upstream still allows it". `readCountBaseline` merges base then overlay per
key; `pruneCountBaseline` writes the overlay when one exists and never upstream's file, which is the
half that matters — a prune that edits upstream's baseline would reintroduce the conflict silently.

Both baselines are restored to the merge base's bytes, not to `upstream/main`'s current ones. That
distinction is the point: aligning to upstream's tip would need a 0-row for every file upstream has
added since, and those rows would fail the moment a sync brings the file in. Restoring to the merge
base leaves the fork with no delta at all, so the next sync takes upstream's version cleanly.

Neither checker moved: 690 reads and 3,462 flaky findings, before and after. 14 tests in
ratchet-baseline.test.mjs, 5 of them new: overlay overrides base, a 0 is a real override rather than
"absent", no overlay means unchanged, and prune writes the overlay while leaving the base untouched.

* refactor(fork): put the TUI agent table back, keep one overlay entry

§5.5. The fork's `tui-agent-config-table.ts` was upstream's 361-line table plus one entry, copied into
a fork-owned file. A copy does not conflict when upstream edits the table — it silently diverges, which
is worse: the fork's copy keeps answering for a table upstream has since changed. The table is back as
upstream's file (from the merge base, so the next sync merges it), and the fork's one entry lives in
`tui-agent-config-np.ts` behind `withForkTuiAgentOverrides`.

Net delta in upstream's file: 5 insertions, 4 deletions — the wrap, the remote-platform launch lookup,
and the comment saying why. The spec hoped for two; the third line is the lookup the fork actually
needs, and squeezing it into the wrap would have made both harder to read.

Behaviour is unchanged, and proved rather than asserted: every agent's resolved config and launch
command was dumped on darwin, linux and win32, local and remote, before and after — identical except the
key order inside the Agent Teams entry. Two tests now cover the overlay entry and the remote rule per
platform. 5/5, 3/3 and 8 passed | 1 skipped across the three suites that touch the table.

`tui-agent-config-types.ts` stays and keeps its fork field: it is upstream's own file at that path, and
deleting it would collide.

* refactor(i18n): the fork's English strings move to an overlay

§5.1. The fork satisfied the single-source rule by writing 722 keys into upstream's `locales/en.json`,
so that file and the derived `en-runtime-required.json` conflicted on every sync — about 20 paths, on
files whose content is entirely machine-checkable.

`src/renderer/src/i18n/fork/en.json` now holds the fork's added and overridden keys (736), with
`fork/en-runtime-required.json` derived from it (204 entries: what upstream's subset leaves out or
contradicts). `config/scripts/fork-english-catalog.mjs` merges overlay onto upstream and routes new
keys into the overlay, so `--fix` can no longer write into upstream's file. Each read site is a
one-line swap: both verifiers, the renderer-string localizer, and the runtime-catalog generator. At
runtime `i18n.ts` adds the overlay bundle after init.

Both upstream files are byte-identical to the merge base, not to `upstream/main`: matching the current
tip would need 26 more overlay keys for entries upstream has since deleted (17) or reworded (9), and
after the next sync those would keep the old wording over upstream's new text. Our side equal to the
base is what makes the next sync take upstream's file as-is.

Non-English locales stay untouched, so the fork's eight translations there still apply.

All four verifiers pass (catalogs, extraction, coverage, runtime catalog); 11 mirror tests and the
runtime-generator and dev-channel suites pass; tc:web clean.

* refactor(build): the Orca NP identity moves into a builder overlay

§5.8. `config/electron-builder.config.cjs` is upstream's file and upstream commits to it about 41 times
a month; the fork carried 45 added and 21 removed lines in it for identity alone, so every one of those
commits collided. Upstream already uses the pattern — `config/electron-builder-pr-linux.config.cjs`
spreads the release config and overrides it — so `config/electron-builder.np.config.cjs` now does the
same for appId, productName, protocols, win.executableName, extraResources CLI names, deb/rpm packageName
and the build patch's publisherName/verifyUpdateCodeSignature.

The base config is upstream's text except `ORCA_MAC_TEAM_ID`: that is a module constant read inside
`afterPack`, so it cannot travel in a config the patch loads later. +6/−2, down from +45/−21.

Six entry points now name the overlay — `fork-release.yml` (mac, win, linux), `build-mac-local.mjs`,
`build-linux-local.mjs`, and `build:unpack` / `build:win` / `build:mac:release`. That adds three fork
hunks to `package.json` in exchange for sixty in the builder config, which is the better trade: package
json is edited far less often, and a new test fails if any of those six points back at upstream's
config, so a sync that takes upstream's side of a build line cannot silently ship as "Orca".

The effective config is unchanged, proved rather than asserted: serialized with sorted keys and hashed
function sources across seven env sets (default, the three platforms' release and hourly variants, and
a local build version), byte-identical before and after. `config/scripts/electron-builder-np-config.test.mjs`
pins it — 7 cases, and it fails if the overlay changes any field outside the identity list.

`check-upstream-identity-literals` moves out of `package.json`'s single `lint` line (100 upstream
commits in 30 days) into `local/gate-lint.mjs` as a fork-only step. It cannot silently stop: the gate
throws when the script is missing instead of skipping, `local/gate-lint.mjs` is fork-owned so a sync
cannot touch it, and `tests/tools/lint-gate.test.mjs` asserts the real repo resolves the step.

`test:skill-sharing:release` now runs through `run-vitest.mjs`; `test:node` deliberately does not, and
that is recorded with its reason.

* refactor(fork): three upstream seams stop being copies

§5.6, three of four. Each was a place where the fork copied upstream's lines to insert a few of its own,
which is a conflict on every sync plus a copy that drifts silently.

**`worktree.remove`** copied ~55 upstream lines into `worktree-remove-method.ts` to add one flag. The
flag now travels through `queueWorkspaceDelete`'s `run` argument from `workspace-lifecycle-commands.ts`,
`worktree.ts` is byte-identical to the merge base again, and the copy is deleted. A test asserts the
flag actually reaches the removal rather than that a function was called.

**`orcad-entry.ts`** evicted upstream's `prepareOrcadSecretStoreAndMigrate` to buy four lines of
`setScreenPromptReader`. Upstream's function is restored byte-identical, `wireScreenPromptReader` is
exported from the existing `server-screen-prompt-wiring.ts` and called from both hosts, and the fork's
`orcad-secret-store-bootstrap.ts` is deleted. The entry's delta drops from +7/−18 to +3/0 and its
next-sync conflict count to zero — and because upstream has since moved that function into
`orcad-secret-store-startup.ts`, the move now merges cleanly instead of colliding.

**`agent-hook-listener.ts`** evicted upstream's SessionEnd and inherited-owner blocks. Both are restored
byte-identical and the two fork helper files are deleted; the delta drops from +62/−67 to +45/−7 and
next-sync conflicts from 2 to 0, so upstream's new `incomingSession` argument lands silently.

Not done, and the reason is worth keeping: the fork's pre-admit has to run *after* upstream's envelope,
OpenCode and compact steps and *before* the inherited-owner check, which a wrapper outside the function
cannot reach without copying those steps — and the post-gate reads internals the function never
returns. The guard calls themselves are byte-unchanged and in the same position; only the code around
them moved, so the ledger's "the fork's guards stay" decision is untouched.

Settings types: the fork-only fields move into `NpSettings` (which `GlobalSettings` already ends with),
upstream's task-view fields go back into upstream's type, and the fork-only `task-view-settings-types.ts`
is deleted.

Verified: tc:node and tc:web clean; 9, 34, 21, 6, 17 and 10 cases pass across the suites covering the
six touched files; every deleted path was confirmed fork-only, since deleting a file upstream owns is
the one thing this policy forbids.

* docs(plan): seven items landed today, and §0 is the record

§4.8, §4.9, §5.1, §5.5, §5.6 (three of four seams), §5.8 and §6.9 are done, and the nightly unblock is
PR #186. §0 lists each with the learning worth keeping, because several of them produced one:

- The merge base, not `upstream/main`, is the right restore target for a file both sides edit — aligning
  to the tip needs a 0-row for every file upstream has added since, and those rows fail the moment a
  sync brings the file in.
- `ORCA_MAC_TEAM_ID` cannot move into the builder overlay: `afterPack` reads the module constant.
- The hook listener's decorator does not fit. The fork's pre-admit runs between upstream's compact steps
  and its inherited-owner check, and a wrapper outside the function cannot get there without copying
  them, which is the copy this plan exists to remove.
- The liveness check's own first version proved the point: a cancelled run is not a verdict, and an
  in-flight run may never stand in for one.

"Still open" now names what is actually untouched — §5.3, §5.7, the other §6 items — rather than a range
that today's work made false.

* fix(build): keep upstream's cast, and suppress only its own finding

Review caught two casts my §5.5 commit added; removing them was wrong for one of them. `Object.fromEntries`
returns `{ [k: string]: TuiAgentConfig }`, which is not assignable to `Record<TuiAgent, …>` — which is why
upstream itself writes `as Record<TuiAgent, TuiAgentConfig>` at that exact spot. The seam only works if that
line is upstream's, so it is restored, and the changed-lines gate is told why the finding appears: our
one-line wrap makes the whole statement a changed hunk, so a cast that exists at the merge base reads as
new until the branch merges.

The other cast was mine to remove and is gone: `withForkTuiAgentOverrides` no longer casts an object key
to `TuiAgent`, because `satisfies` keeps the literal key type and the merge then indexes a typed record
directly.

Worth recording, because it would have failed quietly: the suppression map is a `Map`, so a second entry
under the same key replaces the first. My initial fix added a duplicate
`typescript(consistent-type-assertions)` key, which silently dropped the three restored dashboard suites
from the suppression list. It is now appended to the existing set instead, and a duplicate-key scan over
the map reports none.

tc:node and tc:web clean; the gate passes across 88 changed files; 5/5 in tui-agent-config.test.ts.

* revert(build): test:skill-sharing:release is upstream's, and the bare form works

An agent rewired this script through `run-vitest.mjs` on my instruction, to "remove the vitest-direct
trap". The premise was wrong twice over, and the measurement settles both.

Both scripts the plan named — `test:node` and `test:skill-sharing:release` — are **upstream's**, byte
for byte. `test:node` exists to run the suite on Node rather than Bun, so routing it through
`run-vitest.mjs` (which always launches pinned Bun) would delete its reason to exist; the agent declined
that one and was right. The other script's bare form was then measured instead of reasoned about: 648
passed, 44 skipped, no failures, so the harness env it "skips" costs nothing on the scopes that script
covers.

Which leaves a fork hunk in upstream's package.json that buys no behaviour, on a file upstream commits
to often. Reverted to upstream's text. The residual defect is real but upstream's: a bare `vitest` over
the *whole* suite misses `ORCA_BACKGROUND_LAUNCH=1`, so the browser-manager suites red — that belongs
in the upstream-worthy list (§6.3), not in a permanent fork patch.

Plan §6.8 corrected in the same commit: the item is closed as not-a-trap, with the measurement.

* docs(nightly): the red set was the branch gap, and the third failure was a test defect

The 2026-10-27 dispatch is a cleaner measurement than the cancelled 2026-10-26 run, and it says
something the entry did not: Linux carried no environment-bound red set of its own. Three shards were
green, and the three red files map one-to-one onto commits that exist here and not on nplez1/main —
the cooldown expectation, the fixture's esbuild specifiers, and the unhandled selector_not_found.

That last one was wrong as recorded. fbb41b497d established it was a test defect, not a Linux-only
reproduction: the test attached .rejects after awaiting a different promise, so the refusal settled
unobserved while the assertion still passed. The claim that the allowlist covered it is withdrawn.

Recorded with the run id and the three commits because the next reader's question is "is the nightly
red on main, or is my worktree ahead of main", and only the commit list answers it.

* feat(sync): a fork-surface smoke selector, and the measurement that resizes it

Plan §6.1 wanted a cheap post-sync run over the tests the fork's own patches touch. The selector is
built (local/sync/smoke-selector.mjs over a pure local/sync/lib/smoke-select.mjs, 10 fixture tests) and
it reproduces the plan's 954 fork-touched test files exactly.

It also overturns the plan's cost claim. On merge 281cccf5a4, at the plan's own 1.0 s/file rate:
touched-only is 886 files / 14.8 test-minutes (~7 min at 2 workers); the one-import-level walk is 4,459
files / 74.6 test-minutes (~37 min) — within 15% of the 43-minute full gate it was meant to replace.
The "526 files / 8.8 test-minutes" figure is not reconstructable from the tree. §6.1 now records the
table instead of the claim, and the cheap mode is an explicit --touched-only flag rather than the
default.

The tool also refuses to pretend: it names the touched tests it cannot run, and reports shared harness
inputs in the diff instead of silently expanding to the whole suite.

Stream parsing reads the whole batch response before walking it, so the buffer is concatenated once
rather than per chunk.

* refactor(renderer): the fork's usage providers as data, not five if-arms

The fork threads deepseek/fireworks/copilot through upstream's status-bar chains: five separate
places in status-bar-provider-visibility.ts, plus the segment, tooltip, error-copy and an 85-line
append to icons.tsx. Every upstream commit in that area conflicted, and the ledger calls this the
single most repeated conflict shape in the repo.

Now one mapping file answers all of it. icons.tsx is byte-identical to the merge base, the duplicate
hasUsageData in StatusBarProviderSegment.tsx is gone, fork fields join the upstream types with one "&"
each, and every guard is a single mapping consult.

Measured honestly, and the premise only half holds: hunk counts are nearly unchanged (visibility
10->10, segment 6->7, tooltip 7->7, error-copy 5->6, menu 2->2) because a guard at a call site still
diffs at that call site. What changed is hunk size — 1-2 lines instead of 3-9 — and the largest single
fork block in the renderer is gone. §5.3 records both numbers rather than only the flattering one.

Also fixes a quality-gate finding in the smoke selector: interpolating an inferred-null field in a
template literal is rejected as an untyped expression.

tc:web clean; 89 tests across five files.

* docs(plan): §0 records the renderer half, the selector, and what the nightly measured

Four rows: the usage-provider mapping with its honest hunk-count finding (cheaper conflicts, not
fewer), the smoke selector and the cost claim it overturned, the nightly red set read as the branch
gap rather than Linux environment classes, and PR #186's squash — which is why a PR body has to carry
the story here.

Also joined four table rows that had blank lines between them, which split one table into four.

* refactor(rate-limits): the fork's three providers ride upstream's sibling lane

The fork threaded deepseek/fireworks/copilot through upstream's positional provider tuple, so every
upstream commit touching that tuple conflicted and the merge had to re-resolve indentation. Upstream
already runs grok/cursor/zcode/antigravity outside the tuple via settleSiblingProviderResult; the fork
now does the same through rate-limits/fork/fork-provider-cycle.ts.

The point of the seam is measurable and measured: the 50-line Promise.allSettled destructure and tuple
in service-full-cycle-preparation.ts is now BYTE-IDENTICAL to 0571e45107 (verified with diff), that
file's fork hunks went 11 -> 8, and it lost 60 net lines. The disabled gate moved to the injected
resolver boundary and is applied once in updateState, which let fetchUnlessDisabled be deleted rather
than maintained.

Two tests: sibling settle order (the fork chip is fetching mid-cycle and ok after) and a disabled
provider reading nothing (resolvers, gh probe and fetchers all uncalled, slots null).

Caveat recorded in the plan rather than hidden: with the tuple verbatim, a disabled opencode-go still
enters upstream's fetchOpenCodeGoUsage with an empty config, so its env probe runs and its resolver
does not. That is upstream's early return, not a fork credential read -- gemini-usage-fetcher.ts:221
returns before any OAuth source -- and the pinned test now asserts the flag is passed rather than that
the fetcher is never entered. Keeping "never entered" would have meant keeping a fork hunk in the tuple.

tc:node clean; 17 single-file test runs green; changed-code gate passed.

* fix(test): the vm-recipe mocks passed a stdin that cannot emit errors

4bb73dcb45 observes stdin errors so a shell exiting before reading stdin cannot crash the host with
EPIPE. The three fake children in index-vm-recipe-doctor.test.ts declare stdin as
{ write, end } -- but the production type is ChildProcessWithoutNullStreams, whose stdin is an
EventEmitter. So the mocks were a lie about the type they stand in for, and the fix in production
surfaced it: "child.stdin.on is not a function", plus a cascading expect(undefined) in the
provision-failure case.

Making stdin an EventEmitter like its stdout and stderr siblings keeps the fix in production where the
bug was, rather than guarding it for the benefit of a mock.

4/4 pass; the two cases were red on the gate run before this.

* revert(build): enforce upstream's max-lines budget, and exempt the eight files instead

Reverses 1ba1086499 with the measurement that reverses it. Setting max-lines to "off" in all four
overrides worked, and cost more than it saved:

- two of upstream's own coverage tests in check-max-lines-ratchet.test.mjs go red, because they assert
  the installed oxlint still flags an over-budget file; and
- check-max-lines-ratchet.mjs becomes inert — the script whose whole job is to freeze who may bypass
  the rule cannot see a rule that is off.

Measured with upstream's config restored, only NINE files exceeded budget: eight upstream-owned, over
by 1, 3, 3, 9, 20, 23, 31 and 34 lines, and the fork's own local/sync/conflicts.mjs at 682. So the
whole-repo cap was abandoned to avoid eight one-line exemptions.

Each of the eight now carries `oxlint-disable max-lines -- Why:` naming what the fork adds, plus a
config/max-lines-baseline.txt row — upstream's own escape hatch, whose message says to use it "with
reviewer sign-off for an unavoidable exception". Splitting an upstream file to fit the budget is the
eviction this whole change exists to stop: it costs a permanent hunk on every sync.

conflicts.mjs is split rather than exempted, since it is ours: its rendering moved to
local/sync/lib/conflict-report.mjs, leaving 568 lines. Behaviour verified by running the CLI, not by
inspection.

Verified: ratchet OK with 12 grandfathered entries (4 upstream's own), 19/19 in upstream's test file,
0 max-lines findings across the tree, .oxlintrc.json byte-identical to 0571e45107 again, 63 sync-lib
tests and 4 fork-process-docs tests pass. LOCAL-PATCHES.md local(lint) and plan §5.2/§7 record the
reversal as an addendum rather than rewriting the decision.

Note: this commit also carries the config restore that the pre-commit hook swept into the previous
commit's tree; they are one change and are committed as one.

* feat(nightly): check the Mac allowlist's claims against the Linux runner

local/unit-known-red.json says 17 test entries fail because of this Mac. Nothing ever checked that
claim: a red Linux shard was read as "which shard", not as a verdict.

local/check-nightly-linux-parity.mjs takes a nightly run's downloaded unit-shard artifacts and reports
each allowlisted entry as passed, failed or absent on Linux. Absent is not a pass: a file never
collected, a suite-level failure, or a test reported skipped all read as absent and exit non-zero.
`prunedSince` is reported but not enforced, because those entries were pruned for passing locally, so
their Linux outcome informs the next prune rather than gating it. No reports at all exits 2 -- a
missing input is "cannot tell", never green. The pure core is local/lib/linux-parity.mjs, reusing
allowlistEntries from unit-gate-judgement so the gate and this checker share one entry vocabulary.

Measured, and it settles an assumption: against the 2026-10-26 run's five shard reports, ALL 17
allowlisted entries passed on Linux (22 of 23 prunedSince passed, 1 absent because a macOS-only bundle
test is skipped there). So the Mac allowlist is Mac-specific as claimed, and Linux carries no
environment-bound red set of its own -- which matches the 2026-10-27 dispatch, whose three red files
were all commits missing from main rather than platform differences.

Discoverability: when the liveness verdict names failed jobs it now points at this script, rather than
asking a human to remember it exists. 14 fixture tests; empty-dir exit 2 and real-dir exit 0 both
verified by hand.

* perf(gate): one cold start for all isolation re-runs, and name the worker cap

Two gate-cost items.

Isolation re-runs were serial: each unexpected failing file got its own vitest cold start with a full
60-minute allowance, so N failures paid N cold starts. They now run as one invocation, with
rerunReportsFor mapping the shared report back to each file by exact path, so attribution is unchanged
and a file missing from the report still reads NO_RERUN (every failure reproduced -- fails closed).

The 2,575 s run against a 1,400 s baseline is a worker cap, not more work. Reconstructed from the
gate's own JSON reports: fast runs (10-23) showed up to 9 files concurrent and a 1,354-1,416 s suite;
every run from 10-24 onward is capped at 2 concurrent, yet does LESS work -- 32,068 modules against
44,384, summed per-file time 2,209 s against 4,457 s, and faster per-file medians. So not CPU
contention, not the mobile install, not the bun 1.4.4 pin (slow runs began before it reached this
line). The cap of exactly 2 is VITEST_MAX_WORKERS, exported machine-wide from ~/.zshrc and ~/.zprofile
(mtime 2026-10-23 17:02, between the last fast run at 15:40 and the first slow one).

This commit only makes it visible: the gate now prints `gate-unit: VITEST_MAX_WORKERS=<n>` before
spawning the suite, so the next slow run says why in one line instead of needing a per-file
concurrency reconstruction. Whether to raise the cap is a machine decision, not a code one.

The third item is closed as not-a-defect: recordDependencies=false is deliberate and documented, and
with cold records check:changed stays selective rather than running everything.

Tests: 3 new cases in tests/tools/gate-unit.test.mjs (33 pass), 24 pass in unit-gate.test.mjs.

* docs(plan): §6.2 and §6.8 closed, with the numbers that closed them

§6.2 is done and its assumption is now a measurement: against the 2026-10-26 run's five shard reports,
all 17 Mac-allowlisted entries PASSED on Linux (22 of 23 prunedSince, one absent because a macOS-only
bundle test is skipped there). So the Mac allowlist is Mac-specific as claimed and Linux carries no
environment-bound red set of its own -- which the 10-27 dispatch confirms from the other direction, its
three red files being commits missing from main rather than platform differences. The nightly is
readable as a verdict now, not as "which shard".

§6.8's doubling is a worker cap, measured: fast runs showed 9 files concurrent and 1,354-1,416 s; every
run since 10-24 is capped at 2 and takes ~2,575 s while doing LESS work (32,068 modules vs 44,384,
per-file medians 8.4 ms vs 13.4 ms). Not contention, not the mobile install, not the bun pin. The cap
was VITEST_MAX_WORKERS from the shell profile; it is 4 now, and the gate prints the value so the next
slow run answers this in one line. Isolation re-runs are batched (one cold start instead of N), and
recordDependencies is closed as not-a-defect: the plan's premise that check:changed "runs everything"
is wrong -- with cold records it stays selective.

Also adds the §5.2 addendum: the budget is no longer a reason to evict upstream code, because an
over-budget upstream file now gets an exemption rather than a restructure.

* docs(lint): when a max-lines exemption is right, and a check for the half that is mechanical

Adds the forward-looking rule to LOCAL-PATCHES.md § local(lint), because the exemption question will
come up on every sync that grows an upstream file by a few lines.

The rule is ownership, not size:

- a file the fork owns and that is over budget gets SPLIT -- no exemption, ever. It has no upstream
  copy to conflict with, so the cap is a free guard there;
- a file upstream owns gets one `oxlint-disable max-lines -- Why:` line plus a baseline row, however
  far over it is. Splitting it is a permanent hunk on every sync and drops upstream's later edits
  there silently; a two-line directive conflicts once, if ever.

The cap exists to stop files growing without bound, not to push the fork into restructuring upstream's
code -- which is why a 3-line overage was costing more than the rule saved.

Also recorded: never raise the numbers in .oxlintrc.json (it weakens the guard for every file and
desyncs the defaultLimitForPath constants that upstream's test pins), never turn the rule off (that is
the reversal this section already records), and the probe recipe for listing offenders without editing
the real config.

tests/tools/max-lines-policy.test.mjs enforces the mechanical half: every baseline row must name a file
that exists at the sync base, i.e. an upstream-owned one. Proven by adding `inline
local/sync/conflicts.mjs` and watching it fail with the "must be split, not exempted" instruction.
3 cases pass on the real baseline.

* refactor(pi): upstream's ownership fence beats the fork's, and the roster goes home

Two restorations from §5.7, the first of them gated on evidence rather than judgement.

The fork replaced upstream's omp session-owner module with a 204-line implementation (#68) that decides
ownership by session file and refuses child sessions. Upstream's f37c1f6b95 fixes the same bug in 54
lines: ownership moves only on a session_start whose reason is new, resume or fork. So the gate was run
before any deletion: the fork's #68 suite, unmodified, against upstream's rule -- 22/22 pass, including
all twelve Pi-lane cases (in-process child, reopened child, re-key when a session_start names the
leaving file, nested child, unpersisted session, the three handoffs, forked-session pane, refused
transcript, persisted child off an unpersisted pane, switched unpersisted pane, no session manager).

No case proved upstream's rule insufficient, so the 150-line difference bought nothing. The fork's
module is deleted, upstream's file is live at f37c1f6b95's bytes (verified with an empty diff), and its
own 11-case test passes. getOmpSessionOwnerHandlerSourceLines already takes `kind` upstream, so the
handler source needed only its import and call site.

Second: codex-subagent-roster.ts is back at upstream's path and name, byte-identical to the sync base,
with five sibling files at zero fork hunks (codex-subagent-transcript.ts and test, providers/
codex-events.ts, providers/codex-transcript-poll.ts, codex-status-transcript-line.test.ts). The rename
was costing a modify/delete plus importer rewrites on every sync for no behaviour.
agent-descendant-roster.ts survives as the sibling carrying only the pi/claude lane's extras, 93 lines
of codex-named duplicates dropped.

Docs, because two of them had declared the rename correct: LOCAL-PATCHES' local(agents) entry and
"present-but-dead" list are corrected, and the ledger's two entries get dated addenda that retire them
for the codex half rather than rewriting what was decided. 478 deletions against 155 insertions.

Verified: 22/22 (#68 suite) and 11/11 (upstream's), 5/5 roster test, tc:node clean, changed-code gate
passed, 63 sync-lib and 4 fork-process-docs tests pass.

* fix(renderer): the provider map tripped two of the repo's own linters

The two lint steps the renderer agent could not run, both red on its new file:

- audit:anti-slop rejects `pendingShape`: "shape" names structure, not a domain role. The field is the
  readout shown before a provider reports, so it is `pendingReadout` now, in the type, all three
  entries and its one consumer.
- verify:localization-coverage flagged the three brand labels as unlocalized renderer strings. They are
  brand names shown verbatim, exactly like upstream's own `getProviderDisplayName` ('Claude', 'Codex',
  'Gemini', 'OpenCode Go'), so translating them would be wrong: the reviewed exclusion goes in
  config/localization-coverage-allowlist.json, three entries, and the file's comment now says where the
  exclusion lives instead of claiming a never-translate policy nobody could find.

gate-lint: 39 steps, was 37 green / 2 new red, now all green. tc:web clean; 42 pass in the visibility
suite; changed-code gate passed.

* fix(test): the event-loop arm's extra factor of two is not part of its claim

The gate's one unexpected failure, twice: the foreign-sqlite event-loop test failed in a 137k-test
suite at four workers, and passed 3/3 in isolation and under directory load. Its claim -- a
worker-hosted open keeps the caller responsive while an inline one blocks it -- is a comparison
between two arms measured in the same process, and the third assertion added a /2 margin on top of it.
Under load that margin is scheduler delay on the worker arm, not the open: the arms differ in kind, but
by less than a factor of two when four files run at once.

So the assertion keeps the ordering claim and drops the margin. This is a two-line fork hunk in an
upstream test file, deliberately, rather than an allowlist entry: an entry would mark a test as known
red and then be reported prunable the next time it passed, which is churn on top of a real ambiguity.

Why it appeared now: the worker cap went from 2 to 4, so the suite's own load doubled. That is the cost
side of that change, measured once and fixed at the assertion rather than hidden.

1/1 passes; changed-code gate passed.

* fix(review): a disabled provider was still reading OpenCode's credential store

The Opus review of the branch found one blocker, and it was real: the rate-limits refactor moved the
disabled gate into the injected resolvers, but OpenCode Go is the one provider whose fetcher treats an
empty settings key as "look elsewhere" -- it resolves OpenCode's environment, its v2 credential
database, its inline key and its auth file, and then can send the request. updateState nulling the
result afterwards is too late, and it broke the promise in usage-provider-enablement.ts that a switched
off provider reads no credential.

The fix mirrors the pattern already in this file for cursor and grok: gate that one slot at the call
site with disabledUsageProviderSnapshot(…
nplez1 added a commit that referenced this pull request Oct 28, 2026
… verdict answerable (#188)

* chore(branches): retire the ready-for-upstream branch set, 28 branches archived

Branches were kept near-upstream-ready so a fix could be offered to stablyai/orca on demand. That
premise is gone: a branch 2,800 commits behind is a snapshot, not a ready state, and the ready state
decays faster than it is maintained. If upstream work is wanted it gets cut fresh from nplez1/main,
because the code that lands there is the only version worth offering.

So: 28 branches retired, and every one carries a pushed archive/2026-10-27/<branch> tag first, so
"retired" means unreferenced by a branch, never gone. 8 are zero-loss -- the PR merged and the branch
tip equals the PR head, or the patch is already in main. 20 are archived with unlanded content: the old
nplez1 ideas and all twelve upstream-bound fix/* and feat/* branches, which sat ~2,800 commits behind
and were never opened.

nplez1/AI-Prompts is the one with real unlanded code -- PR #184 was closed unmerged and it creates
src/shared/ai-prompts/**, which main does not have. The archive tag points at its local tip, which
carried a commit the PR never got.

nplez1/main, nplez1/Fork-Opt and the three backup/* snapshots stay. Deleting a safety ref to tidy a
branch list is the worst trade available.

The mobile question the plan had left open is answered by measurement: exactly one archived branch
carried mobile work, and all ten of its files are already on main. mobile/ is upstream's, 3980 files.

* feat(usage): take upstream's Claude spend cap, keep only the Codex half

Upstream shipped the Claude Enterprise spend cap as cffb527 (stablyai#33655); the fork had implemented the
same half itself in dd12489 and kept it. Take upstream's, byte for byte -- all three of its files hash
identically to that commit -- and retire the fork's parallel parser and its separate allowance readout.

Three things upstream does that the fork did not, and two of them were the fork being wrong:

  - the legacy extra_usage percentage is computed from the amounts first, so a 0-1 fraction 0.0958
    reads as 9.58% instead of 0.0958%
  - monthly needs a real cap -- a currency, a percentage and a limit above 0 -- not a bare percentage
  - the window comes from the same ExtraUsageBalance the Usage credits section renders, so the bar and
    that section cannot disagree

The duplicate allowance line goes with it: the Credits section already shows the same bar and amounts.
Net +163/-443 across 5 files, 231 of the deletions a fork-only test.

The Codex spend_control half stays fork-only -- spend_control.individual_limit to monthly plus a
count-unit allowance -- and its 23 cases pass unchanged. #134's OAuth refresh was already isolated in
claude-accounts/claude-oauth-token-refresh.ts and is untouched; upstream deliberately never refreshes.

Two shared comments named Claude as an allowance producer, which stopped being true here, and the usage
doc claimed Claude shows "$192.68 of $8,000.00" and that Orca never computes the percentage from the
amounts. Both are now false, so both say what is true: Copilot and Codex produce the allowance, and
Claude's percentage is one of the two computed ones.

* refactor(renderer): move the fork's styling and transient state out of upstream's files

Both stylesheets are now byte-identical to the merge base: main.css and rich-markdown-editor.css carry no
fork lines, so a conflict there resolves by taking upstream outright. 157 fork lines moved out verbatim
except one comment that said "further down this file", which is no longer true.

  - components/pr-comment-inline/pr-comment-thread.css holds the .orca-pr-comment-* block, imported by
    PRCommentThreadCard.tsx the way upstream imports AgentKanbanBoard's own stylesheet
  - components/comment-composer.css holds the 31 composer lines that were in rich-markdown-editor.css,
    imported by comment-composer.tsx. Not folded into the PR block: CommentComposer is rendered by the
    Jira issue workspace too, and it is the only caller that passes compact or disabled

The load order cannot change the result: no moved selector ties on specificity with a remaining rule that
touches the same element and property. The only overlaps outrank in either order --
.orca-diff-comment-inline > .orca-diff-comment-card (0,2,0) over .orca-pr-comment-thread (0,1,0), and
.monaco-editor .glyph-margin-widgets .cgmr (0,3,0). The composer's own rules (.rich-markdown-editor.
comment-composer-editor at 0,2,0 and its p/ul/ol/blockquote at 0,2,1) outrank every base
.rich-markdown-editor rule, and the rules that would tie are scoped to .linear-issue-markdown-editor and
.rich-markdown-editor-shell, which the composer is never inside. The block was unlayered and stays
unlayered, so it still beats Tailwind's layered utilities.

Transient fork state moves to store/slices/fork-ui.ts -- settingsProjectAddHostRequest,
prCommentInlineDisplayById, their actions and the ProjectHostAddRequest type -- registered beside
worktree-setup-runner. ui.ts, ui-slice-contract.ts, ui-slice-contract-contextual.ts and
ui-slice-surface-actions.ts now carry no fork lines for it; the only one left in
ui-slice-settings-actions.ts is the request clear in closeSettingsPage. store-test-helpers.ts composes
the slice too: a hand-built AppState there fails tc:web without it, which is the check that catches a
future slice registered in one place only. The persisted hiddenWorkspaceStatusIds and
mergeSameBranchWorkspaces stay in upstream's UI slice because they ride its hydration.

task-page/jira/Content.tsx is rebuilt on upstream's nested-ternary shape: one hook (use-jira-board-mode.ts)
plus one early return that mounts the configured board, so its fork delta is 63 changed lines where it was
384.

* test(vm): pin the stdin EPIPE guard the PR #187 review asked about

The review's NIT was right that the guard had no direct regression test: its only coverage was the
context-menu case exiting 9. Adding one, verified both ways -- 8/8 with the guard, and the new case fails
when the guard line is removed.

The emit is synthetic on purpose. A real failing write surfaces as an unhandled error rather than a clean
assertion failure, so emitting error on a stream with no listener tests the same contract and reports
it as an assertion.

Also records why Copilot's hasData is not hasUsageData: that module imports this one, so reusing it would
cycle, and the allowance is Copilot's own readout rather than one term among several.

* docs(plan): record the branch retirement, the spend cap and the renderer seams

Three plan items close in this branch:

  - § 6.7 is decided, not deferred. Branches are no longer kept upstream-ready: the branches it left
    open are retired, the twelve upstream-bound fix/ and feat/ branches among them, each with a pushed
    archive tag so "retired" means unreferenced rather than gone. BRANCHES.md carries the table, the
    restore commands and the per-branch reasoning; § 6.7 keeps the policy and the measurement.
  - § 6.4 records what taking upstream's Claude spend cap actually changed, including the two cases
    where the fork was wrong rather than merely different.
  - § 6.6 records the 157 lines moved out of upstream's stylesheets, why the load order cannot matter,
    and the store slice that tc:web catches if a future one is registered in one place only.

LOCAL-PATCHES.md gains local(usage) and local(renderer) so the next sync knows which files are
byte-identical to upstream and can be resolved by taking theirs.

* feat(ai-vault): give the list tier its own tables, so the version cannot collide again

The fork had added 8 columns plus an FTS table to upstream's sessions table, and both sides bumped the
same version integer (fork 6->9, upstream 6->8). A sync forced a merged value of 10, and the same shape
had already hit the parse cache. SESSION_SEARCH_SCHEMA_VERSION is upstream's 8 again, and the fork's
list state is its own:

  - session_list_facts (session_row_id plus the 8 columns) and session_metadata_fts live beside
    upstream's sessions table, versioned under meta.session_list_schema_version
  - triggers on sessions keep both in step, so upstream's writer, retention purge and replacement delete
    need no fork hunks at all -- session-search-retrieval.ts, session-search-retention-delete.ts and
    ai-vault-host-leg-cache-retention.test.ts are byte-identical to upstream again
  - what is left in upstream's files is 3 lines in session-search-schema.ts (two hooks and an import),
    2 in session-search-file-records.ts, and 5 hunks in ai-vault-host-leg-cache.ts for two features that
    are not the sync delta: the unbounded key and the row-capped prune a paged reply needs

A v10 database is migrated, not dropped. Upstream deletes a file whose version it does not know, so the
migration runs before its opener: it renames sessions_fts, copies the 8 columns into the facts table,
drops them from sessions, sets the version to the literal '8' that upstream content had and writes the
fork key. Verified on real temp-file SQLite, fresh and v10-with-fork-data, plus both discard paths, and
the migration test fails if the migration is removed -- upstream deletes the file instead.

The host-leg delta moves to ipc/ai-vault-host-leg-sync.ts, wrapping scan at the ai-vault.ts call sites,
so the cache's scan signature is upstream's again. One behaviour difference, recorded rather than hidden:
after a delete upstream's cache cannot expand an unchanged reply, so the next plain refresh rescans --
4 where the fork had 3. The held list makes that a small diff request rather than a full re-download, and
a new sync test asserts it.

Also keeps two cross-cutting inventories in step, which is how this change would otherwise have been
caught by gate 1 rather than here: the WSL mover's table list gains session_list_facts and
session_metadata_fts, and its SQL-owning file map gains the new schema module.

* docs(plan): §6.5 records the fork-owned schema, its migration and its expiry

* feat(sync): commit the proof rule the 10-25 sync used, and forecast what a merge breaks

The 10-25 sync resolved 133 hunks by hand under one rule -- upstream never touched this region -- with a
throwaway script that was never committed. This is that rule, generalised, plus the two checks that catch
the class of fork behaviour that has silently disappeared in past syncs.

conflicts.mjs --propose now attaches a per-hunk proof read from each side's TypeScript syntax tree, never
from a regex: upstream changed only comments or whitespace (take ours); the fork did (take theirs); the
fork changed only identity literals (take theirs with the literals put back token for token, which is
stronger than taking theirs and re-running the sweep, because the sweep only rewrites some literals); and
imports only on every side (a three-way union that honours removals, refused if it would import one
module twice or bind one name twice). A ledger or manifest deletion is printed as a git rm command and
never run.

--apply-proven <decision.json> re-proves every file from disk, writes only files where every hunk is
proven, and refuses one edited since the proposal. It stages nothing.

Measured on HEAD against upstream/main 8d72873: 245 of 421 hunks proven, 159 of 267 files with hunks
fully proven. On the 281cccf sync: 187 of 347, 113 of 216.

--rename-forecast <old-base> <new-base> names, before the merge, the fork-added imports, vi.mock paths and
path strings that upstream's renames and deletions will break -- 32 real targets on the 281cccf sync in
2 s, 23 today across 9 modules. fork-symbols.mjs lists the fork-added names a merged file no longer
declares; a symbols entry in expected-deletions.json acknowledges one with a reason. It compares names,
not behaviour, and says so.

Also fixes two things found on the way. deletedForkModules read an acknowledged object that 6357e7b
replaced with the deletions[] manifest, so it reported FAILED on every run; it now reads the manifest
through the shared parser, and asks whether an asserted deletion is present again rather than whether this
one sync deleted it -- the second question reads all 22 settled entries as stale every time. Verified
against a real tip: passed, no false stale lines, and it correctly lists the two files deleted today.

The plan's account of that sync was off in two places, recorded in the plan: the 133-hunk note is at
LOCAL-PATCHES.md:1552, not :1207, and its "50 identity-only hunks" does not reproduce -- only 1-2 qualify
on either range measured, and only 7 unproven hunks in the 281cccf merge contain an identity literal
at all.

* docs(plan): §5.9 records the measured proof coverage and what the tooling found

* feat(ci): judge the nightly against a Linux red set, so one upstream race cannot mute it

The Mac allowlist names this machine's environment; the nightly runs ubuntu-24.04-arm and has its own.
Without a set for it, a nightly is red forever on one upstream test, and a nightly nobody can read is
worse than no nightly.

local/nightly-linux-known-red.json holds the Linux set. An entry needs file, test, class and evidence;
an entry with no evidence is a hard problem, because that is the difference between a verdict and a mute
button. check-nightly-linux-parity.mjs --failures judges every failing assertion in the shards against
it: no entry fails the run, a listed test that passed is reported as prunable but never enforced, and a
file-level failure -- a beforeAll or import error with no assertion rows -- can never be absorbed by a
name-matching entry.

Its first entry is the daemon history-recovery test that failed on Linux in BOTH runs, the 04:53Z
schedule and the dispatch, with DaemonConnectionLostError out of disconnectOnly, while passing 3/3 here.
Every file in that stack is upstream-owned with zero fork hunks, so it is upstream's race under Linux
runner timing; the entry records its exit condition rather than hiding it.

Verified both directions on real artifacts: --failures exits 0 on the current run (1 known, 0 unexpected)
and exits 1 on the earlier one, naming the three branch-gap reds the merge has since fixed. The parity
mode is unchanged and still exits 0 on the same five shards, all 17 Mac entries passing on Linux.

Five new fixture tests, 20/20 in local/lib/linux-parity.test.mjs.

* fix(sync): type the status the reason string interpolates, so the changed-code gate passes

* test(vault): name the schema inventory for its domain role, not its structure

* fix(ci): make the Linux red set fail closed on what the reports cannot show

Three holes an Opus 5.5 review of the branch found in the red-set checker, all of the same kind: a night
could print "every Linux failure is a listed one" while something was red outside the assertion rows.

  - A shard that died or timed out uploads no unit-report.json, so four of five reports read as a clean
    night. --expect-reports N now fails the run when fewer arrived.
  - A report declares its own failing counts. More declared failing tests than could be attributed to a
    row or a file is now a problem, as is a shard whose success is false with no failing test at all --
    an unhandled error or a dead worker, which no assertion row records.
  - A file-level error was recorded only when the file had no failing assertion, so an afterAll or
    teardown error in the very file an allowlisted test failed in was absorbed. It is now its own red.

Also anchors the name match. It was a bare substring, so any sibling test whose title contained the
entry's name was absorbed; it now requires the exact name or the same name as a suffix after the
describe prefix, which is also what parity mode already did -- the two modes agreed by accident before.

A listed entry that stopped failing is now classified rather than assumed prunable: passed on Linux
means the entry can go, but a file that never ran means there is no evidence either way, and calling
that prunable would invite deleting a real entry on the night a shard failed to upload.

Suite-level failures that carry no failing test are printed as a note and never enforced. Vitest counts
a suite as failed for worker-teardown noise (observed on last night's shard 4, one failed suite and no
failing row), and failing the night on that would make the verdict permanently red on something no entry
could honestly claim.

--allowlist and --red-set are now usage errors in the wrong mode instead of silently ignored flags, and
the header documents both modes and their exit codes. 26 fixture tests.

* docs(renderer): say why one fork line stays in upstream's UI slice, and load the thread CSS where the marks are

The review's MINOR and NITs: fork-ui.ts now names the one fork line that stays in
ui-slice-settings-actions.ts and why; the gutter marks import the thread stylesheet directly instead of
reaching it through four modules; Content.tsx gets the blank line after its imports; the usage doc's
percentage claim covers both Claude cases rather than only the fraction; and monthly's doc comment lists
Claude enterprise spend caps among its producers.

* fix(vault): rebuild rather than stamp when the list tier cannot be checked

Four MINORs and two NITs from the review of the schema change, each with a test that fails without it.

A wrong or missing fork key on a file that already holds sessions now rebuilds instead of adopting a
stale tier. Before, when the pre-open check threw (a lock timeout, a transient error), it returned "keep"
and ensureSessionListSchema stamped the current version over whatever was on disk: on a fork-built v8
file that left the fork tables empty and the key claiming 1, so existing sessions never got facts or
metadata rows, because unchanged files are never re-read. It now throws SessionListTierMismatchError with
code SQLITE_CORRUPT, which upstream's openVersionedCacheDatabase already treats as delete-and-rebuild
once, so no new lines in upstream's opener. If upstream changes that rule the test fails rather than the
index degrading quietly.

Adds the INSERT trigger. Metadata search depended on upstream's createSessionRow staying "insert blank,
then UPDATE" -- and the whole point of this change is to survive upstream's writer changing. The body is
shared with the UPDATE trigger and existing files pick it up through IF NOT EXISTS, so no version bump.

Qualifies the fact columns with session_list_facts. in COLUMNS and QUERY_COLUMNS: unqualified, upstream
adding a model column to sessions would fail every list query with "ambiguous column name".

Requires all eight legacy columns before migrating rather than one, so a partial layout is discarded
cleanly instead of failing partway and being deleted by the opener with a "check skipped" warning. And
corrects the host-leg cap comment: the 40k-row cap applies to each of the two maps separately.

* fix(sync): close the prover holes the review found, including one that dropped upstream's change

An independent review of the tooling found one BLOCKER and four SHOULD-FIX items. Every one is a case
where the tool would have resolved a conflict confidently and wrongly.

The BLOCKER: the hunk-edge check looked only at tokens, and comments are not tokens. Upstream can disable
a function by opening /* in one hunk and closing it in the next, with the function in the context
between -- both hunks read "upstream unchanged", the file is fully proved, and --apply-proven writes it,
losing upstream's change. Comments are now collected from the gaps between tokens and a hunk is refused
when a comment crosses its edge on any side, in both directions. A hunk wholly inside one comment on
every side still proves, so conflicts inside a JSDoc block stay resolvable.

Three more soundness holes, all of the same kind:

  - identity re-application rebranded literals the fork had deliberately left alone: positions where the
    fork kept the base literal were skipped, then every matching token in theirs was swapped. It now
    refuses when a mapped literal also appears unchanged, and requires theirs to contain each key exactly
    as many times as base does.
  - JSX whitespace was trimmed on single-line text, where it actually renders -- and a second bug meant
    JSX text's leading whitespace never reached the normalizer at all. Only runs containing a newline are
    collapsed now.
  - fork-symbol-check accepted HEAD as "merged", which during conflict resolution is the pre-sync tip: it
    compared the tip with itself and reported clear. Both ancestry directions are now required, and a
    relocated name fails the scan instead of counting as a pass -- the probe also excludes the fork's own
    added lines, so a dropped function kept alive only by its own test is no longer hidden.

post-sync no longer treats a pending-decision entry as acknowledged. It means "not settled", and
acknowledging an unreferenced export is what expected-deletion is for.

Plus the review's MINORs: an import dropped on one side and changed on the other is refused, duplicate
modules and bindings are checked against imports outside the hunk, a trailing comment after the last
import survives, --apply-proven refuses a path git does not list as unmerged and refuses a symlink, and a
deletion prints plain git rm.

64 node --test cases across the eleven files; each new fixture was proved to fail with its fix removed,
and the relocated-only fixture exists because the first mutation survived the one that was there.

* fix(ci): type the red-set options so the changed-code gate can see expectedReports

* fix(ci,vault): make the red-set note factual, and stop a concurrent open from throwing

Two MINORs and a NIT from the delta re-check.

The red-set note claimed the shards' failed-suite count minus the failing-file count was "usually Vitest
worker teardown", which the check cannot know: Vitest's suite count is not a count of files with a
failing row, and it can include a suite that failed for a reason no assertion row records. It now reports
the comparison and says that it does not attribute the difference. The check that does fail the night --
a shard reporting failure with no failing test -- stays, because a red shard is a red night whatever the
rows say.

The fresh-index stamp is INSERT OR IGNORE. Two processes opening a fresh index can both see the key
absent, and `key` is a primary key, so a plain INSERT threw a constraint error the opener does not treat
as unusable and that open failed. Never overwrite was the intent; not throwing is the fix.

And the README no longer says `merge-base --is-ancestor` is checked "both ways": it checks that both the
tip and the new base are contained in the merged ref, once per ref.

* fix(sync): record trailing comments, and stop refusing every JSX hunk

A delta re-check of the previous round found the BLOCKER only partly closed and one fix over-refusing.
Both were reproduced against proveFile directly, by the reviewer and again here.

The comment-edge check asked TypeScript only for leading comments, but a comment that starts on the same
line as the token before it is that token's *trailing* comment, which getLeadingCommentRanges never
returns. So upstream disabling a function with `const x = 1 /* disabled` in one hunk and `*/ c()` in the
next still proved both hunks "upstream unchanged", marked the file fully proven and wrote a resolution
that dropped upstream's change -- the exact failure the check exists to stop. Trailing ranges are now
collected too, deduplicated by position, with ranges starting inside JSX text dropped: a trailing scan
from a tag's `>` otherwise reads `<p>/* text` as a comment that runs on to the next `*/` in real code and
refuses an unrelated hunk.

The previous fix read JSX text from `pos` in the geometry test, which made the whitespace-only text node
between two JSX elements span the hunk edge: every hunk among JSX children was refused with "the hunk
edge splits a token". A prover that refuses .tsx is a prover nobody runs. The geometry now uses a
region start that skips whitespace-only JSX text, while the normalizer still starts at `pos`.

Plain getStart was not enough for that: it shrinks a rendered single space (`</b> <i />`) to zero width,
so upstream deleting that space would prove "upstream unchanged" and the deletion would be lost. The
region start is getStart for every node except JSX text, which starts just after its last leading
newline -- same-line leading whitespace always sits on the hunk's own line, so it cannot create a
split-token refusal.

31 tests. Five mutations, each failing a different assertion: dropping the trailing scan, dropping the
JSX-text filter, the old pos-based start, plain getStart, and dropping the whitespace-only skip. I
reproduced the last one myself: with the trailing scan removed by an exact-string edit the suite fails
30/1, and the file restores byte-identical.

* fix(ci): find a red-set entry by its short title, so a passed test reads as prunable

The stale classification reused judgeParityEntry, which compares the test name exactly because the Mac
allowlist stores Vitest's full name including the describe prefix. A red-set entry stores the bare title,
so when the daemon history-recovery test PASSED 17/17 on the branch nightly the tool reported "no
evidence ... did not run" and told the operator to keep an entry that could be pruned. It now matches the
way failures are matched, and the same run reads "prunable".

Also records that third run in the entry. It failed twice and passed once, which is what "intermittent"
looks like: one pass is not the two consecutive ones the exit condition asks for, so the entry stays, and
the evidence now says failed, failed, passed rather than implying it always fails.

* fix(ci): a known Linux red stops mailing an alarm, and a new one still does

The nightly reds the shard job on Vitest's exit code, so a failure this repo already triaged emailed an
alarm every time it recurred. At 07:19 today that was the daemon disconnect race -- an upstream,
intermittent failure recorded as a known red with its evidence -- reported as "Fork main unit tests /
test / tests node 24 4/5 Failed". A lane that cries wolf on its own allowlist teaches everyone to ignore
it, which is the failure mode the whole red set exists to prevent.

The shard verdict now reads `local/nightly-linux-known-red.json` when it runs on Linux. A failing test
that matches an entry is reported as a tolerated known red and the shard passes; a failing test that
matches nothing still fails, by name. So the only email left is a failure nobody has classified yet.

Four things cannot be absorbed, because each one is a red the entry does not describe:

- a file-level failure (nothing ran to failure) when the entry names a test. The tolerated test did not
  fail, so something else did.
- a report that declares more failing tests than it lists, which is a red with no name to match.
- an unhandled error that is not the tolerated Vitest teardown race.
- a shard that failed with nothing in the report or log to explain it.

An entry whose file ran here and did not fail is reported as prunable, as a notice -- the same claim the
cross-shard checker makes, and it stays a notice so a stale entry cannot fail a night on its own.

Both matchers are now one function (`entryFileMatches`, `entryMatchesFailure` in `local/lib/linux-parity.mjs`)
shared with the cross-shard checker, so the per-shard and whole-night readers cannot drift apart on the
same artifacts. A path must match across a separator, or `src/a.test.ts` would absorb
`src/other/a.test.ts`.

Verified by replaying real artifacts through the new verdict, not only fixtures. The 07:19 run's shard 4
now exits 0 with the race named and tolerated. Run 40485647652's shards 1, 2, 3 and 5 -- which carry the
three branch-gap reds and two unhandled errors -- still exit 1, each naming its own reds. Four mutations
each kill a different test: dropping the manifest, letting a named entry absorb a file-level failure,
dropping the whole-file entry form, and matching a path without the separator.

* fix(ci): a file-level error beside a known red is its own red, and prune only on a pass

Review found the classification could pass a new failure green, and one notice disagreed with the
cross-shard checker on the same input.

The BLOCKER: the file-level check only looked at files where no row failed, so a file whose afterAll or
teardown threw *and* whose listed test failed that night read as green -- the row was absorbed and the
file's `message` was never read. It now fires on `file.message || no failing row`, the same condition
`local/lib/linux-parity.mjs` already used, so the per-shard verdict and the cross-shard reader agree on
the one input where they disagreed. The test for it needed the fixture to build what Vitest actually
reports -- one file entry carrying both a failed row and a message -- because a second entry for the same
file let the old condition fire and the test passed against the bug.

The prunable notice now requires a *passed* row matching the entry. Before, it fired whenever the
entry's file appeared, so a night where the file crashed before the test ran, or the test was renamed or
skipped, invited deleting an entry that is still doing work.

A file-level failure is now `test: null` on both sides rather than the string '(file-level failure)' in
one and null in the other: the sentinel was only safe until some entry's title ended with it.

Also from review: `--known-red` with no path was silently ignored and is now a usage error, and the test
helper always writes a manifest so a Linux run of the suite cannot fall back to the repo's real one and
mean something different on CI.

What it still cannot see, and now says so: a describe-level hook failure Vitest attributes to a suite
rather than to the file's `message`. `numFailedTestSuites` counts failing describes as well as failing
files -- 2 for one failing file, measured on the 07:19 run -- so it cannot be used to catch that without
failing every night.

* docs(patches): record what a red nightly shard now means, and what it still cannot see

A behaviour this fork owns belongs in the ledger beside its evidence. Says that the shard verdict reads
the Linux known-red manifest, the four inputs it can never absorb, the describe-level hook failure it
cannot see and why, and that the night's verdict is still the cross-shard checker's.
nplez1 added a commit that referenced this pull request Oct 28, 2026
The release gate on the merge found 140 failures across 25 test files. Three
were merge damage in product code:

- `updater-prerelease-feed.ts`: upstream's new `fetchLatestStableReleaseTag`
  hardcoded `stablyai/orca` in both the `releases/latest` URL and the redirect
  regex, so a fork build's stable-fallback probe never matched the fork's own
  redirect and every background check died in preflight. Retargeted to
  `nplez1/orca` with the rest of the file.
- `use-checks-panel-ai-acknowledgement.tsx`: the resolution dropped the
  latest-consumer refresh ref both parents had, so a head-churn rerender bound
  the post-launch refresh to a stale `fetchComments` and the forced refresh was
  silently dropped.
- `ai-vault-session-refresh.ts`: upstream declares the throttle test hook in
  this module; the fork's throttle lives in its own file, so the name is
  re-exported from where upstream's suites import it.

The rest were expectations that predate the code they assert — the fork's own
PRs (#134 refresh-on-refusal, #169 reply-only comment ack, #170 gh exec
options, #198 keep-cached-check-details, #175 window binding) changed behaviour
with `pr.yml` disabled and no unit lane backstopping them, so `main` was red
before this merge. Each expectation now asserts the fork's contract; no test was
deleted, skipped, or widened. Four reference repairs follow modules the merge
moved (`filesystem-auth`, `filesystem-search-file-paths`), two fixtures gained
fields upstream added, and two suites needed the WSL gate mocked so they stop
spawning a real `wsl.exe` per case.

`ws-inbound-message-queue.test.ts` is a GC-timing flake, not a fix: it passes
18/18 in isolation and the gate's isolation re-run reproduces it as green.

Verified per file with `pnpm test <path>` — all 25 green — plus `pnpm tc:web`
clean and the identity sweep still 0 fixable.
nplez1 added a commit that referenced this pull request Oct 28, 2026
…, not just darwin

The Linux nightly on the release revision caught this: the 401/503 case in
service-inactive-claude-cooldown.test.ts expected `stale-token` off darwin, but
the fork's #134 refresh-on-refusal reports
`refreshable-credentials-without-token` on every platform — verified against the
runner's own assertion diff (`stale-token` expected, the refreshable kind
received). The darwin-only ternary predates the unification, and passed locally
only because macOS took the other branch.

The remaining `process.platform === 'darwin'` checks in this suite are about
which credential store answers (Keychain vs credentials file) and stay.
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.

[Bug]: Claude usage never refreshes when Claude only runs on remote machines

1 participant