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
@@ -19,6 +19,7 @@ import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import type { WorkerService } from '../../../worker-service.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
import { checkAndMark } from '../../FileReadGate.js';
export class DataRoutes extends BaseRouteHandler {
constructor(
@@ -62,6 +63,9 @@ export class DataRoutes extends BaseRouteHandler {
// Import endpoint
app.post('/api/import', this.handleImport.bind(this));
// File-context gate
app.post('/api/file-context/gate', this.handleFileContextGate.bind(this));
}
/**
@@ -496,4 +500,20 @@ export class DataRoutes extends BaseRouteHandler {
clearedCount
});
});
/**
* Check if a file has already had its timeline injected in this session
* POST /api/file-context/gate
* Body: { sessionId: string, filePath: string }
* Returns: { firstAttempt: boolean }
*/
private handleFileContextGate = this.wrapHandler((req: Request, res: Response): void => {
const { sessionId, filePath } = req.body;
if (!sessionId || !filePath) {
this.badRequest(res, 'sessionId and filePath are required');
return;
}
const firstAttempt = checkAndMark(sessionId, filePath);
res.json({ firstAttempt });
});
}