Commit graph

71 commits

Author SHA1 Message Date
iamtoruk
8a812abfe5 fix(codex): stop double-billing reasoning, price cache writes at the explicit rate only
Port of #1078 (plus its #1084 hardening) onto feat/core-extraction, whose
pricing seam moved out of the provider decoder.

Fix A: OpenAI bills reasoning tokens as part of output_tokens, not on top of
it. `billableOutputTokens` in models.ts is the single source of truth for the
claude+codex carve-out; the host pricing pass and cachedCallToApiCall (the
cold/warm twins) and the models/audit display sums all route through it.
codexCredits loses its `reasoningTokens` param, which added the same
double-count back on.

Fix B: the core codex decoder reads `cache_write_input_tokens` and clamps it
into the uncached input, but leaves the bucket split to the host: only a model
whose pricing source publishes a real cache-write rate
(`cacheWriteCostIsExplicit`) may move tokens into the cache-write bucket, since
buildCosts fabricates 1.25x input when a source omits one and OpenAI charges
nothing extra before gpt-5.6. The pricing cache file is versioned so a file
written by a pre-fix binary can't read that flag back as undefined.

Cache ladder: codex results cache 12 -> 13, codex parse version gains
`-codex-pricing-v1` (the bucket move does not self-heal on read), daily cache
27 -> 28.
2026-08-21 14:44:46 -07:00
iamtoruk
81a6718add fix(core): cover the codex timing gate and the custom-tool transport
Two gaps the adversarial pass found in the throughput rehome.

The `taskOpen` gate had no behavioral coverage: deleting it left all 613 core
tests green, because the existing cases pin the flag's VALUE and never its
consequence — their resumed pass holds no pending calls, so an earlier
condition already blocks attribution. The new case splits a task with two
token_counts, so the resumed pass holds a real call and real generated tokens
but never saw the task_started. Without the gate its task_complete takes the
whole 6s window for the one call it happens to hold — double that call's share,
with the first call left unattributed. With it, nothing is attributed.

The timing branches spoke only function_call / function_call_output, while main
and the ported codex-throughput.ts also count Codex Desktop's custom-tool
transport. On the real corpus that is 11,390 tool pairs across 134 rollouts,
whose wait time was being counted as active: GPT-5.5 drops from 506,373 to
490,337 active seconds (36.5 -> 37.7 Tok/s), gpt-5.6-sol from 66,194 to 54,120
(35.3 -> 43.2). Tool NAMING for custom_tool_call stays unported, so the start
is captured in its own timing-only branch. Cost, calls and tokens are
unchanged: codex totals are still byte-identical to the base branch over the
1326-session corpus.

Also lands the adversarial append-split fixture as a permanent regression test:
cold parse == warm append parse at EVERY line split point of a rollout with two
complete tasks and a third left open at EOF, plus a three-pass variant and an
idempotence check. It kills both ways of getting the resume point wrong —
replaying past the boundary (callCount not truncated) and resuming at
end-of-file (the open task's window lost). The validator's full O(n^2) three-
pass sweep is trimmed to the boundary-crossing pairs; it adds ~90 parses and no
extra mutation coverage.
2026-08-21 14:04:32 -07:00
iamtoruk
ab3294a3e2 fix(cli): report cline-cli web-search requests, bill zero of them
Review correction. Upstream zeroes only the PRICING argument — it passes a
literal 0 as calculateCost's sixth parameter (main src/providers/cline-cli.ts
:299) while ALWAYS emitting the real count on a per-message call; only the
session-rollup path emits 0 (:349), because that path has no tool blocks to
count. The rehome zeroed the EMITTED metric on every estimated call instead,
which prices correctly but destroys the analytics field.

The count now always rides on the call, and the suppression moves to where the
billing actually happens: the pricing pass passes 0 web-search requests for
cline-cli, whose `fetch_web_content` is a page fetch rather than a billable
provider-side search. The pass is the single place estimated calls are priced
(parser.ts prices every provider call there before anything is cached), and
cline-cli's costUSD is persisted, so the cache read path cannot re-bill it
either.

