guard-journal.ts
115 lines · autoplan/bin/guard-journal.ts at 3f79d81
1/**
2 * What the publication guard reads of its parent journal (CEO-2, ENG-4, ENG-5).
3 * The bounded reader's index keeps every owned record's events without content.
4 * This policy reloads in full only what the evaluation reads: every literal
5 * init and its result, the current call, and inside the invocation window
6 * (from the latest human turn at or before the bound invocation chain's first
7 * init) the results of guarded and close-packet Reads, structured Bash results,
8 * error results, assistant text after the first close-packet Read and
9 * mutations of the active plan; plus the earlier Read a dedup reply cites,
10 * even before the window.
11 */
12import { readOwnedClaudePublicTranscript, type IndexedRecord, type JournalPrefix, type OwnedRead,
13 type OwnedReadMeasure } from '../../lib/claude-owned-journal';
14import { object, sameNativePath, type ClaudeParentPublicEvent, type NativePublicToolEvent } from '../../lib/claude-journal-records';
16type Event = ClaudeParentPublicEvent;
17type Use = NativePublicToolEvent & { order: number };
18/** Claude Code's reply to a Read of an unchanged file it already returned in full. */
19export const DEDUP_REPLY = 'Wasted call — file unchanged since your last Read. Refer to that earlier tool_result instead.';
20/** The longest Read input or Bash command the index keeps; a literal init is far shorter. */
21const INDEX_INPUT_MAX = 4096;
23export function textResult(event: Event): string | undefined {
24 if (event.kind !== 'result' || event.isError !== false) return;
25 if (typeof event.content === 'string') return event.content;
26 if (Array.isArray(event.content) && event.content.length === 1 && event.content[0]?.type === 'text' &&
27 typeof event.content[0].text === 'string') return event.content[0].text;
28}
30/** The hook's own classifiers, passed in so this policy and the evaluation cannot disagree. */
31export interface GuardJournalTests {
32 /** A literal snapshot init's [source, active, restore], or undefined. */
33 init(command: unknown): string[] | undefined;
34 /** A phase driver or artifact Read ('phase'), a close-packet Read ('close'), or neither. */
35 read(file: unknown): 'phase' | 'close' | undefined;
36 reviewer(input: Record<string, unknown>): boolean;
37}
39function indexInput(tests: GuardJournalTests) {
40 return (name: string, input: Record<string, unknown>): Record<string, unknown> | undefined => {
41 if (name === 'Read') return JSON.stringify(input).length <= INDEX_INPUT_MAX ? input : undefined;
42 if (name === 'Bash') return typeof input.command === 'string' && input.command.length <= INDEX_INPUT_MAX ? { command: input.command } : undefined;
43 if (name === 'Agent') return tests.reviewer(input) ? input : undefined;
44 if (name === 'Write' || name === 'Edit')
45 return typeof input.file_path === 'string' && input.file_path.length <= INDEX_INPUT_MAX ? { file_path: input.file_path } : undefined;
46 };
47}
49function select(tests: GuardJournalTests, currentToolUseId: string) {
50 return (records: readonly IndexedRecord[], load: (records: readonly IndexedRecord[]) => void) => {
51 const useOf = new Map<string, { use: Use; record: IndexedRecord }>(), resultsOf = new Map<string, IndexedRecord[]>();
52 const reads: Array<{ use: Use; record: IndexedRecord }> = [], inits: Array<{ use: Use; record: number; args: string[] }> = [];
53 records.forEach((record, at) => {
54 for (const e of record.events) {
55 if (e.kind === 'use') {
56 const use = e as Use;
57 if (!useOf.has(use.toolUseId)) useOf.set(use.toolUseId, { use, record });
58 if (use.name === 'Read') reads.push({ use, record });
59 const args = use.name === 'Bash' ? tests.init(use.input?.command) : undefined;
60 if (args) inits.push({ use, record: at, args });
61 } else if (e.kind === 'result') { const list = resultsOf.get(e.toolUseId) ?? []; list.push(record); resultsOf.set(e.toolUseId, list); }
62 }
63 });
64 const withResults = (id: string) => [...(useOf.has(id) ? [useOf.get(id)!.record] : []), ...resultsOf.get(id) ?? []];
65 // invocation() binds and checks every literal init, so all of them load, window or not.
66 load(inits.flatMap(init => resultsOf.get(init.use.toolUseId) ?? []));
67 let bound: Record<string, any> | undefined, chainStart: number | undefined;
68 for (const init of inits) {
69 const replies = (resultsOf.get(init.use.toolUseId) ?? []).flatMap(r => r.events)
70 .filter(e => e.kind === 'result' && e.toolUseId === init.use.toolUseId && e.order > init.use.order);
71 let reply: unknown;
72 try { reply = replies.length === 1 ? JSON.parse(textResult(replies[0]!) ?? 'null') : undefined; } catch { reply = undefined; }
73 // A reused:true init keeps the earlier binding, as invocation() does.
74 if (object(reply) && reply.reused === true && bound && bound.activePlan === reply.activePlan &&
75 bound.restorePath === reply.restorePath) continue;
76 chainStart = init.record; bound = object(reply) ? reply : undefined;
77 }
78 let start = 0;
79 for (let i = chainStart ?? records.length - 1; i >= 0; i--)
80 if (records[i]!.events.some(e => e.kind === 'user_turn')) { start = i; break; }
81 const activePlans = inits.filter(init => init.record >= start).map(init => init.args[1]);
82 const wanted = new Set<IndexedRecord>(withResults(currentToolUseId));
83 let afterClose = false;
84 for (const record of records.slice(start)) for (const e of record.events) {
85 if (e.kind === 'use' && e.name === 'Read' && tests.read(e.input?.file_path) === 'close') afterClose = true;
86 if (e.kind === 'message' && afterClose) wanted.add(record);
87 if (e.kind === 'use' && (e.name === 'Write' || e.name === 'Edit') && activePlans.some(plan => sameNativePath(plan, e.input?.file_path)))
88 wanted.add(record);
89 if (e.kind !== 'result') continue;
90 const use = useOf.get(e.toolUseId)?.use;
91 if (e.isError || (use?.name === 'Read' && tests.read(use.input?.file_path)) ||
92 (use?.name === 'Bash' && record.jsonResults?.includes(e.toolUseId))) wanted.add(record);
93 }
94 load([...wanted]);
95 // ENG-4: a dedup reply's cited earlier Read (and its own reply) loads even before the window.
96 for (let pending = [...wanted]; pending.length;) {
97 const next: IndexedRecord[] = [];
98 for (const record of pending) for (const e of record.events) {
99 const use = e.kind === 'result' ? useOf.get(e.toolUseId)?.use : undefined;
100 if (!use || use.name !== 'Read' || textResult(e) !== DEDUP_REPLY) continue;
101 const prior = reads.findLast(r => r.use.order < use.order && sameNativePath(r.use.input?.file_path, use.input?.file_path));
102 if (prior) next.push(...withResults(prior.use.toolUseId).filter(r => !r.full));
103 }
104 load(next);
105 pending = next;
106 }
107 };
108}
110/** The guard's owned read: the bounded reader under this policy, keeping the prefix witness across reads. */
111export function readGuardJournal(file: string, owners: readonly string[], sessionId: string, currentToolUseId: string,
112 tests: GuardJournalTests, prior?: JournalPrefix, measure?: OwnedReadMeasure): OwnedRead {
113 return readOwnedClaudePublicTranscript(file, owners, sessionId,
114 { prior, measure, policy: { indexInput: indexInput(tests), select: select(tests, currentToolUseId) } });
115}