/** Lossless, read-only question metadata from one isolated Claude fixture. */ import * as fs from 'node:fs'; import * as path from 'node:path'; export interface NativePlanQuestion { header: string; question: string; options: Array<{ label: string; description?: string }>; multiSelect?: boolean; } export interface NativePlanQuestionCall { sessionId: string; toolUseId: string; questions: NativePlanQuestion[]; answered: boolean; failed?: boolean; failure?: string; answers?: Record; unansweredQuestionIndices?: number[]; answeredAt?: string; } /** Optional public tool projection for the Autoplan delivery audit; never thinking. */ export interface NativePublicToolEvent { sessionId: string; timestamp: string; toolUseId: string; kind: 'use' | 'result'; name?: string; /** Exact native message/request identity, used only for owned queued tools. */ messageId?: string; requestId?: string; input?: Record; content?: unknown; file?: unknown; isError?: boolean; } export interface PlanCountTranscript { status: 'missing' | 'ready' | 'error'; calls: NativePlanQuestionCall[]; assistantMessages: Array<{ sessionId: string; text: string; timestamp: string }>; /** Actual native plan-mode approval requests; pending is the UI gate, never an AUQ. */ planReadyRequests?: Array<{ sessionId: string; toolUseId: string; timestamp: string; failed: boolean; source?: 'pre_tool_use' }>; error?: string; } /** A rejected/refused call needs an actual later answer, not unrelated progress. */ export function unresolvedPlanQuestionCalls(calls: NativePlanQuestionCall[]): NativePlanQuestionCall[] { return calls.filter((call, index) => call.failed && !call.questions.every(q => calls.slice(index + 1).some(later => later.answered && later.answers?.[q.question]))); } const MAX_BYTES = 32 * 1024 * 1024; const MAX_FILES = 64; const object = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); const validTimestamp = (value: unknown): value is string => typeof value === 'string' && Number.isFinite(Date.parse(value)); /** Read one length-delimited protobuf field, rejecting malformed/ambiguous input. */ function signatureField(bytes: Uint8Array | undefined, wanted: number): Uint8Array | undefined { if (!bytes) return; let cursor = 0; let result: Uint8Array | undefined; let seen = false; const integer = () => { let value = 0; for (let shift = 0; shift < 70; shift += 7) { if (cursor >= bytes.length) throw new Error('truncated signature'); const byte = bytes[cursor++]!; value += (byte & 127) * 2 ** shift; if (!Number.isSafeInteger(value)) throw new Error('signature integer overflow'); if (!(byte & 128)) return value; } throw new Error('overlong signature integer'); }; while (cursor < bytes.length) { const key = integer(); const field = Math.floor(key / 8); if (field < 1 || field > 0x1fffffff) throw new Error('invalid signature field'); if (field === wanted) { if (seen) throw new Error('duplicate signature field'); seen = true; } switch (key % 8) { case 0: integer(); break; case 1: cursor += 8; break; case 2: { const length = integer(); if (length > bytes.length - cursor) throw new Error('truncated signature field'); if (field === wanted) result = bytes.subarray(cursor, cursor + length); cursor += length; break; } case 5: cursor += 4; break; default: throw new Error('unsupported signature wire type'); } if (cursor > bytes.length) throw new Error('truncated signature field'); } return result; } /** * Claude's public narration renderer classifies signature fields 2→1→8 as * block_kind="narration": summaries of inter-tool prose, not private reasoning. * Match that metadata only in this already-owned native transcript. This is * classification, not cryptographic signature verification. Never read the * thinking text of an untagged, unknown, malformed or legacy block. */ function publicNarrationText(block: Record): string | undefined { if (block.type !== 'thinking' || typeof block.signature !== 'string' || block.signature.length > 64 * 1024 || !/^[A-Za-z0-9+/]+={0,2}$/.test(block.signature)) return; try { const bytes = Buffer.from(block.signature, 'base64'); const canonical = bytes.toString('base64'); if (block.signature !== canonical && block.signature !== canonical.replace(/=+$/, '')) return; const tag = signatureField(signatureField(signatureField(bytes, 2), 1), 8); if (!tag || Buffer.from(tag).toString('utf8') !== 'narration') return; return typeof block.thinking === 'string' && block.thinking.trim() ? block.thinking : undefined; } catch { return; } } function validQuestions(value: unknown): value is NativePlanQuestion[] { return Array.isArray(value) && value.length > 0 && value.every(q => object(q) && typeof q.header === 'string' && typeof q.question === 'string' && q.question.trim() && Array.isArray(q.options) && q.options.length >= 2 && q.options.every((o: unknown) => object(o) && typeof o.label === 'string' && o.label.trim())); } /** * Count callers consume each answered (sessionId, toolUseId) once, regardless * of questions[].length. A batched tool call must never become N findings. * Partial final lines remain pending; missing/foreign/sidechain records add * no coverage. Traversal stays inside the owned config's projects directory. */ export function readPlanCountTranscript(configDir: string, cwd: string, onPublicToolEvent?: (event: NativePublicToolEvent) => void, /** Optional exact parent journal, already validated by the owning native hook. */ ownedParentTranscript?: string, ): PlanCountTranscript { const calls = new Map(); const assistantMessages: PlanCountTranscript['assistantMessages'] = []; const planReadyRequests = new Map[number]>(); let matched = false; let bytes = 0; let files = 0; const projects = path.join(configDir, 'projects'); try { if (!fs.existsSync(projects)) return { status: 'missing', calls: [], assistantMessages: [] }; const dirs = fs.readdirSync(projects, { withFileTypes: true }).filter(d => d.isDirectory()); if (dirs.length > MAX_FILES) throw new Error('too many project directories'); for (const dir of dirs) { const project = path.join(projects, dir.name); for (const entry of fs.readdirSync(project, { withFileTypes: true })) { if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; if (++files > MAX_FILES) throw new Error('too many transcript files'); const file = path.join(project, entry.name); if (ownedParentTranscript !== undefined && file !== ownedParentTranscript) continue; bytes += fs.statSync(file).size; if (bytes > MAX_BYTES) throw new Error('transcript exceeds 32 MiB read limit'); const text = fs.readFileSync(file, 'utf8'); // Native sessions retain their original journal after Bash changes cwd. // Admit that continuation only through UUID ancestry rooted in this // fixture's first parent user message; legacy records keep exact-cwd scoping. let originSeen = false; const ancestry = new Set(); const nativeUuid = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value); // Claude appends JSONL during rendering; an unfinished record is not // evidence of a call or an answer until its newline has been written. for (const line of text.slice(0, text.lastIndexOf('\n') + 1).split('\n')) { if (!line.trim()) continue; const record = JSON.parse(line); if (!object(record) || typeof record.sessionId !== 'string' || entry.name !== `${record.sessionId}.jsonl` || (ownedParentTranscript !== undefined && record.agentId != null)) continue; const parentMetadata = record.isSidechain === false && record.agentId == null && typeof record.cwd === 'string' && path.isAbsolute(record.cwd) && nativeUuid(record.uuid) && validTimestamp(record.timestamp); const continuation = parentMetadata && nativeUuid(record.parentUuid) && ancestry.has(record.parentUuid) && !ancestry.has(record.uuid); if (!originSeen && object(record.message) && ['user', 'assistant'].includes(record.message.role)) { originSeen = true; if (parentMetadata && record.cwd === cwd && record.message.role === 'user' && record.parentUuid === null) ancestry.add(record.uuid); } if (continuation) ancestry.add(record.uuid); if ((record.cwd !== cwd && !continuation) || record.isSidechain !== false || !object(record.message) || !Array.isArray(record.message.content)) continue; matched = true; for (const block of record.message.content) { if (!object(block)) continue; if (onPublicToolEvent && validTimestamp(record.timestamp)) { if (record.message.role === 'assistant' && block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string' && object(block.input)) { const batch = typeof record.message.id === 'string' && /^msg_[A-Za-z0-9_-]{1,160}$/.test(record.message.id) && typeof record.requestId === 'string' && /^req_[A-Za-z0-9_-]{1,160}$/.test(record.requestId) ? { messageId: record.message.id, requestId: record.requestId } : {}; onPublicToolEvent({ sessionId: record.sessionId, timestamp: record.timestamp, toolUseId: block.id, kind: 'use', name: block.name, input: block.input, ...batch }); } else if (record.message.role === 'user' && block.type === 'tool_result' && typeof block.tool_use_id === 'string') { onPublicToolEvent({ sessionId: record.sessionId, timestamp: record.timestamp, toolUseId: block.tool_use_id, kind: 'result', content: block.content, file: record.toolUseResult?.file, isError: block.is_error === true }); } } if (record.message.role === 'assistant' && validTimestamp(record.timestamp)) { const text = block.type === 'text' && typeof block.text === 'string' && block.text.trim() ? block.text : publicNarrationText(block); if (text) assistantMessages.push({ sessionId: record.sessionId, text, timestamp: record.timestamp }); } if (record.message.role === 'assistant' && block.type === 'tool_use' && block.name === 'ExitPlanMode' && typeof block.id === 'string' && validTimestamp(record.timestamp)) { const key = `${record.sessionId}:${block.id}`; if (!planReadyRequests.has(key)) planReadyRequests.set(key, { sessionId: record.sessionId, toolUseId: block.id, timestamp: record.timestamp, failed: false }); } if (record.message.role === 'assistant' && block.type === 'tool_use' && block.name === 'AskUserQuestion' && typeof block.id === 'string' && object(block.input) && validQuestions(block.input.questions)) { const key = `${record.sessionId}:${block.id}`; const prior = calls.get(key); if (prior && JSON.stringify(prior.questions) !== JSON.stringify(block.input.questions)) { throw new Error('conflicting question metadata for one tool call'); } if (!prior) calls.set(key, { sessionId: record.sessionId, toolUseId: block.id, questions: block.input.questions, answered: false, failed: false }); } else if (record.message.role === 'user' && block.type === 'tool_result' && typeof block.tool_use_id === 'string') { const ready = planReadyRequests.get(`${record.sessionId}:${block.tool_use_id}`); if (ready && block.is_error === true) ready.failed = true; const call = calls.get(`${record.sessionId}:${block.tool_use_id}`); const answers = record.toolUseResult?.answers; const validAnswers = call && object(answers) ? Object.fromEntries(call.questions .filter(q => typeof answers[q.question] === 'string' && answers[q.question].trim()) .map(q => [q.question, answers[q.question]])) : {}; if (call && block.is_error !== true && Object.keys(validAnswers).length > 0) { // The CLI allows submitting a multi-question packet with // unanswered tabs. This completes ONE call, not N questions. call.answered = true; call.failed = false; delete call.failure; call.answers = validAnswers; call.unansweredQuestionIndices = call.questions.flatMap((q, i) => q.question in validAnswers ? [] : [i]); call.answeredAt = validTimestamp(record.timestamp) ? record.timestamp : undefined; } else if (call) { if (call.answered) throw new Error('conflicting successful and failed results for one question call'); call.failed = true; call.failure = block.is_error === true ? 'Native question tool returned is_error' : 'Native question returned no matching nonempty answers'; } } } } } } return { status: matched ? 'ready' : 'missing', calls: [...calls.values()], assistantMessages, ...(planReadyRequests.size ? { planReadyRequests: [...planReadyRequests.values()] } : {}) }; } catch (error) { // A failed read cannot silently turn an incomplete transcript into a // complete review. Keep the diagnostic explicit and return no coverage. return { status: 'error', calls: [], assistantMessages: [], error: `Claude question transcript: ${String(error)}` }; } }