/** * Sole nested-volume reader for gate-cycle facts under session/auditor-roles/. * * Consumers: Analyst sole ledger scan (classifyScopedRun) and Terminal gate * projection (#478). Metric families must not open a second disk scan — they * consume retained facts. Terminal settlement reuses this same pairing seam * (no second auditor-roles scanner). * * Naming: records may carry pre-#440 menxia/jishizhong/fubaolang tool faces or * the current gatekeeper/inspector/notary English face. Projection always uses * the current English officer identity (inspector | notary). * * Missing auditor-roles directory (ENOENT only) → empty rounds (lawful zero). * Path present but not a directory (ENOTDIR) and discovered nested JSONL that * fails canonical read/parse must fail loudly (never silently under-count). * An accepted gate terminating receipt (isError:false pair on dispatch/officer * tool) whose required typed facts are unusable — status, dispatch officer, or * first/last span missing/unknown/unparseable/inverted — also fails loudly via * the same throw→ledger `auditor-roles` unreadable seam. Unknown/non-contract * dispatch status stays loud (#475 abolished Gatekeeper incomplete special-case). * Lawful province non-dispatch release (`pass` on a dispatch tool) opens no * round and must not throw (#597 / ADR 0074: 角色调动无强制,交卷闸派发不断). * True non-gate volumes (soul-audit noise, etc.) stay omitted from pairing. * Historical dispatch↔officer pairing requires a shared typed * `ak_auditor_parent_attempt_binding.parent.attemptEntryId` — never seat/time guessing. * An orphan accepted dispatch must not consume a later same-seat direct officer. */ import { readdir } from "node:fs/promises"; import { join } from "node:path"; import { AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE, } from "./compliance-transport.ts"; import { extractSessionTimestampSpan, intervalRowsAroundAnchor, readLedgerSessionJsonl, type LedgerSessionRow, } from "./ledger-session-read.ts"; /** One completed gate round: direct officer receipt or historical province/officer pair. */ /** Honest origin discriminant: direct summons vs historical province dispatch. */ export type AnalystGateCycleOrigin = | { readonly kind: "direct" } | { readonly kind: "historical_dispatch"; /** Seat-reduction reason from the accepted dispatch receipt; never invented. */ readonly reason?: string; }; export type AnalystGateCycleRound = { /** 1-based chronological order among paired rounds on this leg. */ readonly roundIndex: number; /** Current English officer face after historical alias fold. */ readonly officer: "inspector" | "notary"; /** Typed officer terminal status (pass / bounce / …). */ readonly status: string; /** Officer subsession first→last usable timestamp delta (ms). */ readonly officerWallMs: number; readonly officerStartedAt: string; readonly officerEndedAt: string; /** * Officer findings as recorded. Metrics consume findingsCount only — never prose. */ readonly findings: readonly unknown[]; /** findings.length — retained so metric families need not re-derive. */ readonly findingsCount: number; /** Direct summons or historical province-paired dispatch. */ readonly origin: AnalystGateCycleOrigin; }; const DISPATCH_TOOLS = new Set(["ak_menxia_output", "ak_gatekeeper_output"]); /** Officer terminating tool → current English officer identity. */ const OFFICER_TOOL_TO_FACE: Readonly> = { ak_jishizhong_output: "inspector", ak_inspector_output: "inspector", ak_fubaolang_output: "notary", ak_notary_output: "notary", }; /** Dispatch `officer` argument aliases → current English face. */ const OFFICER_ARG_ALIASES: Readonly> = { jishizhong: "inspector", inspector: "inspector", fubaolang: "notary", notary: "notary", }; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isParentAttemptBindingRow(row: LedgerSessionRow): boolean { return row.type === "custom" && row.customType === AUDITOR_PARENT_ATTEMPT_BINDING_ENTRY_TYPE; } /** One summons' interval via shared #636 binding-slice (parent-attempt markers). */ function intervalRowsForGateCall( rows: readonly LedgerSessionRow[], callRowIndex: number, ): readonly LedgerSessionRow[] { return intervalRowsAroundAnchor(rows, callRowIndex, isParentAttemptBindingRow).rows; } /** Only true absence (ENOENT). ENOTDIR is damaged topology — must stay loud. */ function isMissingDirectoryError(error: unknown): boolean { return ( error instanceof Error && "code" in error && error.code === "ENOENT" ); } function normalizeOfficerArg(raw: unknown): "inspector" | "notary" | undefined { if (typeof raw !== "string") return undefined; return OFFICER_ARG_ALIASES[raw.trim()]; } /** Findings as recorded — missing/non-array → []. */ function asFindings(value: unknown): readonly unknown[] { return Array.isArray(value) ? value : []; } /** * Optional non-empty dispatch reason. Trim only decides emptiness; a non-empty * durable reason is returned as written (gatekeeper keeps reason as-is). */ function optionalDispatchReason(raw: unknown): string | undefined { if (typeof raw !== "string") return undefined; if (raw.trim() === "") return undefined; return raw; } type GateToolCall = { readonly toolName: string; readonly args: Record | undefined; readonly accepted: boolean; }; function isGateTerminatingToolName(toolName: string): boolean { return DISPATCH_TOOLS.has(toolName) || OFFICER_TOOL_TO_FACE[toolName] !== undefined; } /** * toolCallIds whose paired toolResult is an accepted receipt (`isError === false`). * Receipt is the sole lawful role product — rejected / missing results never qualify. */ function acceptedGateReceiptIds( rows: readonly LedgerSessionRow[], ): ReadonlySet { const accepted = new Set(); for (const row of rows) { const message = isRecord(row.message) ? row.message : undefined; if (message?.role !== "toolResult") continue; if (typeof message.toolCallId !== "string" || message.toolCallId.length === 0) continue; if (message.isError === false) accepted.add(message.toolCallId); } return accepted; } function readAcceptedGateStatus( args: Record | undefined, ): string { if (args === undefined) return ""; return typeof args.status === "string" ? args.status : args.status === undefined ? "" : String(args.status); } function requireAcceptedGateSpan( rows: readonly LedgerSessionRow[], filePath: string, ): { readonly startedAt: string; readonly endedAt: string; readonly wallMs: number } { const span = extractSessionTimestampSpan(rows); if (span.startedAt === undefined || span.endedAt === undefined) { throw new Error( `accepted gate volume missing session timestamp span in ${filePath}`, ); } const startedMs = Date.parse(span.startedAt); const endedMs = Date.parse(span.endedAt); if (!Number.isFinite(startedMs) || !Number.isFinite(endedMs) || endedMs < startedMs) { throw new Error( `accepted gate volume has unusable timestamp span in ${filePath}`, ); } return { startedAt: span.startedAt, endedAt: span.endedAt, wallMs: endedMs - startedMs, }; } type ClassifiedVolume = | { readonly kind: "dispatch"; readonly startedAt: string; readonly officer: "inspector" | "notary"; readonly status: string; readonly reason?: string; /** Present only when the volume carries ak_auditor_parent_attempt_binding. */ readonly attemptEntryId?: string; /** Binding parent session file when present (#636 multi-parent memory filter). */ readonly parentSessionFile?: string; } | { readonly kind: "officer"; readonly startedAt: string; readonly endedAt: string; readonly officer: "inspector" | "notary"; readonly status: string; readonly findings: readonly unknown[]; readonly findingsCount: number; readonly officerWallMs: number; readonly attemptEntryId?: string; readonly parentSessionFile?: string; }; /** Nearest preceding attempt binding (id + parent session file) before row index. */ function nearestAttemptBindingBefore( rows: readonly LedgerSessionRow[], beforeIndex: number, ): { readonly attemptEntryId?: string; readonly parentSessionFile?: string } { for (let i = beforeIndex - 1; i >= 0; i -= 1) { const row = rows[i]!; if (!isParentAttemptBindingRow(row)) continue; if (!isRecord(row.data) || !isRecord(row.data.parent)) continue; const id = row.data.parent.attemptEntryId; const sessionFile = row.data.parent.sessionFile; return { ...(typeof id === "string" && id.length > 0 ? { attemptEntryId: id } : {}), ...(typeof sessionFile === "string" && sessionFile.length > 0 ? { parentSessionFile: sessionFile } : {}), }; } return {}; } /** * All accepted gate terminating toolCalls on a volume (dispatch or officer), * each with nearest preceding attempt binding. Ticket-seat continuous memory * (#636) may carry multiple parent summons in one file — last-only would drop rounds. */ function extractAllAcceptedGateToolCalls( rows: readonly LedgerSessionRow[], ): ReadonlyArray { const acceptedIds = acceptedGateReceiptIds(rows); const out: Array = []; for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { const row = rows[rowIndex]!; const message = isRecord(row.message) ? row.message : undefined; if (message?.role !== "assistant" || !Array.isArray(message.content)) continue; for (const part of message.content) { if (!isRecord(part) || part.type !== "toolCall") continue; if (typeof part.id !== "string" || part.id.length === 0) continue; if (typeof part.name !== "string" || part.name.length === 0) continue; if (!isGateTerminatingToolName(part.name)) continue; if (!acceptedIds.has(part.id)) continue; const binding = nearestAttemptBindingBefore(rows, rowIndex); out.push({ toolName: part.name, args: isRecord(part.arguments) ? part.arguments : undefined, accepted: true, rowIndex, ...binding, }); } } return out; } function projectAcceptedGateCall( filePath: string, rows: readonly LedgerSessionRow[], call: GateToolCall & { readonly rowIndex: number; readonly attemptEntryId?: string; readonly parentSessionFile?: string; }, ): ClassifiedVolume | undefined { // Continuous memory volumes carry many summons; span only this binding's interval. const span = requireAcceptedGateSpan(intervalRowsForGateCall(rows, call.rowIndex), filePath); const status = readAcceptedGateStatus(call.args); const findings = asFindings(call.args?.findings); const findingsCount = findings.length; const bindingFields = { ...(call.attemptEntryId === undefined ? {} : { attemptEntryId: call.attemptEntryId }), ...(call.parentSessionFile === undefined ? {} : { parentSessionFile: call.parentSessionFile }), }; if (DISPATCH_TOOLS.has(call.toolName)) { // Lawful province non-dispatch release — opens no round (#597). // #836 / #622: bounce|escalate|unknown status must not throw and kill parent settlement. // Only a real dispatch pairs; everything else is omitted from pairing (原 payload 已在账本). if (status !== "dispatch") { return undefined; } const officer = normalizeOfficerArg(call.args?.officer); if (officer === undefined) { // Missing officer on a dispatch face — omit pairing; do not kill parent (#836). return undefined; } const reason = optionalDispatchReason(call.args?.reason); return { kind: "dispatch", startedAt: span.startedAt, officer, status, ...(reason === undefined ? {} : { reason }), ...bindingFields, }; } const officer = OFFICER_TOOL_TO_FACE[call.toolName]; if (officer === undefined) { // isGateTerminatingToolName already screened; keep loud if tables drift. throw new Error( `accepted gate receipt has unknown officer tool ${call.toolName} in ${filePath}`, ); } return { kind: "officer", startedAt: span.startedAt, endedAt: span.endedAt, officer, status, findings, findingsCount, officerWallMs: span.wallMs, ...bindingFields, }; } async function classifyAuditorVolume( filePath: string, ): Promise { // Canonical JSONL errors propagate — failure honesty (never wash to fewer rounds). const rows = await readLedgerSessionJsonl(filePath); const accepted = extractAllAcceptedGateToolCalls(rows); // Rejected-only / non-gate volumes omit (empty accepted set). if (accepted.length === 0) return []; const volumes: ClassifiedVolume[] = []; for (const call of accepted) { const projected = projectAcceptedGateCall(filePath, rows, call); if (projected !== undefined) volumes.push(projected); } return volumes; } function pairGateRounds( volumes: readonly ClassifiedVolume[], ): readonly AnalystGateCycleRound[] { const ordered = [...volumes].sort((a, b) => { if (a.startedAt !== b.startedAt) return a.startedAt.localeCompare(b.startedAt); // Stable tie-break: dispatch before officer at identical start (should not happen). if (a.kind !== b.kind) return a.kind === "dispatch" ? -1 : 1; return 0; }); const usedOfficerIdx = new Set(); const rounds: AnalystGateCycleRound[] = []; for (let i = 0; i < ordered.length; i += 1) { const vol = ordered[i]!; if (vol.kind !== "dispatch") continue; let match: { index: number; officer: Extract } | undefined; for (let j = i + 1; j < ordered.length; j += 1) { if (usedOfficerIdx.has(j)) continue; const candidate = ordered[j]!; if (candidate.kind !== "officer") continue; if (candidate.officer !== vol.officer) continue; // Durable invocation association — seat/time alone must not pair. if ( vol.attemptEntryId === undefined || candidate.attemptEntryId === undefined || candidate.attemptEntryId !== vol.attemptEntryId ) { continue; } match = { index: j, officer: candidate }; break; } if (match === undefined) continue; usedOfficerIdx.add(match.index); rounds.push({ roundIndex: rounds.length + 1, officer: match.officer.officer, status: match.officer.status, officerWallMs: match.officer.officerWallMs, officerStartedAt: match.officer.startedAt, officerEndedAt: match.officer.endedAt, findings: match.officer.findings, findingsCount: match.officer.findingsCount, origin: { kind: "historical_dispatch", ...(vol.reason === undefined ? {} : { reason: vol.reason }), }, }); } // Current direct-summons volumes have no preceding province dispatch. Every // accepted officer receipt not consumed by a historical pair is its own round. for (let i = 0; i < ordered.length; i += 1) { const vol = ordered[i]!; if (vol.kind !== "officer" || usedOfficerIdx.has(i)) continue; rounds.push({ roundIndex: 0, officer: vol.officer, status: vol.status, officerWallMs: vol.officerWallMs, officerStartedAt: vol.startedAt, officerEndedAt: vol.endedAt, findings: vol.findings, findingsCount: vol.findingsCount, origin: { kind: "direct" }, }); } return rounds .sort((a, b) => a.officerStartedAt.localeCompare(b.officerStartedAt)) .map((round, index) => ({ ...round, roundIndex: index + 1 })); } /** Resolve a direct-officer-run-pointer file to the officer session 正本 path. */ async function resolveOfficerSessionFromPointerFile( pointerPath: string, ): Promise { const { readFile } = await import("node:fs/promises"); let raw: unknown; try { raw = JSON.parse(await readFile(pointerPath, "utf8")); } catch (error) { throw new Error( `direct officer run pointer unreadable in ${pointerPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }, ); } if (!isRecord(raw) || raw.kind !== "direct-officer-run-pointer" || raw.version !== 1) { throw new Error(`direct officer run pointer has unknown shape in ${pointerPath}`); } const sessionFile = raw.sessionFile; if (typeof sessionFile !== "string" || sessionFile.trim() === "") { throw new Error(`direct officer run pointer missing sessionFile in ${pointerPath}`); } return sessionFile; } /** * Read and pair gate-cycle rounds from one or more auditor-roles directories. * Accepts historical nested JSONL volumes and #675 direct-officer-run-pointer * files that name the independent officer session 正本. * ENOENT (directory truly absent) → []. ENOTDIR and other errors propagate * (failure honesty — damaged topology must not wash to zero rounds). * * Pass `parentSessionFile` to keep only rounds whose attempt binding names that parent. */ export async function readAnalystGateCyclesFromAuditorRoles( auditorRolesDirectory: string | readonly string[], options: { readonly parentSessionFile?: string; } = {}, ): Promise { const directories = typeof auditorRolesDirectory === "string" ? [auditorRolesDirectory] : auditorRolesDirectory; const volumes: ClassifiedVolume[] = []; // Same officer session pointed at N times (historical multi-mint pointers, or // one parent booking many leaves) must classify once — else each accepted // seal is counted ×N in terminal gate-round (#753 acceptance A). const classifiedOfficerSessions = new Set(); for (const directory of directories) { let names: string[]; try { const entries = await readdir(directory, { withFileTypes: true }); names = entries .filter( (e) => e.isFile() && (e.name.endsWith(".jsonl") || e.name.endsWith(".pointer.json")), ) .map((e) => e.name) .sort(); } catch (error) { if (isMissingDirectoryError(error)) continue; throw error; } for (const name of names) { const path = join(directory, name); const fromPointer = name.endsWith(".pointer.json"); const sessionPath = fromPointer ? await resolveOfficerSessionFromPointerFile(path) : path; if (sessionPath === undefined) continue; if (fromPointer) { if (classifiedOfficerSessions.has(sessionPath)) continue; classifiedOfficerSessions.add(sessionPath); } const classified = await classifyAuditorVolume(sessionPath); for (const volume of classified) { if ( options.parentSessionFile !== undefined && volume.parentSessionFile !== undefined && volume.parentSessionFile !== options.parentSessionFile ) { continue; } volumes.push(volume); } } } return pairGateRounds(volumes); }