feat: file-read decision gate — block reads when observation history exists

Add a PreToolUse gate that blocks file reads on first attempt when rich
observation history exists, presenting the timeline as feedback. Claude
then decides: use get_observations() (skip read, save tokens) or re-read
(allowed on second attempt).

- FileReadGate: in-memory session-scoped gate with 4h TTL
- POST /api/file-context/gate endpoint in worker
- stderrMessage plumbing in hook-command for exit code 2
- file-context handler uses gate to block/allow reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-03-19 12:11:02 -07:00
parent 47d6d51030
commit c80763390b
6 changed files with 914 additions and 68137 deletions
+22 -9
View File
@@ -64,7 +64,7 @@ function formatFileTimeline(observations: ObservationRow[], filePath: string): s
});
const lines: string[] = [
`Existing observations for this file \u2014 review via get_observations([IDs]) to avoid duplicates:`,
`Read blocked: This file has prior observations. Use get_observations([IDs]) to load what you need. Re-read the file only if you need raw content not captured in observations:`,
];
for (const [day, dayObservations] of sortedDays) {
@@ -128,15 +128,28 @@ export const fileContextHandler: EventHandler = {
return { continue: true, suppressOutput: true };
}
const timeline = formatFileTimeline(data.observations, filePath);
// Check the gate: has this file's timeline been shown in this session?
const gateResponse = await workerHttpRequest('/api/file-context/gate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: input.sessionId, filePath: relativePath }),
});
return {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow',
additionalContext: timeline,
},
};
if (gateResponse.ok) {
const gateData = await gateResponse.json() as { firstAttempt: boolean };
if (gateData.firstAttempt) {
// BLOCK: Show timeline, Claude decides whether to re-read or use get_observations()
const timeline = formatFileTimeline(data.observations, filePath);
return {
exitCode: HOOK_EXIT_CODES.BLOCKING_ERROR,
stderrMessage: timeline,
};
}
}
// ALLOW: Second attempt or gate check failed — let the read proceed silently
return { continue: true, suppressOutput: true };
} catch (error) {
logger.warn('HOOK', 'File context fetch error, skipping', {
error: error instanceof Error ? error.message : String(error),
+8
View File
@@ -84,6 +84,14 @@ export async function hookCommand(platform: string, event: string, options: Hook
console.log(JSON.stringify(output));
const exitCode = result.exitCode ?? HOOK_EXIT_CODES.SUCCESS;
// If handler wants to send a blocking message via stderr (exit code 2 contract),
// restore stderr and write the message before exiting
if (result.stderrMessage && exitCode === HOOK_EXIT_CODES.BLOCKING_ERROR) {
process.stderr.write = originalStderrWrite;
process.stderr.write(result.stderrMessage);
}
if (!options.skipExit) {
process.exit(exitCode);
}
+1
View File
@@ -23,6 +23,7 @@ export interface HookResult {
};
systemMessage?: string;
exitCode?: number;
stderrMessage?: string; // Written to stderr before exit (for exit code 2 blocking feedback)
}
export interface PlatformAdapter {