From 17cac1272d02b275c6fd5e2348b5e6584d066136 Mon Sep 17 00:00:00 2001 From: Franklin Moormann Date: Fri, 7 Aug 2026 17:29:55 -0400 Subject: [PATCH 1/2] fix(adapter): make the non-Claude clients measure what the router measures adapter.mjs serves codex, gemini, qwen and opencode. Its comment claims "Same measurement as the Claude Code router: every client's allowed read feeds the same holdout comparison, or the metric is client-specific and therefore not comparable". Three details made that false, and each produced a WRONG number rather than a missing one -- which is worse, because a wrong number gets quoted. 1. A read was filed under wikiDir(payload.cwd) -- the repo the client happened to be launched in. A session started in repo A that reads a file in repo B appended the cost to A's metrics under an anchor that does not exist in A, while B never saw the cost of its own file. The router already resolves this with projectRootFor; wiki.mjs's own docstring names the same defect ("Observed live: work in another checkout recorded nothing"). 2. recordRead was called without `fp`, so every read from these four clients was stored with a null fingerprint. metrics.mjs classifies a pair with a missing fingerprint as undecidable, so rereadWaste could never reach its wasteful or legitimate branch -- and because the reads still inflate `repeats`, the result is not "no data" but a measurable-looking ZERO waste. Re-reading a file you just edited is correct behaviour; re-reading an unchanged one is not, and fp is what tells them apart. 3. projectBriefing read the graph from the bare process cwd, and wikiDir does no upward walk. Launching an agent from a subdirectory therefore read an empty directory and emitted no "what we have learned" section at all, for a project whose graph was fully populated one level up -- indistinguishable, to the reader, from a project that has learned nothing. session-start.mjs already walks up with the same __session__ sentinel. Six tests assert the shared contract rather than driving the hook binary, so they state what parity means and fail if either side drifts. Two of them pin the negative: that the bare cwd would NOT have resolved to the same graph, and that the file's own repository is what projectRootFor returns. Verified: tests/hooks 709 passed, 2 skipped, 0 failed; sync:hooks:check clean. Co-Authored-By: Claude Opus 5 --- hooks-core/adapter.mjs | 30 +++++- integrations/codex/hooks/lib/adapter.mjs | 30 +++++- .../codex/plugin/hooks/lib/adapter.mjs | 30 +++++- integrations/gemini/hooks/lib/adapter.mjs | 30 +++++- integrations/opencode/hooks/lib/adapter.mjs | 30 +++++- integrations/qwen/hooks/lib/adapter.mjs | 30 +++++- plugin/hooks/lib/adapter.mjs | 30 +++++- tests/hooks/adapter-parity.test.mjs | 99 +++++++++++++++++++ 8 files changed, 274 insertions(+), 35 deletions(-) create mode 100644 tests/hooks/adapter-parity.test.mjs diff --git a/hooks-core/adapter.mjs b/hooks-core/adapter.mjs index 7313ba0cd..9d080e5b9 100644 --- a/hooks-core/adapter.mjs +++ b/hooks-core/adapter.mjs @@ -26,8 +26,9 @@ import { loadState, saveState, alreadyDenied, mode, MODE_OFF, MODE_ADVISE, largeFileBytes, withEscape, } from './policy.mjs'; import { decide, remember, normalizePayload, readCostBytes } from './decide.mjs'; -import { recordRead } from './metrics.mjs'; -import { wikiDir } from './wiki.mjs'; +import { recordRead, fingerprint } from './metrics.mjs'; +import { wikiDir, projectRootFor } from './wiki.mjs'; +import { join } from 'node:path'; import { briefing } from './remedy.mjs'; import { stableText, transcriptFor } from './cache.mjs'; import { cachedRoutingBriefing } from './routing.mjs'; @@ -131,6 +132,13 @@ work out, because that exists nowhere in the source tree.${projectBriefing()}`; function projectBriefing() { try { const cwd = process.cwd(); + // THE GRAPH LIVES AT THE REPOSITORY ROOT, and wikiDir does no upward walk. Trusting the + // bare cwd meant that launching the agent from a subdirectory read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up -- + // indistinguishable, to the reader, from a project that has genuinely learned nothing. + // The sentinel filename is what session-start.mjs uses: the walk wants a path INSIDE the + // tree, not the tree itself. + const dir = wikiDir(projectRootFor(join(cwd, '__session__'), cwd)); // Routing facts join the same briefing, and are number-free for the same // reason the rest of it is: a count that ticks up as evidence accumulates @@ -138,10 +146,10 @@ function projectBriefing() { // The digits live in the model_routing tool, where changing costs nothing. let routing = null; try { - routing = cachedRoutingBriefing(wikiDir(cwd), transcriptFor(cwd)); + routing = cachedRoutingBriefing(dir, transcriptFor(cwd)); } catch { /* no transcript, no routing facts; the rest still applies */ } - const text = briefing(wikiDir(cwd), { extra: routing ? [routing] : [] }); + const text = briefing(dir, { extra: routing ? [routing] : [] }); if (!text) return ''; // CACHE-SAFE BY CONSTRUCTION. This text sits near the front of the prompt @@ -194,10 +202,22 @@ export async function run(clientName, event) { // therefore not comparable. const bytes = readCostBytes(payload); if (bytes) { - recordRead(wikiDir(payload.cwd), { + // THE GRAPH IS PER PROJECT, so a read is filed where the FILE lives, not where the + // client happens to be running. Keying it on payload.cwd meant a session started in + // repo A that reads a file in repo B appended the cost to A's metrics with an anchor + // that does not exist in A, while B never saw the cost of its own file. The Claude Code + // router already resolves it this way (pretooluse-router.mjs), and the comment above + // claims parity with that router -- this is what makes the claim true. + recordRead(wikiDir(projectRootFor(payload.tool_input.file_path, payload.cwd)), { anchor: payload.tool_input.file_path, sessionId: payload.session_id, bytes, + // WITHOUT THIS EVERY RE-READ IS UNDECIDABLE. rereadWaste classifies a pair whose + // fingerprint is missing as unknowable, so an adapter that omits it reports zero + // waste for these clients rather than no data -- the reads still inflate `repeats`, + // so the figure looks measured. Re-reading a file you just edited is correct + // behaviour; re-reading an unchanged one is not, and this is what tells them apart. + fp: fingerprint(payload.tool_input.file_path), }); } process.exit(0); diff --git a/integrations/codex/hooks/lib/adapter.mjs b/integrations/codex/hooks/lib/adapter.mjs index 14a27bb8f..500db602f 100644 --- a/integrations/codex/hooks/lib/adapter.mjs +++ b/integrations/codex/hooks/lib/adapter.mjs @@ -28,8 +28,9 @@ import { loadState, saveState, alreadyDenied, mode, MODE_OFF, MODE_ADVISE, largeFileBytes, withEscape, } from './policy.mjs'; import { decide, remember, normalizePayload, readCostBytes } from './decide.mjs'; -import { recordRead } from './metrics.mjs'; -import { wikiDir } from './wiki.mjs'; +import { recordRead, fingerprint } from './metrics.mjs'; +import { wikiDir, projectRootFor } from './wiki.mjs'; +import { join } from 'node:path'; import { briefing } from './remedy.mjs'; import { stableText, transcriptFor } from './cache.mjs'; import { cachedRoutingBriefing } from './routing.mjs'; @@ -133,6 +134,13 @@ work out, because that exists nowhere in the source tree.${projectBriefing()}`; function projectBriefing() { try { const cwd = process.cwd(); + // THE GRAPH LIVES AT THE REPOSITORY ROOT, and wikiDir does no upward walk. Trusting the + // bare cwd meant that launching the agent from a subdirectory read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up -- + // indistinguishable, to the reader, from a project that has genuinely learned nothing. + // The sentinel filename is what session-start.mjs uses: the walk wants a path INSIDE the + // tree, not the tree itself. + const dir = wikiDir(projectRootFor(join(cwd, '__session__'), cwd)); // Routing facts join the same briefing, and are number-free for the same // reason the rest of it is: a count that ticks up as evidence accumulates @@ -140,10 +148,10 @@ function projectBriefing() { // The digits live in the model_routing tool, where changing costs nothing. let routing = null; try { - routing = cachedRoutingBriefing(wikiDir(cwd), transcriptFor(cwd)); + routing = cachedRoutingBriefing(dir, transcriptFor(cwd)); } catch { /* no transcript, no routing facts; the rest still applies */ } - const text = briefing(wikiDir(cwd), { extra: routing ? [routing] : [] }); + const text = briefing(dir, { extra: routing ? [routing] : [] }); if (!text) return ''; // CACHE-SAFE BY CONSTRUCTION. This text sits near the front of the prompt @@ -196,10 +204,22 @@ export async function run(clientName, event) { // therefore not comparable. const bytes = readCostBytes(payload); if (bytes) { - recordRead(wikiDir(payload.cwd), { + // THE GRAPH IS PER PROJECT, so a read is filed where the FILE lives, not where the + // client happens to be running. Keying it on payload.cwd meant a session started in + // repo A that reads a file in repo B appended the cost to A's metrics with an anchor + // that does not exist in A, while B never saw the cost of its own file. The Claude Code + // router already resolves it this way (pretooluse-router.mjs), and the comment above + // claims parity with that router -- this is what makes the claim true. + recordRead(wikiDir(projectRootFor(payload.tool_input.file_path, payload.cwd)), { anchor: payload.tool_input.file_path, sessionId: payload.session_id, bytes, + // WITHOUT THIS EVERY RE-READ IS UNDECIDABLE. rereadWaste classifies a pair whose + // fingerprint is missing as unknowable, so an adapter that omits it reports zero + // waste for these clients rather than no data -- the reads still inflate `repeats`, + // so the figure looks measured. Re-reading a file you just edited is correct + // behaviour; re-reading an unchanged one is not, and this is what tells them apart. + fp: fingerprint(payload.tool_input.file_path), }); } process.exit(0); diff --git a/integrations/codex/plugin/hooks/lib/adapter.mjs b/integrations/codex/plugin/hooks/lib/adapter.mjs index 14a27bb8f..500db602f 100644 --- a/integrations/codex/plugin/hooks/lib/adapter.mjs +++ b/integrations/codex/plugin/hooks/lib/adapter.mjs @@ -28,8 +28,9 @@ import { loadState, saveState, alreadyDenied, mode, MODE_OFF, MODE_ADVISE, largeFileBytes, withEscape, } from './policy.mjs'; import { decide, remember, normalizePayload, readCostBytes } from './decide.mjs'; -import { recordRead } from './metrics.mjs'; -import { wikiDir } from './wiki.mjs'; +import { recordRead, fingerprint } from './metrics.mjs'; +import { wikiDir, projectRootFor } from './wiki.mjs'; +import { join } from 'node:path'; import { briefing } from './remedy.mjs'; import { stableText, transcriptFor } from './cache.mjs'; import { cachedRoutingBriefing } from './routing.mjs'; @@ -133,6 +134,13 @@ work out, because that exists nowhere in the source tree.${projectBriefing()}`; function projectBriefing() { try { const cwd = process.cwd(); + // THE GRAPH LIVES AT THE REPOSITORY ROOT, and wikiDir does no upward walk. Trusting the + // bare cwd meant that launching the agent from a subdirectory read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up -- + // indistinguishable, to the reader, from a project that has genuinely learned nothing. + // The sentinel filename is what session-start.mjs uses: the walk wants a path INSIDE the + // tree, not the tree itself. + const dir = wikiDir(projectRootFor(join(cwd, '__session__'), cwd)); // Routing facts join the same briefing, and are number-free for the same // reason the rest of it is: a count that ticks up as evidence accumulates @@ -140,10 +148,10 @@ function projectBriefing() { // The digits live in the model_routing tool, where changing costs nothing. let routing = null; try { - routing = cachedRoutingBriefing(wikiDir(cwd), transcriptFor(cwd)); + routing = cachedRoutingBriefing(dir, transcriptFor(cwd)); } catch { /* no transcript, no routing facts; the rest still applies */ } - const text = briefing(wikiDir(cwd), { extra: routing ? [routing] : [] }); + const text = briefing(dir, { extra: routing ? [routing] : [] }); if (!text) return ''; // CACHE-SAFE BY CONSTRUCTION. This text sits near the front of the prompt @@ -196,10 +204,22 @@ export async function run(clientName, event) { // therefore not comparable. const bytes = readCostBytes(payload); if (bytes) { - recordRead(wikiDir(payload.cwd), { + // THE GRAPH IS PER PROJECT, so a read is filed where the FILE lives, not where the + // client happens to be running. Keying it on payload.cwd meant a session started in + // repo A that reads a file in repo B appended the cost to A's metrics with an anchor + // that does not exist in A, while B never saw the cost of its own file. The Claude Code + // router already resolves it this way (pretooluse-router.mjs), and the comment above + // claims parity with that router -- this is what makes the claim true. + recordRead(wikiDir(projectRootFor(payload.tool_input.file_path, payload.cwd)), { anchor: payload.tool_input.file_path, sessionId: payload.session_id, bytes, + // WITHOUT THIS EVERY RE-READ IS UNDECIDABLE. rereadWaste classifies a pair whose + // fingerprint is missing as unknowable, so an adapter that omits it reports zero + // waste for these clients rather than no data -- the reads still inflate `repeats`, + // so the figure looks measured. Re-reading a file you just edited is correct + // behaviour; re-reading an unchanged one is not, and this is what tells them apart. + fp: fingerprint(payload.tool_input.file_path), }); } process.exit(0); diff --git a/integrations/gemini/hooks/lib/adapter.mjs b/integrations/gemini/hooks/lib/adapter.mjs index 14a27bb8f..500db602f 100644 --- a/integrations/gemini/hooks/lib/adapter.mjs +++ b/integrations/gemini/hooks/lib/adapter.mjs @@ -28,8 +28,9 @@ import { loadState, saveState, alreadyDenied, mode, MODE_OFF, MODE_ADVISE, largeFileBytes, withEscape, } from './policy.mjs'; import { decide, remember, normalizePayload, readCostBytes } from './decide.mjs'; -import { recordRead } from './metrics.mjs'; -import { wikiDir } from './wiki.mjs'; +import { recordRead, fingerprint } from './metrics.mjs'; +import { wikiDir, projectRootFor } from './wiki.mjs'; +import { join } from 'node:path'; import { briefing } from './remedy.mjs'; import { stableText, transcriptFor } from './cache.mjs'; import { cachedRoutingBriefing } from './routing.mjs'; @@ -133,6 +134,13 @@ work out, because that exists nowhere in the source tree.${projectBriefing()}`; function projectBriefing() { try { const cwd = process.cwd(); + // THE GRAPH LIVES AT THE REPOSITORY ROOT, and wikiDir does no upward walk. Trusting the + // bare cwd meant that launching the agent from a subdirectory read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up -- + // indistinguishable, to the reader, from a project that has genuinely learned nothing. + // The sentinel filename is what session-start.mjs uses: the walk wants a path INSIDE the + // tree, not the tree itself. + const dir = wikiDir(projectRootFor(join(cwd, '__session__'), cwd)); // Routing facts join the same briefing, and are number-free for the same // reason the rest of it is: a count that ticks up as evidence accumulates @@ -140,10 +148,10 @@ function projectBriefing() { // The digits live in the model_routing tool, where changing costs nothing. let routing = null; try { - routing = cachedRoutingBriefing(wikiDir(cwd), transcriptFor(cwd)); + routing = cachedRoutingBriefing(dir, transcriptFor(cwd)); } catch { /* no transcript, no routing facts; the rest still applies */ } - const text = briefing(wikiDir(cwd), { extra: routing ? [routing] : [] }); + const text = briefing(dir, { extra: routing ? [routing] : [] }); if (!text) return ''; // CACHE-SAFE BY CONSTRUCTION. This text sits near the front of the prompt @@ -196,10 +204,22 @@ export async function run(clientName, event) { // therefore not comparable. const bytes = readCostBytes(payload); if (bytes) { - recordRead(wikiDir(payload.cwd), { + // THE GRAPH IS PER PROJECT, so a read is filed where the FILE lives, not where the + // client happens to be running. Keying it on payload.cwd meant a session started in + // repo A that reads a file in repo B appended the cost to A's metrics with an anchor + // that does not exist in A, while B never saw the cost of its own file. The Claude Code + // router already resolves it this way (pretooluse-router.mjs), and the comment above + // claims parity with that router -- this is what makes the claim true. + recordRead(wikiDir(projectRootFor(payload.tool_input.file_path, payload.cwd)), { anchor: payload.tool_input.file_path, sessionId: payload.session_id, bytes, + // WITHOUT THIS EVERY RE-READ IS UNDECIDABLE. rereadWaste classifies a pair whose + // fingerprint is missing as unknowable, so an adapter that omits it reports zero + // waste for these clients rather than no data -- the reads still inflate `repeats`, + // so the figure looks measured. Re-reading a file you just edited is correct + // behaviour; re-reading an unchanged one is not, and this is what tells them apart. + fp: fingerprint(payload.tool_input.file_path), }); } process.exit(0); diff --git a/integrations/opencode/hooks/lib/adapter.mjs b/integrations/opencode/hooks/lib/adapter.mjs index 14a27bb8f..500db602f 100644 --- a/integrations/opencode/hooks/lib/adapter.mjs +++ b/integrations/opencode/hooks/lib/adapter.mjs @@ -28,8 +28,9 @@ import { loadState, saveState, alreadyDenied, mode, MODE_OFF, MODE_ADVISE, largeFileBytes, withEscape, } from './policy.mjs'; import { decide, remember, normalizePayload, readCostBytes } from './decide.mjs'; -import { recordRead } from './metrics.mjs'; -import { wikiDir } from './wiki.mjs'; +import { recordRead, fingerprint } from './metrics.mjs'; +import { wikiDir, projectRootFor } from './wiki.mjs'; +import { join } from 'node:path'; import { briefing } from './remedy.mjs'; import { stableText, transcriptFor } from './cache.mjs'; import { cachedRoutingBriefing } from './routing.mjs'; @@ -133,6 +134,13 @@ work out, because that exists nowhere in the source tree.${projectBriefing()}`; function projectBriefing() { try { const cwd = process.cwd(); + // THE GRAPH LIVES AT THE REPOSITORY ROOT, and wikiDir does no upward walk. Trusting the + // bare cwd meant that launching the agent from a subdirectory read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up -- + // indistinguishable, to the reader, from a project that has genuinely learned nothing. + // The sentinel filename is what session-start.mjs uses: the walk wants a path INSIDE the + // tree, not the tree itself. + const dir = wikiDir(projectRootFor(join(cwd, '__session__'), cwd)); // Routing facts join the same briefing, and are number-free for the same // reason the rest of it is: a count that ticks up as evidence accumulates @@ -140,10 +148,10 @@ function projectBriefing() { // The digits live in the model_routing tool, where changing costs nothing. let routing = null; try { - routing = cachedRoutingBriefing(wikiDir(cwd), transcriptFor(cwd)); + routing = cachedRoutingBriefing(dir, transcriptFor(cwd)); } catch { /* no transcript, no routing facts; the rest still applies */ } - const text = briefing(wikiDir(cwd), { extra: routing ? [routing] : [] }); + const text = briefing(dir, { extra: routing ? [routing] : [] }); if (!text) return ''; // CACHE-SAFE BY CONSTRUCTION. This text sits near the front of the prompt @@ -196,10 +204,22 @@ export async function run(clientName, event) { // therefore not comparable. const bytes = readCostBytes(payload); if (bytes) { - recordRead(wikiDir(payload.cwd), { + // THE GRAPH IS PER PROJECT, so a read is filed where the FILE lives, not where the + // client happens to be running. Keying it on payload.cwd meant a session started in + // repo A that reads a file in repo B appended the cost to A's metrics with an anchor + // that does not exist in A, while B never saw the cost of its own file. The Claude Code + // router already resolves it this way (pretooluse-router.mjs), and the comment above + // claims parity with that router -- this is what makes the claim true. + recordRead(wikiDir(projectRootFor(payload.tool_input.file_path, payload.cwd)), { anchor: payload.tool_input.file_path, sessionId: payload.session_id, bytes, + // WITHOUT THIS EVERY RE-READ IS UNDECIDABLE. rereadWaste classifies a pair whose + // fingerprint is missing as unknowable, so an adapter that omits it reports zero + // waste for these clients rather than no data -- the reads still inflate `repeats`, + // so the figure looks measured. Re-reading a file you just edited is correct + // behaviour; re-reading an unchanged one is not, and this is what tells them apart. + fp: fingerprint(payload.tool_input.file_path), }); } process.exit(0); diff --git a/integrations/qwen/hooks/lib/adapter.mjs b/integrations/qwen/hooks/lib/adapter.mjs index 14a27bb8f..500db602f 100644 --- a/integrations/qwen/hooks/lib/adapter.mjs +++ b/integrations/qwen/hooks/lib/adapter.mjs @@ -28,8 +28,9 @@ import { loadState, saveState, alreadyDenied, mode, MODE_OFF, MODE_ADVISE, largeFileBytes, withEscape, } from './policy.mjs'; import { decide, remember, normalizePayload, readCostBytes } from './decide.mjs'; -import { recordRead } from './metrics.mjs'; -import { wikiDir } from './wiki.mjs'; +import { recordRead, fingerprint } from './metrics.mjs'; +import { wikiDir, projectRootFor } from './wiki.mjs'; +import { join } from 'node:path'; import { briefing } from './remedy.mjs'; import { stableText, transcriptFor } from './cache.mjs'; import { cachedRoutingBriefing } from './routing.mjs'; @@ -133,6 +134,13 @@ work out, because that exists nowhere in the source tree.${projectBriefing()}`; function projectBriefing() { try { const cwd = process.cwd(); + // THE GRAPH LIVES AT THE REPOSITORY ROOT, and wikiDir does no upward walk. Trusting the + // bare cwd meant that launching the agent from a subdirectory read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up -- + // indistinguishable, to the reader, from a project that has genuinely learned nothing. + // The sentinel filename is what session-start.mjs uses: the walk wants a path INSIDE the + // tree, not the tree itself. + const dir = wikiDir(projectRootFor(join(cwd, '__session__'), cwd)); // Routing facts join the same briefing, and are number-free for the same // reason the rest of it is: a count that ticks up as evidence accumulates @@ -140,10 +148,10 @@ function projectBriefing() { // The digits live in the model_routing tool, where changing costs nothing. let routing = null; try { - routing = cachedRoutingBriefing(wikiDir(cwd), transcriptFor(cwd)); + routing = cachedRoutingBriefing(dir, transcriptFor(cwd)); } catch { /* no transcript, no routing facts; the rest still applies */ } - const text = briefing(wikiDir(cwd), { extra: routing ? [routing] : [] }); + const text = briefing(dir, { extra: routing ? [routing] : [] }); if (!text) return ''; // CACHE-SAFE BY CONSTRUCTION. This text sits near the front of the prompt @@ -196,10 +204,22 @@ export async function run(clientName, event) { // therefore not comparable. const bytes = readCostBytes(payload); if (bytes) { - recordRead(wikiDir(payload.cwd), { + // THE GRAPH IS PER PROJECT, so a read is filed where the FILE lives, not where the + // client happens to be running. Keying it on payload.cwd meant a session started in + // repo A that reads a file in repo B appended the cost to A's metrics with an anchor + // that does not exist in A, while B never saw the cost of its own file. The Claude Code + // router already resolves it this way (pretooluse-router.mjs), and the comment above + // claims parity with that router -- this is what makes the claim true. + recordRead(wikiDir(projectRootFor(payload.tool_input.file_path, payload.cwd)), { anchor: payload.tool_input.file_path, sessionId: payload.session_id, bytes, + // WITHOUT THIS EVERY RE-READ IS UNDECIDABLE. rereadWaste classifies a pair whose + // fingerprint is missing as unknowable, so an adapter that omits it reports zero + // waste for these clients rather than no data -- the reads still inflate `repeats`, + // so the figure looks measured. Re-reading a file you just edited is correct + // behaviour; re-reading an unchanged one is not, and this is what tells them apart. + fp: fingerprint(payload.tool_input.file_path), }); } process.exit(0); diff --git a/plugin/hooks/lib/adapter.mjs b/plugin/hooks/lib/adapter.mjs index 14a27bb8f..500db602f 100644 --- a/plugin/hooks/lib/adapter.mjs +++ b/plugin/hooks/lib/adapter.mjs @@ -28,8 +28,9 @@ import { loadState, saveState, alreadyDenied, mode, MODE_OFF, MODE_ADVISE, largeFileBytes, withEscape, } from './policy.mjs'; import { decide, remember, normalizePayload, readCostBytes } from './decide.mjs'; -import { recordRead } from './metrics.mjs'; -import { wikiDir } from './wiki.mjs'; +import { recordRead, fingerprint } from './metrics.mjs'; +import { wikiDir, projectRootFor } from './wiki.mjs'; +import { join } from 'node:path'; import { briefing } from './remedy.mjs'; import { stableText, transcriptFor } from './cache.mjs'; import { cachedRoutingBriefing } from './routing.mjs'; @@ -133,6 +134,13 @@ work out, because that exists nowhere in the source tree.${projectBriefing()}`; function projectBriefing() { try { const cwd = process.cwd(); + // THE GRAPH LIVES AT THE REPOSITORY ROOT, and wikiDir does no upward walk. Trusting the + // bare cwd meant that launching the agent from a subdirectory read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up -- + // indistinguishable, to the reader, from a project that has genuinely learned nothing. + // The sentinel filename is what session-start.mjs uses: the walk wants a path INSIDE the + // tree, not the tree itself. + const dir = wikiDir(projectRootFor(join(cwd, '__session__'), cwd)); // Routing facts join the same briefing, and are number-free for the same // reason the rest of it is: a count that ticks up as evidence accumulates @@ -140,10 +148,10 @@ function projectBriefing() { // The digits live in the model_routing tool, where changing costs nothing. let routing = null; try { - routing = cachedRoutingBriefing(wikiDir(cwd), transcriptFor(cwd)); + routing = cachedRoutingBriefing(dir, transcriptFor(cwd)); } catch { /* no transcript, no routing facts; the rest still applies */ } - const text = briefing(wikiDir(cwd), { extra: routing ? [routing] : [] }); + const text = briefing(dir, { extra: routing ? [routing] : [] }); if (!text) return ''; // CACHE-SAFE BY CONSTRUCTION. This text sits near the front of the prompt @@ -196,10 +204,22 @@ export async function run(clientName, event) { // therefore not comparable. const bytes = readCostBytes(payload); if (bytes) { - recordRead(wikiDir(payload.cwd), { + // THE GRAPH IS PER PROJECT, so a read is filed where the FILE lives, not where the + // client happens to be running. Keying it on payload.cwd meant a session started in + // repo A that reads a file in repo B appended the cost to A's metrics with an anchor + // that does not exist in A, while B never saw the cost of its own file. The Claude Code + // router already resolves it this way (pretooluse-router.mjs), and the comment above + // claims parity with that router -- this is what makes the claim true. + recordRead(wikiDir(projectRootFor(payload.tool_input.file_path, payload.cwd)), { anchor: payload.tool_input.file_path, sessionId: payload.session_id, bytes, + // WITHOUT THIS EVERY RE-READ IS UNDECIDABLE. rereadWaste classifies a pair whose + // fingerprint is missing as unknowable, so an adapter that omits it reports zero + // waste for these clients rather than no data -- the reads still inflate `repeats`, + // so the figure looks measured. Re-reading a file you just edited is correct + // behaviour; re-reading an unchanged one is not, and this is what tells them apart. + fp: fingerprint(payload.tool_input.file_path), }); } process.exit(0); diff --git a/tests/hooks/adapter-parity.test.mjs b/tests/hooks/adapter-parity.test.mjs new file mode 100644 index 000000000..bfc43d666 --- /dev/null +++ b/tests/hooks/adapter-parity.test.mjs @@ -0,0 +1,99 @@ +/** + * The adapter measures the same things the Claude Code router measures. + * + * adapter.mjs serves codex, gemini, qwen and opencode; plugin/hooks/pretooluse-router.mjs serves + * Claude Code. Its comment claims "Same measurement as the Claude Code router: every client's + * allowed read feeds the same holdout comparison, or the metric is client-specific and therefore + * not comparable". Three details made that claim false, and each produced a wrong number rather + * than a missing one -- which is worse, because a wrong number gets quoted. + * + * These tests assert the shared CONTRACT rather than driving the hook binary, so they state + * exactly what parity means and fail if either side drifts from it. + */ +import { describe, test, expect, beforeEach, afterEach } from '@jest/globals'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +import { wikiDir, projectRootFor } from '../../hooks-core/wiki.mjs'; +import { recordRead, fingerprint, readMetrics } from '../../hooks-core/metrics.mjs'; + +let repoA, repoB, fileA, fileB; + +beforeEach(() => { + repoA = mkdtempSync(join(tmpdir(), 'adapter-a-')); + repoB = mkdtempSync(join(tmpdir(), 'adapter-b-')); + for (const r of [repoA, repoB]) mkdirSync(join(r, '.git'), { recursive: true }); + fileA = join(repoA, 'a.js'); writeFileSync(fileA, 'export const a = 1;\n'); + fileB = join(repoB, 'b.js'); writeFileSync(fileB, 'export const b = 2;\n'); +}); + +afterEach(() => { + for (const r of [repoA, repoB]) { + try { rmSync(r, { recursive: true, force: true }); } catch { /* windows lock */ } + } +}); + +describe('a read is filed against the project the FILE belongs to', () => { + test("a session in repo A reading repo B's file records into B, not A", () => { + // The defect: recordRead(wikiDir(payload.cwd), ...) appended the cost to whichever repo the + // client was launched in, with an anchor that does not exist there -- while the repo that + // owns the file never saw the cost of its own file. + const dir = wikiDir(projectRootFor(fileB, repoA)); + mkdirSync(dir, { recursive: true }); + recordRead(dir, { anchor: fileB, sessionId: 's1', bytes: 1234, fp: fingerprint(fileB) }); + + // Landed in B. + expect(readMetrics(dir).some((e) => e.kind === 'read' && e.anchor === fileB)).toBe(true); + // And not in A. + const dirA = wikiDir(repoA); + const inA = existsSync(dirA) ? readMetrics(dirA) : []; + expect(inA.some((e) => e.anchor === fileB)).toBe(false); + }); + + test('projectRootFor sends the file to its own repository, not the caller cwd', () => { + expect(projectRootFor(fileB, repoA)).toBe(repoB.split('\\').join('/')); + }); +}); + +describe('a recorded read carries a fingerprint', () => { + test('fingerprint distinguishes an unchanged file from a changed one', () => { + // Without fp, rereadWaste classifies every pair as undecidable, so waste reports as ZERO + // for these clients rather than as unmeasured -- and the reads still inflate `repeats`, so + // the zero looks measured. + const before = fingerprint(fileA); + expect(before).toBeTruthy(); + expect(fingerprint(fileA)).toBe(before); + + writeFileSync(fileA, 'export const a = 99;\n'); + expect(fingerprint(fileA)).not.toBe(before); + }); + + test('a read written with a fingerprint round-trips it', () => { + const dir = wikiDir(repoA); + mkdirSync(dir, { recursive: true }); + recordRead(dir, { anchor: fileA, sessionId: 's1', bytes: 10, fp: fingerprint(fileA) }); + const read = readMetrics(dir).find((e) => e.kind === 'read' && e.anchor === fileA); + expect(read.fp).toBeTruthy(); + }); +}); + +describe('the briefing is read from the repository root', () => { + test('a subdirectory launch resolves to the same graph as a root launch', () => { + // wikiDir does no upward walk, so trusting the bare cwd read an empty directory and + // reported "nothing learned" for a project whose graph was fully populated one level up. + const sub = join(repoA, 'packages', 'web'); + mkdirSync(sub, { recursive: true }); + + const fromRoot = wikiDir(projectRootFor(join(repoA, '__session__'), repoA)); + const fromSub = wikiDir(projectRootFor(join(sub, '__session__'), sub)); + expect(fromSub).toBe(fromRoot); + }); + + test('the bare cwd would NOT have resolved to the same place', () => { + // Pins why the walk is required rather than merely tidy. + const sub = join(repoA, 'packages', 'web'); + mkdirSync(sub, { recursive: true }); + expect(wikiDir(sub)).not.toBe(wikiDir(repoA)); + }); +}); From fc255ffeffe77b17408728a4348f6d816427c630 Mon Sep 17 00:00:00 2001 From: Franklin Moormann Date: Fri, 7 Aug 2026 18:08:58 -0400 Subject: [PATCH 2/2] fix(adapter): scope denial state to the agent, as the router already does Found while auditing decide.mjs. A fourth divergence from the Claude Code router, same shape as the other three in this PR. The router keys policy state on `transcript_path` and explains why: "Subagents inherit the parent's session id, so keying state on the session alone made one agent's reads silence another's -- observed: an agent refused a file it had never opened because a sibling had read it." adapter.mjs called loadState and both saveState sites with the session id alone, so that fix was live on Claude Code and silently absent on codex, gemini, opencode and qwen. normalizePayload already produces transcript_path for exactly this purpose, so nothing new was needed to supply it. Two tests: one asserting two agents in one session do not share denial state, one asserting a single agent still sees its own -- the scope must not be so narrow that the repeat-denial escape hatch stops working. Verified: tests/hooks 711 passed, 2 skipped, 0 failed. Co-Authored-By: Claude Opus 5 --- hooks-core/adapter.mjs | 14 +++++++-- integrations/codex/hooks/lib/adapter.mjs | 14 +++++++-- .../codex/plugin/hooks/lib/adapter.mjs | 14 +++++++-- integrations/gemini/hooks/lib/adapter.mjs | 14 +++++++-- integrations/opencode/hooks/lib/adapter.mjs | 14 +++++++-- integrations/qwen/hooks/lib/adapter.mjs | 14 +++++++-- plugin/hooks/lib/adapter.mjs | 14 +++++++-- tests/hooks/adapter-parity.test.mjs | 31 +++++++++++++++++++ 8 files changed, 108 insertions(+), 21 deletions(-) diff --git a/hooks-core/adapter.mjs b/hooks-core/adapter.mjs index 9d080e5b9..8b2931621 100644 --- a/hooks-core/adapter.mjs +++ b/hooks-core/adapter.mjs @@ -190,12 +190,20 @@ export async function run(clientName, event) { const payload = normalizePayload(raw); if (!payload.tool_name) process.exit(0); - const state = loadState(payload.session_id); + // THE AGENT, not just the session -- the same scope the Claude Code router + // applies. Subagents inherit the parent's session id, so keying state on the + // session alone lets one agent's reads silence another's: the router's comment + // records it observed live, an agent refusing a file it had never opened + // because a sibling had read it. That fix was never carried across to the four + // clients this adapter serves. `transcript_path` is per agent; absent, this + // falls back to session scope, which is right for a main session's own calls. + const agentScope = payload.transcript_path || null; + const state = loadState(payload.session_id, agentScope); const verdict = decide(payload, state); if (!verdict) { remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // Same measurement as the Claude Code router: every client's allowed read // feeds the same holdout comparison, or the metric is client-specific and @@ -225,7 +233,7 @@ export async function run(clientName, event) { const repeat = alreadyDenied(state, verdict.key); remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // A post-tool hook has already paid for the call, so a denial would cost a // turn and save nothing. It advises about the NEXT one instead. diff --git a/integrations/codex/hooks/lib/adapter.mjs b/integrations/codex/hooks/lib/adapter.mjs index 500db602f..5a51e9a16 100644 --- a/integrations/codex/hooks/lib/adapter.mjs +++ b/integrations/codex/hooks/lib/adapter.mjs @@ -192,12 +192,20 @@ export async function run(clientName, event) { const payload = normalizePayload(raw); if (!payload.tool_name) process.exit(0); - const state = loadState(payload.session_id); + // THE AGENT, not just the session -- the same scope the Claude Code router + // applies. Subagents inherit the parent's session id, so keying state on the + // session alone lets one agent's reads silence another's: the router's comment + // records it observed live, an agent refusing a file it had never opened + // because a sibling had read it. That fix was never carried across to the four + // clients this adapter serves. `transcript_path` is per agent; absent, this + // falls back to session scope, which is right for a main session's own calls. + const agentScope = payload.transcript_path || null; + const state = loadState(payload.session_id, agentScope); const verdict = decide(payload, state); if (!verdict) { remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // Same measurement as the Claude Code router: every client's allowed read // feeds the same holdout comparison, or the metric is client-specific and @@ -227,7 +235,7 @@ export async function run(clientName, event) { const repeat = alreadyDenied(state, verdict.key); remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // A post-tool hook has already paid for the call, so a denial would cost a // turn and save nothing. It advises about the NEXT one instead. diff --git a/integrations/codex/plugin/hooks/lib/adapter.mjs b/integrations/codex/plugin/hooks/lib/adapter.mjs index 500db602f..5a51e9a16 100644 --- a/integrations/codex/plugin/hooks/lib/adapter.mjs +++ b/integrations/codex/plugin/hooks/lib/adapter.mjs @@ -192,12 +192,20 @@ export async function run(clientName, event) { const payload = normalizePayload(raw); if (!payload.tool_name) process.exit(0); - const state = loadState(payload.session_id); + // THE AGENT, not just the session -- the same scope the Claude Code router + // applies. Subagents inherit the parent's session id, so keying state on the + // session alone lets one agent's reads silence another's: the router's comment + // records it observed live, an agent refusing a file it had never opened + // because a sibling had read it. That fix was never carried across to the four + // clients this adapter serves. `transcript_path` is per agent; absent, this + // falls back to session scope, which is right for a main session's own calls. + const agentScope = payload.transcript_path || null; + const state = loadState(payload.session_id, agentScope); const verdict = decide(payload, state); if (!verdict) { remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // Same measurement as the Claude Code router: every client's allowed read // feeds the same holdout comparison, or the metric is client-specific and @@ -227,7 +235,7 @@ export async function run(clientName, event) { const repeat = alreadyDenied(state, verdict.key); remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // A post-tool hook has already paid for the call, so a denial would cost a // turn and save nothing. It advises about the NEXT one instead. diff --git a/integrations/gemini/hooks/lib/adapter.mjs b/integrations/gemini/hooks/lib/adapter.mjs index 500db602f..5a51e9a16 100644 --- a/integrations/gemini/hooks/lib/adapter.mjs +++ b/integrations/gemini/hooks/lib/adapter.mjs @@ -192,12 +192,20 @@ export async function run(clientName, event) { const payload = normalizePayload(raw); if (!payload.tool_name) process.exit(0); - const state = loadState(payload.session_id); + // THE AGENT, not just the session -- the same scope the Claude Code router + // applies. Subagents inherit the parent's session id, so keying state on the + // session alone lets one agent's reads silence another's: the router's comment + // records it observed live, an agent refusing a file it had never opened + // because a sibling had read it. That fix was never carried across to the four + // clients this adapter serves. `transcript_path` is per agent; absent, this + // falls back to session scope, which is right for a main session's own calls. + const agentScope = payload.transcript_path || null; + const state = loadState(payload.session_id, agentScope); const verdict = decide(payload, state); if (!verdict) { remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // Same measurement as the Claude Code router: every client's allowed read // feeds the same holdout comparison, or the metric is client-specific and @@ -227,7 +235,7 @@ export async function run(clientName, event) { const repeat = alreadyDenied(state, verdict.key); remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // A post-tool hook has already paid for the call, so a denial would cost a // turn and save nothing. It advises about the NEXT one instead. diff --git a/integrations/opencode/hooks/lib/adapter.mjs b/integrations/opencode/hooks/lib/adapter.mjs index 500db602f..5a51e9a16 100644 --- a/integrations/opencode/hooks/lib/adapter.mjs +++ b/integrations/opencode/hooks/lib/adapter.mjs @@ -192,12 +192,20 @@ export async function run(clientName, event) { const payload = normalizePayload(raw); if (!payload.tool_name) process.exit(0); - const state = loadState(payload.session_id); + // THE AGENT, not just the session -- the same scope the Claude Code router + // applies. Subagents inherit the parent's session id, so keying state on the + // session alone lets one agent's reads silence another's: the router's comment + // records it observed live, an agent refusing a file it had never opened + // because a sibling had read it. That fix was never carried across to the four + // clients this adapter serves. `transcript_path` is per agent; absent, this + // falls back to session scope, which is right for a main session's own calls. + const agentScope = payload.transcript_path || null; + const state = loadState(payload.session_id, agentScope); const verdict = decide(payload, state); if (!verdict) { remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // Same measurement as the Claude Code router: every client's allowed read // feeds the same holdout comparison, or the metric is client-specific and @@ -227,7 +235,7 @@ export async function run(clientName, event) { const repeat = alreadyDenied(state, verdict.key); remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // A post-tool hook has already paid for the call, so a denial would cost a // turn and save nothing. It advises about the NEXT one instead. diff --git a/integrations/qwen/hooks/lib/adapter.mjs b/integrations/qwen/hooks/lib/adapter.mjs index 500db602f..5a51e9a16 100644 --- a/integrations/qwen/hooks/lib/adapter.mjs +++ b/integrations/qwen/hooks/lib/adapter.mjs @@ -192,12 +192,20 @@ export async function run(clientName, event) { const payload = normalizePayload(raw); if (!payload.tool_name) process.exit(0); - const state = loadState(payload.session_id); + // THE AGENT, not just the session -- the same scope the Claude Code router + // applies. Subagents inherit the parent's session id, so keying state on the + // session alone lets one agent's reads silence another's: the router's comment + // records it observed live, an agent refusing a file it had never opened + // because a sibling had read it. That fix was never carried across to the four + // clients this adapter serves. `transcript_path` is per agent; absent, this + // falls back to session scope, which is right for a main session's own calls. + const agentScope = payload.transcript_path || null; + const state = loadState(payload.session_id, agentScope); const verdict = decide(payload, state); if (!verdict) { remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // Same measurement as the Claude Code router: every client's allowed read // feeds the same holdout comparison, or the metric is client-specific and @@ -227,7 +235,7 @@ export async function run(clientName, event) { const repeat = alreadyDenied(state, verdict.key); remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // A post-tool hook has already paid for the call, so a denial would cost a // turn and save nothing. It advises about the NEXT one instead. diff --git a/plugin/hooks/lib/adapter.mjs b/plugin/hooks/lib/adapter.mjs index 500db602f..5a51e9a16 100644 --- a/plugin/hooks/lib/adapter.mjs +++ b/plugin/hooks/lib/adapter.mjs @@ -192,12 +192,20 @@ export async function run(clientName, event) { const payload = normalizePayload(raw); if (!payload.tool_name) process.exit(0); - const state = loadState(payload.session_id); + // THE AGENT, not just the session -- the same scope the Claude Code router + // applies. Subagents inherit the parent's session id, so keying state on the + // session alone lets one agent's reads silence another's: the router's comment + // records it observed live, an agent refusing a file it had never opened + // because a sibling had read it. That fix was never carried across to the four + // clients this adapter serves. `transcript_path` is per agent; absent, this + // falls back to session scope, which is right for a main session's own calls. + const agentScope = payload.transcript_path || null; + const state = loadState(payload.session_id, agentScope); const verdict = decide(payload, state); if (!verdict) { remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // Same measurement as the Claude Code router: every client's allowed read // feeds the same holdout comparison, or the metric is client-specific and @@ -227,7 +235,7 @@ export async function run(clientName, event) { const repeat = alreadyDenied(state, verdict.key); remember(payload, state); - saveState(payload.session_id, state); + saveState(payload.session_id, state, agentScope); // A post-tool hook has already paid for the call, so a denial would cost a // turn and save nothing. It advises about the NEXT one instead. diff --git a/tests/hooks/adapter-parity.test.mjs b/tests/hooks/adapter-parity.test.mjs index bfc43d666..71ef70a0e 100644 --- a/tests/hooks/adapter-parity.test.mjs +++ b/tests/hooks/adapter-parity.test.mjs @@ -17,6 +17,7 @@ import { tmpdir } from 'os'; import { wikiDir, projectRootFor } from '../../hooks-core/wiki.mjs'; import { recordRead, fingerprint, readMetrics } from '../../hooks-core/metrics.mjs'; +import { loadState, saveState } from '../../hooks-core/policy.mjs'; let repoA, repoB, fileA, fileB; @@ -97,3 +98,33 @@ describe('the briefing is read from the repository root', () => { expect(wikiDir(sub)).not.toBe(wikiDir(repoA)); }); }); + +describe('state is scoped to the agent, not just the session', () => { + test('two agents in one session do not share denial state', () => { + // Subagents inherit the parent's session id. The router already keys state on + // transcript_path for this reason -- its comment records the failure observed live: "an + // agent refused a file it had never opened because a sibling had read it." The adapter, + // which serves codex/gemini/qwen/opencode, was never given the same scope. + const session = 'shared-session-id'; + const a = loadState(session, '/transcripts/agent-a.jsonl'); + const b = loadState(session, '/transcripts/agent-b.jsonl'); + + a.denied = { 'read:big.ts': 1 }; + saveState(session, a, '/transcripts/agent-a.jsonl'); + + const bAfter = loadState(session, '/transcripts/agent-b.jsonl'); + expect(bAfter.denied?.['read:big.ts']).toBeUndefined(); + expect(b).toBeTruthy(); + }); + + test('the same agent still sees its own state', () => { + // The scope must not be so narrow that a single agent forgets its own denials, or the + // repeat-denial escape hatch stops working. + const session = 'shared-session-id-2'; + const scope = '/transcripts/agent-a.jsonl'; + const first = loadState(session, scope); + first.denied = { 'read:big.ts': 1 }; + saveState(session, first, scope); + expect(loadState(session, scope).denied?.['read:big.ts']).toBe(1); + }); +});