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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user