diff --git a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts index 2a1758423..2e3e101be 100644 --- a/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts +++ b/cli/src/contexts/telemetry/application/diagnose-telemetry-use-case.ts @@ -17,6 +17,11 @@ import type { TelemetryEvidenceReader } from "../domain/ports/telemetry-evidence import type { TelemetrySink } from "../domain/ports/telemetry-sink.js"; import type { VersionControl } from "../domain/ports/version-control.js"; import { resolveSessionAnchor } from "../domain/session-anchor.js"; +import { + anchorProjectElsewhere, + resolveSessionProject, + type SessionProject, +} from "../domain/session-project.js"; import { attributeMoment, buildStepIntervals, @@ -230,6 +235,7 @@ export class DiagnoseTelemetryUseCase { const unrecognisedPayload = await this.evidence.readUnrecognisedPayload(options.projectRoot); const hookTrust = await this.resolveHookTrust(options.env, currentSessionId); const toolReads = await this.gatherToolReads(journals); + const anchorInAnotherProject = await this.resolveAnchorProject(currentSessionId, journals); return { journals: journals.map(toClaimJournal), toolReads, @@ -237,6 +243,7 @@ export class DiagnoseTelemetryUseCase { currentSessionId, unrecognisedPayloadAt: unrecognisedPayload?.at, hookTrust, + ...(anchorInAnotherProject === null ? {} : { anchorInAnotherProject }), recorderDeclared: recorderDeclaration.declared, recorderDeclarationReadable: recorderDeclaration.unreadable.length === 0, foreignSchemaVersions: await this.runJournalReader.listForeignSchemas(), @@ -258,6 +265,21 @@ export class DiagnoseTelemetryUseCase { return null; } + /** Asked only of an anchor that left no run file here: one journalled here is ours already. */ + private async resolveAnchorProject( + currentSessionId: string | undefined, + journals: readonly RunJournal[] + ): Promise { + if (currentSessionId === undefined) return null; + if (journals.some((journal) => journal.session?.vendor_id === currentSessionId)) return null; + const here = journals + .map(resolveSessionProject) + .filter((project): project is SessionProject => project !== null); + if (here.length === 0) return null; + const stored = await this.telemetrySink.readRecordsForVendor(currentSessionId); + return anchorProjectElsewhere(here, stored); + } + // Only Codex gates a hook behind a trust grant it can decline in silence: a session // running under any other tool has nothing to read here, and asks nothing of it. private async resolveHookTrust( diff --git a/cli/src/contexts/telemetry/domain/session-project.ts b/cli/src/contexts/telemetry/domain/session-project.ts index 530399cc1..e34c33c53 100644 --- a/cli/src/contexts/telemetry/domain/session-project.ts +++ b/cli/src/contexts/telemetry/domain/session-project.ts @@ -9,6 +9,12 @@ export interface SessionProject { readonly projectField: ProjectField; } +/** A stored record's project, and which field named it — the pair, never the value alone. */ +export interface RecordProjectClaim { + readonly project_id?: string; + readonly project_field?: string; +} + /** `project_remote` wins when present: one value for every checkout of a repository, where * `project_id` carries no such guarantee. Neither field named answers `null`, never a guess. */ export function resolveSessionProject(journal: RunJournal | null): SessionProject | null { @@ -22,3 +28,31 @@ export function resolveSessionProject(journal: RunJournal | null): SessionProjec } return null; } + +function projectOfRecord(record: RecordProjectClaim): SessionProject | null { + const { project_id: projectId, project_field: projectField } = record; + if (projectId === undefined || projectId === "") return null; + if (projectField !== "project_id" && projectField !== "project_remote") return null; + return { projectId, projectField }; +} + +function sameProject(left: SessionProject, right: SessionProject): boolean { + return left.projectField === right.projectField && left.projectId === right.projectId; +} + +/** The project the stored records put an anchored session under, when it is not one of `here`'s. + * Compares only values named by the same field — `project_id` is a directory name and + * `project_remote` a URL, so across the two a match reads as a mismatch — and one record naming + * a project in `here` settles the session as this project's, whatever the others say. */ +export function anchorProjectElsewhere( + here: readonly SessionProject[], + anchorRecords: readonly RecordProjectClaim[] +): string | null { + const comparable = anchorRecords + .map(projectOfRecord) + .filter((project): project is SessionProject => project !== null) + .filter((project) => here.some((mine) => mine.projectField === project.projectField)); + if (comparable.length === 0) return null; + if (comparable.some((project) => here.some((mine) => sameProject(mine, project)))) return null; + return [...new Set(comparable.map((project) => project.projectId))].join(", "); +} diff --git a/cli/src/contexts/telemetry/domain/telemetry-claim.ts b/cli/src/contexts/telemetry/domain/telemetry-claim.ts index 9dec40064..e10e34ef6 100644 --- a/cli/src/contexts/telemetry/domain/telemetry-claim.ts +++ b/cli/src/contexts/telemetry/domain/telemetry-claim.ts @@ -32,6 +32,7 @@ export type TelemetryClaimReason = | NoRunFileReason | "unrecognised-payload" | "session-left-no-run-file" + | "anchor-in-another-project" | "no-session-anchor" | "turn-closed" | "only-session-start" @@ -88,6 +89,10 @@ export interface TelemetryEvidence { readonly currentSessionId?: string; readonly unrecognisedPayloadAt?: string; readonly hookTrust?: TelemetryCodexHookTrust; + /** The project the stored records put the anchored session under, present only when that is + * decidably not this one: an anchor is inherited by any process nested inside a session, + * whatever directory it runs in. */ + readonly anchorInAnotherProject?: string; /** Whether the recorder is declared anywhere this build checks — read the same way * `TelemetrySetup`'s own `recorderDeclaration` is, so the two cannot disagree. Never proof * the hook will fire: a declaration can be silently dropped. */ @@ -256,13 +261,30 @@ function noAnchorClaim(journals: readonly TelemetryClaimJournal[], latest: strin }; } +function anotherProjectClaim(project: string, latest: string): TelemetryClaim { + return { + claim: "hook-fired", + verdict: "unknown", + reason: "anchor-in-another-project", + detail: + `this session belongs to ${project}, not to this project — its stored figures name that ` + + `project, and nothing here is evidence about its hook. The newest run file here is from ` + + latest, + }; +} + function sessionAnchoredClaim( journals: readonly TelemetryClaimJournal[], latest: string, currentSessionId: string, - hookTrust: TelemetryCodexHookTrust | undefined + hookTrust: TelemetryCodexHookTrust | undefined, + anchorInAnotherProject: string | undefined ): TelemetryClaim { if (!firedForSession(journals, currentSessionId)) { + // Before the trust gate: local trust state says nothing about another project's session. + if (anchorInAnotherProject !== undefined) { + return anotherProjectClaim(anchorInAnotherProject, latest); + } if (hookTrust && trustExplainsAbsence(hookTrust)) return untrustedHookClaim(hookTrust); return { claim: "hook-fired", @@ -301,7 +323,8 @@ function claimHookFired(evidence: TelemetryEvidence): TelemetryClaim { sessionJournals, latest, evidence.currentSessionId, - evidence.hookTrust + evidence.hookTrust, + evidence.anchorInAnotherProject ); } diff --git a/cli/tests/architecture/comments.arch.test.ts b/cli/tests/architecture/comments.arch.test.ts index 88fab05fc..f5fe5fd4e 100644 --- a/cli/tests/architecture/comments.arch.test.ts +++ b/cli/tests/architecture/comments.arch.test.ts @@ -20,8 +20,8 @@ const EXTERNAL_REFERENCE: readonly { readonly name: string; readonly pattern: Re { name: "pull request", pattern: /\bpull request\b|\bPR\s*#?\d/i }, ]; -/** Comment lines under `src/` and `tests/` may only decrease; a raise needs its reason here: tests/ 2960 to 2984, four guard files and their probes; 2984 to 2987, the reason the display folder is baselined over the size limit. */ -const MAX_COMMENT_LINES = { src: 4471, tests: 2987 }; +/** Comment lines under `src/` and `tests/` may only decrease; a raise needs its reason here: tests/ 2960 to 2984, four guard files and their probes; 2984 to 2987, the reason the display folder is baselined over the size limit; src/ 4471 to 4481, why an inherited session anchor is decidable and why comparing two namings of a project is not. */ +const MAX_COMMENT_LINES = { src: 4481, tests: 2987 }; function testFiles(): string[] { const out: string[] = []; diff --git a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts index 091d508cd..ad3ee8e71 100644 --- a/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts +++ b/cli/tests/contexts/telemetry/application/diagnose-telemetry-use-case.unit.test.ts @@ -97,6 +97,7 @@ function buildUseCase(options: { hookTrustReader?: StubHookTrustReader; manifestRepo?: ManifestRepository; hostRegistries?: ReadonlyMap; + sink?: InMemoryTelemetrySink; }) { const evidence = options.evidence ?? new StubEvidenceReader(); const journalReader = new InMemoryRunJournalReader(); @@ -104,6 +105,7 @@ function buildUseCase(options: { journalReader.set(journal.session?.vendor_id ?? `no-session-${index}`, journal); } const hookTrustReader = options.hookTrustReader ?? new StubHookTrustReader(); + const sink = options.sink ?? new InMemoryTelemetrySink(); const useCase = new DiagnoseTelemetryUseCase( evidence, versionControl(options.isRepository ?? true), @@ -111,7 +113,7 @@ function buildUseCase(options: { options.readers ?? new Map(), hookTrustReader, new InMemoryPersonIdentityStore(), - new InMemoryTelemetrySink(), + sink, new FakeCurrentVersion("9.9.9-check"), installedPluginsFromManifest( options.manifestRepo ?? { @@ -123,7 +125,7 @@ function buildUseCase(options: { ), options.hostRegistries ?? new Map() ); - return { useCase, evidence, hookTrustReader, journalReader }; + return { useCase, evidence, hookTrustReader, journalReader, sink }; } function runOptions(env: NodeJS.ProcessEnv = {}) { @@ -585,6 +587,112 @@ function useCaseOverIdentity(store: InMemoryPersonIdentityStore): DiagnoseTeleme ); } +const HERE = "git@github.com:acme/widgets.git"; + +async function sinkHolding( + vendorId: string, + projectRemote: string +): Promise { + const sink = new InMemoryTelemetrySink(); + await sink.appendRecord( + { + sink_schema_version: 1, + kind: "request", + provenance: "local-read", + tool: "claude", + vendor_id: vendorId, + vendor_field: "session_id", + step_attribution: "unattributed", + project_id: projectRemote, + project_field: "project_remote", + }, + new Date("2026-08-20T09:01:00Z") + ); + return sink; +} + +function journalledHere(vendorId: string): RunJournal { + const session = sessionStart(vendorId); + return journalOf(session && { ...session, project_remote: HERE }); +} + +describe("DiagnoseTelemetryUseCase — an anchor inherited from another project", () => { + it("names the project the stored figures put the anchored session under", async () => { + const { useCase } = buildUseCase({ + journals: [journalledHere("s-old")], + sink: await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"), + }); + + const result = claimsOf( + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" })) + ); + + const hookFired = result.claims.find((claim) => claim.claim === "hook-fired"); + expect(hookFired?.verdict).toBe("unknown"); + expect(hookFired?.detail).toContain("git@github.com:acme/other.git"); + }); + + it("still fails an anchor the stored figures put in this very project", async () => { + const { useCase } = buildUseCase({ + journals: [journalledHere("s-old")], + sink: await sinkHolding("s-mine", HERE), + }); + + const result = claimsOf( + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-mine" })) + ); + + expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe( + "session-left-no-run-file" + ); + }); + + it("asks the sink nothing about a session this project journalled itself", async () => { + const sink = await sinkHolding("s-1", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ + journals: [journalOf(undefined), journalledHere("s-old"), journalledHere("s-1")], + sink, + }); + + const result = claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-1" }))); + + expect(result.claims.find((claim) => claim.claim === "hook-fired")?.verdict).toBe("ok"); + expect(sink.vendorsRead).toStrictEqual([]); + }); + + it("asks the sink nothing when no anchor names a session at all", async () => { + const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink }); + + claimsOf(await useCase.execute(runOptions())); + + expect(sink.vendorsRead).toStrictEqual([]); + }); + + it("asks the sink nothing when no run file here names a project to compare against", async () => { + const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ journals: [journalOf(sessionStart("s-old"))], sink }); + + const result = claimsOf( + await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" })) + ); + + expect(result.claims.find((claim) => claim.claim === "hook-fired")?.reason).toBe( + "session-left-no-run-file" + ); + expect(sink.vendorsRead).toStrictEqual([]); + }); + + it("asks the sink about the anchor alone", async () => { + const sink = await sinkHolding("s-elsewhere", "git@github.com:acme/other.git"); + const { useCase } = buildUseCase({ journals: [journalledHere("s-old")], sink }); + + claimsOf(await useCase.execute(runOptions({ CLAUDE_CODE_SESSION_ID: "s-elsewhere" }))); + + expect(sink.vendorsRead).toStrictEqual(["s-elsewhere"]); + }); +}); + describe("DiagnoseTelemetryUseCase — the setup it prints", () => { it("names the sink's own root as where records land", async () => { const { useCase } = buildUseCase({}); diff --git a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts index 755e3e42c..25cebeaa9 100644 --- a/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/session-project.unit.test.ts @@ -3,7 +3,11 @@ import type { RunJournal, RunJournalSessionStart, } from "../../../../src/contexts/telemetry/domain/ports/run-journal-reader.js"; -import { resolveSessionProject } from "../../../../src/contexts/telemetry/domain/session-project.js"; +import { + anchorProjectElsewhere, + resolveSessionProject, + type SessionProject, +} from "../../../../src/contexts/telemetry/domain/session-project.js"; function sessionOf(overrides: Partial = {}): RunJournalSessionStart { return { @@ -73,3 +77,111 @@ describe("resolveSessionProject", () => { ).toBeNull(); }); }); + +describe("anchorProjectElsewhere", () => { + const HERE_REMOTE = "git@github.com:acme/widgets.git"; + const here: readonly SessionProject[] = [ + { projectId: HERE_REMOTE, projectField: "project_remote" }, + ]; + const BOTH: readonly SessionProject[] = [ + ...here, + { projectId: "widgets", projectField: "project_id" }, + ]; + + it("names the project a stored record puts the anchored session under", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + ]) + ).toBe("git@github.com:acme/other.git"); + }); + + it("names nothing when a stored record puts the anchor under this very project", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/widgets.git", project_field: "project_remote" }, + ]) + ).toBeNull(); + }); + + it("compares nothing across two different namings", () => { + expect( + anchorProjectElsewhere(here, [{ project_id: "widgets", project_field: "project_id" }]) + ).toBeNull(); + }); + + it("reads one project named by both fields as two values, never one match", () => { + expect( + anchorProjectElsewhere( + [...here, { projectId: "acme-other", projectField: "project_id" }], + [{ project_id: HERE_REMOTE, project_field: "project_id" }] + ) + ).toBe(HERE_REMOTE); + }); + + it("names nothing for a record whose field is no field this journal ever wrote", () => { + expect( + anchorProjectElsewhere(BOTH, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_slug" }, + ]) + ).toBeNull(); + }); + + it("names nothing for a record whose field names a project the record itself does not", () => { + expect(anchorProjectElsewhere(BOTH, [{ project_field: "project_id" }])).toBeNull(); + }); + + it("names nothing for a record naming a project by no field at all", () => { + expect(anchorProjectElsewhere(BOTH, [{ project_id: "acme-other" }])).toBeNull(); + }); + + it("reads an empty project on a record as naming none", () => { + expect( + anchorProjectElsewhere(BOTH, [{ project_id: "", project_field: "project_id" }]) + ).toBeNull(); + }); + + it("names a project the records put the anchor under by directory name", () => { + expect( + anchorProjectElsewhere(BOTH, [{ project_id: "acme-other", project_field: "project_id" }]) + ).toBe("acme-other"); + }); + + it("names nothing when the stored records say nothing about a project", () => { + expect(anchorProjectElsewhere(here, [{}, { project_id: "" }])).toBeNull(); + }); + + it("names nothing when no record carries the field that named the project", () => { + expect( + anchorProjectElsewhere(here, [{ project_id: "git@github.com:acme/other.git" }]) + ).toBeNull(); + }); + + it("names nothing when nothing here names a project to compare against", () => { + expect( + anchorProjectElsewhere( + [], + [{ project_id: "git@github.com:acme/other.git", project_field: "project_remote" }] + ) + ).toBeNull(); + }); + + it("names every project the records disagree on, rather than picking one", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + { project_id: "git@github.com:acme/third.git", project_field: "project_remote" }, + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + ]) + ).toBe("git@github.com:acme/other.git, git@github.com:acme/third.git"); + }); + + it("names nothing when one of several records puts the anchor here", () => { + expect( + anchorProjectElsewhere(here, [ + { project_id: "git@github.com:acme/other.git", project_field: "project_remote" }, + { project_id: "git@github.com:acme/widgets.git", project_field: "project_remote" }, + ]) + ).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts index 3d4184f62..52bd5cc38 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-claim-details.unit.test.ts @@ -213,6 +213,27 @@ describe("the hook-fired claim, word for word", () => { }); }); + it("spells out whose project the anchored session is, word for word", () => { + const hookFired = claimOf( + { + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-08-20T09:00:00Z" })], + currentSessionId: "s-elsewhere", + anchorInAnotherProject: "git@github.com:acme/other.git", + }, + "hook-fired" + ); + + expect(hookFired).toStrictEqual({ + claim: "hook-fired", + verdict: "unknown", + reason: "anchor-in-another-project", + detail: + "this session belongs to git@github.com:acme/other.git, not to this project — its " + + "stored figures name that project, and nothing here is evidence about its hook. The " + + "newest run file here is from 2026-08-20T09:00:00Z", + }); + }); + it("spells out the missing anchor beside the newest session_start", () => { const hookFired = claimOf( { journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })] }, diff --git a/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts b/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts index 6f25f54bb..8f3c48dbb 100644 --- a/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts +++ b/cli/tests/contexts/telemetry/domain/telemetry-claim.unit.test.ts @@ -145,6 +145,44 @@ describe("diagnoseTelemetryClaims — hook fired", () => { expect(hookFired?.detail).toContain("2026-07-01T09:00:00Z"); }); + it("names the other project the anchored session belongs to, rather than failing", () => { + const result = diagnoseTelemetryClaims( + evidence({ + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "s-elsewhere", + anchorInAnotherProject: "git@github.com:acme/other.git", + }) + ); + const hookFired = claim(result, "hook-fired"); + expect(hookFired?.verdict).toBe("unknown"); + expect(hookFired?.reason).toBe("anchor-in-another-project"); + expect(hookFired?.detail).toContain("git@github.com:acme/other.git"); + }); + + it("still fails a session left unproven when no other project claims the anchor", () => { + const result = diagnoseTelemetryClaims( + evidence({ + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "s-current", + }) + ); + + expect(claim(result, "hook-fired")?.reason).toBe("session-left-no-run-file"); + }); + + it("prefers the other project over an untrusted Codex hook", () => { + const result = diagnoseTelemetryClaims( + evidence({ + journals: [journal({ vendorId: "s-old", sessionStartAt: "2026-07-01T09:00:00Z" })], + currentSessionId: "codex-elsewhere", + anchorInAnotherProject: "git@github.com:acme/other.git", + hookTrust: { readable: true, trusted: false, configPath: "/home/.codex/config.toml" }, + }) + ); + + expect(claim(result, "hook-fired")?.reason).toBe("anchor-in-another-project"); + }); + it("cannot tell whether this session's hook fired without an anchor", () => { const result = diagnoseTelemetryClaims( evidence({ journals: [journal({ vendorId: "s-1", sessionStartAt: "2026-08-20T09:00:00Z" })] }) diff --git a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts b/cli/tests/helpers/ports/in-memory-telemetry-sink.ts index a4c2b2f1c..2b6c00d78 100644 --- a/cli/tests/helpers/ports/in-memory-telemetry-sink.ts +++ b/cli/tests/helpers/ports/in-memory-telemetry-sink.ts @@ -52,7 +52,10 @@ export class InMemoryTelemetrySink implements TelemetrySink { this.deletedFiles.push(fileName); } + readonly vendorsRead: string[] = []; + async readRecordsForVendor(vendorId: string): Promise { + this.vendorsRead.push(vendorId); return [...this.files.values()].flat().filter((record) => record.vendor_id === vendorId); }