diff --git a/hooks-core/adapter.mjs b/hooks-core/adapter.mjs index 7313ba0cd..8b2931621 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 @@ -182,22 +190,42 @@ 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 // 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); @@ -205,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 14a27bb8f..5a51e9a16 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 @@ -184,22 +192,42 @@ 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 // 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); @@ -207,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 14a27bb8f..5a51e9a16 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 @@ -184,22 +192,42 @@ 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 // 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); @@ -207,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 14a27bb8f..5a51e9a16 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 @@ -184,22 +192,42 @@ 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 // 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); @@ -207,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 14a27bb8f..5a51e9a16 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 @@ -184,22 +192,42 @@ 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 // 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); @@ -207,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 14a27bb8f..5a51e9a16 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 @@ -184,22 +192,42 @@ 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 // 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); @@ -207,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 14a27bb8f..5a51e9a16 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 @@ -184,22 +192,42 @@ 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 // 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); @@ -207,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 new file mode 100644 index 000000000..71ef70a0e --- /dev/null +++ b/tests/hooks/adapter-parity.test.mjs @@ -0,0 +1,130 @@ +/** + * 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'; +import { loadState, saveState } from '../../hooks-core/policy.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)); + }); +}); + +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); + }); +});