diff --git a/src/parser.ts b/src/parser.ts index 13449e5e..712295b0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3595,13 +3595,31 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo // Prompt-linked sessions become valid cwd anchors too. Attribute only when an // exact cwd maps to one PR set; a main checkout used for multiple PRs remains // intentionally ambiguous. - const refsByCwd = new Map>() + // + // Time-bounded (the eywa#160 lesson): cwd evidence also carries the evidence + // sessions' own activity window, and only sessions OVERLAPPING that window + // (plus a pad) inherit the PR. Without the bound, a repo whose only captured + // PR link was pasted once became a black hole — every session ever run in + // that checkout, a month of unrelated work included, was attributed to it + // (129 of 131 sessions, ~$7.4K direct, observed on real data). The rule's + // charter is "a tool session launched around PR work in this checkout", + // which is inherently a same-working-stretch claim. + const CWD_WINDOW_PAD_MS = 6 * 60 * 60 * 1000 + type CwdAnchor = { refs: string[]; startMs: number; endMs: number } + const refsByCwd = new Map>() for (const [session, evidenceRefs] of evidence) { const cwd = normalizedWorkingDirectory(session.workingDirectory) if (!cwd || evidenceRefs.length !== 1) continue + const startMs = Date.parse(session.firstTimestamp) + const endMs = Date.parse(session.lastTimestamp) + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue const refs = evidenceRefs.slice().sort() - const sets = refsByCwd.get(cwd) ?? new Map() - sets.set(refs.join('\0'), refs) + const key = refs.join('\0') + const sets = refsByCwd.get(cwd) ?? new Map() + const existing = sets.get(key) + sets.set(key, existing + ? { refs, startMs: Math.min(existing.startMs, startMs), endMs: Math.max(existing.endMs, endMs) } + : { refs, startMs, endMs }) refsByCwd.set(cwd, sets) } for (const session of sessions) { @@ -3609,7 +3627,13 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo const cwd = normalizedWorkingDirectory(session.workingDirectory) if (!cwd) continue const sets = refsByCwd.get(cwd) - if (sets?.size === 1) assignCorrelatedPrs(session, [...sets.values()][0]!, 'working-directory') + if (sets?.size !== 1) continue + const anchor = [...sets.values()][0]! + const sessionStart = Date.parse(session.firstTimestamp) + const sessionEnd = Date.parse(session.lastTimestamp) + if (!Number.isFinite(sessionStart) || !Number.isFinite(sessionEnd)) continue + if (sessionEnd < anchor.startMs - CWD_WINDOW_PAD_MS || sessionStart > anchor.endMs + CWD_WINDOW_PAD_MS) continue + assignCorrelatedPrs(session, anchor.refs, 'working-directory') } } diff --git a/tests/cross-provider-pr-correlation.test.ts b/tests/cross-provider-pr-correlation.test.ts index 8353957b..9968700a 100644 --- a/tests/cross-provider-pr-correlation.test.ts +++ b/tests/cross-provider-pr-correlation.test.ts @@ -106,3 +106,28 @@ describe('cross-provider PR correlation', () => { expect(codex.prAttributionSource).toBe('launcher-prompt') }) }) + +describe('working-directory correlation is time-bounded (eywa#160 regression)', () => { + it('attributes a same-cwd session inside the evidence window, never one weeks later', () => { + // One session in the repo genuinely produced the PR link. A tool session + // an hour later in the same checkout is PR work; a session three weeks + // later in the same checkout is just... work. Before the bound, the + // later session (and 128 real siblings) inherited the PR, attributing a + // month of unrelated spend ($7.4K observed) to a single PR row. + const linked = session({ id: 'anchor', provider: 'claude', timestamp: '2026-07-11T10:00:00Z', message: 'open the PR', refs: [A], cwd: '/repo/eywa' }) + const nearby = session({ id: 'nearby', provider: 'codex', timestamp: '2026-07-11T11:30:00Z', message: 'run the review suite for the change', cwd: '/repo/eywa' }) + const weeksLater = session({ id: 'later', provider: 'codex', timestamp: '2026-08-02T09:00:00Z', message: 'completely unrelated feature work', cwd: '/repo/eywa' }) + correlateCrossProviderPrSessions([project([linked, nearby, weeksLater])]) + expect(nearby.prLinks).toEqual([A]) + expect(nearby.prAttributionSource).toBe('working-directory') + expect(weeksLater.prLinks).toBeUndefined() + }) + + it('widens the window across multiple evidence sessions for the same PR', () => { + const early = session({ id: 'e1', provider: 'claude', timestamp: '2026-07-11T10:00:00Z', message: 'open', refs: [A], cwd: '/repo/eywa' }) + const late = session({ id: 'e2', provider: 'claude', timestamp: '2026-07-14T10:00:00Z', message: 'follow-up', refs: [A], cwd: '/repo/eywa' }) + const between = session({ id: 'mid', provider: 'codex', timestamp: '2026-07-12T12:00:00Z', message: 'work in between the two linked sessions', cwd: '/repo/eywa' }) + correlateCrossProviderPrSessions([project([early, late, between])]) + expect(between.prLinks).toEqual([A]) + }) +})