The test asserted toBe(0) where main yields 1 — it certified the regression.
It now proves both halves at once: the count is emitted on the estimated AND
the metered call, and the estimated cost equals that of an identical session
with no fetch. Reverting either half fails it (count -> 0, or cost +$0.01).
2026-08-21 14:04:32 -07:00
ozymandiashh
8904b11b98
Merge pull request #1086 from getagentseal/feat/rehome-landing
feat(cli): rehome cline-cli and codex throughput (landing #940)
2026-08-22 00:01:28 +03:00
iamtoruk
8ec2753cbd feat(cli): rehome codex tool-excluded active throughput
The second half of #940 (@ozymandiashh): upstream `main` measures Codex
throughput — a task's wall time minus its recorded tool wait, divided across
the task's calls by generated tokens — and none of it exists on this branch.
Added on one side only, so a `main` merge would land `src/codex-throughput.ts`
at a path npm workspaces does not build.

Rehomed against main as it ships today, with one deliberate divergence from
#940, which the maintainer decided: main's resume design wins.

  - Timing state is captured ONLY at a `task_started` boundary, where every
    per-task accumulator is provably empty, and a task's calls are buffered
    until its window is known. No recorded call is ever mutated after it has
    been handed to the host. #940's alternative — threading the open task
    window through the serialized state and back-patching earlier-pass calls
    via applyCodexTimingPatches — is dropped in full.

  - The branch's Phase-4 token-decode resume is untouched: it stays any-offset
    and round-trip proven. Marrying the two needed one adaptation, since core
    decodes records and never sees bytes: the decoder now reports its last
    task_started as a `checkpoint` (record index + call count + state), and the
    CLI turns that index into a byte offset and replays only the calls before
    it, letting the still-open task re-derive. A pass that crosses no boundary
    keeps the previous one; a cold decode of a file with no task_started at all
    falls back to end-of-file with `taskOpen: false`, so a task_complete whose
    window this pass never saw attributes nothing rather than spreading a whole
    task's active time over part of its tokens.

Restores the three fad84662 review fixes that #940 reverted: the discovery
fast path already short-circuits on cachedProject before isValidCodexSession
(unchanged here, verified); payload-level `duration_ms` outranks any nested one
(`timingDuration ?? timingNumber('duration_ms')`), so a duration buried in an
oversized mcp_tool_call_end's invocation.arguments can no longer inflate tool
wait; and MIN_WIDE stays 90 with the Tok/s column behind a showTps gate rather
than jumping to 130 and costing 90-129 column terminals their two-column
dashboard. Also ports the fork-suppressed-task_started regression test and the
depth-1 payloadString helper (main 1d36f444/497f6556), which the branch lacked.

Scope discipline: main's codex pricing work (billableOutputTokens, #1078) is
NOT dragged along — that is #1083 — and neither are its unported parser
changes (custom-tool transport, exact token counts and MCP names on oversized
lines), so cost, calls and tokens are untouched. Verified on a 1326-session
real corpus: codex totals byte-identical to the base branch, with 1302 of 1328
model slices now carrying timing (36.5 Tok/s on GPT-5.5).

CODEX_CACHE_VERSION takes 12, clear of main's ladder (11 as of #1078) so a
cache written by either line can never be read as current by the other, and
the codex parse version bumps in lockstep so session-cache.json cannot keep
serving timing-less turns without invoking the parser.
2026-08-21 13:37:22 -07:00
iamtoruk
d64b29f64d feat(cli): rehome the cline-cli provider into the workspace layout
Upstream `main` ships a provider this branch has no counterpart for: the
Cline CLI (npm `cline`, 3.x), whose sessions live in a layout unrelated to the
VS Code extension tree `cline.ts` reads. Files added on one side only produce
no merge conflict, so a `main` merge would happily create `src/providers/
cline-cli.ts` — a path npm workspaces does not build and no registry imports.
Green build, and a whole provider quietly does not exist.

Ported from #940 (@ozymandiashh), which carried it faithfully: identical dedup
keys, all 34 upstream test cases, and the clean phase-8 split — discovery and
file I/O host-side, pure record decode in @codeburn/core, registered next to
its neighbours and deliberately separate from the shared vscode-cline tier.

Two changes on top of that port:

  - `observations.ts` follows the post-#1074 conventions: the model is routed
    through `normalizeModelIdentifier` at the observation boundary like every
    other provider, and measured cost is carried the way the sibling decoders
    carry it rather than through a cast.

  - the estimated-cost path reports zero web-search requests. Upstream prices
    that path with a hardcoded 0; letting the decoded `fetch_web_content`
    count reach the pricing pass would bill $0.01 per fetch on top of tokens,
    a billing change nobody asked for. A metered call keeps the real count —
    its dollar figure comes from the CLI, so nothing prices off it.

DAILY_CACHE_VERSION takes 27 (MIN_SUPPORTED 27): every historical Cline CLI
session contributes usage no older rollup ever contained, and usage-aggregator
serves every day before today from that cache for ten years, so without the
bump an upgrading user would keep cline-cli-less history forever while today's
numbers silently included it.
2026-08-21 13:27:57 -07:00
ozymandiashh
2223f8523e fix(core): reconcile #1074 migrations without data loss
Preserve and re-key durable Copilot history across fingerprint and privacy-key changes, retain local sent-ledger aliases for all affected providers, and retry transient durable reads without clearing cached turns.

Restore the published observation 0.2.0 contract byte-for-byte and move the model identifier hardening to observation 0.3.0.
2026-08-21 22:25:53 +03:00
Resham Joshi
de536cf55c
Merge pull request #1074 from getagentseal/fix/core-model-bound-guard-landing
fix(core): bound the model field at the observation boundary, and make the privacy guards prove it
2026-08-21 11:03:59 -07:00
iamtoruk
895639ee1f test(cli): pin the privacy-key rotation end to end; harden the key guard
Three review follow-ups.

1. The rotation guarantee is now pinned by a real parse, not by two unit
tests that both stub the fingerprint. The original defect was a wiring
bug — right value, wrong selector — and a stubbed fingerprint cannot see
a wiring bug. The new case warms the cache under key K1 (one JetBrains
turn, one call), rotates the key file to K2, appends a second turn, and
re-parses in a fresh module registry: exactly two calls, no K1-era dedup
key anywhere, on disk or in the result. Restore the substring-sniff
selector and it reports three calls for two turns — the user-visible
shape of the bug.

2. The test-helper guard compared UNRESOLVED paths, so a symlink at a
sandbox-shaped location (HOME=$TMPDIR/codeburn-test-env-x -> /Users/me)
passed the prefix test and the write landed in the real home anyway. Both
sides are realpath'd before comparing, which also stops a sibling
directory sharing the tmpdir prefix from matching. Same refusal message.

3. CHANGELOG records the two API breaks that were missing: decodeCopilot
throws on an empty privacyKey for JetBrains records, and
SOURCE_REF_KEYED_PARSE_VERSION is no longer exported (KEY_DERIVED_PROVIDERS
replaces it).
2026-08-21 10:59:50 -07:00
iamtoruk
e7c6dc1560 test(cli): refuse to overwrite a real privacy key from the test helper
fixed-privacy-key.ts writes <home>/.config/codeburn/privacy-key
unconditionally. Under vitest that is the throwaway sandbox
env-isolation.ts mints, and harmless. Imported from anywhere else — tsx,
a stray node -e, a REPL — homedir() is the developer's real home and the
write silently replaces their key, re-keying every resource fingerprint
and orphaning whatever was already synced. privacy-key.ts refuses to
cause that on its own; a test helper must not cause it either.

The sandbox has a recognizable shape (a mkdtemp dir under tmpdir() named
codeburn-test-env-*), so the guard needs no new marker: anything else
throws with the reason.
2026-08-21 10:48:21 -07:00
iamtoruk
52e6678cd3 fix(cli): select the privacy-key fold set explicitly, not by substring
computeEnvFingerprint decided which providers re-parse on a privacy-key
change by sniffing 'source-ref-fingerprint-v1' out of the parse-version
string. That silently missed copilot. Copilot's dedup keys are just as
key-derived — createHmac(privacyKey) over the JetBrains reply text — but
its parse version reads '…-dedup-key-hmac-v1', so its fingerprint held
still across a key rotation.

Copilot is the sole DURABLE provider: its union-merge never deletes
cached turns, it appends any turn whose key is not already cached. A
fingerprint that does not move therefore does not merely re-parse — it
keeps the old key AND appends the new one for the same turn. Two real
turns became three calls. And a corrupt or unreadable key file yields a
fresh ephemeral key in EVERY process, so that inflation compounded on
every run.

KEY_DERIVED_PROVIDERS lists the seven explicitly. A provider is in the
set because of what its decoder does, which no substring of a version
label can know. The fold also moves out from under `if (parseVersion)`:
the key dependency comes from the decoder, not from having a parse
version entry.

The bridge comment claimed the fold covered "the affected providers" and
that the ephemeral-key fallback only happens when the cache cannot
persist. Both were false — the config dir (~/.config/codeburn) and the
cache dir (~/.cache/codeburn, or CODEBURN_CACHE_DIR) are different,
separately overridable directories, so a writable cache plus an
unreadable key file means a new key every process. Corrected.

Tests: the rotation pair now covers all seven key-derived providers and
five unaffected ones, plus a dedicated copilot case that pins the exact
regression (parse version has no source-ref token, provider is durable,
fingerprint must still move).
2026-08-21 10:48:21 -07:00
iamtoruk
5c4a8a5299 chore: hold workspace versions at 0.9.20, defer the bump to release
Maintainer call: the diagnostics/observation breaking changes are
documented in the CHANGELOG now, but the version number moves at the
next actual publish (core before cli), not on the branch. The 0.x bump
owed for the core API break is recorded in the CHANGELOG heading.
2026-08-21 10:38:17 -07:00
iamtoruk
0fee325b24 Merge branch 'feat/core-extraction' into fix/port-misc
Conflict resolution rules applied:

- packages/cli/src/daily-cache.ts (the only textual conflict): kept the BASE
  side of the version constants and their coordination comment verbatim
  (DAILY_CACHE_VERSION / MIN_SUPPORTED_VERSION stay at 26 — this change moves
  when the watermark advances, not what any cached day contains, so no
  re-derive is owed). Kept the BASE widened re-derive window
  (DAILY_CACHE_RETENTION_DAYS, with its straddle/tz comment) and layered this
  branch's completeness logic on top of it: capture the parse result
  (freshProjects) so sessionComplete() can read the tag off the exact array,
  priorWatermark hold-back on a partial parse, complete: parseWasComplete, and
  the watermarkTrusted stamp.

- packages/cli/src/parser.ts: auto-merged; #929's per-call slicing and #930's
  discovery changes are intact, and this branch's read-only-path additions
  (readOnlyServedStale on the stale/missing-cache-entry arms, network providers
  served from cache instead of re-fetched, hydration completeness tagged onto
  the result array) attach to the post-#929 code paths. Re-read end to end
  after the merge.

- packages/cli/tests/cache-refresh-lock*.test.ts, vitest.config.ts,
  package.json: kept the base config values (testTimeout 30s, retry 2, the
  lock-quarantine test/test:locks scripts, #1068's inode fix, #921's retries)
  and added this branch's corrupt-lock arms on top. The new describe's retry
  is 3, not 6, per review. Added the new corrupt-body file to test:locks:
  the base `test` script excludes tests/cache-refresh-lock*, so the new file
  would otherwise run in no suite.

- Everything else auto-merged as a union; no hunk from either side dropped.
2026-08-21 10:34:13 -07:00
iamtoruk
916f32530e Merge origin/feat/core-extraction into fix/port-kiro
Resolves daily-cache version coordination: base landed v25 (#930); this
PR's kiro chat-file fix takes the next number, v26, since it changes
historical kiro chat-file numbers and needs its own one-time
re-derivation. Session-cache kiro parse fingerprint bump (v1 -> v2) is
kept alongside base's opencode fingerprint addition. CHANGELOG entries
combined (keep-both), with the kiro entry's cache-version reference
updated to v26.
2026-08-21 10:21:52 -07:00
iamtoruk
e1234b0312 chore: 0.10.0 and changelog for the observation-boundary hardening
@codeburn/core takes a minor bump under 0.x semver: the diagnostics API
is a breaking change (DIAGNOSTIC_DETAIL_MAX deleted, sanitizeDetail
signature, DiagnosticDetail retightened) and observation-0.2.0.json is
tightened in place, so an archived pre-hardening envelope whose model
held a display name no longer validates against that same version string.

check-workspace-versions requires root, cli and core to move together, so
all three (and package-lock.json) go to 0.10.0. Publish order at the next
publish is core before cli, as always.
2026-08-21 10:18:58 -07:00
iamtoruk
fb433df656 fix(core): key the diagnostic detail and copilot's JetBrains digest
A diagnostic detail was "any string without a path separator, capped at
200 chars". That let a path with no slashes, a command fragment, a prompt
line, or an API key through verbatim — the rule was structural but it
guarded the wrong structure. A detail is now the 16-hex HMAC-SHA256
fingerprint of the offending input under the host privacy key: identical
failures dedupe, distinct failures differ, and no substring of the input
survives. A caller with no key omits the field entirely rather than
emitting an unkeyed digest, which is what `keyedDetail` is for.

isolateRecords is the only place a detail is derived. Diagnostics a
caller RETURNS are trusted for index and code only; any detail they carry
is stripped, so an unkeyed or caller-invented digest cannot cross the
boundary through a loose cast. The three decoders that reported
malformed-json (opencode-session, vscode-cline, zed) are wired to it.

Copilot's JetBrains dedup key embedded an unkeyed sha256 of the
assistant's REPLY TEXT — dictionary-attackable for short replies ("OK",
"Done.") — and it crosses into the envelope and the CLI ledger. It is now
an HMAC under the host privacy key, with a
`cli-shutdown-cost-v1-skills-dedup-key-hmac-v1` parse version: copilot is
the sole durable provider, so its union-merge would otherwise keep the
old-shape keys and append the new ones for the same records.

Breaking for consumers: DIAGNOSTIC_DETAIL_MAX is gone, sanitizeDetail
takes a key, DiagnosticDetail accepts only the fingerprint, and
RecordOutcome.diagnostics may no longer carry a detail.
2026-08-21 10:18:58 -07:00
iamtoruk
a4be04d926 fix(core,cli): fingerprint the source path out of dedup keys
dedupKey is a CallObservation field: it ships on the envelope. Six
decoders — codebuff, zerostack, pi, omp, grok and lingtai-tui — were
folding the absolute source path (a chat directory, a session file) into
it raw, so a host path rode every payload derived from those sessions.

They now fold `sourceRefFingerprint`, a keyed HMAC-SHA256 under a new
`source` domain. Like every other fingerprint in that module the key is
required and an empty key throws, so a source ref can never degrade to an
unkeyed, dictionary-attackable digest.

That means the CLI bridge has to supply a real key. It passed `''`, which
was correct when minimization happened only on the sync path; it now
threads getHostPrivacyKey() — per-install stable, so dedup keys stay
stable across runs.

The keys change VALUE, so the six carry a `source-ref-fingerprint-v1`
parse version that forces one re-parse and drops the cached raw-path keys
instead of re-ingesting the same records under two shapes. A parse
version cannot see the privacy key change, though, and a lost, rotated,
or ephemeral (unwritable config dir) key would silently produce keys that
never match the cached ones — so computeEnvFingerprint folds a digest of
the key in for exactly those providers.

The five bridge parity goldens used to compute their expected keys with
the same function and key production uses, which pins nothing. They now
re-derive the fingerprint longhand under a pinned test key, assert the key
SHAPE, and assert the raw fixture path appears nowhere in it.
2026-08-21 10:18:58 -07:00
iamtoruk
63196ed449 merge feat/core-extraction: keep both CHANGELOG entry sets 2026-08-21 10:01:49 -07:00
iamtoruk
11bd721c01 test(sync): assert repo path and commit subject never hit the wire
Strengthens the --attribution wire assertions to confirm the local
repo directory and a fixture commit subject never appear in the raw
OTLP payload, not just that known-sensitive markers are absent.
2026-08-21 09:32:11 -07:00
iamtoruk
9045f75f8e Merge remote-tracking branch 'origin/feat/core-extraction' into pr932-fix 2026-08-21 09:30:16 -07:00
iamtoruk
aaa73da654 merge feat/core-extraction (#929 landed), re-bump daily-cache to 25
#929 merged mid-task and claimed DAILY_CACHE_VERSION 24 for the codex
structural-discovery + midnight-straddle fixes. This PR's pi/omp/cline/
opencode discovery fix now takes 25, the next free number, so it forces
its own one-time re-derivation on top of #929's. Test literal updated to
match (pre-fix pinned at 24, the base's actual value before this bump).
2026-08-21 09:22:37 -07:00
iamtoruk
789fb53cb5 test(session-cache): cover opencode/kilo-code parser-version bumps
No test exercised the reparse gate for the PROVIDER_PARSE_VERSIONS entries
this PR bumps. Reproduce each provider's pre-bump fingerprint and assert it
now differs, proving a cache keyed under the old fingerprint is treated as
stale.
2026-08-21 09:21:39 -07:00
iamtoruk
cc774f812a test(daily-cache): derive PRE_FIX_CACHE_VERSION from the actual base version
The seeded old-cache version was a guessed 16 that was never this branch's
real pre-fix version, so the re-derivation test passed even with the
version bump reverted. Pin it to 23 (the base's DAILY_CACHE_VERSION right
before this PR's bump), verified to fail when the bump is reverted.
2026-08-21 09:21:34 -07:00
Resham Joshi
142ee4dbfd
Merge pull request #929 from ozymandiashh/fix/port-parser
fix(parser): keep both halves of a midnight-straddling turn, and stop --provider leaking claude
2026-08-21 09:19:48 -07:00
iamtoruk
0e2f75ab86 merge feat/core-extraction into pr930-fix, resolve daily-cache version conflict
Base moved to DAILY_CACHE_VERSION 23; this PR's discovery fix now takes 24
to force its own one-time re-derivation on top of the codex structural-
discovery bump. CHANGELOG keeps both entries.
2026-08-21 09:16:17 -07:00
iamtoruk
1e1b3635b9 test: guarantee a distinct inode in the file-replaced edge case
unlink-then-recreate at the same path lets ext4 hand the freed inode
straight back, which CI runners reproduce reliably; the assertion then
fails as 'expected N not to be N'. Write the replacement beside the
original while the old inode is still allocated, then rename over it.
2026-08-21 09:14:31 -07:00
iamtoruk
0e591d66aa revert(main): drop the buildJsonReport dailyMap hunk — the fallback is unreachable (both call sites always pass durable); leave dead code untouched, file follow-up instead 2026-08-21 09:09:27 -07:00
iamtoruk
90e6c8b67a test(parser): pin the re-anchor case on the claude path, add the straddle report test
The "anchor sits before the range" test ran on the codex path, where the
turn anchor is derived from the first call — the scenario it describes cannot
occur there, so it passed with parser.ts fully reverted. Moved to the claude
path, where turn.timestamp is the user-message time and can precede the first
assistant call. Verified: fails on pre-fix parser.ts with the 23:57 anchor,
passes with the fix.

Added a `report --format json` test for a turn whose two calls straddle local
midnight, asserting both days appear with a one-call-each split and that the
per-day costs still sum to the headline.

Also drop the `?? classifiedFull` fallback in both parseProviderSources loops.
turnSlicedToRange has already proved a call is in range over the same call
list, so the fallback is unreachable — and if it were ever reached it would
silently restore the whole turn and reintroduce the straddle bug rather than
fail loudly.
2026-08-21 09:06:06 -07:00
iamtoruk
08922bdc74 Merge branch 'feat/core-extraction' into fix/port-parser
Conflict: packages/cli/src/daily-cache.ts. #926 landed
DAILY_CACHE_VERSION/MIN_SUPPORTED_VERSION at 23 for the structural codex
discovery re-derivation; this branch had claimed 17 for the midnight-straddle
re-derivation. Kept #926's rationale as the foundation and took 24, the next
free number, with the straddle reason appended: per-call day slicing changes
which day a historical straddling call lands on, so it needs its own one-time
re-derivation and cannot ride on a 23 cache written by a #926 binary.
2026-08-21 09:05:52 -07:00
iamtoruk
34d50e1185 fix(cache): bump DAILY_CACHE_VERSION to 23, not 16
Base has moved since this branch opened: main shipped 17 in v0.9.20 and
now sits at 20, with 21 (#946) and 22 (#1056) claimed on the main-side
pipeline. Bumping to only 16 would let a main-built cache pass the
version check unchanged, so the widened Codex discovery re-derivation
this PR depends on would never fire for those users. Take 23 to stay
above every value a real cache file can carry on either line of
history, and update the test literal and MIN_SUPPORTED_VERSION to
match.

Also documents, next to CODEX_CACHE_VERSION, why that constant is
deliberately not bumped alongside it: the guarded record shapes were
measured at 0 occurrences across 136k real events, so forcing a full
re-parse of multi-GB rollout corpora for them is a bad trade. The
daily-cache bump alone already propagates the discovery widening.
2026-08-21 08:51:27 -07:00
iamtoruk
dc630a4c94 Merge remote-tracking branch 'origin/feat/core-extraction' into pr926-fix 2026-08-21 08:47:57 -07:00
Resham Joshi
e604f58f44
Merge pull request #925 from ozymandiashh/fix/junk-reads
fix(optimize): one junk vocabulary for the count, the trend and the display
2026-08-21 08:41:52 -07:00
iamtoruk
a23d0526a0 test(cli): restore CI timeout headroom and lock-suite quarantine lost in packages/cli split
The packages/cli move (dc97ab49, Jul 26) forked vitest.config.ts and the test
script off main before three since-merged CI-stability fixes existed there,
and #921's later "port upstream fixes" pass (f3f5814b) missed all three since
it only cross-referenced one specific main commit:

- 30037b33: global vitest testTimeout raised from the 5s default to 30s
  (real-I/O tests exceed 5s under CI runner load)
- 8c758ddf: file-level 30s timeout on cli-status-menubar.test.ts (spawns the
  real CLI; individual cases observed needing 6-8s on a shared 2-core runner)
- 4ca2d482: cache-refresh-lock tests excluded from the parallel `test` run and
  quarantined behind a serial `test:locks` script (they contend for the fork
  pool with the spawned-subprocess tests and starve everyone under load)

None of this showed up until #923 wired `npm test --workspace=codeburn` into
CI for the first time, which then failed deterministically at the vitest
default 5000ms on exactly the three test categories these fixes cover.

Ports all three, matching main's current config/script.
2026-08-21 08:28:55 -07:00
iamtoruk
5ea455ecbe test(cli): price off the bundled snapshot; fix platform-dependent proxy-path assertion
Two pre-existing CLI-suite failures blocked #923's new cli CI job on Linux:

- tests/models.test.ts DeepSeek v4 pricing (x3): loadPricing() fetched the
  live LiteLLM table during tests, and live data wins over the bundled
  snapshot, so the DeepSeek v4 assertions went red when upstream repriced.
  Ports main's fix (4866332b): CODEBURN_PRICING_SNAPSHOT_ONLY skips the fetch
  and prices purely off the bundled snapshot; env-isolation.ts sets it for
  the whole suite.

- tests/parser-proxy-pricing.test.ts case-insensitive isProxiedPath: the
  branch's isProxiedPath/normalizeProxyPath is byte-identical to main's
  (case folding is deliberately darwin/win32-only, per the function's own
  comment). The test hardcoded the macOS-only expectation, so it fails by
  design on Linux CI. Main hit and fixed the same thing (bcf11552); this
  ports that fix verbatim: assert the platform-correct behavior instead of
  a hardcoded `true`.
2026-08-21 08:11:34 -07:00
ozymandiashh
f29d27e39d feat(sync): push git attribution spans, with the hardening that followed
Ports three upstream commits this branch never received: the attribution
feature (1bf7206), the review hardening on top of it (ccee28a), and the
security follow-up that closed credential-leak paths and added session
retraction (50c8251).

They are ported as an end state rather than in sequence. Two and three revise
one, so replaying them in order would have introduced the very issues they fix
and then removed them again — and anything missed in the third pass would have
shipped a feature with a reopened hole, which is the specific way this port
could have gone wrong. The credential-leak paths that commit closes are
enumerated and checked off individually against the result.

One correction to an earlier draft of this message, which claimed no new
unkeyed digest is introduced. That was wrong: stateHash in sync/otlp.ts is a
new unkeyed sha256, and it feeds deriveSpanId, so it is an input to a value
that goes on the wire. It is not a D1 violation — D1 governs core's fingerprint
module and its caller-supplied key, while stateHash is a local ledger
discriminator computed over data that is itself sent in cleartext, so it hides
nothing and leaks nothing. But the sentence was false and is worth correcting
rather than quietly dropping.

This branch now carries #931's commit (c467548, "fix(sync): key the device,
span and trace digests") beneath this one — cherry-picked onto the shared base
so the history stays two clean commits. That ordering is load-bearing:
reconciliation is mandatory in every merge order, not optional. git merge-tree
reports no conflict against #931 in either direction, yet the merged file does
not compile: #931 drops the createHash import and gives the derive functions a
privacyKey first argument, so an unreconciled attribution section leaves
stateHash with an undefined symbol and two one-argument call sites. Rebasing
replays the same breakage, which is why the earlier "land #931 first, OR
reconcile" framing was wrong.

The two call sites are reconciled INTO #931's keyed signatures, in the
direction #931 demands: buildAttributionOtlpPayload obtains the persisted host
privacy key exactly as buildOtlpPayload does — one getPersistedHostPrivacyKey
call per builder, no second source of the key — and threads it into
deriveTraceId and deriveSpanId. This is the security point of the
reconciliation: loosening the signatures back to one argument would
reintroduce exactly the unkeyed span and trace ids #931 exists to remove, in
new code.

stateHash stays unkeyed, deliberately: it is a local ledger discriminator over
One more merge-compat fix, in #931's own test file (sync-privacy-key.test.ts):
the concurrency fixture path was built from process.cwd(), which is the repo
root under `--root packages/cli` — the worker then exited on a nonexistent
file before writing its ready file and the race test timed out. The path is
now anchored to the test file's own location (fileURLToPath(import.meta.url)).
This is the only line of #931's tree this branch touches; 37a5b46 remains a
verbatim copy of c467548.

Second fix in #931's tree, same motivation: the concurrency race test
adopted with only a 50ms budget. createKeyFileExclusive polled the winner's
file 5x10ms after EEXIST, and the strict entry check refused an 'invalid'
file INSTANTLY — but the winner's create (open) and write are separate
syscalls, and under load the loser can read the still-empty file either at
entry or inside the poll. Both windows now share one bounded awaitValidKey
(500ms) that ADOPTS the winner's key when it lands and otherwise throws the
same refusal. Nothing is ever overwritten; a file left invalid by a crash or
truncated write still fails loudly. This is the second #931 file this branch
touches; 37a5b46 remains a verbatim copy of c467548.
2026-08-05 18:50:23 +03:00
ozymandiashh
6247e40a3c fix(cache): port #920 provider-env fingerprint declarations onto the extraction layout
The env-fingerprint fix ships on main via PR #927, but merging the
extraction branch as-is would silently drop it: the CLI moved from src/ to
packages/cli/ and rename detection does not carry the declaration change
across, so PROVIDER_ENV_VARS here declares fewer providers than main. A
provider absent from that map is never cache-invalidated when the user
changes the env var that relocates its data — codeburn keeps serving
cached sessions from a directory the user has since pointed elsewhere,
which is the exact defect #920 fixed. The extraction side had nothing #927
lacks, so this is a strict-superset port of the end state.

Port the full end state of fork/fix/920-provider-env-fingerprints, not
just the nine headline providers: the follow-up commits changed existing
declarations too (claude, cursor, goose, crush, ibm-bob gained vars;
cursor's entry was corrected from XDG_DATA_HOME to
CODEBURN_CURSOR_MAX_BUBBLES), added the vercel-gateway credential
declaration with the read-only-refresh rationale, and reworked doctor to
skip ambient Windows paths (APPDATA/LOCALAPPDATA) and redact credential
values.

Declarations added or extended for: codebuff, claude, crush, cursor,
goose, grok, ibm-bob, kilo-code, kimi, kiro, mistral-vibe, mux,
open-design, vercel-gateway, zerostack. Copilot stays deliberately
undeclared (declaring it would force the durable re-parse that loses
pruned OTel history); the guard test pins that intent.

cline-cli has no counterpart on this branch: the provider file exists only
on the sibling fix/rehome-new-files branch, and this branch's cline is a
bridged scanner with zero process.env reads, so the CLINE_* vars #927
declared have nothing to attach to here. The ported guard test will fail
loudly if/when cline-cli lands, exactly as intended.

Tests ported and adapted (paths only): the provider-env-declarations
static guard (every process.env read in src/providers must be declared or
allowlisted, file-scoped), the #920 fingerprint cases, and the doctor
override/redaction/ambient cases. The CODEBURN_VERBOSE allowlist moved
from sqlite-session-parser.ts (original) to opencode.ts and kilo-code.ts,
where that read lives on this branch. Each test was confirmed FAILING
against the pre-port map: 10 fingerprint cases, 4 doctor cases, and 24
guard findings across 12 providers.

No cache version bump: computeEnvFingerprint hashes the map at runtime,
so the changed map already changes the fingerprint for exactly the
affected providers and forces their one-time re-parse; a version bump
would re-parse every provider globally for no reason.

Verify: npx vitest run tests/session-cache.test.ts tests/doctor.test.ts
tests/provider-env-declarations.test.ts --root packages/cli (109 pass);
npm test --workspace=@codeburn/core (509 pass).
2026-08-05 18:49:55 +03:00
ozymandiashh
37a5b46f85 fix(sync): key the device, span and trace digests
The sync path derived three identifiers with bare SHA-256 and sent them to a
configured endpoint.

`deriveDeviceId` hashed `hostname:username` and truncated to 64 bits, commented
"pseudonymous, stable". An unkeyed digest of a host and username pair is not
pseudonymous against anyone who can guess plausible values: hash the guess,
compare, done. `deriveSpanId` hashed the dedup key — and for pi, zerostack,
lingtai-tui and codebuff that key embeds the raw absolute source path, home
directory included, because the bridge passes `source.path` straight through.
Guess a plausible home and project name and the same confirmation works.

This is the project's own standard, not an outside opinion. Decision D1 requires
a caller-supplied HMAC key for fingerprints precisely so digests of paths cannot
be dictionary-attacked, and core's fingerprint module throws on an empty key to
enforce it. The sync path bypassed the primitive entirely. It also contradicted
the project's own user-facing guarantee: docs/sync/README.md promises that
code, file contents, diffs and PATHS stay local, and the unkeyed span id shipped
absolute paths (for the four providers above) in a form confirmable by anyone
with a plausible guess.

All three ids are now HMAC-SHA256 under the per-install privacy key — the same
key core's fingerprints use — with domain prefixes so one value in two
positions never yields the same digest, and composite inputs joined with the
same ASCII Unit Separator (0x1f) core/fingerprint.ts uses so a value containing
':' cannot forge a field boundary. The derive functions throw on an empty key
rather than degrading. The payload builder obtains the key itself, so the
decode path, which runs with an empty key by design, never reaches it.

Sync now REQUIRES the persisted key: privacy-key.ts exposes a strict variant
that aborts the push instead of falling back to per-process randomness when the
config dir is unwritable, and refuses to silently regenerate a key file that
fails validation (truncated by a full disk, a partial write). Cross-process id
stability is load-bearing — partially rejected batches are not ledgered
precisely because deterministic span ids make full-batch retry safe — so a
per-process fallback key would emit fresh ids on every retry and let the backend
double-count accepted spans, and a silent re-key would orphan everything already
pushed. The fingerprint consumers keep the tolerant fallback: they only need
per-process stability.

The refusal is now complete, and enforced for every corrupt shape: "no file at
all" is the only state a first use may create. A file that exists but is
unreadable, zero-byte or whitespace-only (a partial write), or fails hex
validation aborts the push and is left untouched — treating those as MISSING
would silently regenerate the file and re-key every id, which is exactly the
case the strict path exists to refuse. First creation is also exclusive
(O_CREAT|O_EXCL): when two processes race the first use, the loser re-reads and
adopts the winner's key, so concurrent pushes can never mint different keys and
mix cached device ids with spans derived from the other.

Scope, stated honestly: sync is opt-in and needs an endpoint plus credentials,
the digests are of identifiers rather than prompts or file contents, and this
predates the extraction. It is not an active leak of user content. It is a weak
construction the project already knows how to do properly. This change narrows
the exposure rather than closing it: ai.project still ships a project name in
the clear, and in one Claude fallback path that name is a dash-encoded absolute
path.

Blast radius: every id is re-keyed once at upgrade, so anything already pushed
stops correlating with new sends and the backend sees a fresh device identity.
Ids stay stable afterwards unless the key file is lost. The host-side sent
ledger keys off the raw dedup key and is unaffected, so re-push filtering keeps
working.
2026-08-05 18:37:50 +03:00
ozymandiashh
6f5793b063 fix(cli): restore pi/omp, cline and opencode session discovery
Three discovery paths silently dropped sessions. Each fix carries a regression
test that fails against the current code.

**pi/omp** inspected only the first physical line of each transcript. When Oh
My Pi started writing a `type: "title"` slot line ahead of the session header,
every OMP session became invisible. Discovery now scans a bounded twenty
leading lines for the session record, skipping blank and malformed lines rather
than giving up; the bound caps how many LINES are inspected (twenty parse
attempts per file), so a message-only or pathological transcript costs at most
twenty line reads instead of a scan to EOF. It does not cap bytes: the
streaming reader buffers one physical line whole, so a single oversized line
is bounded only by the stream reader's cap, not by this bound. Discovery also
validates the session record's `cwd` before `basename`, falling back to the
project directory for a malformed non-string value — a `cwd: 42` record no
longer crashes discovery.

**cline** scanned only the VS Code stable globalStorage root, so sessions
written under Code - Insiders or VSCodium were never found. All variant roots
are scanned now, matching the roo-code and kilo-code siblings, plus Cline's
home-data root, deduplicated by task id with the newest copy winning.

**opencode and kilo-code** had a session-level fallback query selecting
`model_id` — a column neither schema has; both store `model` as JSON text. On
any real database the query threw, the fallback returned null, and the
zero-yield session rollup never emitted a call. Usage for interrupted or
user-only sessions was simply missing, with no error surfaced. The query now
reads the real column and reports `providerID/id`, which the display-name and
pricing paths already canonicalize. A kilo-code mirror of the opencode
zero-yield fallback test pins the fix on the kilo path end to end.

**Versioning.** The per-provider parse fingerprints (session-cache.ts) are
bumped so already-cached sessions re-parse once and pick up the working
fallback; they do not collide with the sibling ports. The shared
DAILY_CACHE_VERSION/MIN_SUPPORTED_VERSION is bumped 16 -> 17: a warm
complete daily cache skips re-hydration, and the ten-year retention window
would otherwise keep serving the pre-fix opencode/kilo/pi/omp zeroes forever,
exactly where this fix matters most. The bump forces the one-time re-derive;
a regression test seeds the pre-fix v16 complete cache and proves the
recovered usage lands. v16 itself is skipped: main already spent it on the
codex structural-discovery fix (eece4cf), so claiming 16 here would load a
main-built v16 cache as current and complete and the invalidation would
never fire.

One caveat: pi/omp discovery now uses the streaming reader with its 4 GB cap
while parse still caps at 128 MB, so a file between those sizes is listed at
discovery and skipped at parse. No usage delta, but the asymmetry is real.
2026-08-05 17:16:02 +03:00
ozymandiashh
bc5a85df3e fix(kiro): estimate chat-file input tokens from every human turn, and invalidate cached history
decodeKiroChatFile estimated input tokens from pendingUserMessage - the last
human turn sliced to 500 chars - while output summed every bot char, so any
multi-turn session or final prompt over 500 chars under-reported input tokens
(and therefore costUSD) severalfold.

The estimate is now the sum of every human turn's full character count, with
the 500-char cap kept for the display userMessage only. That closes most of
the gap but does NOT reach parity with the modern-execution, CLI-session and
V2 arms: those count tool and system records as input - their code comments
state tool results are fed back to the model - while the chat arm still
counts only human records. Tool content demonstrably exists in the format
(the G2 fixture carries a tool record), so the chat arm still under-reports;
it just under-reports far less than before. The blast radius is the
chat-file arm alone: the IDE-file dispatcher routes any record carrying a
chat array plus metadata to decodeKiroChatFile, so this is chat-shaped Kiro
IDE files, not every Kiro prompt.

The identity-preamble exclusion now trims leading whitespace before its
startsWith match. Pre-fix, a near miss (a leading newline, a BOM, a wrapper)
was nearly harmless, because the preamble only mattered if it happened to be
the last human turn; post-fix, every unmatched system-injected human record
adds its FULL length to input, and preambles are large - a missed match is a
silent multi-thousand-token inflation on every affected session. Leading
whitespace tolerance is cheap (a genuine prompt never starts with whitespace
plus an identity tag) and the failure asymmetry favours exclusion: a false
negative inflates tokens, a false positive only skips a preamble. A renamed
preamble remains a residual risk, noted in the near-miss regression test.

Cached history is affected, which is what a user actually sees. session-cache
serves unchanged files without invoking the provider parser, so bump kiro's
PROVIDER_PARSE_VERSIONS fingerprint (ide-parsing-v1 -> v2) to force one
re-parse of every already-cached kiro session; without it the pre-fix token
and cost numbers would be served forever.

The daily rollup ALSO needs invalidating for this fix to be fully visible:
days finalized before the fix keep their pre-fix kiro cost in the daily
cache, and ensureCacheHydrated re-derives them only on a version bump, a
savings-config change, a timezone change, or an incomplete cache — the
session-cache re-parse alone leaves finalized day totals untouched. So this
commit bumps BOTH layers: the session-cache PROVIDER_PARSE_VERSIONS
fingerprint above forces the one re-parse of every already-cached kiro
session, and DAILY_CACHE_VERSION (15 -> 17, MIN_SUPPORTED_VERSION raised
with it; 16 is skipped because main already claimed it for the codex
structural-discovery fix, and claiming 16 here would load a main-built v16
cache as current and complete, so the invalidation would never fire) forces
the daily rollup's one-time re-derivation, so finalized day
totals are rebuilt under the corrected estimate. The re-derive reaches every
day whose kiro chat files still exist; sourceless days carry forward with
their pre-fix totals under the v14 NEVER-LOSE rule (a carry-forward, not a
refresh — nothing can reconstruct them once the files are gone).

Update the G2 parity golden: A1 was pinned at 125 tokens for a 3000-char
prompt (the 500-char slice / 4); the corrected value is 750 (3000 / 4), with
a comment marking 125 as a pre-fix value so it is not restored. 3000, 2400
and 1000+1000 are all exact multiples of four, so add G2b pinning the
estimator's rounding with an odd length (3001 chars -> 751 tokens; round and
floor would both give 750). Add a money-path regression test (2400-char
prompt -> 600 tokens, userMessage still 500-capped for display), a
multi-turn accumulation test (an identical resubmitted prompt counts again -
a real second model input; identity messages stay excluded), a near-miss
identity test (leading-newline and BOM preambles stay excluded), extend the
kiro cache-invalidation test to pin the v1 -> v2 fingerprint bump, and add a
daily-cache regression test seeding a complete pre-fix v15 cache (unchanged
savings hash and timezone, so nothing but the version bump can invalidate
it) and proving the bump forces the re-derive that lands the corrected kiro
cost — while the v15 file is never rewritten.
2026-08-05 17:15:39 +03:00
ozymandiashh
556b59d7d8 fix(parser): keep both halves of a midnight-straddling turn, and stop --provider leaking claude
Ports upstream fixes for two defects this branch never received.

**A turn that spans local midnight was filtered as a unit.** The range and day
filter keyed on the turn's FIRST call, so every later call that landed in the
requested day was discarded along with it. A long autonomous Codex run, or
Claude work that crossed midnight, made `codeburn today` under-report until the
turn ended, and multi-day totals attributed the whole turn to its start day.

Range and day filters now slice inside the turn: only the calls inside the
requested window survive, and the turn's timestamp re-anchors to its first
surviving call so turn-anchored rollups — category, editTurns, oneShotTurns,
the daily cache — land on the day the retained calls actually happened. Cost,
calls, savings and tokens bucket under each call's own local day, so day N plus
day N+1 conserves the whole-range total. A sliced turn is still classified from
its FULL call list, because category, hasEdits and retries describe the whole
exchange rather than the surviving slice — matching the Claude path.

**The inverse leak hit provider-filtered runs.** `--provider <other>` still
entered the claude scan, whose orphan pass read the entire cached claude
section, treated every cached PR-bearing transcript as no-longer-discovered and
re-injected it. By Project and By Model listed Anthropic spend under
`--provider cursor` while the headline showed cursor alone. The scan is now
guarded by an explicit in-scope check — deliberately not a directory-count
check, so when claude IS in scope but every transcript has been pruned,
PR-attributed orphans still survive.

**The daily cache is bumped to 17**, because leaving it at 15 would double-count
the post-midnight half of a straddling turn for an upgrading user. A v15
rollup finalized by the pre-fix binary holds the WHOLE turn on its start day,
and the new slicing then also puts the post-midnight call on the next day —
the same cost twice, in a cache whose ten-year retention never ages it out.
The bump mints a fresh filename; adoption marks the merged result incomplete,
so the next hydration re-derives every day whose sources survive under
per-call bucketing and carries forward only what it cannot re-derive.
16 is deliberately skipped: main already spent it on the codex
structural-discovery fix (eece4cf), so claiming 16 here would load a
main-built v16 cache — which holds only the codex fix — as current and
complete and the invalidation would never fire.

Blast radius: daily-history rows, the JSON daily fallback and range-query
session totals change shape for straddling days, as call-derived values move
to the call's own day — the intended correction, asserted by this commit's
tests. The session-cache FORMAT is unchanged.

One caveat worth stating rather than leaving to be found. The multi-day
all-provider By Activity rollup still derives today's slice from the unsliced
range parse, so a straddling turn's category cost stays anchored on its start
day and categories can sum below the headline on that surface; upstream has a
follow-up for it.
2026-08-05 17:15:30 +03:00
ozymandiashh
62bfa16b03 fix(codex): validate rollouts structurally, guard the parse path
Ports two upstream fixes this branch never received (eece4cf, 4ff3497).

**Discovery gated on a client identity string.** `isValidCodexSession`
required `payload.originator` to start with "codex". But `originator` is a
free-form client identity, not a format marker: anything driving
`codex app-server` writes structurally identical rollouts with its own value —
"t3code_desktop", "JetBrains.IntelliJ IDEA", whatever ships next. Every
third-party frontend was silently dropped, and each one needed a new allowlist
entry (#626, #873). Validation is now structural.

Be clear about what that gate was and was not. It was never a security
boundary — anyone able to write into the sessions directory could write
`"originator":"codex-cli"` and pass it. It was accidental integrity
protection, and removing it widens what gets ingested from those directories
to any well-formed `session_meta` line. The trust boundary is unchanged: write
access to the Codex home, which is itself configurable via `CODEX_HOME`. A
crafted rollout can inflate cost or impersonate a project path, exactly as it
could before by spelling the originator correctly.

**Non-string fields on the parse path.** A garbage `timestamp` threw
RangeError out of `toISOString()` and zeroed the session. Guarding only that
one would have been the smaller half of the problem: the timestamps that reach
emitted calls were unguarded too, and a numeric one produces `NaN-NaN-NaN` day
buckets that the daily cache then keeps for ten years — silent, and persistent.
Token counts could go NaN and slip past a `=== 0` check into reported cost.
`session_id` and `forked_from_id` could coerce an object into a dedup key.
All of those are now guarded; the fields still read through a raw cast are
listed nowhere, because there are none left on this path.

**The cache is bumped to 16.** Rollouts rejected before they were ever parsed
now contribute usage, and nothing downstream can notice: the aggregator serves
every day before today from this cache, with ten-year retention, so an
upgrading user would keep pre-fix history forever while today disagreed with it.

Reviewers split on this bump and both arguments are worth having. For it: cache
versions are per-branch lineage, and landing unbumped leaves a user on this
branch with stale history that merging main cannot repair retroactively.
Against: most users are unaffected and pay a full re-derivation for nothing.

The number stays 16, and it means the same thing it means on main: main's 16
was set by eece4cf, the structural-discovery fix this PR ports — same change,
same bump, no collision to resolve. The sibling PRs in this batch that need
their own invalidation are moving to 17 instead of colliding on 16.
2026-08-05 17:14:42 +03:00
ozymandiashh
6f7a3cd0bc fix: recover a corrupt refresh lock instead of freezing ingestion
A lock file whose body never parses — the zero-byte leftover of a crash between
open and the body write, or a heartbeat truncated by a full disk — was
classified as 'unavailable'. Every later refresh then took the read-only path,
permanently. Nothing on the machine repairs that file, so new sessions stopped
being ingested, the menubar served the snapshot from before the crash forever,
and the only remedy was deleting the lock by hand.

Corruption is now its own class. A body that fails to parse is recovered
through the unmodified staleness gate, exactly like an abandoned lock: waited
out while fresh, because it may belong to a live owner whose heartbeat is about
to repair it, then taken over once its mtime ages past the stale window. That
bounds the freeze to one stale window instead of forever. 'unavailable' is
reserved for locks that genuinely cannot be read.

The takeover stability check now compares the raw bytes as well as token and
mtime: on filesystems with coarse mtime granularity a live owner's heartbeat
can rewrite a body without moving mtime, and token equality alone could not
tell "unchanged" from "rewritten".

The other half of the freeze was in the daily cache. A timed-out refresh serves
the prior snapshot; when anything changed underneath, that snapshot is partial,
and finalizing history off it advanced the watermark past days the parse never
covered. Since the gap scan starts after the watermark, the hole became
invisible to it forever and empty days froze into the trend. The parser now
reports a stale read-only serve as an incomplete hydration, and the daily cache
refuses to advance its watermark or mark itself complete unless the parse
behind it was complete. Caches already corrupted this way heal once: a
watermark that outruns its newest populated day is pulled back so the ordinary
gap parse re-derives the tail, and a trust stamp keeps a legitimately idle tail
from being re-derived on every launch.

That covers every file-backed source. The network-backed one (Vercel AI
Gateway) cannot be fingerprinted at all, so the staleness signal had to be
different: it has no file whose mtime proves the cached rows are current — the
report moves on the API's side, and the read-only path must never re-fetch it.
A read-only serve of a network source is therefore always unverifiable, and is
now always marked stale: it serves the cached rows (the snapshot is what
read-only runs are for) but reports an incomplete hydration, so a timed-out
refresh can no longer finalize daily history off network totals frozen at an
old report. The next full parse re-fetches and advances the watermark.

Two satellite fixes ride along. The context budget counted every skill and the
home CLAUDE.md twice when the scanned project IS the home directory; dedup is
now by resolved path, so a symlinked home is caught too. And the optimize
result-cache key was a projection of project count and api-call sum, so any two
datasets agreeing on those collided and served stale findings within the TTL;
it now folds in cost, savings and proxied cost.

Being accurate about that last one: the key is still a projection of five
aggregates, so datasets agreeing on all five — same totals, different per-model
distribution — can still collide, and the detectors that key off token ratios
and tool counts would differ. The 60-second TTL bounds it. The comment says so
rather than claiming the collision is closed.
2026-08-05 15:59:04 +03:00
ozymandiashh
913d0bd019 test: reap worker children and move fixture off window boundaries
Second-reviewer findings on top of the port (f3f5814). Test-only.

cache-refresh-lock-process: afterEach removed the temp roots but never
killed the spawned workers, which block on their barrier files
indefinitely. The success path reaps them (waitForExit after each run),
but a failed assertion or waitFor timeout leaves the blocked winner
behind; with the global retry: 2, every attempt then spawns a fresh pair
on top of the leaked ones, so they accumulate. Verified: a forced
assertion failure left 3 stray worker processes after the run before
this fix, and 0 after, with the kill path running between retry
attempts. afterEach now kills every child it spawned and waits for it to
actually die (SIGTERM, then SIGKILL after 1s), is robust to children
that already exited (exitCode !== null short-circuits), and detaches
waitForExit listeners first so a SIGTERM cannot surface an unhandled
rejection on top of the real failure.

Upstream 2a4b8f2 has the identical leaky afterEach, so this is not a
regression the port introduces; it is a latent leak the retry makes
reachable.

Same file: worker() resolved its fixture relative to process.cwd(), so
`vitest run --root packages/cli` from the repo root spawned children at
a nonexistent tests/fixtures path and every one died on ENOENT before
touching a barrier. Resolve the fixture relative to this file
(import.meta.dirname) so the suite works from any invocation cwd.

parser.test.ts createJsonlSession: the fixture dated events at exactly
now minus two days. That is safely inside the 90-day retention window
but sits exactly ON the 48h 'recent' cutoff in optimize.ts
(RECENT_WINDOW_MS — recent iff ts >= now-48h), so any clock skew between
the helper and a consumer flips the classification, and a current-month
date range (cli-date.ts `month`, which starts at local midnight of the
1st) would exclude it on the 1st-2nd. Moved the offset to 6h, clamped
into the current month. Six hours keeps the events unambiguously recent
(42h clear of the cutoff) and inside retention with ~89 days of
headroom; the clamp keeps them inside any current-month range in any
timezone (it uses the same local-calendar construction as cli-date.ts).
Both existing cases that use this helper, (a) and (f), pass with the new
offset.
2026-08-05 04:22:49 +03:00
ozymandiashh
7be5a69c87 fix(optimize): one junk vocabulary for the count, the trend and the display
detectJunkReads took its count from core's detector and re-derived its display
and trend from the host's own JUNK_DIRS regex. The two lists disagreed: core
classifies `vendor`, `site-packages`, `out` and `target` as junk; the host's
did not.

Core was already counting reads under those segments — the count is not what
was wrong. The host's derivation was. In a repo whose junk reads all live under
one of them — Go and PHP vendor, Python site-packages, Rust target, Java or
Next out — the host loop matched nothing, so `recentJunkReads` stayed at zero.
Where the window also had recent activity, `computeTrend` read that as fixed
and returned 'resolved', and the finding was dropped. Whole ecosystems never
saw it. In mixed repos it survived but rendered incoherently: the explanation
quoted core's total while the directory list and the CLAUDE.md suggestion came
from the narrower host counts, so the numbers did not add up and the suggested
directories omitted the one actually causing the waste.

Core now exports `junkSegmentOf`, which returns the exact segment that made a
path junk. The host deletes JUNK_DIRS and JUNK_PATTERN outright and asks core
in both loops — junk-reads and duplicate-reads, which had the same split. The
host still names the directory for the payload, because a class alone cannot
render `vendor/ (5x)`; it just no longer decides what junk means.

Inside core, the precedence rule (dependency > build > vcs) lived in the order
of three loops, and adding junkSegmentOf duplicated them. Both functions now
consult one private helper, so the two cannot drift — a second copy of one rule
is what caused this bug in the first place.

Two display changes fall out. A path under two junk segments of the same table
now names the first in path order rather than the first in array order, so
`/x/build/dist/y` reports `build` where it used to report `dist`; the count is
unchanged. Terminal junk directories and Windows-style paths are now matched,
which the old slash-delimited regex missed — those align the host with what
core was already counting.

One robustness note: the old `JUNK_PATTERN.test()` coerced a truthy non-string
`file_path`, while `junkSegmentOf` would throw on one. The type is narrowed at
the point of use so the new path cannot throw where the old one could not.
2026-08-05 04:05:22 +03:00
ozymandiashh
f3f5814b84 test: port upstream fixture and retry fixes (2a4b8f2)
The CLI suite is red on this branch for two reasons that upstream already
fixed and that never made it here.

parser.test.ts (a)/(f): createJsonlSession stamped fixture events at a fixed
2026-05-01. Durable providers age out at 90 days, so on 2026-07-30 the fixture
silently pruned to zero and both cases began failing with `expected +0 to be
200`. Confirmed directly: the identical fixture dated relative to now yields
one project and 200 output tokens; with the literal it yields none. Date the
events relative to now so they stay inside the window whenever the suite runs.

vitest.config.ts / cache-refresh-lock: a handful of integration tests exercise
real servers, spawned subprocesses and real filesystem locks, and starve under
a saturated parallel run — failing closed, which is correct behaviour but not
what those tests measure. A small global retry rides that out; a real
regression is deterministic and fails every attempt.

Two upstream hunks are deliberately not ported. cli-durable-totals already has
an equivalent fix on this branch (ec449af) that clamps to a fraction of the
elapsed day rather than a fixed offset. The `corrupt lock recovery` describe
does not exist here, because upstream's d514459 has not been ported either.

Test-only; no production code changed.
2026-08-05 03:21:24 +03:00
Paul Logan
c52c567cdd fix: couple core and CLI releases 2026-07-27 11:55:29 -07:00
iamtoruk
6983b29bf3 refactor(core): vercel-gateway decode into core, report fetch host-side (phase 8, network special)
COMPLETES Phase 8 of the @codeburn/core extraction: 36/36 provider identities
(including qwen) now decode in core.

The row -> call mapping moves to packages/core/src/providers/vercel-gateway/
verbatim: day/model/cost defaults, the all-zero skip BEFORE the dedup key is
burned, the `vercel-gateway:<day>:<model>` key with add-after-skip semantics,
`${day}T12:00:00.000Z` timestamp synthesis ('' for a missing day), and the
`${day}:${model}` session id. The decoder is pure over supplied rows: no fs,
env, clock, or network.

Everything network stays host-side and byte-identical: the authenticated
/v1/report fetch, the AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN reads, both stderr
warnings, discovery, `network: true`, and the gate that yields nothing when the
scan has no date range.

Adapter shape adjudication: the draft used createBridgedProvider and needed two
contortions to fit it — a Symbol-keyed dateRange stashed on the shared
SessionSource, and `project` packed into the records payload then unpacked by a
decode wrapper (the bridge passes neither the date range nor the source to the
mapping step). The Symbol injection also mutated the caller's discovered source
object, an observable behavior change. Rejected both; this provider now uses a
plain bespoke adapter like antigravity/kiro, which the bridge's own header
already documents as the escape hatch for providers it was not built to cover.
No change to bridge.ts, parser.ts, pricing-pass.ts, or session-cache.ts.

Emitted key shape is unchanged and gated by key-set assertions: `costUSD` is
present and there is NO `costBasis` key, so parser.ts keeps passing the
gateway's own dollar figure through instead of repricing it.

Validator fixes on top of the draft:
- Dropped the Symbol side-channel and the packed-payload decode wrapper.
- Corrected a false claim in the core observations header: it said the raw `day`
  was never emitted, but `day` is spliced verbatim into the timestamp, into
  startedAt/endedAt, and into the dedup key. The envelope's date-time constraint
  is what actually bounds it; a new smuggling arm pins that a hostile `day`
  fails envelope validation.
- Added golden arms for both stderr warnings, for the no-date-range arm making
  no fetch at all, and for the provider not mutating the discovered source.

Verification: the strengthened goldens were run against the pre-migration
provider restored in place and pass identically (13/13). Swapping the all-zero
skip and the dedup burn fails tests at both the core and CLI layers.
Core 509 tests, CLI 2470, root 2470 — all green.
2026-07-27 09:40:04 -07:00
iamtoruk
0540102a3f refactor(core): kiro decode into core, stores and companions host-side (phase 8, stateful tier)
Move all five kiro parse arms (legacy .chat, v1 modern execution,
workspace-session, CLI .jsonl, v2 IDE event log) into
@codeburn/core/providers/kiro as pure decode. The host keeps discovery,
every file read, the companion-file reads (CLI .json, v2 session.json),
the workspace-session mtime stat, project attribution, model display
names, and all pricing.

Behaviour is byte-identical to the pre-move provider; the four dedup-key
namespaces (kiro:, kiro:ws-session:, kiro-cli:, kiro-v2:) and the five
per-arm ParsedProviderCall key sets are unchanged.

Preserved verbatim, with tests that discriminate against plausible
refactors:
- A4's asymmetric turnIndex: a zero-output turn does NOT consume a
  user_turn_metadatas slot, while the dedup-hit and bad-timestamp skip
  arms DO. Every later turn's timestamp and metered credits depend on
  this. All three arms are mutation-tested.
- A1's toolSequence key stays present-with-undefined for single-entry
  sequences, gated by an Object.keys() assertion (toEqual cannot see it).
- A1's input tokens still derive from the already-truncated 500-char
  prompt, unlike every other arm.
- A5's dedup fallback stays `execId || String(calls.length)`, evaluated
  at flush time.
- The three-way workspace-session prepare/finish split keeps the mtime
  stat behind both content gates rather than hoisting it.

Credits seam: core emits `credits: number` and never prices. The host
multiplies by USD_PER_KIRO_CREDIT and builds costUSD/costBasis, matching
the codebuff precedent.

Validator fixes on top of the migration:
- A1's dedup key regained the raw `data.executionId` field; a fallback to
  basename(path) had been introduced, changing keys for chat files with a
  missing, empty, or non-string executionId.
- The content-smuggling non-vacuousness guard was vacuous per vector (an
  aggregate re-decode compared against itself); it now asserts per-vector
  call counts, verified by breaking two fixtures.
- Restored a mangled comment on the load-bearing v2-root derivation and
  several explanatory comments dropped during the move.
- Added goldens G1b (raw executionId), G4b (exact credit products) and
  G6b (turnIndex advances across a bad-timestamp turn), plus a core test
  for the same; all reproduce against the pre-move provider.

PROVIDER_PARSE_VERSIONS['kiro'] and CACHE_VERSION are unchanged. The
companion-file fingerprint blind spot (session-cache.ts) and the dead
project parameter on the old parseChatFile are left as-is.

This completes the Phase 8 tail.
2026-07-27 09:09:15 -07:00
iamtoruk
5960ae3535 refactor(core): cursor decode into core, queries and caches host-side (phase 8, stateful tier)
Cursor is the bucket-D stateful multi-store provider: it keeps its bespoke
adapter (no createBridgedProvider) and the host retains every store read.

Tagged-record composition over five query families. The host issues all five
queries in the load-bearing order [1] composerData meta, [2] bubble COUNT,
[3] bubbles (paged or since), [4] agentKv, [5] user messages, preserving the
per-query degradation semantics: [1]/[4]/[5] degrade to empty, [2] to total=0,
and a [3] failure early-returns zero calls while still writing the (empty)
cache and skipping [4]/[5]. decodeCursor() receives the four row sets plus the
host-supplied agentKv timestamp and performs the whole stitched pass — the
pre-pass scan, the agentKv fold, the user-message queue, and all three emit
arms (per-bubble, per-conversation input, stream-only).

The 19-key emitted call shape is a cache-compatibility contract. CURSOR_CACHE_VERSION
stays 6 and PROVIDER_PARSE_VERSIONS['cursor'] is unchanged, so existing
cursor-results.json files on disk are replayed through the new path; adding,
dropping, or undefined-ing any key would poison them. The goldens gate the key
set with Object.keys() on every arm, which is what catches a key present with an
undefined value — toEqual cannot see one, and JSON.stringify drops exactly those
on the way into the cache.

Pricing, bash base-name extraction, display names, project attribution, SQL,
paging, env reads and clock reads all stay host-side; toProviderCall is the
single mapper that re-adds costBasis 'estimated', costIsEstimated true and the
resolved pricing model. No costUSD is ever emitted.

Composer-id / dedup-key envelope finding: composer ids and request ids flow into
sessionId and into the envelope's dedupKeys by design, exactly like every other
provider's machine identifiers, so the content-smuggling block deliberately does
not plant a secret there. Hashing dedup keys uniformly is a schema-wide
follow-up, not a cursor-local change.

Validator fixes on top of the migration:
- relocated the content-smuggling block from cursor-decode.test.ts into
  content-smuggling.test.ts, matching all 25 other providers, and added a probe
  proving each planted secret actually reaches the field it guards
- G11 now gates the undefined-key cache contract it claimed to (toEqual passed
  under an injected extra undefined key; toStrictEqual plus key-set assertions
  catch it)
- restored the CODEBURN_CURSOR_MAX_BUBBLES override in the golden's afterEach so
  G12 cannot leak the scan budget into cursor-large-db-cap.test.ts
- restored the comment rationales that must travel with their code verbatim
  (pos-cursor queue performance note, parseComposerIdFromKey CR/LF signature,
  agentKv stream and pending-flush notes, dedup-key history, costIsEstimated)

The 12 goldens were cross-checked against the pre-migration provider by
restoring it in place: they pass identically on both sides. H2 (queue pop before
skip) and H4 (arm B dedup-key burn before the timestamp check) were mutation
tested and are killed by G8 and C6 respectively.
2026-07-27 08:05:46 -07:00
iamtoruk
2f3f65533b refactor(core): antigravity decode into core, stitching and caches host-side (phase 8, stateful tier)
Moves every record-parsing arm into @codeburn/core/providers/antigravity:
the protobuf wire reader, the gen_metadata row decode, the RPC
generatorMetadata decode, the statusline JSONL run-collapse/delta decode,
and model canonicalization. All five emit arms (statusline, cache-hit,
sqlite, RPC, RPC-failure fallback) plus the snapshot write-path keep their
host-side control flow byte-for-byte.

Cache integrity: CACHE_VERSION stays 5 and antigravity-results.json keeps
its shape. The cache is not re-derivable — the RPC-failure fallback arm
replays cached.calls for cascades whose language server is gone — so no
bump, and the cache-write-before-seenKeys-filter ordering is preserved in
both the sqlite and RPC arms.

Pricing stays host-side: normalizePricingModel / PRICING_ALIASES remain in
the CLI, and every arm now emits through one shared toProviderCall carrying
costBasis 'estimated' plus pricingModel, and no costUSD.

parseStatusLinePayload's wall clock becomes an injected `at` parameter; the
host passes new Date().toISOString() at each call site, once per payload.

Validator fixes on top of the migration:
- content-smuggling: the hostile statusline record used the hook-payload
  shape, so the decoder dropped it and the cwd/session_id assertions proved
  nothing; corrected to the recorded-event shape, with a call-count guard.
- goldens: G14 maps through the exported host toProviderCall instead of a
  private copy, and a new G15 pins the emit loop's turnIndex-before-seenKeys
  and previousSnapshotUsage-before-skip ordering, which no golden could
  previously detect.
- core: removed an unused type import and a dead CANONICAL_TOOL_NAME const;
  restored the statusline reasoningTokens rationale comment.
2026-07-27 01:04:25 -07:00