import type { Clock } from "../platform/clock"; import type { EpisodeState, ModelKey, Tier } from "../episode/types"; export interface ToolCallEvent { toolName: string; toolCallId: string; input?: unknown; } export interface ToolResultEvent { toolName: string; toolCallId: string; details?: unknown; content?: unknown; } export interface ToolResultPatch { details?: Record; } /** Kept byte-for-byte in step with `review/types.ts`'s declaration — see R16's note there for why the * tier is recorded rather than derived, and why absence means "older build" rather than "tier 1". */ export interface FlaggedFileRecord { path: string; model: ModelKey; timestamp: string; tier?: Tier; } export interface ShellActivityRecord { command: string; toolCallId: string; timestamp: string; } export interface ReviewRecords { flaggedFiles: FlaggedFileRecord[]; shellActivity: ShellActivityRecord[]; } export interface ReviewFlaggerOptions { clock: Clock; flagShellActivity?: boolean; } type BuiltInTool = "edit" | "write" | "bash" | "powershell"; type PendingTool = { tool: BuiltInTool; path?: string; command?: string }; const isPlainObject = (value: unknown): value is Record => { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; }; const stringField = (value: unknown, key: string): string | undefined => { if (!isPlainObject(value) || typeof value[key] !== "string" || value[key].length === 0) return undefined; return value[key] as string; }; export function classifyToolName(value: unknown): BuiltInTool | undefined { if (typeof value !== "string") return undefined; const name = value.toLowerCase(); return name === "edit" || name === "write" || name === "bash" || name === "powershell" ? name : undefined; } export function isActiveEpisode(state: Pick): boolean { return state.status === "active" && state.substate !== undefined; } export function mergeFallbackReviewDetails(details: unknown, marker: { model: ModelKey; timestamp: string }): ToolResultPatch { return { details: { ...(isPlainObject(details) ? details : {}), fallbackReview: marker } }; } export class ReviewFlagger { private readonly pending = new Map(); private readonly flaggedFiles: FlaggedFileRecord[] = []; private readonly shellActivity: ShellActivityRecord[] = []; private readonly flagShellActivity: boolean; constructor(private readonly options: ReviewFlaggerOptions) { this.flagShellActivity = options.flagShellActivity !== false; } captureToolCall(event: ToolCallEvent, state: EpisodeState): void { if (!isActiveEpisode(state)) { this.pending.clear(); return; } const tool = classifyToolName(event.toolName); if (!tool || typeof event.toolCallId !== "string") return; const input = event.input; const pending: PendingTool = { tool }; if (tool === "edit" || tool === "write") { const path = stringField(input, "path"); if (path) pending.path = path; } else if (this.flagShellActivity) { const command = stringField(input, "command"); if (command) pending.command = command; } this.pending.set(event.toolCallId, pending); } captureToolResult(event: ToolResultEvent, state: EpisodeState): ToolResultPatch | undefined { if (!isActiveEpisode(state)) { this.pending.clear(); return undefined; } const tool = classifyToolName(event.toolName); if (!tool || typeof event.toolCallId !== "string") return undefined; const pending = this.pending.get(event.toolCallId); this.pending.delete(event.toolCallId); const timestamp = this.options.clock.iso(); const segment = state.segments.at(-1); const model = segment?.active ?? state.activeModel; // R16: read off the SEGMENT, which recorded the tier at entry. `undefined` here means there is no // segment at all, which `isActiveEpisode` above has already made unreachable in practice; the // optionality exists for the record shape, not as a silent default to tier 1. const tier: Tier | undefined = segment?.tier; if (tool === "edit" || tool === "write") { if (pending?.tool === tool && pending.path) this.flaggedFiles.push({ path: pending.path, model, timestamp, ...(tier === undefined ? {} : { tier }) }); // The inline marker stays model-only, per the proposal's decision on this exact point. return mergeFallbackReviewDetails(event.details, { model, timestamp }); } if (this.flagShellActivity && pending?.tool === tool && pending.command) { this.shellActivity.push({ command: pending.command, toolCallId: event.toolCallId, timestamp }); } return undefined; } snapshot(): ReviewRecords { return { flaggedFiles: this.flaggedFiles.map((record) => ({ ...record })), shellActivity: this.shellActivity.map((record) => ({ ...record })), }; } }