From 46ed165c48b6222b950a6193f81dd96277cdf6d5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 26 Aug 2026 11:49:58 +0900 Subject: [PATCH] devlog: quota-window and backlog roadmap (260826) Docs-only roadmap cycle for a seven-phase loop. No runtime change. The unit exists because Codex re-introduced the 5-hour rate-limit window for Plus and Team while Pro stays weekly-only, and OpenCodex has two quota parsers that disagree about what a short window means. Proven live rather than inferred: identical upstream data yields {weeklyPercent:97} from the header parser and {shortPercent:97, weeklyPercent:12} from the WHAM parser. The audit is the reason this is worth reading. Three rounds with an independent reviewer turned a one-file plan into a two-file one: fixing parseUpstreamQuotaHeaders alone would have moved routing headroom for a 5h-exhausted account from 0.03 to 0.88, because src/routing/quota.ts omits shortPercent and is currently reading the burst value only by accident through the very bug the fix removes. That finding, and four others, are recorded in 001_audit_response.md along with one partial rebuttal about phase ordering. Phases: 010 header parser + routing fold, 020 Spark hidden by default behind a Codex Auth switch, 030 #2406 CommandCode image capabilities, 040 #1215 noProxy, 050 #1060 billing-period date, 060 evidence-backed closures, 070 backlog triage. --- .../000_plan.md | 89 ++++++++ .../001_audit_response.md | 103 +++++++++ .../010_phase1.md | 208 ++++++++++++++++++ .../020_phase2.md | 135 ++++++++++++ .../030_phase3.md | 91 ++++++++ .../040_phase4.md | 102 +++++++++ .../050_phase5.md | 95 ++++++++ .../060_phase6.md | 50 +++++ .../070_phase7.md | 65 ++++++ 9 files changed, 938 insertions(+) create mode 100644 devlog/_plan/260826_quota_window_and_backlog/000_plan.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/010_phase1.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/020_phase2.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/030_phase3.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/040_phase4.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/050_phase5.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/060_phase6.md create mode 100644 devlog/_plan/260826_quota_window_and_backlog/070_phase7.md diff --git a/devlog/_plan/260826_quota_window_and_backlog/000_plan.md b/devlog/_plan/260826_quota_window_and_backlog/000_plan.md new file mode 100644 index 0000000000..6cb8628e9c --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/000_plan.md @@ -0,0 +1,89 @@ +# 000 — quota_window_and_backlog: Plan + +## Objective + +Codex removed the 5-hour rate-limit window some time ago and has now re-introduced it for +**Plus and Team**, while **Pro stays weekly-only**. OpenCodex has two quota parsers and only one +of them learned the lesson. Display and pool routing are both wrong for the affected plans. + +Additionally: hide the Codex Spark window by default behind an operator switch, land three +quick wins, and close the backlog items that are already terminal. + +## The observed failure (proven live, not inferred) + +Running both parsers against the SAME upstream data on `dev` at `0a0a8821b`: + +``` +headers {primary 97% / 300 min, secondary 12% / 10080 min} + parseUpstreamQuotaHeaders -> {"weeklyPercent":97,"weeklyResetAt":...} + parseUsageQuota (WHAM) -> {"shortPercent":97,"shortWindowSeconds":18000,"weeklyPercent":12} +``` + +The header parser has only a monthly-vs-else branch +([quota.ts:344](../../../src/codex/quota.ts)), so **anything that is not explicitly monthly +becomes weekly** — including a 5-hour burst window. The WHAM parser classifies by duration +([quota.ts:205](../../../src/codex/quota.ts), `isExplicitShortWindow`) and gets it right. + +Three consequences, in increasing order of damage: + +1. The genuine weekly reading (12%) is **discarded** — `weeklyPercent` is overwritten by the + burst value before the secondary is ever consulted. +2. A 5h-exhausted account records `weeklyPercent: 100`. `isCodexQuotaExhausted` returns true, + which is the right answer for the wrong reason — and it **stays** true after the 5-hour + window resets, because nothing re-derives it until a WHAM refresh lands. Pool routing keeps + avoiding a healthy account. +3. The GUI shows a weekly bar at 100% and **no 5h bar at all**, so the operator cannot tell + which limit they actually hit. + +The comment directly above the call site records the now-stale premise: +*"primary was the 5h window; it now carries weekly data for GPT plans"* +([core.ts:3777](../../../src/server/responses/core.ts)). That was true while the 5h window was +gone. It is not true now. + +Corroborating evidence that this is a parser gap rather than a missing feature: +`tests/ws-endpoint.test.ts:287` already carries a `"x-codex-primary-window-minutes": "15"` +fixture — a 15-minute window — and nothing in the suite classifies it as short. + +## Loop-spec + +- **Loop archetype:** verifier-defined repair (wp1, wp3-wp5), judged design (wp2), evidence + closure (wp6-wp7). +- **Trigger:** owner report that Codex restored the 5h limit for Plus and Team. +- **Write scope:** `src/codex/quota.ts`, `src/providers/registry.ts`, + `src/providers/quota.ts`, `src/config.ts`, `src/types/`, `gui/`, `docs-site/`, `tests/`, + `devlog/`. +- **Out of scope:** npm publish, tag push, main/preview promotion, security pre-disclosure + notes in devlog, rewriting the WHAM parser (it is correct — the header parser converges on + it, not the other way round). +- **Verifier:** focused `bun test` per phase; `bun run typecheck` + `bun run test` before each + merge; `cd gui && bun test` for GUI phases. +- **Stop condition:** seven work-phases merged to dev, every named issue/PR terminal, dev HEAD + green. +- **Bounds:** commits are `--no-verify`; CI is fixed at the end; admin squash-merge per phase. + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp1 | 010 | Header parser learns the duration rule; Plus/Team get a 5h bar, Pro unchanged | — | +| wp2 | 020 | Spark hidden by default + Codex Auth switch | — | +| wp3 | 030 | #2406 CommandCode image capabilities | — | +| wp4 | 040 | #1215 OpenCodex-scoped noProxy | — | +| wp5 | 050 | #1060 subscription billing-period date | — | +| wp6 | 060 | Evidence-backed closures (#2442 #2423 #2060, PR #1769 #2215) | — | +| wp7 | 070 | Backlog triage devlog | wp6 (records what wp6 closed) | + +wp1 is sequenced before wp2 for review clarity, not as a data dependency (audit finding 7): +both touch the quota display contract, and hiding one row is easier to review once the +neighbouring 5h/weekly rows are correct. Neither consumes the other's output. wp3-wp5 are independent and could run in any +order; they are sequenced by ascending blast radius. wp7 is last because it records wp6's +outcome. + +## Accept criteria + +Mirrored into the goalplan `criteria[]` — see `.codexclaw/goalplans/opencodex-quota-window-backlog-cleanup-loop-2608/goalplan.json`. + +The load-bearing one is wp1's: **given identical upstream data, the two parsers must agree**. +That is a property, not an example, and it is the assertion that would have caught this defect +when the 5h window first disappeared. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md b/devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md new file mode 100644 index 0000000000..04580c0b9a --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/001_audit_response.md @@ -0,0 +1,103 @@ +# 001 — audit response: the roadmap was locally right and globally incomplete + +An independent read-only auditor returned **FAIL** with five blocking findings. I verified each +against the tree. **All five hold.** One of them is the kind that turns a fix into a regression, +so it is worth stating plainly rather than burying in a table. + +## B1 — the parser fix alone would have BROKEN routing. ACCEPTED, and it is the important one. + +`src/routing/quota.ts:37` computes routing-profile headroom from `weeklyPercent` and +`monthlyPercent` — and **not** `shortPercent`. That omission is invisible today precisely +because the header parser is broken: the 5h value is being written into `weeklyPercent`, so +routing accidentally sees it. + +Measured on the live module: + +``` +headroom BEFORE the wp1 fix : 0.03 (reads 97% used — accidentally correct) +headroom AFTER the wp1 fix : 0.88 (reads 12% used — WRONG, burst is at 97%) +``` + +Fixing the parser without fixing `routing/quota.ts` would take a 5-hour-exhausted account from +"3% headroom" to "88% headroom" and route traffic straight into a 429. The bug is currently +cancelling itself out, and the roadmap's "only the parser changes" claim would have removed one +half of the cancellation. + +Note the asymmetry that made this easy to miss: `computeCodexUsageScore` in +`src/codex/routing.ts:339` **does** fold in `shortPercent`, and that is the file I read. +`src/routing/quota.ts` is a different module with a similar name and a different rule. + +**Fold:** wp1 gains `src/routing/quota.ts` — add `shortPercent` to the percent set and +`shortResetAt` to the reset set — plus a regression asserting headroom stays low when only the +burst window is exhausted. + +## B2 — wp2's server-filter rationale named the wrong surface. ACCEPTED with a correction. + +I justified server-side filtering by claiming `maxQuotaUtilisation` would reorder Codex account +cards. The auditor checked: `maxQuotaUtilisation` sorts the **Providers overview** +(`ProviderOverviewDashboard.tsx:66`), not the Codex Auth cards. My stated reason was wrong. + +The conclusion survives on a better reason. Spark reaches the GUI through **two** independent +projections — `/api/codex-auth/accounts` and `/api/provider-quotas` (the latter via +`listCodexAuthAccountsSnapshot`, `providers/quota.ts:1129`). Filtering one leaves the other +showing the row the operator switched off. + +**Fold:** wp2 filters at a shared projection covering both surfaces, and tests both. The +label-exact requirement stands and is now better supported: `customWindows` carries Cursor's +`First-party models`/`API usage`, Anthropic's `Fable`/`Opus`/`Sonnet`, Antigravity's +`Gem`/`Cla`, Kimi's `Total subscription credits` and a dozen dynamic provider labels. A +"drop custom windows" filter would blank all of them. + +## B3 — wp4's own dedupe criterion would have failed. ACCEPTED. + +`applyProxyEnv` builds `seen` from **lowercased** entries (`config.ts:3122`) but my proposed +loop pushed configured entries without normalizing, so a configured `LOCALHOST` would be +followed by `localhost`. The plan's own accept-criteria row would have failed the plan's own +code. + +Also accepted: #1215 asks for `string[]`; I specified a comma-separated `string` without +recording the deviation. **Decision, now recorded:** accept `string | string[]` and normalize. +The string form matches `NO_PROXY` syntax the operator already knows and matches the sibling +`proxy` field; the array form is what the issue asked for and is unambiguous about separators. +Supporting both costs one `Array.isArray` branch. + +## B4 — wp5 was written against a UI that does not exist. ACCEPTED. + +I claimed the GUI "drops `expiresAt`". It drops the **entire `creditsUsd` object** +(`report.ts:42`), and `AccountQuota` has no credits contract at all. There is no credits +figure to render a date beneath, and `gui/tests/provider-report.test.ts` — which I cited as the +test location — does not exist. + +**Fold:** wp5 projects the whole typed `creditsUsd` shape and creates the presentation, which +makes it the largest of the three quick wins rather than the smallest. Label corrected to +**"Billing period ends"**: the source is `subscription.currentPeriodEnd`, and "Renews" asserts +a continuation the field does not promise. + +## B5 — acceptance evidence gaps. ACCEPTED. + +- The wp1 "property" test was three fixed examples. Add the **24-hour boundary**: 1439 minutes + is short, 1440 is not — strict `<` in both predicates, verified at `quota.ts:211`. +- wp3's negative table used short names; upstream ids are `deepseek/deepseek-v4-flash`, + `deepseek/deepseek-v4-pro`, `zai-org/GLM-5.2`, `zai-org/GLM-5.3`, `xai/grok-4.6`. A + shortened name asserts absence of something that was never present — vacuously green. +- Goalplan criteria carry no `expectedEvidence` and no work-phase mapping. Fill both. + +## B6 (finding 7) — phase ordering. PARTIALLY REBUTTED. + +The auditor is right that wp1→wp2 is not a data dependency and that wp3-wp5 are independent. +I accept the correction and have removed the dependency claim from wp1→wp2. + +Where I do not fully agree: PHASE-SPLIT-01 forbids ordering by **effort or payoff speed**, not +ordering independent slices at all. wp3-wp5 have no edges between them, so *some* order must be +chosen; ascending blast radius (static data → config plumbing → GUI surface) is a risk ordering, +not a quick-win-first ordering. B4 makes this concrete: wp5 turned out to be the largest of the +three, and it stays last — an effort ordering would now move it first. + +## Net effect + +Five folds, one partial rebuttal. The scope grows in two places that matter: wp1 gains a second +file without which it would regress routing, and wp5 roughly doubles. The roadmap docs are +amended in place; this document records why. + +VERDICT accepted: **near-pass with five folded blockers**. Proceeding to B. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/010_phase1.md b/devlog/_plan/260826_quota_window_and_backlog/010_phase1.md new file mode 100644 index 0000000000..d4132c910d --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/010_phase1.md @@ -0,0 +1,208 @@ +# 010 — wp1: the header quota parser learns the duration rule + +## The defect in one line + +`parseUpstreamQuotaHeaders` branches on "explicitly monthly, or else weekly". There is no +third branch, so a 5-hour primary window is recorded as the weekly reading. + +## MODIFY map + +### `src/codex/quota.ts` + +**1. Add a minutes-domain short-window predicate next to the existing monthly one** + +The seconds-domain predicate already exists (`isExplicitShortWindow`, line ~205) and the +minutes-domain monthly predicate already exists (`isExplicitMonthlyWindowMinutes`, line ~221). +The missing piece is the minutes-domain SHORT predicate. Both share one numeric parse, so +factor that out rather than writing the coercion twice. + +```ts +/** Minutes-domain twin of isExplicitShortWindow: the header wire reports minutes, not seconds. */ +function windowMinutes(value: unknown): number | undefined { + const minutes = typeof value === "number" + ? value + : typeof value === "string" && value.trim() !== "" + ? Number(value) + : undefined; + return typeof minutes === "number" && Number.isFinite(minutes) ? minutes : undefined; +} + +function isExplicitShortWindowMinutes(value: unknown): boolean { + const minutes = windowMinutes(value); + return minutes !== undefined && minutes > 0 && minutes < WEEKLY_WINDOW_MIN_MINUTES; +} +``` + +`WEEKLY_WINDOW_MIN_MINUTES` is NEW and mirrors the existing monthly constant: + +```ts +const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60; // exists, line ~96 +const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60; // exists, line ~97 +const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60; // NEW — 1440 +``` + +Deriving it from the seconds constant is deliberate: the two parsers must not be able to drift +to different thresholds, which is exactly the class of bug this phase is fixing. + +**2. Give the parser its third branch** + +Before (line ~344): + +```ts +const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes); + +if (primaryIsMonthly) { + ... +} else { + const weeklyPercent = primaryPercent ?? secondaryPercent; + ... +} +``` + +After: + +```ts +const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes); +// Codex restored the 5-hour window for Plus and Team (Pro stays weekly-only). A primary window +// that DECLARES a sub-day duration is a burst window, and folding it into weeklyPercent both +// discards the real weekly reading and leaves the account looking exhausted after the burst +// window resets. Duration decides, exactly as the WHAM parser already does. +const primaryIsShort = primaryRaw !== null && isExplicitShortWindowMinutes(primaryWindowMinutes); + +if (primaryIsMonthly) { + // ... unchanged ... +} else if (primaryIsShort) { + if (primaryPercent !== undefined) { + quota.shortPercent = primaryPercent; + if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt; + const minutes = windowMinutes(primaryWindowMinutes); + if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60); + } + // The burst window vacates the primary slot, so the weekly reading is the secondary — which + // is where it actually was all along. + if (secondaryPercent !== undefined) { + quota.weeklyPercent = secondaryPercent; + if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt; + } +} else { + // ... unchanged: primary-or-secondary weekly ... +} +``` + +**3. Do NOT touch** the tertiary handling below it, `isCodexQuotaExhausted`, +`computeCodexUsageScore`, or the WHAM parser. They already read `shortPercent` correctly +([quota.ts:117](../../../src/codex/quota.ts), [routing.ts:339](../../../src/codex/routing.ts)); +this phase only makes the header path produce the field they are already waiting for. + +**4. Update the stale premise comment** at +[core.ts:3777](../../../src/server/responses/core.ts): "primary was the 5h window; it now +carries weekly data for GPT plans" is false again. Replace with a note that the slot is +duration-classified and the plan does not decide. + +### \`src/routing/quota.ts\` — the fold that stops this fix becoming a regression (audit B1) + +\`codexAccountQuotaEvidence\` (line ~37) computes routing headroom from \`weeklyPercent\` and +\`monthlyPercent\` only. That omission is invisible TODAY because the broken parser writes the +5h value into \`weeklyPercent\` — routing sees the burst by accident. Measured live: + +\`\`\` +headroom BEFORE the parser fix : 0.03 (reads 97% used — accidentally correct) +headroom AFTER the parser fix : 0.88 (reads 12% used — WRONG, burst is at 97%) +\`\`\` + +Fixing the parser alone would route traffic into a 429. Add \`shortPercent\` to the percent set +and \`shortResetAt\` to the reset set: + +\`\`\`ts +const percents = [ + ...(monthly ? [] : [quota.weeklyPercent]), + quota.monthlyPercent, + // The burst window is upstream-enforced independently of the governing window, so an account + // at 97% here has 3% headroom regardless of its weekly figure. computeCodexUsageScore already + // folds it in (codex/routing.ts:339); this module must not disagree. + quota.shortPercent, +].filter(...) +\`\`\` + +Same treatment for \`resets\` with \`quota.shortResetAt\`, so a burst-limited account reports the +burst reset rather than a distant weekly one. + +### Nothing else changes — beyond the two files above + +- `setAccountQuotaFromParsed` already merges `short*` fields (line ~318, `snapshotHasShort`). +- `updateAccountQuota` already preserves them (line ~413). +- The DTO already returns `shortPercent` ([auth-api.ts:212](../../../src/codex/auth-api.ts)). +- The GUI already aliases it to `fiveHourPercent` and renders the bar + ([codex-quota-utils.ts:27](../../../gui/src/codex-quota-utils.ts), + [QuotaBars.tsx:45](../../../gui/src/components/QuotaBars.tsx)). + +Storage and display were already correct: one parser was filling the wrong pipe, and one +routing consumer was reading that wrong pipe. **This phase changes two runtime files** — +`src/codex/quota.ts` and `src/routing/quota.ts` — and they must land together. Shipping the +parser alone converts a display bug into a routing bug (audit B1). + +## TESTS + +### `tests/rate-limit-reset-credits.test.ts` (extend — it owns the header-parser cases) + +| Case | Input | Expected | +|---|---|---| +| Plus/Team 5h primary + weekly secondary | primary 97% / 300 min, secondary 12% / 10080 min | `{shortPercent:97, shortWindowSeconds:18000, weeklyPercent:12}` | +| 5h exhausted does not poison weekly | primary 100% / 300 min, secondary 8% / 10080 min | `weeklyPercent === 8`, `shortPercent === 100` | +| Pro weekly-only unchanged | primary 80% / 10080 min | `{weeklyPercent:80}`, no `shortPercent` | +| Monthly primary unchanged | primary 100% / 43800 min | existing expectation holds verbatim | +| Reset instant travels with its window | primary 300 min + reset | `shortResetAt` set, `weeklyResetAt` NOT set from the primary | +| Absent window-minutes header | primary 80%, no minutes header | weekly (unchanged legacy behaviour) | +| 24h boundary, below | primary 60% / 1439 min | `shortPercent` — strict `<` (audit B5) | +| 24h boundary, at | primary 60% / 1440 min | `weeklyPercent` — exactly a day is NOT short | +| Routing headroom holds | `{shortPercent:97, weeklyPercent:12}` | `codexAccountQuotaEvidence` headroom <= 0.05, not 0.88 | + +The last row is load-bearing: an upstream that omits the duration header must keep behaving +exactly as it does today. Duration-classification is opt-in on the presence of a declared +duration, never a guess. + +### `tests/codex-quota-parser-parity.test.ts` (NEW) + +The property assertion, and the one that would have caught this defect the first time: + +```ts +// Given the SAME upstream reading expressed both ways, the two parsers must agree on which +// window each number belongs to. Any future change that teaches one parser a rule the other +// does not know fails here. +for (const c of [ + { minutes: 300, seconds: 18000, primary: 97, secondary: 12 }, // Plus/Team 5h + { minutes: 10080, seconds: 604800, primary: 80, secondary: undefined }, // Pro weekly + { minutes: 43800, seconds: 2628000, primary: 100, secondary: 22 }, // monthly plan +]) { ... expect header-derived window assignment to equal WHAM-derived ... } +``` + +Compare the WINDOW ASSIGNMENT (which field each percent lands in), not raw object equality — +the WHAM parser also emits `monthlyIsPrimaryWindow` and Spark custom windows that the header +wire does not carry. + +### Falsification (mandatory before trusting either) + +Revert the `primaryIsShort` branch and confirm both new suites go red. A parity test that +passes against the broken parser is worthless. + +## Verification (C) + +```bash +bun test tests/rate-limit-reset-credits.test.ts tests/codex-quota-parser-parity.test.ts \ + tests/ws-endpoint.test.ts tests/codex-routing.test.ts +bun x tsc --noEmit # exit 0 +bun run test # 0 fail — quota.ts is shared runtime +``` + +## Activation scenario (C-ACTIVATION-GROUNDING-01) + +The new branch is a conditional, so C must prove it ARMS rather than merely compiles: +feeding `x-codex-primary-window-minutes: 300` must move the 97 from `weeklyPercent` to +`shortPercent` **and** let 12 reach `weeklyPercent`. Observing only "shortPercent is set" +would pass even if the secondary were still being dropped. + +## Out of scope for wp1 + +The Spark row and the GUI switch are wp2. Capacity weights (`plus:1`) are untouched: a new +window is not evidence that the pooled display ratio is wrong, and that ratio is display-only +([codex-capacity.ts:166](../../../src/providers/codex-capacity.ts)). diff --git a/devlog/_plan/260826_quota_window_and_backlog/020_phase2.md b/devlog/_plan/260826_quota_window_and_backlog/020_phase2.md new file mode 100644 index 0000000000..6e251f1510 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/020_phase2.md @@ -0,0 +1,135 @@ +# 020 — wp2: Codex Spark hidden by default, behind a Codex Auth switch + +## What the operator sees today + +Every account card on the Codex Auth page carries a second bar labelled +`GPT-5.3-Codex-Spark Weekly`, and in the owner's live dashboard all four accounts show it at +0%. It is emitted unconditionally by the WHAM parser +([quota.ts:611](../../../src/codex/quota.ts)) whenever `additional_rate_limits` contains the +`codex_bengalfox` feature. + +Owner decision: **Spark is not shown by default.** It is a niche model window, it is 0% for +most operators, and on a four-account pool it doubles the row count for information almost +nobody is acting on. The switch exists because "not by default" is not the same as "never". + +## Design direction (cxc-dev-uiux-design) + +Three placements were considered against the existing page: + +| Option | Verdict | +|---|---| +| Per-account toggle on each card | Rejected — the setting is about a *window kind*, not an account. Four toggles that must agree is a state-sync bug waiting to happen. | +| Advanced settings drawer | Rejected — the drawer already exists at the page foot, but burying a display toggle there means the operator who wants Spark back cannot find why it vanished. | +| **Page-header control row, beside Pause exhausted / Refresh quotas** | **Chosen.** | + +The header row is where the page already keeps its *view-and-pool-wide* actions. A Spark +toggle is exactly that: it changes what every card renders, and it belongs next to the other +control that operates on all cards at once. + +Presentation follows the existing header controls rather than introducing a new visual +vocabulary: same pill height, same border treatment, label + switch. It reads +`Codex Spark quota` with an on/off switch, not a bare unlabelled toggle — an unlabelled +switch in a header is a guessing game. + +Copy: `codexAuth.showSparkQuota` = "Codex Spark quota", with a title/tooltip explaining that +the window only applies to GPT-5.3-Codex-Spark and is hidden by default. Localized across all +nine locales; `Codex Spark` stays untranslated as a product name (same treatment the +intentional-English allowlist already gives product nouns). + +## MODIFY / NEW map + +### Server: the setting must persist + +**`src/types/config.ts`** — extend the existing GUI-preferences area with: + +```ts +/** + * Show the GPT-5.3-Codex-Spark weekly window on Codex account cards. Default false: the + * window applies to one model, reads 0% for most operators, and doubles the bar count on a + * multi-account pool. + */ +showCodexSparkQuota?: boolean; +``` + +**`src/server/management/*-routes.ts`** — read/write through the existing settings surface that +the Codex Auth page already talks to. Follow the surrounding preservation discipline: an +unrelated save must not drop it (the `oauthAccountFailover` lesson from #2568d). + +### Where the row is suppressed — server, both surfaces (audit B2) + +The Spark window is dropped from the **API projection**, not hidden with CSS: anything the +client does not render, it should not receive. + +An earlier draft justified this by claiming `maxQuotaUtilisation` would reorder Codex account +cards. That was wrong — it sorts the **Providers overview** +([ProviderOverviewDashboard.tsx:66](../../../gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx)), +not the account cards. The real reason is worse for a naive fix: + +**Spark reaches the GUI through TWO independent projections.** `/api/codex-auth/accounts` +builds its rows through `quotaForPlan` ([auth-api.ts:212](../../../src/codex/auth-api.ts)), and +`/api/provider-quotas` reaches the same data through `listCodexAuthAccountsSnapshot` +([providers/quota.ts:1129](../../../src/providers/quota.ts)). Filtering one leaves the other +still rendering the row the operator just switched off. + +So the filter lands in a shared projection consumed by both, keyed on the exact raw label. + +**Label-exact is not a nicety.** `customWindows` is the generic carrier for Cursor +(`First-party models`, `API usage`), Anthropic (`Fable`/`Opus`/`Sonnet`), Antigravity +(`Gem`/`Cla`), Kimi (`Total subscription credits`) and a dozen dynamic provider labels. A +filter written as "drop custom windows" blanks every one of them. + + +### Client + +**`gui/src/components/CodexAccountPool.tsx`** — header control + optimistic state, following +the existing `refreshQuotas` / `pauseExhausted` handler shape. + +**i18n** — `codexAuth.showSparkQuota` + tooltip in all nine locale files. Note the parser +landmine fixed in #2640: these catalogs pack several entries per line, so a new key must be +added in the same shape or the parity test's regex sees only the first entry per line. + +## TESTS + +| Layer | Case | File | +|---|---|---| +| Server | default (setting absent) → no Spark window on `/api/codex-auth/accounts` | `tests/codex-auth-api.test.ts` | +| Server | setting true → Spark window present, unchanged | same | +| Server | setting survives an unrelated settings save | same | +| Server | **default → no Spark window on `/api/provider-quotas`** (audit B2 round 2) | `tests/provider-quota.test.ts` | +| Server | non-Spark custom windows are NEVER filtered — Cursor, Anthropic, Antigravity, Kimi | `tests/provider-quota.test.ts` | +| GUI | `buildQuotaRows` renders a Spark row when given one | `gui/tests/quota-bars-rows.test.ts` | +| GUI | locale parity holds after the new keys | existing parity suites | + +The fourth row is the one that matters most. Cursor's `First-party models` / `API usage` +windows travel through the same `customWindows` array +([QuotaBars.tsx:86](../../../gui/src/components/QuotaBars.tsx)); a filter written as "drop +custom windows" instead of "drop the Spark label" would silently blank the Cursor provider +card. Pin it by label. + +## Verification (C) + +```bash +bun test tests/codex-auth-api.test.ts +cd gui && bun test # 994+ pass, 0 fail +bun run typecheck # exit 0 +``` + +Plus a **rendered screenshot of both states** — Spark hidden (default) and Spark shown after +flipping the switch — captured against a dev build via agbrowse. The PR gate requires a GUI +screenshot, and a claim that a row is hidden is only credible when the absence is visible. + +## Activation scenario + +Default-off must be proven by ABSENCE with the data present: the WHAM payload still carries +`codex_bengalfox`, the parser still writes the custom window, and the DTO still omits it. A +test that simply omits Spark from the fixture proves nothing. + +## Dependency + +Runs after wp1: both phases touch the quota display contract, and hiding one row is easier to +review once the neighbouring 5h/weekly rows are correct. +**Owner of the shared projection.** Both surfaces already funnel through `quotaForPlan` +([auth-api.ts:212](../../../src/codex/auth-api.ts)) — the Codex Auth rows directly, and +`/api/provider-quotas` via `listCodexAuthAccountsSnapshot` +([providers/quota.ts:1129](../../../src/providers/quota.ts)). `quotaForPlan` is therefore the +single filtering point, and B is done only when BOTH test targets above are green. diff --git a/devlog/_plan/260826_quota_window_and_backlog/030_phase3.md b/devlog/_plan/260826_quota_window_and_backlog/030_phase3.md new file mode 100644 index 0000000000..a3c0ed80ad --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/030_phase3.md @@ -0,0 +1,91 @@ +# 030 — wp3: #2406 CommandCode image capabilities + +## The gap + +CommandCode's registry entries declare `modelInputModalities` for exactly two ids — +`stealth/ox-alpha` and the DeepSeek vision preview — in BOTH the OAuth preset +([registry.ts:1183](../../../src/providers/registry.ts)) and the API-key preset +([registry.ts:1925](../../../src/providers/registry.ts)). Every other CommandCode model is +therefore text-only in the generated Codex catalog. + +This is not cosmetic. Combo capability intersection treats an absent declaration as text-only, +so a single unmarked target disables image input for the entire combo. + +## What the reporter proved, and what they disproved + +The issue carries end-to-end image probes, and it is careful in both directions. Image input +**succeeded** for: + +`gpt-5.6-luna`, `gpt-5.6-sol`, `MiniMaxAI/MiniMax-M3`, `moonshotai/Kimi-K3`, +`meta/muse-spark-1.2`, `meta/muse-spark-1.2-contributor`, `openai/ox-alpha`, +`deepseek/deepseek-v4-flash-vision-exp` + +Image input did **not** deliver for `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, +`zai-org/GLM-5.2`, `zai-org/GLM-5.3`, `xai/grok-4.6`. + +**The negative list is as load-bearing as the positive one.** Marking a route image-capable +that silently drops the image is worse than leaving it text-only: the request succeeds, the +model answers about an image it never received, and nothing surfaces an error. This phase adds +only the verified-positive ids. + +Note `openai/ox-alpha` versus the already-present `stealth/ox-alpha` — the catalog serves the +same model under two ids and only one carries the declaration. + +## MODIFY map + +### `src/providers/registry.ts` — both presets, identical content + +Extract the shared map to a named constant rather than duplicating a growing literal twice. +The current two-entry duplication is already a drift hazard; at ten entries it is a certainty. + +```ts +/** + * CommandCode routes verified to accept image input end-to-end (#2406). + * + * Verified-negative and therefore deliberately ABSENT: deepseek-v4-flash, deepseek-v4-pro, + * GLM-5.2, GLM-5.3, grok-4.6. Those routes accept the request and drop the image, which is + * worse than declining it — the model answers about an image it never saw. Do not add an id + * here on family resemblance; capability intersection trusts this map. + */ +const COMMAND_CODE_IMAGE_MODELS = [ + "stealth/ox-alpha", + "openai/ox-alpha", + `deepseek/${DEEPSEEK_VISION_PREVIEW_MODEL}`, + "gpt-5.6-luna", + "gpt-5.6-sol", + "MiniMaxAI/MiniMax-M3", + "moonshotai/Kimi-K3", + "meta/muse-spark-1.2", + "meta/muse-spark-1.2-contributor", +] as const; + +const COMMAND_CODE_MODEL_INPUT_MODALITIES: Record = + Object.fromEntries(COMMAND_CODE_IMAGE_MODELS.map(id => [id, ["text", "image"]])); +``` + +Both presets then reference `COMMAND_CODE_MODEL_INPUT_MODALITIES`, replacing their inline +literals. Placement: next to `COMMAND_CODE_MODEL_REASONING_EFFORTS`, which solves the same +shared-facts problem the same way. + +## TESTS — `tests/command-code-provider.test.ts` + +| Case | Assertion | +|---|---| +| Every verified id is image-capable | each id in the positive list resolves to `["text","image"]` in BOTH presets | +| Verified-negative ids stay text-only | full upstream ids only (audit B5): `deepseek/deepseek-v4-flash`, `deepseek/deepseek-v4-pro`, `zai-org/GLM-5.2`, `zai-org/GLM-5.3`, `xai/grok-4.6` carry no image modality | +| Preset parity | OAuth and API-key modality maps are deeply equal | + +The parity assertion is what makes the shared constant enforceable rather than merely tidy. + +## Verification (C) + +```bash +bun test tests/command-code-provider.test.ts +bun x tsc --noEmit +``` + +Registry facts are static data, so the focused suite plus typecheck is proportionate; a +repo-wide run is not required for a data-only change (AGENTS.md scoped-check rule). + +Closes #2406. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/040_phase4.md b/devlog/_plan/260826_quota_window_and_backlog/040_phase4.md new file mode 100644 index 0000000000..08a6864bd5 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/040_phase4.md @@ -0,0 +1,102 @@ +# 040 — wp4: #1215 OpenCodex-scoped noProxy + +## The gap + +`applyProxyEnv` ([config.ts:3116](../../../src/config.ts)) already does the hard part: it +merges the inherited `NO_PROXY`/`no_proxy` with the loopback hosts, deduplicating +case-insensitively. What it lacks is a way for the operator to add their own entries WITHOUT +setting a machine-wide environment variable. + +The reporter's case: internal hosts that must bypass the corporate proxy, on a machine where +`NO_PROXY` is owned by another tool. + +## MODIFY map + +### `src/types/config.ts` — beside the existing `proxy` field (~line 501) + +```ts +/** + * Hosts that bypass `proxy` for OpenCodex's own outbound provider calls, merged into + * NO_PROXY at startup. Accepts a comma-separated string (NO_PROXY syntax) or an array. + * Loopback is always excluded regardless of this setting, and an inherited NO_PROXY is + * preserved — this ADDS entries, it never replaces the environment. + */ +noProxy?: string | string[]; +``` + +**`string | string[]`, recorded as a deviation (audit B3).** #1215 asks for `string[]`. +The sibling `proxy` field is a `string` and `NO_PROXY` syntax is comma-separated, so an +operator will reach for the string form by muscle memory. Both are accepted and normalized +identically: the array costs one `Array.isArray` branch and removes any ambiguity about +separators appearing inside a value. + +### `src/config.ts` — inside `applyProxyEnv` + +The merge loop already exists; the change is the source list it walks and one normalization. + +Before: + +```ts +for (const host of ["localhost", "127.0.0.1", "::1", "[::1]"]) { + if (!seen.has(host)) { + entries.push(host); + seen.add(host); + } +} +``` + +After: + +```ts +// Configured entries first, then loopback: loopback is unconditional, so appending it last +// keeps it present even when the operator lists a loopback host themselves. +const raw = config.noProxy; +const configured = (Array.isArray(raw) ? raw : (resolveEnvValue(raw) ?? "").split(",")) + .map(entry => entry.trim()) + .filter(Boolean); +for (const host of [...configured, "localhost", "127.0.0.1", "::1", "[::1]"]) { + const key = host.toLowerCase(); + if (!seen.has(key)) { + entries.push(host); + seen.add(key); + } +} +``` + +**The `toLowerCase()` is audit finding B3.** `seen` is built from lowercased entries +([config.ts:3122](../../../src/config.ts)), but the original draft pushed configured entries +without normalizing — so a configured `LOCALHOST` would have been followed by `localhost`, +failing this phase's own dedupe criterion. The pushed VALUE keeps the operator's casing; only +the lookup key is normalized. + +`resolveEnvValue` gives the string form the same `${VAR}` indirection `proxy` already +supports — they are a pair and should not diverge. + +**Early-return trap.** `applyProxyEnv` returns immediately when `config.proxy` is unset +(line ~3118). That is correct and stays: `noProxy` without a proxy is meaningless, and +writing `NO_PROXY` for a process that proxies nothing would leak OpenCodex config into +unrelated child processes. Pin it by test rather than leaving it to be "fixed" later. + +## TESTS — `tests/proxy-env.test.ts` + +| Case | Assertion | +|---|---| +| Configured string reaches `NO_PROXY` | `"internal.example,10.0.0.0/8"` both present | +| Configured array reaches `NO_PROXY` | `["internal.example","10.0.0.0/8"]` equivalent to the string form | +| Loopback survives | all four loopback forms still present | +| Inherited `NO_PROXY` preserved | a pre-set value is merged, not replaced | +| Case-insensitive dedupe | configured `"LOCALHOST"` produces no duplicate (audit B3) | +| `${VAR}` indirection | env-referenced string value resolves | +| No proxy configured | `NO_PROXY` untouched — the early return holds | + +## Verification (C) + +```bash +bun test tests/proxy-env.test.ts +bun x tsc --noEmit +``` + +Docs: add `noProxy` to the configuration reference next to `proxy`, showing both forms. + +Closes #1215. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/050_phase5.md b/devlog/_plan/260826_quota_window_and_backlog/050_phase5.md new file mode 100644 index 0000000000..6ee007928f --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/050_phase5.md @@ -0,0 +1,95 @@ +# 050 — wp5: #1060 subscription billing-period date + +## The gap, restated after audit B4 + +The original draft said the GUI "drops `expiresAt`". It is worse than that: the GUI drops the +**entire `creditsUsd` object** ([report.ts:42](../../../gui/src/provider-workspace/report.ts)), +and `AccountQuota` has no credits contract at all +([codex-quota-utils.ts:1](../../../gui/src/codex-quota-utils.ts)). + +So there is no existing credits figure to hang a date beneath. This phase creates the credits +presentation and then dates it — which makes it the LARGEST of the three quick wins, not the +smallest. It stays last in the phase order for exactly that reason. + +## What the backend already has + +CommandCode's probe resolves `expiresAt` from `subscription.currentPeriodEnd` +([quota.ts:1824](../../../src/providers/quota.ts)) and attaches it to `creditsUsd` +(line ~1849). The type declares the full shape +([quota.ts:83](../../../src/providers/quota.ts)): + +```ts +export interface ProviderQuotaCreditsUsd { + used: number; limit: number; remaining: number; percent: number; + expiresAt?: number; unlimited?: boolean; +} +``` + +## The condition worth reading carefully + +```ts +...(expiresAt !== undefined && purchased <= 0 ? { expiresAt } : {}) +``` + +`expiresAt` is emitted **only when no credits were separately purchased**. With a purchase, +the subscription period end no longer describes when the displayed balance resets, so the field +is withheld rather than shown misleadingly. The GUI must therefore treat absence as normal and +render nothing — not "unknown", not an em-dash placeholder. + +## Label: "Billing period ends", not "Renews" (audit B4) + +The source field is `currentPeriodEnd`. "Renews" asserts a continuation the field does not +promise — a cancelled subscription has a period end and no renewal. The existing quota bars say +"resets"; using that word here would imply the credit balance and the usage windows share a +clock, which they do not. + +## MODIFY map + +### `gui/src/provider-workspace/report.ts` + +Project the whole typed `creditsUsd` object, not one field. `quotaFromUnknown` narrows +unknown wire data field by field; `creditsUsd` gets the same treatment with the existing +`finite()` guard on each numeric member and `expiresAt` optional. + +### `gui/src/codex-quota-utils.ts` + +`AccountQuota` gains the optional credits member so the projection has somewhere to land. + +### `gui/src/components/provider-workspace/ProviderCapacityQuota.tsx` + +New credits presentation: the balance figure, plus the billing-period line when `expiresAt` +is present. Locale-aware date formatting via the `bcp47` helper the quota surface already +uses ([QuotaBars.tsx](../../../gui/src/components/QuotaBars.tsx)). + +### i18n + +`quota.creditsBalance` and `quota.creditsPeriodEnds` = "Billing period ends {date}" in all +nine locales. Catalogs pack several entries per line — match the surrounding shape. + +## TESTS + +| Layer | Case | File | +|---|---|---| +| GUI | full `creditsUsd` survives the projection | `gui/tests/provider-workspace-state.test.ts` | +| GUI | malformed members are dropped, not propagated as NaN | same | +| GUI | absent `expiresAt` renders no period line | component test | +| GUI | present `expiresAt` renders a localized date | component test | +| GUI | locale parity after the new keys | existing parity suites | + +Audit B4 flagged that the originally named `gui/tests/provider-report.test.ts` does not +exist, and that a projection test cannot prove rendered absence. The projection cases go in the +existing `provider-workspace-state.test.ts`; the two render cases need a real component +test. + +## Verification (C) + +```bash +cd gui && bun test +bun x tsc --noEmit +``` + +Plus a rendered screenshot of the Providers workspace showing the credits line with its billing +period — the PR gate requires one for GUI changes. + +Closes #1060. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/060_phase6.md b/devlog/_plan/260826_quota_window_and_backlog/060_phase6.md new file mode 100644 index 0000000000..54d2c8d734 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/060_phase6.md @@ -0,0 +1,50 @@ +# 060 — wp6: evidence-backed closures + +Five items are already terminal on `dev`. Each gets a comment citing the specific code or +commit that makes it so, then closes. No code changes in this phase. + +## Issues + +**#2442 — OpenCode Go rejects `search_content_types`.** The adapter already strips that field +for Muse Spark plain `web_search` +([openai-responses.ts:1587](../../../src/adapters/openai-responses.ts)), with positive and +isolation coverage in `tests/muse-spark-web-search-compat.test.ts:38`. + +**#2423 — OpenRouter Ox Alpha HTTP 200 with an empty completion.** A terminal-less pre-output +EOF now raises a retryable empty-completion error +([empty-completion-guard.ts:309](../../../src/server/responses/empty-completion-guard.ts)), +pinned by `tests/empty-completion-guard.test.ts:326`. + +**#2060 — OpenCode Go account-pool round-robin.** Closing with an explicit adjudication rather +than a claim of equivalence, because the two are not the same thing. + +The request was continuous per-request round-robin. What ships is 429-driven failover: +`hasKeyPoolFailover` + `rotateProviderTransportOn429` +([key-failover.ts:82](../../../src/providers/key-failover.ts)) rotate to the next non-cooled +key on upstream 429 and replay the same request. + +Owner decision: **429 failover is the correct default**, and continuous round-robin belongs to +the pool feature rather than to the provider path. Rotating every request would shred prompt +caching and spread thread affinity across keys for no benefit while every key is healthy. The +comment states this as a decision, not as "already implemented" — the reporter asked for +something real and deserves to know it was considered and declined. + +## Pull requests + +**#1769 — manual paste fallback for OAuth add-account.** Superseded by `74e8ce557`, which +landed the manual redirect/code paste fallback BEFORE this PR opened. Current `dev` waits for +and validates pasted input including attempt-state matching +([oauth/index.ts:1338](../../../src/oauth/index.ts)) and exposes the management endpoint +([oauth-account-routes.ts:206](../../../src/server/management/oauth-account-routes.ts)). + +**#2215 — document V2 fork override rule.** Superseded by `7fdb2cb8e`, which documents that +full-history V2 forks inherit the parent model and that model/effort overrides need partial or +no history ([sub-agent-surface.md:68](../../../docs-site/src/content/docs/guides/sub-agent-surface.md) +and :222). + +## Verification (C) + +`gh issue view ` / `gh pr view ` reporting `CLOSED` for all five, each with its comment +posted. Contributor-facing courtesy: name the commit, not just the conclusion — a superseded +author should be able to see their work was checked rather than dismissed. + diff --git a/devlog/_plan/260826_quota_window_and_backlog/070_phase7.md b/devlog/_plan/260826_quota_window_and_backlog/070_phase7.md new file mode 100644 index 0000000000..ba8975e740 --- /dev/null +++ b/devlog/_plan/260826_quota_window_and_backlog/070_phase7.md @@ -0,0 +1,65 @@ +# 070 — wp7: backlog triage devlog + +## Deliverable + +`devlog/_plan/260826_backlog_triage/` — a factual record of the current backlog so the next +maintainer session starts from evidence instead of re-auditing 39 items. + +Two audits already ran against `dev` at `0a0a8821b` and their findings are written up rather +than re-derived. + +## Stale pull requests — 18 audited + +Verdicts, with "behind" as GitHub compare's count of `dev` commits absent from the PR head: + +- **SUPERSEDED (closed in wp6):** #1769 (`74e8ce557`), #2215 (`7fdb2cb8e`). +- **REVIVABLE-SMALL:** #2033 — 14 lines across 2 files, and a real gap: both GET and PUT + sidecar responses omit an `enabled` field + ([config-routes.ts:571](../../../src/server/management/config-routes.ts) and :809). Worth a + maintainer revival. Caveat: 869 commits behind. +- **REVIVABLE-LARGE:** #1794, #1829 (0 behind), #2050, #2122, #2299. +- **NEEDS-AUTHOR:** #1557, #1645, #1756, #2083, #2113, #2123, #2213, #2230, #2244, #2326. + +Findings worth recording because they are non-obvious: + +- **#1829 is 0 commits behind dev** with CI green — the only stalled PR that is not stale. +- **#2083 does not merely conflict, it disagrees.** The xAI image bridge landed via + `de35caa4d`, but current code returns no image credential for OAuth configurations + ([images/plan.ts:32](../../../src/images/plan.ts)) and the public guide states an API key is + required. The PR proposes the opposite contract; that is an owner decision, not a rebase. +- **#1794 is a partial duplicate, not superseded.** Core recovery landed via `9bea7707b` and + configurable OpenRouter routing via `3c6f3caa4`, but the PR's GUI exposure files have no + equivalent on `dev`. +- **#2123 is NOT superseded** by existing Antigravity quota work: per-account eligibility + still accepts Anthropic only ([quota.ts:1447](../../../src/providers/quota.ts)). +- No PR qualifies as abandoned — all 16 distinct author accounts still resolve. Conflict + volume alone was not treated as abandonment. + +## Open issues — 21 audited for quick-win feasibility + +- **QUICK-WIN:** #2406 (wp3), #1215 (wp4), #1060 (wp5) — all three implemented in this loop. +- **ALREADY-DONE:** #2442, #2423 — closed in wp6 with code citations. +- **DECLINED WITH REASON:** #2060 — closed in wp6; 429 failover is the intended default. +- **MEDIUM (140-300 lines):** #2539, #2279, #2201, #1820, #1690, #1533. +- **NOT-QUICK (250-700 lines):** #2511, #2455, #2399, #2275, #2221, #2046, #1711, #1525, #1213. + +`#1820` is the most attractive of the MEDIUM tier: the backend already computes aggregate +cache tokens and per-model estimated cost +([summary.ts:67](../../../src/usage/summary.ts)); only the GUI row types and tables omit the +columns. + +## Structure + +- `000_snapshot.md` — audit basis, date, dev SHA, method. +- `010_stale_prs.md` — the 18-row table with per-PR evidence. +- `020_issue_quick_wins.md` — the 21-row table with per-issue file:line evidence. +- `030_recommendations.md` — what to do next and in what order. + +## Verification (C) + +Files exist with the stated content, and every verdict carries a commit SHA or a file:line +pointer. A triage doc whose claims cannot be rechecked is worse than none — it ages into +confident misinformation. + +Runs last: it records what wp6 actually closed. +