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),