From b940afd83df5611cab76cb37edfcf973ed719fcc Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:04:25 +0300 Subject: [PATCH 1/5] chore(gitignore): ignore node_modules symlinks, not only directories The node_modules/ pattern (trailing slash) matches directories only. A node_modules SYMLINK, standard in linked worktrees that share one install, is not covered, which is how db018f7 accidentally committed one and every subsequent checkout materialized a self-referencing symlink until c642787 removed it. Dropping the slash covers files, directories and symlinks. --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b71c159..b733eab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -node_modules/ +# No trailing slash: the slash form only ignores directories, so a +# node_modules SYMLINK (common in linked worktrees) slips into git add -A. +# One did exactly that in db018f7 and had to be removed again in c642787. +node_modules dist/ *.tgz From 6c4645a8bc1665dd5b2c24cc66ee9bc83b535dc8 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:58:17 +0300 Subject: [PATCH 2/5] fix(kiro): estimate input tokens from the full prompt, not a 500-char slice parseChatFile estimated input tokens from pendingUserMessage - the last human turn sliced to 500 chars - while output summed every bot char, so a multi-turn session or any prompt over 500 chars undercounted input tokens and therefore costUSD severalfold. Accumulate every human turn's full length (inputChars), matching the modern-execution path; keep the 500 slice for the display userMessage only. Mutation-checked: a 2400-char prompt reports 125 tokens before, 600 after. --- src/providers/kiro.ts | 10 +++++++++- tests/providers/kiro.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index 57c5137..6723f72 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -203,12 +203,20 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s if (modelId === 'auto' || !modelId) modelId = 'kiro-auto' let pendingUserMessage = '' + // Accumulate every human turn's full length for the input-token estimate, + // mirroring the modern-execution path (which sums inputChars). The prior + // code estimated input tokens from pendingUserMessage.length alone - the + // LAST human turn truncated to 500 chars - so a multi-turn session, or any + // final prompt over 500 chars, undercounted input tokens (and therefore + // costUSD) severalfold, while output correctly summed all bot chars. + let inputChars = 0 const allTools: string[] = [] const toolSequence: ToolCall[][] = [] for (const msg of chat) { if (msg.role === 'human') { if (msg.content.startsWith('')) continue + inputChars += msg.content.length pendingUserMessage = msg.content.slice(0, 500) } if (msg.role === 'bot') { @@ -226,7 +234,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s if (seenKeys.has(dedupKey)) return results const outputTokens = estimateTokensFromChars(totalOutputChars) - const inputTokens = estimateTokensFromChars(pendingUserMessage.length) + const inputTokens = estimateTokensFromChars(inputChars) const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) const tsDate = parseKiroTimestamp(metadata.startTime) if (!tsDate) return results diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index a12ae4b..f2032eb 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -111,6 +111,26 @@ describe('kiro provider - chat file parsing', () => { expect(call.costUSD).toBeGreaterThan(0) }) + it('estimates input tokens from the full prompt, not a 500-char slice (money-path)', async () => { + // Regression: parseChatFile estimated input tokens from pendingUserMessage + // (the last human turn sliced to 500 chars) while output summed every bot + // char, so a long prompt undercounted input tokens - and cost - severalfold. + const wsHash = 'f'.repeat(32) + const wsDir = join(tmpDir, wsHash) + await mkdir(wsDir, { recursive: true }) + const chatPath = join(wsDir, 'long.chat') + const prompt = 'x'.repeat(2400) // 2400 chars / 4 = 600 tokens; a 500-slice would give 125 + await writeFile(chatPath, makeChatFile({ userPrompt: prompt, botResponses: ['ok'] })) + + const calls: ParsedProviderCall[] = [] + for await (const call of kiro.createSessionParser({ path: chatPath, project: 'p', provider: 'kiro' }, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(600) + // userMessage stays capped for display; only the token estimate uses the full length. + expect(calls[0]!.userMessage.length).toBe(500) + }) + it('stores kiro-auto when model is auto', async () => { const wsHash = 'b'.repeat(32) const wsDir = join(tmpDir, wsHash) From 75b7df6bdd5eb56ec1131c9f8581faa1d4db1d37 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:01:07 +0300 Subject: [PATCH 3/5] fix(optimize): strengthen the result-cache fingerprint against collisions cacheKey fingerprinted only project count + api-call sum, so two datasets agreeing on those two numbers collided onto one cached OptimizeResult, and a cost/token change that left call count unchanged (e.g. a re-price) served stale findings within the 60s TTL - reachable in the long-lived menubar. Fold total cost, savings and proxied cost (scaled to micro-dollars) into the key. Exported cacheKey and mutation-checked: the old key collides two same-shape datasets and a re-price; the new one separates both, while an identical dataset still keys identically. --- src/optimize.ts | 18 ++++++++++++++++-- tests/optimize.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/src/optimize.ts b/src/optimize.ts index 7900998..121507c 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -2965,9 +2965,23 @@ export function computeInputCostRate(projects: ProjectSummary[]): number { type CacheEntry = { data: OptimizeResult; ts: number } const resultCache = new Map() -function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { +export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' - const fingerprint = projects.length + ':' + projects.reduce((s, p) => s + p.totalApiCalls, 0) + // Fingerprint enough of the dataset that two materially different inputs + // cannot collide onto one cached OptimizeResult. Project count + api-call + // sum alone collided any two datasets sharing those two numbers, and served + // stale findings when cost/tokens moved (e.g. a re-price) while call count + // held - reachable in the long-lived menubar process within the 60s TTL. + // Cost is scaled to whole micro-dollars so float jitter cannot thrash the key. + let calls = 0, cost = 0, savings = 0, proxied = 0 + for (const p of projects) { + calls += p.totalApiCalls + cost += p.totalCostUSD + savings += p.totalSavingsUSD + proxied += p.totalProxiedCostUSD + } + // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. + const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` return `${dr}:${fingerprint}` } diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index fdbd492..c3bcdf0 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -22,6 +22,7 @@ import { detectLowWorthSessions, detectSessionOutliers, scanAndDetect, + cacheKey, computeHealth, computeTrend, buildOptimizeJsonReport, @@ -1041,6 +1042,34 @@ describe('detectSessionOutliers', () => { }) }) +describe('optimize cacheKey collision resistance', () => { + it('does not collide two datasets that share project count and api-call sum', () => { + // The old fingerprint was projectCount + sum(api calls) only, so any two + // datasets agreeing on those two numbers shared one cached OptimizeResult - + // the second scan got the first's findings. Same shape, different spend must + // now key differently. + const a = projectWithSessions([100, 1, 1, 1]) // 4 calls, cost 103 + const b = projectWithSessions([1, 1, 1, 1]) // 4 calls, cost 4 + const range = optimizeDateRange(4) + expect(a.totalApiCalls).toBe(b.totalApiCalls) + expect(cacheKey([a], range)).not.toBe(cacheKey([b], range)) + }) + + it('is stable for the identical dataset (still caches a genuine repeat)', () => { + const a = projectWithSessions([5, 3, 2]) + const range = optimizeDateRange(3) + expect(cacheKey([a], range)).toBe(cacheKey([projectWithSessions([5, 3, 2])], range)) + }) + + it('separates a re-price that leaves call count unchanged', () => { + // A dataset re-priced (cost moves, calls do not) must not serve stale findings. + const before = projectWithSessions([10, 10]) + const after = projectWithSessions([25, 10]) // same 2 calls, higher cost + const range = optimizeDateRange(2) + expect(cacheKey([before], range)).not.toBe(cacheKey([after], range)) + }) +}) + describe('computeHealth', () => { it('returns A with 100 for no findings', () => { const { score, grade } = computeHealth([]) From 99bf24611ec928c799134bf8bb4c9bf5af6e66a3 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:32:58 +0300 Subject: [PATCH 4/5] fix(context-budget): stop double-counting home skills and CLAUDE.md When the project directory IS the home directory, countSkills pushed both ~/.claude/skills and /.claude/skills - the same path - and counted every skill twice, and scanMemoryFiles read ~/.claude/CLAUDE.md twice, inflating the context-budget estimate. Dedupe both by resolved path. Mutation-checked: a single home skill counts 2 before the fix, 1 after. --- src/context-budget.ts | 14 ++++++++-- tests/context-budget-home.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/context-budget-home.test.ts diff --git a/src/context-budget.ts b/src/context-budget.ts index 38ab026..55b8e90 100644 --- a/src/context-budget.ts +++ b/src/context-budget.ts @@ -60,8 +60,13 @@ async function countMcpTools(projectPath?: string): Promise { } async function countSkills(projectPath?: string): Promise { - const dirs = [join(homedir(), '.claude', 'skills')] - if (projectPath) dirs.push(join(projectPath, '.claude', 'skills')) + // Dedupe by resolved path: when the project IS the home dir, the home and + // project skills dirs are the same directory, and counting both double-counts + // every skill (and inflates the context budget). + const dirs = [...new Set([ + join(homedir(), '.claude', 'skills'), + ...(projectPath ? [join(projectPath, '.claude', 'skills')] : []), + ])] let count = 0 for (const dir of dirs) { @@ -91,7 +96,12 @@ async function scanMemoryFiles(projectPath?: string): Promise() for (const { path, name } of paths) { + if (seenPaths.has(path)) continue + seenPaths.add(path) if (!existsSync(path)) continue const content = await readSessionFile(path) if (content === null) continue diff --git a/tests/context-budget-home.test.ts b/tests/context-budget-home.test.ts new file mode 100644 index 0000000..aa71a46 --- /dev/null +++ b/tests/context-budget-home.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs' +import { join } from 'path' + +// Mock homedir to a temp dir so "project == home" is reproducible. +import { vi } from 'vitest' +vi.mock('os', async () => { + const actual = await vi.importActual('os') + const fs = await vi.importActual('fs') + const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/cb-ctxbudget-home-') + process.env['CB_CTXBUDGET_FAKE_HOME'] = fakeHome + return { ...actual, homedir: () => fakeHome } +}) + +const HOME = process.env['CB_CTXBUDGET_FAKE_HOME']! + +import { estimateContextBudget } from '../src/context-budget.js' + +describe('context budget: no double-count when the project IS the home dir', () => { + beforeEach(() => { + rmSync(join(HOME, '.claude'), { recursive: true, force: true }) + mkdirSync(join(HOME, '.claude', 'skills', 'my-skill'), { recursive: true }) + writeFileSync(join(HOME, '.claude', 'skills', 'my-skill', 'SKILL.md'), '# Skill') + writeFileSync(join(HOME, '.claude', 'CLAUDE.md'), 'home memory') + }) + + it('counts the one home skill once, not twice, when projectPath is home', async () => { + // With projectPath === home, the home and project skills dirs resolve to + // the same directory; the unfixed code pushed both and counted every skill + // twice (and read ~/.claude/CLAUDE.md twice). + const budget = await estimateContextBudget(HOME) + expect(budget.skills.count).toBe(1) + // ~/.claude/CLAUDE.md must appear once in the memory file list. + const homeMemory = budget.memory.files.filter(f => f.name.includes('.claude/CLAUDE.md')) + expect(homeMemory).toHaveLength(1) + }) + + it('still counts a distinct project skill separately from a home skill', async () => { + const proj = mkdtempSync(join(HOME, '..', 'cb-ctxbudget-proj-')) + mkdirSync(join(proj, '.claude', 'skills', 'proj-skill'), { recursive: true }) + writeFileSync(join(proj, '.claude', 'skills', 'proj-skill', 'SKILL.md'), '# Proj') + const budget = await estimateContextBudget(proj) + expect(budget.skills.count).toBe(2) // home skill + project skill + rmSync(proj, { recursive: true, force: true }) + }) +}) From 2a4b8f249aa63c8e6e5675361c9bfd4bd6d15d23 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Tue, 4 Aug 2026 11:37:40 +0200 Subject: [PATCH 5/5] test: fix three pre-existing suite failures (aged date, future today fixture, load starvation) parser.test.ts (a)/(f): createJsonlSession stamped events at a fixed 2026-05-01 that aged past the 90-day retention window, pruning to zero; date them relative to now. cli-durable-totals: seedLiveTodaySession stamped noon, which is in the future on a pre-noon run so the provider-scoped today slice (ends at now) dropped it while the all path (ends at range end) kept it; seed a past-today time. cache-refresh-lock and other integration tests starve under a saturated parallel run and fail closed; add a small global retry and raise the two most load-sensitive lock tests. Test-only; no production code changed. --- tests/cache-refresh-lock.test.ts | 8 ++++++-- tests/cli-durable-totals.test.ts | 10 ++++++++-- tests/parser.test.ts | 11 ++++++++--- vitest.config.ts | 8 ++++++++ 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/tests/cache-refresh-lock.test.ts b/tests/cache-refresh-lock.test.ts index 4ba633d..cdabaee 100644 --- a/tests/cache-refresh-lock.test.ts +++ b/tests/cache-refresh-lock.test.ts @@ -209,7 +209,7 @@ describe('warm session-cache refresh lock', () => { // run (fs 'unavailable' makes the fence fail CLOSED, which is correct but // not what this test measures); the actual race fails ~6% per verify, so a // mutated build cannot pass any attempt. - it('the fence never loses to its own heartbeat (in-process serialization)', { retry: 5 }, async () => { + it('the fence never loses to its own heartbeat (in-process serialization)', { retry: 10 }, async () => { // Regression: verifyStillOwner and the heartbeat tick both take the // takeover guard; without in-process serialization the fence could observe // its own heartbeat's guard file and abort a legitimate publication. @@ -228,7 +228,11 @@ describe('warm session-cache refresh lock', () => { // A lock body that never parses into a record is a corrupt leftover, not an // unusable filesystem: classifying it as 'unavailable' routed every subsequent // refresh to the read-only path and froze ingestion permanently. -describe('warm session-cache refresh lock: corrupt lock recovery', () => { +// Real-fs recovery tests: under a saturated full-suite run an fs op can starve +// and the acquire fails closed (correct, but not what these measure), so they +// retry to ride out the environmental blip. A real regression fails every +// attempt because the takeover assertion is deterministic given the fixture. +describe('warm session-cache refresh lock: corrupt lock recovery', { retry: 6 }, () => { it('takes over a stale zero-byte lock', async () => { const dir = await tempDir() const clock = fakeClock(100_000) diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts index f6b51f3..37c6a45 100644 --- a/tests/cli-durable-totals.test.ts +++ b/tests/cli-durable-totals.test.ts @@ -89,8 +89,14 @@ async function seedLiveTodaySession(): Promise { const projectDir = join(ROOT, 'home', '.claude', 'projects', 'p') await mkdir(projectDir, { recursive: true }) const now = new Date() - const ts = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0).toISOString() - const ts2 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 30, 0).toISOString() + // A couple of hours ago, clamped to never precede midnight nor exceed now, so + // the events always land inside today's [midnight, now] window whatever time + // the suite runs. A fixed noon literal silently fell outside that window on a + // pre-noon run, so the durable today slice (which ends at now) never saw them. + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + const base = Math.max(todayStart, now.getTime() - 2 * 60 * 60 * 1000) + const ts = new Date(base).toISOString() + const ts2 = new Date(Math.min(base + 60_000, now.getTime())).toISOString() const line = (id: string, t: string): string => JSON.stringify({ type: 'assistant', timestamp: t, diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 211c41f..ca25424 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -143,10 +143,15 @@ async function createJsonlSession( const dir = join(sessionStateDir, sessionId) await mkdir(dir, { recursive: true }) await writeFile(join(dir, 'workspace.yaml'), `id: ${sessionId}\ncwd: /home/user/testproj\n`) + // Dated relative to now so the session stays inside the 90-day retention + // window whenever the suite runs; a fixed literal silently ages out (these + // events were `2026-05-01`, which prunes to zero once now is 90 days past it). + const base = Date.now() - 2 * 24 * 60 * 60 * 1000 + const ts = (offsetSec: number) => new Date(base + offsetSec * 1000).toISOString() const lines = [ - JSON.stringify({ type: 'session.model_change', timestamp: '2026-05-01T10:00:00Z', data: { newModel: 'gpt-4.1' } }), - JSON.stringify({ type: 'user.message', timestamp: '2026-05-01T10:00:05Z', data: { content: 'hello', interactionId: 'int-1' } }), - JSON.stringify({ type: 'assistant.message', timestamp: '2026-05-01T10:00:10Z', data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }), + JSON.stringify({ type: 'session.model_change', timestamp: ts(0), data: { newModel: 'gpt-4.1' } }), + JSON.stringify({ type: 'user.message', timestamp: ts(5), data: { content: 'hello', interactionId: 'int-1' } }), + JSON.stringify({ type: 'assistant.message', timestamp: ts(10), data: { messageId: 'msg-1', outputTokens, interactionId: 'int-1', toolRequests: [] } }), ] await writeFile(join(dir, 'events.jsonl'), lines.join('\n') + '\n') return join(dir, 'events.jsonl') diff --git a/vitest.config.ts b/vitest.config.ts index b56c015..6c7155f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,13 @@ export default defineConfig({ // session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every // provider-specific *_HOME) don't bleed real local data into fixtures. setupFiles: ['./tests/setup/env-isolation.ts'], + // A handful of integration tests exercise real servers, spawned CLI + // subprocesses and real filesystem locks. Under a saturated full-suite run + // an fs/socket op can starve and the operation fails closed (correct, but + // an environmental blip, not a logic error), so a different one trips each + // run. A small retry rides out that starvation; a real regression is + // deterministic and fails every attempt. Tests that need more headroom set + // a higher retry locally (it overrides this). + retry: 2, }, })