From 11473ff1df982c10b6f0659084fe9c94136dc36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Monle=C3=B3n?= Date: Thu, 10 Sep 2026 16:45:48 +0100 Subject: [PATCH 1/2] feat(gentle-pi): add execute vector for review.capture-result with agent=pi Add compatibility layer for Gentle AI upstream patch supporting the new execute-native flow for reviewer capture: - lib/review-host-relay.ts: Add ReviewHostRelayExecuteSlot interface, isReviewHostRelayExecuteInput() classifier, and reviewHostRelayExecuteSlots() extractor for execute=true + agent=pi routing - lib/native-review-cli.ts: Add CAPTURE_EXECUTE operation, types, decoder, and captureExecute() method for the native Go-owned reviewer capture flow - extensions/gentle-ai.ts: Add execute routing between materialize and correction-plan checks, with executeReviewHostRelayExecuteCapture() function mirroring the provider role vector pattern - tests/review-host-relay-routing.test.ts: Add 5 tests covering execute capture success, closure response, parameter rejection, unsupported surface, and failure reporting The execute vector is self-contained: Go materializes the prompt, spawns its locked-down pi subprocess, and admits the raw verdict. The host never materializes, launches pi, or submits - it runs one CLI invocation verbatim. --- extensions/gentle-ai.ts | 78 ++++++++++-- lib/native-review-cli.ts | 84 ++++++++++++- lib/review-host-relay.ts | 62 ++++++++-- tests/review-host-relay-routing.test.ts | 158 ++++++++++++++++++++++++ 4 files changed, 361 insertions(+), 21 deletions(-) diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 27a0e15bf..75044688d 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -77,6 +77,7 @@ import { REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, + reviewHostRelayExecuteSlots, reviewHostRelaySlots, reviewProviderRoleVectorSlots, resolveReviewHostRelaySubmission, @@ -87,6 +88,7 @@ import { type ReviewHostRelayRequest, type ReviewHostRelayRunner, type ReviewHostRelaySlot, + type ReviewHostRelayExecuteSlot, type ReviewProviderRoleVectorSlot, } from "../lib/review-host-relay.ts"; import { @@ -157,6 +159,8 @@ import { type NativeReviewProcessDiagnostics, type NativeStartResult, type NativeReviewAssessRequest, + type NativeReviewCaptureExecuteRequest, + type NativeReviewCaptureExecuteOutcome, type ExecFileAdapter, type ExecFileResult, } from "../lib/native-review-cli.ts"; @@ -557,7 +561,7 @@ function isTaskScopedRepositoryRelativePath(value: string, isWholeEntryBackticke return false; } - return !/[?*\[\]{}]/.test(withoutCurrentDirectory.split("/")[0]); + return !/[?*[\]{}]/.test(withoutCurrentDirectory.split("/")[0]); } type AllowedEditSurfaceEntry = { @@ -929,7 +933,7 @@ async function resolveReviewAssessmentPlan( const targetIdentity = input.nativeReviewOutcome === undefined ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined; const derived = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity); const nativeReviewOutcome: NativeReviewOutcome = input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN; - const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome !== undefined ? "explicit" : derived === undefined ? "unknown" : "derived"; + const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome === undefined ? derived === undefined ? "unknown" : "derived" : "explicit"; const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome }); return { schema: "gentle-pi.review-assessment-plan/v1", @@ -3376,10 +3380,10 @@ async function authorizeDestructiveReviewOperation( } const maintenanceAuthorization = maintenance === undefined ? undefined : nativeMaintenanceAuthorization(maintenance, input); const approved = await ctx.ui.confirm( - maintenance !== undefined ? `Authorize review authority ${parameters.operation.toUpperCase()}?` : `Authorize destructive review authority ${parameters.operation.toUpperCase()}?`, - maintenance !== undefined - ? [`Operation: ${parameters.operation.toUpperCase()}`, "Exact published authorization binding:", maintenanceAuthorization!, maintenance === "abandon" ? "The native command may quarantine only an eligible pristine compact-v2 lineage." : maintenance === "quarantineLegacy" ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched."].join("\n") - : [`Operation: ${parameters.operation.toUpperCase()}`, `Repository: ${input.repositoryId}`, `Exact challenge: ${input.confirmation}`, "This invalidates all prior review authority for this repository."].join("\n"), + maintenance === undefined ? `Authorize destructive review authority ${parameters.operation.toUpperCase()}?` : `Authorize review authority ${parameters.operation.toUpperCase()}?`, + maintenance === undefined + ? [`Operation: ${parameters.operation.toUpperCase()}`, `Repository: ${input.repositoryId}`, `Exact challenge: ${input.confirmation}`, "This invalidates all prior review authority for this repository."].join("\n") + : [`Operation: ${parameters.operation.toUpperCase()}`, "Exact published authorization binding:", maintenanceAuthorization!, maintenance === "abandon" ? "The native command may quarantine only an eligible pristine compact-v2 lineage." : maintenance === "quarantineLegacy" ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched."].join("\n"), ); if (!approved) throw new Error(`Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`); } @@ -5023,6 +5027,51 @@ async function executeProviderRoleVectorCapture( } } +// gentle-pi execute-native compat: self-contained execute vector for one +// `review.capture-result` slot with `--agent=pi --execute=true`. Go materializes +// the prompt, spawns its locked-down pi subprocess, and admits the raw verdict. +// The host never materializes, launches pi, or submits anything — it runs one +// CLI invocation verbatim and re-queries negotiated STATUS. +async function executeReviewHostRelayExecuteCapture( + slot: ReviewHostRelayExecuteSlot, + nativeReviewCli: NativeReviewCli, + cwd: string, + binding: ReviewLastEventClosureBinding, + selections: Map, + route: RetainedNativeCaptureRoute | undefined, + signal?: AbortSignal, +): Promise> { + if (nativeReviewCli.captureExecute === undefined) { + return { + tool: "gentle_review_capture", + status: "blocked", + outcome: "execute-capture-unsupported", + reason: "The provider issued a self-contained execute capture vector, but this runtime has no native execute capture surface.", + mutation_performed: false, + mutation_outcome: "none", + }; + } + try { + const result = await nativeReviewCli.captureExecute({ + argumentTokens: slot.captureArgumentTokens, + cwd, + ...(signal === undefined ? {} : { signal }), + }); + if ("operation" in result) return mapAndClearLastEventClosure(result, binding, selections, cwd); + return { + tool: "gentle_review_capture", + status: "captured", + outcome: "native-execute-captured", + lineage_id: result.lineageId, + target_identity: result.targetIdentity, + transport: "go_owned_pi_process", + capture_operation: "review.capture-result", + }; + } catch (error) { + return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route); + } +} + // The provider-named lenses still awaiting a reviewer result: one lens per // pending `review.capture-result` collect input, in provider order. function pendingReviewerLenses(status: ReviewStatusV3): readonly string[] { @@ -5106,7 +5155,7 @@ function hostTransportUnavailable( operation: REVIEW_CONTROLLER_OPERATION.INSPECT, ...(isCapture ? { then: operation } : {}), }, - next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${!isCapture ? " and follow the transition it returns" : operation === "gentle_review_capture_group" ? " and resubmit gentle_review_capture_group with the complete exact ordered collectBindings that fresh STATUS returns" : " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`, + next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${isCapture ? operation === "gentle_review_capture_group" ? " and resubmit gentle_review_capture_group with the complete exact ordered collectBindings that fresh STATUS returns" : " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns" : " and follow the transition it returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`, }; } @@ -5445,6 +5494,18 @@ async function executeReviewCaptureOperation( return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal)); } + // gentle-pi execute-native compat: self-contained execute vector for + // `review.capture-result` with `--agent=pi --execute=true`. Go materializes + // the prompt, spawns its locked-down pi subprocess, and admits the raw + // verdict. This mirrors the provider role vector pattern. + const executeSlots = reviewHostRelayExecuteSlots([selected.input]); + if (executeSlots.length === 1) { + if (parameters.reviewerRunAcknowledged !== undefined || parameters.correctionLines !== undefined) { + return captureBindingRejected("reviewerRunAcknowledged and correctionLines are not valid for an execute capture"); + } + return withCorrectionTarget(await executeReviewHostRelayExecuteCapture(executeSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal)); + } + if (selected.input.captureOperation === "review.capture-correction-plan") { if (parameters.reviewerRunAcknowledged !== undefined) return captureBindingRejected("reviewerRunAcknowledged is valid only for a materialize reviewer capture"); const submission = selected.input.submission; @@ -6521,8 +6582,7 @@ function createGentleAiExtensionForTesting( try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ } const reason = (event as { reason?: unknown }).reason; if (reason !== "reload") { - if (childStandingReviewPermissionLease !== undefined) childStandingReviewPermissionLease.closeIfCurrent(); - else childStandingReviewPermission?.close(); + if (childStandingReviewPermissionLease === undefined) childStandingReviewPermission?.close(); else childStandingReviewPermissionLease.closeIfCurrent(); revokeCurrentReviewSessionPermission(context); } const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey); diff --git a/lib/native-review-cli.ts b/lib/native-review-cli.ts index be25c5af3..2c8f8dc63 100644 --- a/lib/native-review-cli.ts +++ b/lib/native-review-cli.ts @@ -65,6 +65,7 @@ export const NATIVE_REVIEW_OPERATION = { CAPTURE_RESULT: "review/capture-result", CAPTURE_CORRECTION_PLAN: "review/capture-correction-plan", CAPTURE_PROVIDER_ROLE: "review/capture-provider-role", + CAPTURE_EXECUTE: "review/capture-execute", ACKNOWLEDGE_APPROVED: "review/acknowledge-approved", } as const; export type NativeReviewOperation = (typeof NATIVE_REVIEW_OPERATION)[keyof typeof NATIVE_REVIEW_OPERATION]; @@ -106,6 +107,7 @@ export interface NativeReviewCli { captureResult?(request: NativeReviewCaptureResultRequest): Promise; captureCorrectionPlan?(request: NativeReviewCorrectionPlanCaptureRequest): Promise; captureProviderRole?(request: NativeReviewProviderRoleCaptureRequest): Promise; + captureExecute?(request: NativeReviewCaptureExecuteRequest): Promise; // Dark until a negotiated version reports the `mode` capability true // (Design Decision #7, organic-rdd-parity). Plain versioned CLI operation, // outside the negotiated review-integration protocol — same shape as @@ -434,6 +436,34 @@ export interface NativeReviewProviderRoleCaptureArtifact { /** Refuter and validator captures close only when they are the native last event. */ export type NativeReviewProviderRoleCaptureOutcome = NativeReviewProviderRoleCaptureArtifact | ReviewLastEventClosureV1; +// --------------------------------------------------------------------------- +// Execute vector for review.capture-result (gentle-pi execute-native compat) +// +// A self-contained execute vector for `review.capture-result` with +// `--agent=pi --execute=true`. Mirrors the provider role vector pattern +// (capture-refuter, capture-validation) but for the reviewer capture operation. +// --------------------------------------------------------------------------- + +export const NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA = "gentle-ai.review-capture-execute/v1"; + +export interface NativeReviewCaptureExecuteRequest { + /** Every provider-issued argument token, verbatim, in provider order. */ + readonly argumentTokens: readonly string[]; + /** Process working directory only; never rendered into the invocation. */ + readonly cwd: string; + readonly signal?: AbortSignal; +} + +export interface NativeReviewCaptureExecuteResult { + readonly schema: typeof NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA; + readonly lineageId: string; + readonly targetIdentity: string; + readonly captured: true; +} + +/** Execute capture outcome: either an admitted artifact or a terminal closure. */ +export type NativeReviewCaptureExecuteOutcome = NativeReviewCaptureExecuteResult | ReviewLastEventClosureV1; + export const NATIVE_UNTRACKED_SCOPE = { EXCLUDE: "exclude", SELECT: "select", @@ -1820,14 +1850,37 @@ function decodeNativeProviderRoleCaptureArtifact(value: unknown): NativeReviewPr const role = text("role"); if (role !== "refuter" && role !== "targeted-validator") throw new TypeError(`native provider role capture artifact role must be refuter or targeted-validator, received ${role}`); if (body.captured !== true) throw new TypeError("native provider role capture artifact must report captured: true"); + return Object.freeze({ + schema: NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA, + lineageId: text("lineage_id"), + targetIdentity: text("target_identity"), + role, + captured: true, + }); + } + +// gentle-pi execute-native compat: the strict acknowledgement for one executed +// reviewer capture vector. The immutable verdict bytes live in the Go-owned +// compact store slot; this envelope only names the binding the capture proved. +function decodeNativeReviewCaptureExecuteResult(value: unknown): NativeReviewCaptureExecuteResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native review capture execute result must be an object"); + const body = value as Record; + const allowed = new Set(["schema", "lineage_id", "target_identity", "captured"]); + for (const key of Object.keys(body)) if (!allowed.has(key)) throw new TypeError(`native review capture execute result carries unexpected key ${key}`); + const text = (key: string): string => { + const found = body[key]; + if (typeof found !== "string" || found.trim() !== found || found.length === 0) throw new TypeError(`native review capture execute result ${key} must be a non-empty trimmed string`); + return found; + }; + if (text("schema") !== NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA) throw new TypeError(`native review capture execute result schema must be ${NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA}`); + if (body.captured !== true) throw new TypeError("native review capture execute result must report captured: true"); return Object.freeze({ - schema: NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA, + schema: NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA, lineageId: text("lineage_id"), targetIdentity: text("target_identity"), - role, captured: true, }); -} + } export class NativeReviewCliV216 implements NativeReviewCli { private readonly plain: NativeReviewPlainCli; @@ -2194,6 +2247,31 @@ export class NativeReviewCliV216 implements NativeReviewCli { }); } + // gentle-pi execute-native compat: executes one provider-rendered self-contained + // reviewer capture execute vector (`review.capture-result --agent=pi --execute=true`) + // exactly as rendered — one CLI invocation, verbatim tokens in provider order, in + // the foreground. Go materializes the reviewer prompt, spawns its own locked-down + // pi subprocess, and admits the raw verdict. Pi never adds, removes, or reorders + // a single token (not even --cwd: the vector's --repository-context is + // authoritative and mutually exclusive with a path). + async captureExecute(request: NativeReviewCaptureExecuteRequest): Promise { + if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_EXECUTE requires the provider-rendered argument tokens"); + if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_EXECUTE argument tokens must all be non-empty strings"); + const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, true); + const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, request.cwd, [ + "review", "capture-result", + ...request.argumentTokens, + ], true, request.signal, executable); + return decode(NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, true, () => { + const body = object(execution.body); + if (body.schema === "gentle-ai.review-last-event-closure/v1") { + return decodeReviewLastEventClosureV1(body); + } + return decodeNativeReviewCaptureExecuteResult(body); + }); + } + + // gentle-pi#311 P5: executes one provider-rendered `review.finalize` // execute transition exactly as rendered. The tokens come verbatim from // the negotiated next_transition (e.g. `--lineage= diff --git a/lib/review-host-relay.ts b/lib/review-host-relay.ts index 26ac3ac55..5fe8333a4 100644 --- a/lib/review-host-relay.ts +++ b/lib/review-host-relay.ts @@ -212,15 +212,59 @@ export function isReviewProviderRoleVectorInput(input: ReviewCollectInputV3): bo && argumentValue(input, "agent") === "pi"; } -export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewProviderRoleVectorSlot[] { - return inputs.filter((input) => isReviewProviderRoleVectorInput(input)).map((input) => ({ - captureOperation: input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"], - argumentTokens: input.arguments.map((argument) => renderToken(argument)), - name: input.name, - })); -} - -// Resolves the provider-owned submission form into an executable binding. + export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewProviderRoleVectorSlot[] { + return inputs.filter((input) => isReviewProviderRoleVectorInput(input)).map((input) => ({ + captureOperation: input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"], + argumentTokens: input.arguments.map((argument) => renderToken(argument)), + name: input.name, + })); + } + + // --------------------------------------------------------------------------- + // Execute vector for review.capture-result (gentle-pi execute-native compat) + // + // A self-contained execute vector for `review.capture-result` with + // `--agent=pi --execute=true`. Unlike the materialize path (which materializes + // a prompt, runs Pi, and submits the result), the execute path is handled + // by the native CLI directly: Go materializes the prompt, spawns its locked-down + // pi subprocess, and admits the raw verdict. This mirrors the provider role + // vector pattern but for the reviewer capture operation. + // --------------------------------------------------------------------------- + + export interface ReviewHostRelayExecuteSlot { + /** Every provider-issued argument token, verbatim, in provider order. */ + readonly captureArgumentTokens: readonly string[]; + readonly lens?: string; + readonly order?: string; + readonly subjectHash?: string; + } + + /** + * Classifies a collect input as an execute vector for review.capture-result. + * Matches: captureOperation === "review.capture-result" && agent === "pi" && execute === "true" + * + * This is distinct from the materialize path which requires materialize === "true". + */ + export function isReviewHostRelayExecuteInput(input: ReviewCollectInputV3): boolean { + return input.captureOperation === "review.capture-result" + && argumentValue(input, "agent") === "pi" + && argumentValue(input, "execute") === "true"; + } + + /** + * Extracts execute vector slots from collect inputs. + * Returns slots for review.capture-result with agent=pi and execute=true. + */ + export function reviewHostRelayExecuteSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelayExecuteSlot[] { + return inputs.filter((input) => isReviewHostRelayExecuteInput(input)).map((input) => ({ + captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)), + ...(argumentValue(input, "lens") === undefined ? {} : { lens: argumentValue(input, "lens") }), + ...(argumentValue(input, "order") === undefined ? {} : { order: argumentValue(input, "order") }), + ...(input.artifactSubject === undefined ? {} : { subjectHash: input.artifactSubject.subjectHash }), + })); + } + + // Resolves the provider-owned submission form into an executable binding. // Fails closed with a typed contract-mismatch error whenever the completing // form is absent or cannot bind exactly one artifact value; the relay never // repairs, filters, or synthesizes it. diff --git a/tests/review-host-relay-routing.test.ts b/tests/review-host-relay-routing.test.ts index cfec2a8f0..bf3d0b293 100644 --- a/tests/review-host-relay-routing.test.ts +++ b/tests/review-host-relay-routing.test.ts @@ -568,3 +568,161 @@ test("collect inputs without the provider-issued materialize token never reach t } assert.equal(relayCalls, 0); }); + +// --------------------------------------------------------------------------- +// gentle-pi execute-native compat: execute vector for `review.capture-result` +// with `--agent=pi --execute=true`. Go materializes the prompt, spawns its +// locked-down pi subprocess, and admits the raw verdict. The host never +// materializes, launches pi, or submits anything — it runs one CLI invocation +// verbatim and re-queries negotiated STATUS. +// --------------------------------------------------------------------------- + +function executeCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCollectInputV3 { + return { + name: "reviewer_result", + schema: "https://gentle-ai.dev/schema/review/reviewer/v1", + captureOperation: "review.capture-result", + arguments: [ + ...bindingArguments(lineageId, lens, order, revision), + { name: "agent", value: "pi", token: "--agent=pi" }, + { name: "execute", value: "true", token: "--execute=true" }, + ], + artifactSubject: { + schema: "gentle-ai.review-artifact-subject/v2", subjectHash: `sha256:${String(order).repeat(64)}`, + lineageId, authorityRevision: revision, targetIdentity: SHA, baseTree: TREE, candidateTree: TREE, + changedPathManifestSha256: SHA, lens, selectedOrder: order, + }, + baseTree: TREE, candidateTree: TREE, changedPathManifest: [], + }; +} + +test("one execute binding routes exactly one provider slot through the native execute surface", async (t) => { + const cwd = repository(t); + const lineageId = "execute-lineage"; + const input = executeCollectInput(lineageId, "review-risk", 0); + const harness = nativeHarness([finalizeStatus(lineageId, [input])]); + let executeCalls = 0; + harness.native.captureExecute = async (request) => { + executeCalls += 1; + assert.deepEqual(request.argumentTokens, input.arguments.map((argument) => argument.token)); + return { + schema: "gentle-ai.review-capture-execute/v1", + lineageId, + targetIdentity: SHA, + captured: true, + }; + }; + + // Execute routing does not accept reviewerRunAcknowledged because it's self-contained + const result = await runCapture(cwd, harness, lineageId, {}); + + assert.equal(executeCalls, 1); + assert.equal(result.status, "captured"); + assert.equal(result.outcome, "native-execute-captured"); + assert.equal(result.transport, "go_owned_pi_process"); + assert.equal(result.capture_operation, "review.capture-result"); + assert.equal(result.lineage_id, lineageId); + assert.equal(result.target_identity, SHA); +}); + +test("execute capture with closure response returns closed status", async (t) => { + const cwd = repository(t); + const lineageId = "execute-closure-lineage"; + const input = executeCollectInput(lineageId, "review-risk", 0); + const harness = nativeHarness([finalizeStatus(lineageId, [input])]); + harness.native.captureExecute = async () => { + return { + schema: "gentle-ai.review-last-event-closure/v1", + operation: "review/capture-result", + lineageId, + state: "approved", + storeRevision: SHA, + action: "stop", + }; + }; + + // Execute routing does not accept reviewerRunAcknowledged + const result = await runCapture(cwd, harness, lineageId, {}); + + assert.equal(result.status, "closed"); + assert.equal(result.outcome, "native-last-event-closure"); +}); + +test("execute capture rejects reviewerRunAcknowledged and correctionLines", async (t) => { + const cwd = repository(t); + const lineageId = "execute-reject-params-lineage"; + const harness = nativeHarness([ + finalizeStatus(lineageId, [executeCollectInput(lineageId, "review-risk", 0)]), + finalizeStatus(lineageId, [executeCollectInput(lineageId, "review-risk", 0)]), + ]); + harness.native.captureExecute = async () => { + throw new Error("should not be called"); + }; + + const resultWithAck = await runCapture(cwd, harness, lineageId, { reviewerRunAcknowledged: true }); + assert.equal(resultWithAck.outcome, "capture-binding-rejected"); + + const resultWithCorrection = await runCapture(cwd, harness, lineageId, { correctionLines: 5 }); + assert.equal(resultWithCorrection.outcome, "capture-binding-rejected"); +}); + +test("execute capture without native surface reports unsupported", async (t) => { + const cwd = repository(t); + const lineageId = "execute-unsupported-lineage"; + const input = executeCollectInput(lineageId, "review-risk", 0); + const statusQueue = [finalizeStatus(lineageId, [input])]; + const harness = { + statusQueue, + statusCalls: [] as Array<{ cwd: string; lineageId?: string; agent?: "pi" }>, + native: { + targetStatus: async (request: { cwd: string; lineageId?: string; agent?: string }) => { + harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent as "pi" }) }); + const next = harness.statusQueue.shift(); + if (next === undefined) throw new Error("status queue exhausted"); + return next; + }, + // Intentionally do NOT set captureExecute + } as unknown as NativeReviewCli, + }; + + const result = await __testing.executeReviewCaptureOperation( + { lineageId, collectBinding: JSON.stringify(input) }, + cwd, + harness.native, + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.outcome, "execute-capture-unsupported"); + assert.match(String(result.reason), /self-contained execute capture vector/); +}); + +test("execute capture failure is reported as native operation failure", async (t) => { + const cwd = repository(t); + const lineageId = "execute-fail-lineage"; + const input = executeCollectInput(lineageId, "review-risk", 0); + const statusQueue = [finalizeStatus(lineageId, [input]), finalizeStatus(lineageId)]; + const harness = { + statusQueue, + statusCalls: [] as Array<{ cwd: string; lineageId?: string; agent?: "pi" }>, + native: { + targetStatus: async (request: { cwd: string; lineageId?: string; agent?: string }) => { + harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent as "pi" }) }); + const next = harness.statusQueue.shift(); + if (next === undefined) throw new Error("status queue exhausted"); + return next; + }, + captureExecute: async () => { + throw new Error("execute capture failed"); + }, + } as unknown as NativeReviewCli, + }; + + const result = await __testing.executeReviewCaptureOperation( + { lineageId, collectBinding: JSON.stringify(input) }, + cwd, + harness.native, + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.outcome, "native-operation-failed"); +}); From befa72ecb57576183a35f9f79092442f6e29bdd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Monle=C3=B3n?= Date: Thu, 10 Sep 2026 17:23:43 +0100 Subject: [PATCH 2/2] feat(gentle-pi): add execute vector for review.capture-result with agent=pi Capture the provider metadata bound for the host relay execute slot, generalize captureProviderRole into a self-contained native method, and route the new execute slots while preserving legacy materialize+submission. Add focused tests. --- extensions/gentle-ai.ts | 6923 +++++++++++++++++------ lib/native-review-cli.ts | 3758 +++++++++--- lib/review-host-relay.ts | 671 ++- tests/review-host-relay-routing.test.ts | 1105 +++- 4 files changed, 9606 insertions(+), 2851 deletions(-) diff --git a/extensions/gentle-ai.ts b/extensions/gentle-ai.ts index 75044688d..c5e88c5d5 100644 --- a/extensions/gentle-ai.ts +++ b/extensions/gentle-ai.ts @@ -1,6 +1,13 @@ -import { consumeReviewMutation, pendingReviewMutation, recordReviewMutation } from "../lib/review-reminder-receipt.ts"; +import { + consumeReviewMutation, + pendingReviewMutation, + recordReviewMutation, +} from "../lib/review-reminder-receipt.ts"; import { resolveSessionWorktree } from "../lib/session-worktree-registry.ts"; -import { resolveResearchCapabilities, renderResearchCapabilities } from "../lib/sdd-research-capabilities.ts"; +import { + resolveResearchCapabilities, + renderResearchCapabilities, +} from "../lib/sdd-research-capabilities.ts"; import { declareReviewRelayHandshake } from "../lib/review-relay-contract.ts"; import { execFileSync } from "node:child_process"; import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; @@ -15,13 +22,7 @@ import { rmSync, writeFileSync, } from "node:fs"; -import { - access, - mkdir, - readFile, - readdir, - writeFile, -} from "node:fs/promises"; +import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; @@ -110,9 +111,20 @@ import { type ReviewMode, type ReviewProjectionV1, } from "../lib/review-snapshot.ts"; -import { renderGentleAiLifecycleCall, renderGentleAiResult, type GentleAiRenderContext } from "../lib/gentle-ai-renderer.ts"; +import { + renderGentleAiLifecycleCall, + renderGentleAiResult, + type GentleAiRenderContext, +} from "../lib/gentle-ai-renderer.ts"; import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts"; -import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts"; +import { + CandidateViewError, + CandidateViewRegistry, + injectReviewCandidateView, + readCandidateContextManifestPage, + resolveCanonicalCandidateBase, + type CandidateView, +} from "../lib/review-candidate-view.ts"; import { GentleAiDevBinaryOverrideError, GENTLE_AI_INSTALL_RECOVERY_COMMAND, @@ -185,8 +197,14 @@ import { type ReviewStatusV3, } from "../lib/review-integration-v2.ts"; import { reconcileUnknownReviewLastEventCapture } from "../lib/review-last-event-controller.ts"; -import { acquireChildStandingReviewPermissionClient, type ChildStandingReviewPermissionClient } from "../lib/review-session-standing-permission-ipc.ts"; -import { isPiConsentV3, presentReviewConsentUi } from "../lib/review-consent-ui.ts"; +import { + acquireChildStandingReviewPermissionClient, + type ChildStandingReviewPermissionClient, +} from "../lib/review-session-standing-permission-ipc.ts"; +import { + isPiConsentV3, + presentReviewConsentUi, +} from "../lib/review-consent-ui.ts"; import { captureReviewSessionIdentity, grantReviewSessionPermission, @@ -199,7 +217,8 @@ import { type ReviewSessionIdentity, } from "../lib/review-session-standing-permission.ts"; -const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations"; +const GRAPH_V1_ORDINARY_READ_ONLY = + "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations"; const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const ASSETS_DIR = join(PACKAGE_ROOT, "assets"); @@ -207,7 +226,10 @@ function gentlePiAgentHome(): string { return resolveGentlePiAgentHome(); } -function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides: number } { +function packageAssetAudit(owner: PackageAssetOwner): { + stale: number; + overrides: number; +} { let stale = 0; let overrides = 0; for (const [assetSubdir, installedSubdir, ownershipPrefix] of [ @@ -218,7 +240,11 @@ function packageAssetAudit(owner: PackageAssetOwner): { stale: number; overrides const assetDir = join(ASSETS_DIR, assetSubdir); if (!existsSync(assetDir)) continue; for (const entry of readdirSync(assetDir, { withFileTypes: true })) { - if (!entry.isFile() || getPackageAssetOwner(`${ownershipPrefix}/${entry.name}`) !== owner) continue; + if ( + !entry.isFile() || + getPackageAssetOwner(`${ownershipPrefix}/${entry.name}`) !== owner + ) + continue; const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name); try { if (!existsSync(installedPath)) { @@ -261,35 +287,47 @@ function packageAssetDiagnosticLines(cwd: string): string[] { const onDemand = owner === "sdd" && !hasPackageAssetOwnerInstallation(owner); const { stale, overrides } = packageAssetAudit(owner); const local = localAgentOverrideCount(cwd, owner); - const lines = [onDemand - ? `info: Global ${label} assets: on demand (not installed)` - : `${stale > 0 ? "warn" : "pass"}: Global ${label} assets stale: ${stale} file(s)`]; + const lines = [ + onDemand + ? `info: Global ${label} assets: on demand (not installed)` + : `${stale > 0 ? "warn" : "pass"}: Global ${label} assets stale: ${stale} file(s)`, + ]; if (!onDemand && stale > 0) { lines[0] += ` — run /gentle:install-${owner} --force to refresh managed assets`; } if (overrides > 0) { - lines.push(`info: Global ${label} user overrides: ${overrides} file(s); preserved, not package drift`); + lines.push( + `info: Global ${label} user overrides: ${overrides} file(s); preserved, not package drift`, + ); } if (local > 0) { - lines.push(`warn: Active ${label} agent overrides: ${local} file(s) — active non-builtin ${label} agents shadow package assets; keep only intentional overrides`); + lines.push( + `warn: Active ${label} agent overrides: ${local} file(s) — active non-builtin ${label} agents shadow package assets; keep only intentional overrides`, + ); } return lines; }); } -function localAgentOverrideCount(cwd: string, owner: PackageAssetOwner): number { +function localAgentOverrideCount( + cwd: string, + owner: PackageAssetOwner, +): number { const packageSddAgentsDir = join(ASSETS_DIR, "agents"); const packageSddAgentNames = new Set( listAgentsFromDir(packageSddAgentsDir, "builtin") - .filter((agent) => - getPackageAssetOwner( - `agents/${relative(packageSddAgentsDir, agent.filePath).split(sep).join("/")}`, - ) === owner, + .filter( + (agent) => + getPackageAssetOwner( + `agents/${relative(packageSddAgentsDir, agent.filePath).split(sep).join("/")}`, + ) === owner, ) .map((agent) => agent.name), ); let count = 0; - for (const { dir, source, packageManaged } of discoverableNonBuiltinAgentRoots(cwd)) { + for (const { dir, source, packageManaged } of discoverableNonBuiltinAgentRoots( + cwd, + )) { if (packageManaged || !existsSync(dir)) continue; for (const agent of listAgentsFromDir(dir, source)) { if (packageSddAgentNames.has(agent.name)) count += 1; @@ -399,7 +437,13 @@ function resolveBackgroundSubagentsPolicy( globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE); const projectFileExists = existsSync(projectFile); const globalFileExists = existsSync(globalFile); - const locations = { projectFile, globalFile, projectFileExists, globalFileExists, envValue }; + const locations = { + projectFile, + globalFile, + projectFileExists, + globalFileExists, + envValue, + }; for (const [source, path, present] of [ ["project_file", projectFile, projectFileExists], ["global_file", globalFile, globalFileExists], @@ -418,7 +462,12 @@ function resolveBackgroundSubagentsPolicy( : { policy: decoded, source, malformed: false, ...locations }; } if (envValue === "on" || envValue === "off") { - return { policy: envValue, source: "environment", malformed: false, ...locations }; + return { + policy: envValue, + source: "environment", + malformed: false, + ...locations, + }; } return { policy: "off", source: "default", malformed: false, ...locations }; } catch { @@ -495,12 +544,15 @@ function renderBackgroundSubagentsReport( } if (resolution.malformed) { const path = - resolution.source === "project_file" ? resolution.projectFile : resolution.globalFile; + resolution.source === "project_file" + ? resolution.projectFile + : resolution.globalFile; lines.push( `${path} is present but malformed, so the policy fails closed to off and no lower-priority source is consulted.`, ); } - const outranksTheWrite = wrote !== undefined && resolution.source === "project_file"; + const outranksTheWrite = + wrote !== undefined && resolution.source === "project_file"; if (outranksTheWrite) { lines.push( `That global write does not take effect here: the project file ${resolution.projectFile} outranks it. Edit or remove that project file to let the global setting decide.`, @@ -539,7 +591,10 @@ const MARKDOWN_LIST_MARKER = /^(?:[-*+]|\d+[.)]) +/; const WRITER_EDIT_SURFACE_REJECTION = "Writer tasks must include the exact Markdown heading `## Allowed edit surfaces` with narrow repository-relative paths or narrow globs, one per line. Every non-empty line belongs to the section until the next canonical Markdown heading and must be a valid surface entry. Paths containing whitespace require whole-entry backticks; begin explanatory prose under the next Markdown heading. The parent must derive or map that canonical block from the delegated task and relaunch the writer; do not accept aliases, and do not ask the human to author paths or globs."; -function isTaskScopedRepositoryRelativePath(value: string, isWholeEntryBackticked: boolean): boolean { +function isTaskScopedRepositoryRelativePath( + value: string, + isWholeEntryBackticked: boolean, +): boolean { const normalized = value.replace(/\\/g, "/"); if ( normalized.length === 0 || @@ -580,7 +635,8 @@ function readSurfaceEntry(line: string): AllowedEditSurfaceEntry { value: backticked?.[1] ?? withoutListMarker, isWholeEntryBackticked: backticked !== null, isValidMarkdownSyntax: - !/^(?:[-*+]|\d+[.)])$/.test(line) && (!withoutListMarker.includes("`") || backticked !== null), + !/^(?:[-*+]|\d+[.)])$/.test(line) && + (!withoutListMarker.includes("`") || backticked !== null), }; } @@ -588,9 +644,13 @@ function readSurfaceEntry(line: string): AllowedEditSurfaceEntry { * Reads every non-empty line until the next Markdown heading as an edit surface. * A prose line cannot terminate this section: it must fail validation instead. */ -function readAllowedEditSurfaceEntries(following: string): AllowedEditSurfaceEntry[] { +function readAllowedEditSurfaceEntries( + following: string, +): AllowedEditSurfaceEntry[] { const lines = following.split(/\r?\n/); - const headingIndex = lines.findIndex((line) => MARKDOWN_HEADING_LINE.test(line)); + const headingIndex = lines.findIndex((line) => + MARKDOWN_HEADING_LINE.test(line), + ); return (headingIndex === -1 ? lines : lines.slice(0, headingIndex)) .map((line) => line.replace(/ +$/g, "")) .filter((line) => line.length > 0) @@ -614,13 +674,18 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean { (entry) => entry.isValidMarkdownSyntax && !/\p{Cc}|\p{Zl}|\p{Zp}/u.test(entry.source) && - isTaskScopedRepositoryRelativePath(entry.value, entry.isWholeEntryBackticked), + isTaskScopedRepositoryRelativePath( + entry.value, + entry.isWholeEntryBackticked, + ), ) ) { return false; } - const uniqueEntries = [...new Set(entries.map((entry) => entry.value))].sort(); + const uniqueEntries = [ + ...new Set(entries.map((entry) => entry.value)), + ].sort(); if ( expectedEntries && (expectedEntries.length !== uniqueEntries.length || @@ -636,7 +701,9 @@ function hasTaskScopedAllowedEditSurfaces(...values: unknown[]): boolean { return hasSection; } -function rejectUnscopedBoundedWriterDispatch(input: unknown): { block: true; reason: string } | undefined { +function rejectUnscopedBoundedWriterDispatch( + input: unknown, +): { block: true; reason: string } | undefined { if ( !isRecord(input) || typeof input.agent !== "string" || @@ -677,7 +744,8 @@ function hasInstalledSubagentsPackage(cwd: string): boolean { function hasSubagentRunTool(activeTools: readonly string[]): boolean { return activeTools.some( - (name) => name === SUBAGENT_RUN_TOOL || name.endsWith(`.${SUBAGENT_RUN_TOOL}`), + (name) => + name === SUBAGENT_RUN_TOOL || name.endsWith(`.${SUBAGENT_RUN_TOOL}`), ); } @@ -750,10 +818,15 @@ function renderBackgroundSubagentsStatusLine( function isValidRddModeStatus( status: NativeReviewModeStatus | undefined, ): status is NativeReviewModeStatus { - if (status === undefined || status === null || typeof status !== "object") return false; + if (status === undefined || status === null || typeof status !== "object") + return false; if (status.effective !== "on" && status.effective !== "off") return false; - const validSources: readonly string[] = Object.values(NATIVE_REVIEW_MODE_SOURCE); - return typeof status.source === "string" && validSources.includes(status.source); + const validSources: readonly string[] = Object.values( + NATIVE_REVIEW_MODE_SOURCE, + ); + return ( + typeof status.source === "string" && validSources.includes(status.source) + ); } /** @@ -788,7 +861,13 @@ const RDD_STATUS_TIMEOUT_MS = 3000; // retry every agent start), trading a slower recovery signal for far fewer // spawns; the one-shot notify below still surfaces a sustained outage. const RDD_STATUS_MEMO_TTL_MS = 30_000; -const rddStatusMemo = new Map(); +const rddStatusMemo = new Map< + string, + { + readonly status: NativeReviewModeStatus | undefined; + readonly expiresAt: number; + } +>(); /** @internal test seam: clears the per-cwd RDD status memo. */ function clearRddStatusMemoForTesting(): void { @@ -801,9 +880,15 @@ function clearRddStatusMemoForTesting(): void { // Only declined/unavailable are ever written (from ANSWER_CONSENT); `closed` // is never written/derived -- pass it explicitly. A missing entry reads back // `undefined`, treated as `unknown` (fail closed, exactly like `off`). -const nativeReviewOutcomeByCandidate = new Map(); +const nativeReviewOutcomeByCandidate = new Map< + string, + "declined" | "unavailable" +>(); -function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string { +function nativeReviewOutcomeMemoKey( + cwd: string, + targetIdentity: string, +): string { try { return `${realpathSync(cwd)}\u0000${targetIdentity}`; } catch { @@ -811,13 +896,25 @@ function nativeReviewOutcomeMemoKey(cwd: string, targetIdentity: string): string } } -function recordNativeReviewOutcome(cwd: string, targetIdentity: string, outcome: "declined" | "unavailable"): void { - nativeReviewOutcomeByCandidate.set(nativeReviewOutcomeMemoKey(cwd, targetIdentity), outcome); +function recordNativeReviewOutcome( + cwd: string, + targetIdentity: string, + outcome: "declined" | "unavailable", +): void { + nativeReviewOutcomeByCandidate.set( + nativeReviewOutcomeMemoKey(cwd, targetIdentity), + outcome, + ); } /** Returns the recorded outcome for exactly this candidate, or `undefined` when none is recorded. */ -function readNativeReviewOutcome(cwd: string, targetIdentity: string): "declined" | "unavailable" | undefined { - return nativeReviewOutcomeByCandidate.get(nativeReviewOutcomeMemoKey(cwd, targetIdentity)); +function readNativeReviewOutcome( + cwd: string, + targetIdentity: string, +): "declined" | "unavailable" | undefined { + return nativeReviewOutcomeByCandidate.get( + nativeReviewOutcomeMemoKey(cwd, targetIdentity), + ); } /** @internal test seam: clears the per-candidate native review outcome memo. */ @@ -834,8 +931,13 @@ async function readCurrentTargetIdentityBestEffort( ): Promise { if (nativeReviewCli?.targetStatus === undefined) return undefined; try { - const status = await nativeReviewCli.targetStatus({ cwd, ...(signal === undefined ? {} : { signal }) }); - return status.applicability === "current_target" ? status.targetIdentity : undefined; + const status = await nativeReviewCli.targetStatus({ + cwd, + ...(signal === undefined ? {} : { signal }), + }); + return status.applicability === "current_target" + ? status.targetIdentity + : undefined; } catch { return undefined; } @@ -847,7 +949,11 @@ function rddAbortRejection(signal: AbortSignal): Promise { reject(signal.reason ?? new Error("aborted")); return; } - signal.addEventListener("abort", () => reject(signal.reason ?? new Error("aborted")), { once: true }); + signal.addEventListener( + "abort", + () => reject(signal.reason ?? new Error("aborted")), + { once: true }, + ); }); } @@ -858,8 +964,15 @@ async function readRddModeStatusOnce( ): Promise { if (!nativeReviewCli?.reviewMode) return undefined; try { - const call = nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, signal }); - const result = signal === undefined ? await call : await Promise.race([call, rddAbortRejection(signal)]); + const call = nativeReviewCli.reviewMode({ + cwd, + operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, + signal, + }); + const result = + signal === undefined + ? await call + : await Promise.race([call, rddAbortRejection(signal)]); return result.status; } catch { return undefined; @@ -894,30 +1007,42 @@ interface ReviewAssessmentPlanDetails { } async function resolveReviewAssessmentPlan( - nativeReviewCli: Pick | null | undefined, + nativeReviewCli: + | Pick + | null + | undefined, cwd: string, input: ReviewAssessInput, signal?: AbortSignal, ): Promise { - if (input.baseRef !== undefined && input.committedOnly !== true) throw new Error("Review assess baseRef requires committedOnly: true"); - if (input.baseRef === undefined && input.committedOnly !== undefined) throw new Error("Review assess committedOnly requires an explicit baseRef"); + if (input.baseRef !== undefined && input.committedOnly !== true) + throw new Error("Review assess baseRef requires committedOnly: true"); + if (input.baseRef === undefined && input.committedOnly !== undefined) + throw new Error("Review assess committedOnly requires an explicit baseRef"); const status = await readRddModeStatusOnce(nativeReviewCli, cwd, signal); - const rddLine: RddLine = isValidRddModeStatus(status) ? status.effective : RDD_LINE.UNKNOWN; + const rddLine: RddLine = isValidRddModeStatus(status) + ? status.effective + : RDD_LINE.UNKNOWN; const writerProfile = resolveWriterProfile({ - ...(input.writerModelId === undefined ? {} : { model: { id: input.writerModelId } }), + ...(input.writerModelId === undefined + ? {} + : { model: { id: input.writerModelId } }), thinking: input.writerEffort, }); let assessment: ReviewAssessmentV1 | undefined; let unassessableDetail: string | undefined; if (nativeReviewCli?.assess === undefined) { - unassessableDetail = "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command."; + unassessableDetail = + "native review assess is unavailable: the installed gentle-ai binary does not expose the assess command."; } else { try { const request: NativeReviewAssessRequest = { cwd, - ...(input.baseRef === undefined ? {} : { baseRef: input.baseRef, committedOnly: true as const }), + ...(input.baseRef === undefined + ? {} + : { baseRef: input.baseRef, committedOnly: true as const }), ...(signal === undefined ? {} : { signal }), }; assessment = await nativeReviewCli.assess(request); @@ -926,22 +1051,56 @@ async function resolveReviewAssessmentPlan( } } - const risk: VerificationTier = assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE; + const risk: VerificationTier = + assessment?.risk ?? VERIFICATION_TIER.UNASSESSABLE; // gentle-pi#668: explicit always wins; otherwise derive only for THIS // candidate's own target identity, never repository-only. `closed` is // never derived. - const targetIdentity = input.nativeReviewOutcome === undefined ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) : undefined; - const derived = targetIdentity === undefined ? undefined : readNativeReviewOutcome(cwd, targetIdentity); - const nativeReviewOutcome: NativeReviewOutcome = input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN; - const outcomeSource: "explicit" | "derived" | "unknown" = input.nativeReviewOutcome === undefined ? derived === undefined ? "unknown" : "derived" : "explicit"; - const plan = verificationPlan({ rddLine, risk, writerProfile, nativeReviewOutcome }); + const targetIdentity = + input.nativeReviewOutcome === undefined + ? await readCurrentTargetIdentityBestEffort(nativeReviewCli, cwd, signal) + : undefined; + const derived = + targetIdentity === undefined + ? undefined + : readNativeReviewOutcome(cwd, targetIdentity); + const nativeReviewOutcome: NativeReviewOutcome = + input.nativeReviewOutcome ?? derived ?? NATIVE_REVIEW_OUTCOME.UNKNOWN; + const outcomeSource: "explicit" | "derived" | "unknown" = + input.nativeReviewOutcome === undefined + ? derived === undefined + ? "unknown" + : "derived" + : "explicit"; + const plan = verificationPlan({ + rddLine, + risk, + writerProfile, + nativeReviewOutcome, + }); return { schema: "gentle-pi.review-assessment-plan/v1", risk, - reasons: assessment?.reasons ?? (unassessableDetail === undefined ? [] : [{ code: "native-assess-unavailable", path: "", detail: unassessableDetail }]), + reasons: + assessment?.reasons ?? + (unassessableDetail === undefined + ? [] + : [ + { + code: "native-assess-unavailable", + path: "", + detail: unassessableDetail, + }, + ]), changedPaths: assessment?.changedPaths ?? 0, changedLines: assessment?.changedLines ?? 0, - candidate: assessment === undefined ? null : { kind: assessment.candidate.kind, baseRef: assessment.candidate.baseRef }, + candidate: + assessment === undefined + ? null + : { + kind: assessment.candidate.kind, + baseRef: assessment.candidate.baseRef, + }, rddLine, nativeReviewOutcome, outcome_source: outcomeSource, @@ -979,7 +1138,7 @@ async function resolveRddModeStatus( rddStatusUnavailableWarned = true; if (ctx?.hasUI) { ctx.ui.notify( - "Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders \"unknown\" until this recovers; this notice will not repeat this session.", + 'Gentle AI: receipt-driven-development status is unavailable (native review CLI absent, timed out, or failed). The parent prompt renders "unknown" until this recovers; this notice will not repeat this session.', "warning", ); } @@ -995,7 +1154,9 @@ async function resolveRddStatusLine( now: () => number = Date.now, ctx?: Pick, ): Promise { - return renderRddStatusLine(await resolveRddModeStatus(nativeReviewCli, cwd, signal, now, ctx)); + return renderRddStatusLine( + await resolveRddModeStatus(nativeReviewCli, cwd, signal, now, ctx), + ); } // Rendered prompts are memoized per background policy/capability/RDD-status @@ -1033,10 +1194,7 @@ function renderOrchestratorPrompt( const backgroundPolicyBlock = `${renderBackgroundSubagentsStatusLine(background)}\n${rddStatusLine}`; return readFileSync(join(assetsDir, "orchestrator.md"), "utf8") .replaceAll("{{GENTLE_PI_ASSETS_ROOT}}", assetsDir) - .replaceAll( - "{{GENTLE_PI_BACKGROUND_POLICY}}", - backgroundPolicyBlock, - ) + .replaceAll("{{GENTLE_PI_BACKGROUND_POLICY}}", backgroundPolicyBlock) .trim(); } @@ -1049,7 +1207,11 @@ function renderOrchestratorPrompt( // rendered fragment for the process lifetime. It is deliberately NOT folded // into getOrchestratorPrompt/orchestratorPromptCache: that core prompt is // pinned at an 8192-byte budget (tests/orchestrator-budget.test.ts). -const PROVIDER_CONTRACT_MIRROR_ROOT = join(PACKAGE_ROOT, "contracts", "review-provider-contract-mirror"); +const PROVIDER_CONTRACT_MIRROR_ROOT = join( + PACKAGE_ROOT, + "contracts", + "review-provider-contract-mirror", +); const PROVIDER_CONTRACT_LOCK_FILE = "provider-contract.lock.json"; const PI_ORCHESTRATION_RUNTIME = "pi"; @@ -1057,20 +1219,40 @@ let reviewContractPromptFragmentCache: string | null | undefined; let reviewContractPromptMissingWarned = false; // Verifies the mirrored orchestration/pi.md bytes against the lock's digest before injection (gentle-ai R1/R3). -function readMirroredReviewContractFragment(mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT): string | null { +function readMirroredReviewContractFragment( + mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT, +): string | null { try { const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE); const lock = JSON.parse(readFileSync(lockPath, "utf8")) as { contract_semver?: unknown; entries?: Record; }; - if (typeof lock.contract_semver !== "string" || lock.contract_semver === "") return null; - const expectedSha256 = lock.entries?.[`orchestration/${PI_ORCHESTRATION_RUNTIME}.md`]; - if (typeof expectedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(expectedSha256)) return null; - const contractPath = join(mirrorRoot, `v${lock.contract_semver}`, "bundle", "orchestration", `${PI_ORCHESTRATION_RUNTIME}.md`); + if (typeof lock.contract_semver !== "string" || lock.contract_semver === "") + return null; + const expectedSha256 = + lock.entries?.[`orchestration/${PI_ORCHESTRATION_RUNTIME}.md`]; + if ( + typeof expectedSha256 !== "string" || + !/^[0-9a-f]{64}$/.test(expectedSha256) + ) + return null; + const contractPath = join( + mirrorRoot, + `v${lock.contract_semver}`, + "bundle", + "orchestration", + `${PI_ORCHESTRATION_RUNTIME}.md`, + ); const rawBytes = readFileSync(contractPath); const actualSha256 = createHash("sha256").update(rawBytes).digest("hex"); - if (!timingSafeEqual(Buffer.from(expectedSha256, "hex"), Buffer.from(actualSha256, "hex"))) return null; + if ( + !timingSafeEqual( + Buffer.from(expectedSha256, "hex"), + Buffer.from(actualSha256, "hex"), + ) + ) + return null; const text = rawBytes.toString("utf8").trim(); if (text.length === 0) return null; return `## Gentle AI review execution contract (mirrored provider bundle ${lock.contract_semver})\n\n${text}`; @@ -1084,9 +1266,13 @@ function loadReviewContractPromptFragment( mirrorRoot: string = PROVIDER_CONTRACT_MIRROR_ROOT, ): string | null { if (reviewContractPromptFragmentCache === undefined) { - reviewContractPromptFragmentCache = readMirroredReviewContractFragment(mirrorRoot); + reviewContractPromptFragmentCache = + readMirroredReviewContractFragment(mirrorRoot); } - if (reviewContractPromptFragmentCache === null && !reviewContractPromptMissingWarned) { + if ( + reviewContractPromptFragmentCache === null && + !reviewContractPromptMissingWarned + ) { reviewContractPromptMissingWarned = true; if (ctx.hasUI) { ctx.ui.notify( @@ -1183,8 +1369,12 @@ const DENIED_BASH_PATTERNS: RegExp[] = [ /\bgit\s+reset\s+--hard\b/, /\bgit\s+clean\b(?=[^\n]*(?:-[^\n]*f|--force))(?=[^\n]*(?:-[^\n]*d|--directories))/, // Force-push deny: tolerates git global flags (e.g. -C /repo) before the subcommand - new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s--force(?:-with-lease)?\b)`), - new RegExp(String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s-[^\s-]*f)`), + new RegExp( + String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s--force(?:-with-lease)?\b)`, + ), + new RegExp( + String.raw`\bgit${GIT_GLOBAL_FLAGS_SRC}push\b(?=[^\n]*\s-[^\s-]*f)`, + ), /\bchmod\s+-R\s+777\b/, /\bchown\s+-R\b/, ]; @@ -1210,7 +1400,8 @@ const GUARDED_COMMAND_KEY = { PI_REMOVE: "piRemove", } as const; -type GuardedCommandKey = (typeof GUARDED_COMMAND_KEY)[keyof typeof GUARDED_COMMAND_KEY]; +type GuardedCommandKey = + (typeof GUARDED_COMMAND_KEY)[keyof typeof GUARDED_COMMAND_KEY]; type GuardedCommandsConfig = Partial>; @@ -1227,7 +1418,8 @@ interface LoadGuardrailsOptions { const GUARDED_KEY_PATTERNS: Record = { gitPush: GIT_PUSH_RE, gitRebase: /\bgit\s+rebase\b/, - gitBranchDeleteForce: /\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/, + gitBranchDeleteForce: + /\bgit\s+branch\s+(?:-[a-zA-Z]*D[a-zA-Z]*|-[a-zA-Z]*d[a-zA-Z]*f[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*d[a-zA-Z]*|--delete\b[^\n]*--force\b|--force\b[^\n]*--delete\b)/, npmPublish: /\bnpm\s+publish\b/, piRemove: /\bpi\s+remove\b/, }; @@ -1265,7 +1457,10 @@ function classifyGuardedCommand( } // Step 2 & 3: find which guarded key (if any) this command matches - for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [GuardedCommandKey, RegExp][]) { + for (const [key, pattern] of Object.entries(GUARDED_KEY_PATTERNS) as [ + GuardedCommandKey, + RegExp, + ][]) { if (!pattern.test(command)) continue; // Matched a guarded key @@ -1295,7 +1490,9 @@ function parseGuardrailsConfigFile( const autonomousMode = parsed.autonomousMode === true; - const rawCommands = isRecord(parsed.guardedCommands) ? parsed.guardedCommands : {}; + const rawCommands = isRecord(parsed.guardedCommands) + ? parsed.guardedCommands + : {}; const guardedCommands: GuardedCommandsConfig = {}; const validActions = new Set(["allow", "confirm", "block"]); for (const [key, value] of Object.entries(rawCommands)) { @@ -1334,9 +1531,17 @@ function loadRuntimeGuardrailsConfig( const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome(); const globalConfigPath = join(configHome, "runtime-guardrails.json"); - const projectConfigPath = join(cwd, ".pi", "gentle-ai", "runtime-guardrails.json"); + const projectConfigPath = join( + cwd, + ".pi", + "gentle-ai", + "runtime-guardrails.json", + ); - let merged: RuntimeGuardrailsConfig = { autonomousMode: false, guardedCommands: {} }; + let merged: RuntimeGuardrailsConfig = { + autonomousMode: false, + guardedCommands: {}, + }; if (existsSync(globalConfigPath)) { const globalParsed = parseGuardrailsConfigFile( @@ -1495,7 +1700,11 @@ function sddPhaseFromAgentStartEvent(event: unknown): SddPhase | undefined { } function normalizePolicyPath(value: string): string { - return value.trim().replace(/^~(?=\/|$)/, homedir()).replace(/\\/g, "/").toLowerCase(); + return value + .trim() + .replace(/^~(?=\/|$)/, homedir()) + .replace(/\\/g, "/") + .toLowerCase(); } function isSensitivePath(value: string): boolean { @@ -1504,8 +1713,10 @@ function isSensitivePath(value: string): boolean { } function collectPathInputs(value: unknown, key?: string): string[] { - if (typeof value === "string") return key && PATH_INPUT_KEYS.has(key) ? [value] : []; - if (Array.isArray(value)) return value.flatMap((item) => collectPathInputs(item, key)); + if (typeof value === "string") + return key && PATH_INPUT_KEYS.has(key) ? [value] : []; + if (Array.isArray(value)) + return value.flatMap((item) => collectPathInputs(item, key)); if (!isRecord(value)) return []; return Object.entries(value).flatMap(([entryKey, entryValue]) => collectPathInputs(entryValue, entryKey), @@ -1553,14 +1764,17 @@ const HERDR_BLOCKER_LABEL = { QUESTIONNAIRE: "Questionnaire awaiting input", } as const; -type HerdrBlockerLabel = (typeof HERDR_BLOCKER_LABEL)[keyof typeof HERDR_BLOCKER_LABEL]; +type HerdrBlockerLabel = + (typeof HERDR_BLOCKER_LABEL)[keyof typeof HERDR_BLOCKER_LABEL]; type HerdrConfirmationLifecycle = { begin(): void; settle(): void; }; -function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): HerdrConfirmationLifecycle { +function createHerdrConfirmationLifecycle( + events: ExtensionAPI["events"], +): HerdrConfirmationLifecycle { let pending = 0; let choiceActive = false; let questionnaireActive = false; @@ -1580,13 +1794,23 @@ function createHerdrConfirmationLifecycle(events: ExtensionAPI["events"]): Herdr }; events?.on?.(ASK_USER_CHOICE_BLOCKED_EVENT, (event) => { - if (!isRecord(event) || typeof event.active !== "boolean" || event.active === choiceActive) return; + if ( + !isRecord(event) || + typeof event.active !== "boolean" || + event.active === choiceActive + ) + return; choiceActive = event.active; emitEffectiveBlocker(); }); events?.on?.("rpiv:ask-user:blocked", (event) => { - if (!isRecord(event) || typeof event.active !== "boolean" || event.active === questionnaireActive) return; + if ( + !isRecord(event) || + typeof event.active !== "boolean" || + event.active === questionnaireActive + ) + return; questionnaireActive = event.active; emitEffectiveBlocker(); }); @@ -1634,11 +1858,7 @@ async function confirmCommand( "Gentle AI safety policy requires interactive confirmation before this command.", }; } - const preview = truncateToWidth( - command.replace(/\s+/g, " ").trim(), - 180, - "…", - ); + const preview = truncateToWidth(command.replace(/\s+/g, " ").trim(), 180, "…"); const requestId = randomUUID(); const emitPermissionRequest = ( state: "waiting" | "approved" | "denied", @@ -1663,7 +1883,9 @@ async function confirmCommand( confirmationError = error; } finally { try { - emitPermissionRequest(confirmationFailed || !approved ? "denied" : "approved"); + emitPermissionRequest( + confirmationFailed || !approved ? "denied" : "approved", + ); } finally { herdrLifecycle.settle(); } @@ -1682,7 +1904,9 @@ function isRecord(value: unknown): value is Record { } function gentleAiConfigHome(): string { - return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai"); + return ( + process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai") + ); } function modelConfigPath(_cwd: string): string { @@ -1750,7 +1974,10 @@ async function readSavedModelConfigAsync( cwd: string, ): Promise { const projectPath = legacyProjectModelConfigPath(cwd); - const result = await readModelRoutingAuthorityAsync(modelConfigPath(cwd), projectPath); + const result = await readModelRoutingAuthorityAsync( + modelConfigPath(cwd), + projectPath, + ); return result.status === "invalid" && result.path === projectPath ? { status: "valid", config: {} } : result; @@ -1775,7 +2002,10 @@ function writeModelConfig(cwd: string, config: AgentModelConfig): void { writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`); } -async function writeModelConfigAsync(cwd: string, config: AgentModelConfig): Promise { +async function writeModelConfigAsync( + cwd: string, + config: AgentModelConfig, +): Promise { const path = modelConfigPath(cwd); await mkdir(dirname(path), { recursive: true }); const cleaned = normalizeModelConfig(config) ?? {}; @@ -1784,13 +2014,15 @@ async function writeModelConfigAsync(cwd: string, config: AgentModelConfig): Pro function parseModelExport(value: unknown): AgentModelConfig | undefined { if (!isRecord(value)) return undefined; - if (value.kind !== MODEL_EXPORT_KIND || value.version !== MODEL_EXPORT_VERSION) return undefined; + if (value.kind !== MODEL_EXPORT_KIND || value.version !== MODEL_EXPORT_VERSION) + return undefined; return normalizeModelConfig(value.agents); } async function exportSavedModelConfig(ctx: ExtensionContext): Promise { const saved = await readSavedModelConfigAsync(ctx.cwd); - if (saved.status === "invalid") throw new Error(`Invalid model config: ${saved.path}`); + if (saved.status === "invalid") + throw new Error(`Invalid model config: ${saved.path}`); const agents = saved.status === "valid" ? saved.config : {}; const path = modelExportPath(ctx.cwd); await mkdir(dirname(path), { recursive: true }); @@ -1801,9 +2033,13 @@ async function exportSavedModelConfig(ctx: ExtensionContext): Promise { return Object.keys(agents).length; } -async function readModelExport(ctx: ExtensionContext): Promise { +async function readModelExport( + ctx: ExtensionContext, +): Promise { try { - return parseModelExport(JSON.parse(await readFile(modelExportPath(ctx.cwd), "utf8"))); + return parseModelExport( + JSON.parse(await readFile(modelExportPath(ctx.cwd), "utf8")), + ); } catch { return undefined; } @@ -1947,15 +2183,29 @@ interface DiscoverableNonBuiltinAgentRoot { packageManaged: boolean; } -function discoverableNonBuiltinAgentRoots(cwd: string): DiscoverableNonBuiltinAgentRoot[] { +function discoverableNonBuiltinAgentRoots( + cwd: string, +): DiscoverableNonBuiltinAgentRoot[] { const globalAgentHome = gentlePiAgentHome(); const roots: DiscoverableNonBuiltinAgentRoot[] = [ - { dir: join(globalAgentHome, "agents"), source: "user", packageManaged: true }, - { dir: join(globalAgentHome, "subagents"), source: "user", packageManaged: false }, + { + dir: join(globalAgentHome, "agents"), + source: "user", + packageManaged: true, + }, + { + dir: join(globalAgentHome, "subagents"), + source: "user", + packageManaged: false, + }, { dir: join(homedir(), ".agents"), source: "user", packageManaged: false }, { dir: join(cwd, ".agents"), source: "project", packageManaged: false }, { dir: join(cwd, ".pi", "agents"), source: "project", packageManaged: false }, - { dir: join(cwd, ".pi", "subagents"), source: "project", packageManaged: false }, + { + dir: join(cwd, ".pi", "subagents"), + source: "project", + packageManaged: false, + }, ]; const unique = new Map(); for (const root of roots) { @@ -1971,7 +2221,11 @@ function discoverableNonBuiltinAgentRoots(cwd: string): DiscoverableNonBuiltinAg // another physical root appears between the duplicate entries. A merged // package-managed root must keep its installer-owned path: ownership // updates validate that lexical path against the managed manifest root. - const managedRoot = existing.packageManaged ? existing : root.packageManaged ? root : undefined; + const managedRoot = existing.packageManaged + ? existing + : root.packageManaged + ? root + : undefined; unique.delete(canonical); unique.set(canonical, { dir: managedRoot?.dir ?? root.dir, @@ -1987,7 +2241,14 @@ function builtinAgentDirs(cwd: string): string[] { return [ join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"), join(cwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"), - join(homedir(), ".local", "lib", "node_modules", "pi-subagents-j0k3r", "agents"), + join( + homedir(), + ".local", + "lib", + "node_modules", + "pi-subagents-j0k3r", + "agents", + ), join(PACKAGE_ROOT, "..", "pi-subagents", "agents"), join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"), join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"), @@ -2092,7 +2353,8 @@ function updateSubagentModelProfileAtPath( if (options.preserveExisting && isRecord(modelProfiles[name])) return false; modelProfiles[name] = profile; } else delete modelProfiles[name]; - if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles; + if (Object.keys(modelProfiles).length > 0) + config.model_profiles = modelProfiles; else delete config.model_profiles; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); @@ -2122,7 +2384,8 @@ async function updateSubagentModelProfileAtPathAsync( if (options.preserveExisting && isRecord(modelProfiles[name])) return false; modelProfiles[name] = profile; } else delete modelProfiles[name]; - if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles; + if (Object.keys(modelProfiles).length > 0) + config.model_profiles = modelProfiles; else delete config.model_profiles; await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(config, null, 2)}\n`); @@ -2183,27 +2446,40 @@ function migrateLegacyProjectModelOverrides(cwd: string): number { } catch { return 0; } - const subagents = isRecord(settings.subagents) ? settings.subagents : undefined; + const subagents = isRecord(settings.subagents) + ? settings.subagents + : undefined; const agentOverrides = isRecord(subagents?.agentOverrides) ? subagents.agentOverrides : undefined; if (!agentOverrides) return 0; - const agentsByName = new Map(listDiscoverableAgents(cwd).map((agent) => [agent.name, agent])); + const agentsByName = new Map( + listDiscoverableAgents(cwd).map((agent) => [agent.name, agent]), + ); const migratableEntries = Object.entries(agentOverrides) .map(([name, value]) => ({ name, entry: normalizeRoutingEntry(value) })) - .filter((item): item is { name: string; entry: AgentRoutingEntry } => - item.entry !== undefined && !isClearRoutingEntry(item.entry), + .filter( + (item): item is { name: string; entry: AgentRoutingEntry } => + item.entry !== undefined && !isClearRoutingEntry(item.entry), ); const targetPaths = new Set( migratableEntries.map(({ name }) => - agentModelProfileConfigPath(cwd, agentsByName.get(name)?.source ?? "project"), + agentModelProfileConfigPath( + cwd, + agentsByName.get(name)?.source ?? "project", + ), ), ); if (![...targetPaths].every(isValidJsonObjectFileOrMissing)) return 0; let migrated = 0; for (const { name, entry } of migratableEntries) { const source = agentsByName.get(name)?.source ?? "project"; - if (updateSubagentModelProfile(cwd, source, name, entry, { preserveExisting: true })) migrated += 1; + if ( + updateSubagentModelProfile(cwd, source, name, entry, { + preserveExisting: true, + }) + ) + migrated += 1; } removeLegacyAgentOverridesFromSettings(settingsPath, settings); return migrated; @@ -2239,7 +2515,8 @@ export function applyModelConfig( continue; } if (agent.source === "builtin") { - if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1; + if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) + updated += 1; else skipped += 1; continue; } @@ -2251,13 +2528,16 @@ export function applyModelConfig( if (next === original) { skipped += 1; } else { - if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) { + if ( + !updatePackageManagedSddAgentOwnership(agent.filePath, original, next) + ) { writeFileSync(agent.filePath, next); } updated += 1; } } - if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1; + if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) + updated += 1; else skipped += 1; } for (const [name, entry] of Object.entries(config)) { @@ -2284,7 +2564,9 @@ export async function applyModelConfigAsync( continue; } if (agent.source === "builtin") { - if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry)) + if ( + await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry) + ) updated += 1; else skipped += 1; continue; @@ -2297,13 +2579,17 @@ export async function applyModelConfigAsync( if (next === original) { skipped += 1; } else { - if (!updatePackageManagedSddAgentOwnership(agent.filePath, original, next)) { + if ( + !updatePackageManagedSddAgentOwnership(agent.filePath, original, next) + ) { await writeFile(agent.filePath, next); } updated += 1; } } - if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry)) + if ( + await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry) + ) updated += 1; else skipped += 1; } @@ -2328,10 +2614,7 @@ export async function applySavedModelConfig( if (result.status === "invalid") { return { updated: 0, skipped: 0, invalidPath: result.path }; } - return applyConfig( - ctx.cwd, - result.status === "valid" ? result.config : {}, - ); + return applyConfig(ctx.cwd, result.status === "valid" ? result.config : {}); } function describeModelConfig(cwd: string, config: AgentModelConfig): string[] { @@ -2529,8 +2812,7 @@ class SddModelPanel implements OverlayComponent { const row = this.rows[this.cursor]; if (row === SET_ALL_AGENTS) this.done({ type: "custom", agent: "all", config: this.draft }); - else if (row) - this.done({ type: "custom", agent: row, config: this.draft }); + else if (row) this.done({ type: "custom", agent: row, config: this.draft }); return; } if (!matchesKey(data, "return")) return; @@ -2593,9 +2875,7 @@ class SddModelPanel implements OverlayComponent { this.mode = "agents"; return; } - this.applyModelSelection( - selected === INHERIT_MODEL ? undefined : selected, - ); + this.applyModelSelection(selected === INHERIT_MODEL ? undefined : selected); this.mode = "agents"; return; } @@ -2839,9 +3119,13 @@ function renderSddModelPanelForTesting( width: number, theme?: Theme, ): string[] { - return new SddModelPanel(initialConfig, modelOptions, agents, () => {}, theme).render( - width, - ); + return new SddModelPanel( + initialConfig, + modelOptions, + agents, + () => {}, + theme, + ).render(width); } async function showSddModelPanel( @@ -2877,14 +3161,24 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise { } let config = savedConfig.status === "valid" ? savedConfig.config : {}; let result = await showSddModelPanel(ctx, config); - while (result.type === "custom" || result.type === "export" || result.type === "restore") { + while ( + result.type === "custom" || + result.type === "export" || + result.type === "restore" + ) { config = cloneModelConfig(result.config); if (result.type === "export") { try { const count = await exportSavedModelConfig(ctx); - ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info"); + ctx.ui.notify( + `el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, + "info", + ); } catch (error) { - ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning"); + ctx.ui.notify( + `Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, + "warning", + ); } result = await showSddModelPanel(ctx, config); continue; @@ -2892,34 +3186,49 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise { if (result.type === "restore") { const restored = await readModelExport(ctx); if (!restored) { - ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning"); + ctx.ui.notify( + `Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, + "warning", + ); result = await showSddModelPanel(ctx, config); continue; } - const approved = await ctx.ui.confirm("Restore saved model routing?", `Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`); + const approved = await ctx.ui.confirm( + "Restore saved model routing?", + `Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`, + ); if (approved) { try { await writeModelConfigAsync(ctx.cwd, restored); } catch (error) { - ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning"); + ctx.ui.notify( + `Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, + "warning", + ); result = await showSddModelPanel(ctx, config); continue; } config = restored; try { const applyResult = await applyModelConfigAsync(ctx.cwd, restored); - ctx.ui.notify([ - "el Gentleman restored global model config.", - `Import: ${modelExportPath(ctx.cwd)}`, - `Global config: ${modelConfigPath(ctx.cwd)}`, - `Agents updated: ${applyResult.updated}`, - ].join("\n"), "info"); + ctx.ui.notify( + [ + "el Gentleman restored global model config.", + `Import: ${modelExportPath(ctx.cwd)}`, + `Global config: ${modelConfigPath(ctx.cwd)}`, + `Agents updated: ${applyResult.updated}`, + ].join("\n"), + "info", + ); } catch (error) { - ctx.ui.notify([ - "el Gentleman restored global model config, but applying it to agents failed.", - `Global config: ${modelConfigPath(ctx.cwd)}`, - `Apply error: ${error instanceof Error ? error.message : String(error)}`, - ].join("\n"), "warning"); + ctx.ui.notify( + [ + "el Gentleman restored global model config, but applying it to agents failed.", + `Global config: ${modelConfigPath(ctx.cwd)}`, + `Apply error: ${error instanceof Error ? error.message : String(error)}`, + ].join("\n"), + "warning", + ); } } result = await showSddModelPanel(ctx, config); @@ -3035,7 +3344,9 @@ function reviewToolOperationPath(args: unknown): string { const operation = isRecord(args) ? args.operation : undefined; if ( typeof operation !== "string" || - !Object.values(REVIEW_CONTROLLER_OPERATION).includes(operation as ReviewControllerOperation) + !Object.values(REVIEW_CONTROLLER_OPERATION).includes( + operation as ReviewControllerOperation, + ) ) { return "review"; } @@ -3050,17 +3361,28 @@ const REVIEW_CONTROLLER_PARAMETERS = { operation: { type: "string", enum: Object.values(REVIEW_CONTROLLER_OPERATION), - description: "Controller operation. Inspect authority before start. Reset requires the exact challenge returned by inspect.", + description: + "Controller operation. Inspect authority before start. Reset requires the exact challenge returned by inspect.", }, lineageId: { type: "string", - description: "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.", + description: + "Bounded review lineage identifier. A failed start creates no lineage; do not use it with status or advance.", + }, + selectionBinding: { + type: "string", + description: + "Opaque provider-issued pre-lineage intended-untracked selection binding.", + }, + intendedUntracked: { + type: "array", + items: { type: "string" }, + description: "Repository-relative paths selected from the provider binding.", }, - selectionBinding: { type: "string", description: "Opaque provider-issued pre-lineage intended-untracked selection binding." }, - intendedUntracked: { type: "array", items: { type: "string" }, description: "Repository-relative paths selected from the provider binding." }, changeName: { type: "string", - description: "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.", + description: + "Canonical OpenSpec change name required to resolve a recovered authority during lifecycle validate.", }, idempotencyKey: { type: "string", @@ -3072,15 +3394,33 @@ const REVIEW_CONTROLLER_PARAMETERS = { }, input: { type: "string", - description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668); omitting writerModelId and writerEffort assesses the ambient working tree and fails closed to a small writer profile (never large) because the writer's actual profile is unknown to this call. nativeReviewOutcome (one of closed, declined, unavailable, unknown) tells ASSESS whether the native review actually closed for this candidate: when Receipt-driven development reads on but the review was declined for this candidate, is unavailable, or its outcome is unknown, ASSESS falls back to the exact risk-gated plan it returns when RDD is off, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below the RDD-off path. Omitting it lets ASSESS try to derive declined/unavailable from what this process itself recorded for this exact candidate (never a different one, and never from repository state alone), failing closed to unknown when it cannot; `closed` is never derived -- pass it explicitly, and only right after acknowledging the approved review for this same candidate. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.", + description: + 'A JSON-serialized object string, not a nested object. New native ordinary START uses {"mode":"ordinary"}; answer-consent uses exactly {"consentBinding":"","answer":"granted|declined"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668); omitting writerModelId and writerEffort assesses the ambient working tree and fails closed to a small writer profile (never large) because the writer\'s actual profile is unknown to this call. nativeReviewOutcome (one of closed, declined, unavailable, unknown) tells ASSESS whether the native review actually closed for this candidate: when Receipt-driven development reads on but the review was declined for this candidate, is unavailable, or its outcome is unknown, ASSESS falls back to the exact risk-gated plan it returns when RDD is off, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below the RDD-off path. Omitting it lets ASSESS try to derive declined/unavailable from what this process itself recorded for this exact candidate (never a different one, and never from repository state alone), failing closed to unknown when it cannot; `closed` is never derived -- pass it explicitly, and only right after acknowledging the approved review for this same candidate. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.', + }, + outputPath: { + type: "string", + description: + "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired.", + }, + inputPath: { + type: "string", + description: + "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired.", + }, + operationId: { + type: "string", + description: + "Retired with legacy bundle transport; ignored. Export/import return legacy-operation-retired.", + }, + lineageIds: { + type: "string", + description: + "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired.", }, - outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." }, - inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." }, - operationId: { type: "string", description: "Retired with legacy bundle transport; ignored. Export/import return legacy-operation-retired." }, - lineageIds: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." }, workspaceRoot: { type: "string", - description: "Optional explicit user-authorized absolute path inside the Git worktree that owns this review. It must resolve to an existing Git worktree; nested paths are canonicalized to that worktree root. Pi never invents this selector. Absent, the session cwd is used unless one unambiguous lineage binding already identifies its target root.", + description: + "Optional explicit user-authorized absolute path inside the Git worktree that owns this review. It must resolve to an existing Git worktree; nested paths are canonicalized to that worktree root. Pi never invents this selector. Absent, the session cwd is used unless one unambiguous lineage binding already identifies its target root.", }, }, } as const; @@ -3093,25 +3433,30 @@ const REVIEW_CAPTURE_PARAMETERS = { lineageId: { type: "string", minLength: 1, - description: "Exact lineage from the current provider-issued collect transition.", + description: + "Exact lineage from the current provider-issued collect transition.", }, collectBinding: { type: "string", minLength: 1, - description: "JSON-serialized exact copy of one decoded provider-owned next_transition.collect input from current STATUS.", + description: + "JSON-serialized exact copy of one decoded provider-owned next_transition.collect input from current STATUS.", }, reviewerRunAcknowledged: { type: "boolean", - description: "Required only after the one-slot materialize reviewer forecast; authorizes exactly one Pi host-relay run.", + description: + "Required only after the one-slot materialize reviewer forecast; authorizes exactly one Pi host-relay run.", }, correctionLines: { type: "integer", minimum: 1, - description: "Positive correction-line plan in diff lines: one replaced source line counts as two (one deletion plus one addition). A different unit from the provider's frozen logical correction budget. Accepted only for the selected provider correction-plan slot and within its exact bounds.", + description: + "Positive correction-line plan in diff lines: one replaced source line counts as two (one deletion plus one addition). A different unit from the provider's frozen logical correction budget. Accepted only for the selected provider correction-plan slot and within its exact bounds.", }, workspaceRoot: { type: "string", - description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics.", + description: + "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics.", }, }, } as const; @@ -3129,10 +3474,29 @@ const REVIEW_CAPTURE_GROUP_PARAMETERS = { additionalProperties: false, required: ["lineageId", "collectBindings"], properties: { - lineageId: { type: "string", minLength: 1, description: "Exact lineage from the current provider-issued collect transition." }, - collectBindings: { type: "array", minItems: 1, items: { type: "string", minLength: 1 }, description: "Ordered JSON-serialized exact copies of the complete current materialize reviewer collect set." }, - reviewerRunAcknowledged: { type: "boolean", description: "Required after the one group forecast; authorizes exactly the forecast reviewer runs." }, - workspaceRoot: { type: "string", description: "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics." }, + lineageId: { + type: "string", + minLength: 1, + description: + "Exact lineage from the current provider-issued collect transition.", + }, + collectBindings: { + type: "array", + minItems: 1, + items: { type: "string", minLength: 1 }, + description: + "Ordered JSON-serialized exact copies of the complete current materialize reviewer collect set.", + }, + reviewerRunAcknowledged: { + type: "boolean", + description: + "Required after the one group forecast; authorizes exactly the forecast reviewer runs.", + }, + workspaceRoot: { + type: "string", + description: + "Optional explicit existing Git worktree root, resolved with the controller's worktree confinement semantics.", + }, }, } as const; @@ -3148,9 +3512,24 @@ const REVIEW_SCOPE_PARAMETERS = { additionalProperties: false, required: ["manifest", "sha256"], properties: { - manifest: { type: "string", maxLength: 4_096, description: "Exact controller-supplied gzip/base64url frozen changed-scope manifest." }, - sha256: { type: "string", pattern: "^[0-9a-f]{64}$", description: "Exact controller-supplied SHA-256 of the decompressed canonical manifest bytes." }, - cursor: { type: "integer", minimum: 0, description: "Pagination cursor. Start at 0 and continue with nextCursor until absent." }, + manifest: { + type: "string", + maxLength: 4_096, + description: + "Exact controller-supplied gzip/base64url frozen changed-scope manifest.", + }, + sha256: { + type: "string", + pattern: "^[0-9a-f]{64}$", + description: + "Exact controller-supplied SHA-256 of the decompressed canonical manifest bytes.", + }, + cursor: { + type: "integer", + minimum: 0, + description: + "Pagination cursor. Start at 0 and continue with nextCursor until absent.", + }, }, } as const; @@ -3179,29 +3558,75 @@ interface ReviewAssessInput { } function isNativeReviewOutcome(value: unknown): value is NativeReviewOutcome { - return typeof value === "string" && (Object.values(NATIVE_REVIEW_OUTCOME) as readonly string[]).includes(value); + return ( + typeof value === "string" && + (Object.values(NATIVE_REVIEW_OUTCOME) as readonly string[]).includes(value) + ); } -function parseReviewAssessInput(operation: ReviewControllerOperation, raw: string | undefined): ReviewAssessInput { +function parseReviewAssessInput( + operation: ReviewControllerOperation, + raw: string | undefined, +): ReviewAssessInput { if (raw === undefined) return {}; const value = parseControllerJson(raw, operation); - const allowed = new Set(["baseRef", "committedOnly", "writerModelId", "writerEffort", "nativeReviewOutcome"]); + const allowed = new Set([ + "baseRef", + "committedOnly", + "writerModelId", + "writerEffort", + "nativeReviewOutcome", + ]); const unexpected = Object.keys(value).find((key) => !allowed.has(key)); - if (unexpected !== undefined) throw new Error(`Review controller ${operation} input does not accept ${unexpected}`); - const { baseRef, committedOnly, writerModelId, writerEffort, nativeReviewOutcome } = value; - if (baseRef !== undefined && typeof baseRef !== "string") throw new Error(`Review controller ${operation} input baseRef must be a string`); - if (committedOnly !== undefined && typeof committedOnly !== "boolean") throw new Error(`Review controller ${operation} input committedOnly must be a boolean`); - if (writerModelId !== undefined && typeof writerModelId !== "string") throw new Error(`Review controller ${operation} input writerModelId must be a string`); - if (writerEffort !== undefined && typeof writerEffort !== "string") throw new Error(`Review controller ${operation} input writerEffort must be a string`); - if (nativeReviewOutcome !== undefined && !isNativeReviewOutcome(nativeReviewOutcome)) { - throw new Error(`Review controller ${operation} input nativeReviewOutcome must be one of ${Object.values(NATIVE_REVIEW_OUTCOME).join(", ")}`); + if (unexpected !== undefined) + throw new Error( + `Review controller ${operation} input does not accept ${unexpected}`, + ); + const { + baseRef, + committedOnly, + writerModelId, + writerEffort, + nativeReviewOutcome, + } = value; + if (baseRef !== undefined && typeof baseRef !== "string") + throw new Error( + `Review controller ${operation} input baseRef must be a string`, + ); + if (committedOnly !== undefined && typeof committedOnly !== "boolean") + throw new Error( + `Review controller ${operation} input committedOnly must be a boolean`, + ); + if (writerModelId !== undefined && typeof writerModelId !== "string") + throw new Error( + `Review controller ${operation} input writerModelId must be a string`, + ); + if (writerEffort !== undefined && typeof writerEffort !== "string") + throw new Error( + `Review controller ${operation} input writerEffort must be a string`, + ); + if ( + nativeReviewOutcome !== undefined && + !isNativeReviewOutcome(nativeReviewOutcome) + ) { + throw new Error( + `Review controller ${operation} input nativeReviewOutcome must be one of ${Object.values(NATIVE_REVIEW_OUTCOME).join(", ")}`, + ); } return { ...(baseRef === undefined ? {} : { baseRef: baseRef as string }), - ...(committedOnly === undefined ? {} : { committedOnly: committedOnly as boolean }), - ...(writerModelId === undefined ? {} : { writerModelId: writerModelId as string }), - ...(writerEffort === undefined ? {} : { writerEffort: writerEffort as string }), - ...(nativeReviewOutcome === undefined ? {} : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }), + ...(committedOnly === undefined + ? {} + : { committedOnly: committedOnly as boolean }), + ...(writerModelId === undefined + ? {} + : { writerModelId: writerModelId as string }), + ...(writerEffort === undefined + ? {} + : { writerEffort: writerEffort as string }), + ...(nativeReviewOutcome === undefined + ? {} + : { nativeReviewOutcome: nativeReviewOutcome as NativeReviewOutcome }), }; } @@ -3223,7 +3648,9 @@ interface ReviewControllerParameters { } type NativeReviewAcknowledgementCli = NativeReviewCli & { - acknowledgeApproved?: (request: NativeReviewAcknowledgeApprovedRequest) => Promise; + acknowledgeApproved?: ( + request: NativeReviewAcknowledgeApprovedRequest, + ) => Promise; }; interface ReviewControllerStartInput { @@ -3235,33 +3662,106 @@ interface ReviewControllerStartInput { parentLineageId?: string; } -function isReviewControllerOperation(value: string): value is ReviewControllerOperation { - return Object.values(REVIEW_CONTROLLER_OPERATION).some((operation) => operation === value); +function isReviewControllerOperation( + value: string, +): value is ReviewControllerOperation { + return Object.values(REVIEW_CONTROLLER_OPERATION).some( + (operation) => operation === value, + ); } -function parseReviewControllerParameters(value: unknown): ReviewControllerParameters { - if (!isRecord(value)) throw new Error("Review controller parameters must be an object"); - if (typeof value.operation !== "string" || !isReviewControllerOperation(value.operation)) { +function parseReviewControllerParameters( + value: unknown, +): ReviewControllerParameters { + if (!isRecord(value)) + throw new Error("Review controller parameters must be an object"); + if ( + typeof value.operation !== "string" || + !isReviewControllerOperation(value.operation) + ) { throw new Error("Review controller operation is unsupported"); } - if (value.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) { - const unexpected = Object.keys(value).find((key) => !["operation", "selectionBinding", "intendedUntracked", "workspaceRoot"].includes(key)); - if (unexpected !== undefined || typeof value.selectionBinding !== "string" || !Array.isArray(value.intendedUntracked) || (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string")) throw new Error("Review intended-untracked selection accepts exactly selectionBinding and intendedUntracked, with optional workspaceRoot"); - return { operation: value.operation, selectionBinding: value.selectionBinding, intendedUntracked: value.intendedUntracked, ...(typeof value.workspaceRoot === "string" ? { workspaceRoot: value.workspaceRoot } : {}) }; + if ( + value.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED + ) { + const unexpected = Object.keys(value).find( + (key) => + ![ + "operation", + "selectionBinding", + "intendedUntracked", + "workspaceRoot", + ].includes(key), + ); + if ( + unexpected !== undefined || + typeof value.selectionBinding !== "string" || + !Array.isArray(value.intendedUntracked) || + (value.workspaceRoot !== undefined && + typeof value.workspaceRoot !== "string") + ) + throw new Error( + "Review intended-untracked selection accepts exactly selectionBinding and intendedUntracked, with optional workspaceRoot", + ); + return { + operation: value.operation, + selectionBinding: value.selectionBinding, + intendedUntracked: value.intendedUntracked, + ...(typeof value.workspaceRoot === "string" + ? { workspaceRoot: value.workspaceRoot } + : {}), + }; } - const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.STATUS, REVIEW_CONTROLLER_OPERATION.EXPORT, REVIEW_CONTROLLER_OPERATION.IMPORT, REVIEW_CONTROLLER_OPERATION.INSPECT, REVIEW_CONTROLLER_OPERATION.RESET, REVIEW_CONTROLLER_OPERATION.RECOVER, REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, REVIEW_CONTROLLER_OPERATION.ABANDON, REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, REVIEW_CONTROLLER_OPERATION.REPAIR, REVIEW_CONTROLLER_OPERATION.ASSESS].includes(value.operation as ReviewControllerOperation); - if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) { + const needsLineage = ![ + REVIEW_CONTROLLER_OPERATION.START, + REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, + REVIEW_CONTROLLER_OPERATION.STATUS, + REVIEW_CONTROLLER_OPERATION.EXPORT, + REVIEW_CONTROLLER_OPERATION.IMPORT, + REVIEW_CONTROLLER_OPERATION.INSPECT, + REVIEW_CONTROLLER_OPERATION.RESET, + REVIEW_CONTROLLER_OPERATION.RECOVER, + REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK, + REVIEW_CONTROLLER_OPERATION.ABANDON, + REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY, + REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY, + REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS, + REVIEW_CONTROLLER_OPERATION.REPAIR, + REVIEW_CONTROLLER_OPERATION.ASSESS, + ].includes(value.operation as ReviewControllerOperation); + if ( + needsLineage && + (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0) + ) { throw new Error("Review controller requires a lineageId"); } const parameters: ReviewControllerParameters = { operation: value.operation, - ...(typeof value.lineageId === "string" ? { lineageId: value.lineageId } : {}), + ...(typeof value.lineageId === "string" + ? { lineageId: value.lineageId } + : {}), }; - for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) { + for (const key of [ + "changeName", + "idempotencyKey", + "transition", + "input", + "outputPath", + "inputPath", + "operationId", + "lineageIds", + "acknowledgeUntrustedBundleSource", + "workspaceRoot", + ] as const) { const optional = value[key]; if (optional !== undefined && typeof optional !== "string") { - if (value.operation === REVIEW_CONTROLLER_OPERATION.START && key === "input") { - throw new Error("Review controller START input must be a JSON string encoding an object, not a nested object. No lineage was created; do not call STATUS or ADVANCE for this attempted lineage."); + if ( + value.operation === REVIEW_CONTROLLER_OPERATION.START && + key === "input" + ) { + throw new Error( + "Review controller START input must be a JSON string encoding an object, not a nested object. No lineage was created; do not call STATUS or ADVANCE for this attempted lineage.", + ); } throw new Error(`Review controller ${key} must be a string`); } @@ -3271,44 +3771,115 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame } function parseReviewCaptureParameters(value: unknown): ReviewCaptureParameters { - if (!isRecord(value)) throw new Error("Review capture parameters must be an object"); - const allowed = new Set(["lineageId", "collectBinding", "reviewerRunAcknowledged", "correctionLines", "workspaceRoot"]); + if (!isRecord(value)) + throw new Error("Review capture parameters must be an object"); + const allowed = new Set([ + "lineageId", + "collectBinding", + "reviewerRunAcknowledged", + "correctionLines", + "workspaceRoot", + ]); const unexpected = Object.keys(value).find((key) => !allowed.has(key)); - if (unexpected !== undefined) throw new Error(`Review capture does not accept ${unexpected}`); - if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture requires an exact non-empty lineageId"); - if (typeof value.collectBinding !== "string" || value.collectBinding.length === 0) throw new Error("Review capture requires a JSON-serialized collectBinding"); - if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture reviewerRunAcknowledged must be boolean"); - if (value.correctionLines !== undefined && (!Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1)) throw new Error("Review capture correctionLines must be a positive integer"); - if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture workspaceRoot must be a string"); + if (unexpected !== undefined) + throw new Error(`Review capture does not accept ${unexpected}`); + if (!isCanonicalProcessString(value.lineageId)) + throw new Error("Review capture requires an exact non-empty lineageId"); + if ( + typeof value.collectBinding !== "string" || + value.collectBinding.length === 0 + ) + throw new Error("Review capture requires a JSON-serialized collectBinding"); + if ( + value.reviewerRunAcknowledged !== undefined && + typeof value.reviewerRunAcknowledged !== "boolean" + ) + throw new Error("Review capture reviewerRunAcknowledged must be boolean"); + if ( + value.correctionLines !== undefined && + (!Number.isSafeInteger(value.correctionLines) || value.correctionLines < 1) + ) + throw new Error("Review capture correctionLines must be a positive integer"); + if ( + value.workspaceRoot !== undefined && + typeof value.workspaceRoot !== "string" + ) + throw new Error("Review capture workspaceRoot must be a string"); return { lineageId: value.lineageId, collectBinding: value.collectBinding, - ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }), - ...(value.correctionLines === undefined ? {} : { correctionLines: value.correctionLines }), - ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }), + ...(value.reviewerRunAcknowledged === undefined + ? {} + : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }), + ...(value.correctionLines === undefined + ? {} + : { correctionLines: value.correctionLines }), + ...(value.workspaceRoot === undefined + ? {} + : { workspaceRoot: value.workspaceRoot }), }; } -function parseReviewCaptureGroupParameters(value: unknown): ReviewCaptureGroupParameters { - if (!isRecord(value)) throw new Error("Review capture group parameters must be an object"); - const allowed = new Set(["lineageId", "collectBindings", "reviewerRunAcknowledged", "workspaceRoot"]); +function parseReviewCaptureGroupParameters( + value: unknown, +): ReviewCaptureGroupParameters { + if (!isRecord(value)) + throw new Error("Review capture group parameters must be an object"); + const allowed = new Set([ + "lineageId", + "collectBindings", + "reviewerRunAcknowledged", + "workspaceRoot", + ]); const unexpected = Object.keys(value).find((key) => !allowed.has(key)); - if (unexpected !== undefined) throw new Error(`Review capture group does not accept ${unexpected}`); - if (!isCanonicalProcessString(value.lineageId)) throw new Error("Review capture group requires an exact non-empty lineageId"); - if (!Array.isArray(value.collectBindings) || value.collectBindings.length === 0 || value.collectBindings.some((binding) => typeof binding !== "string" || binding.length === 0)) throw new Error("Review capture group requires one or more JSON-serialized collectBindings"); - if (value.reviewerRunAcknowledged !== undefined && typeof value.reviewerRunAcknowledged !== "boolean") throw new Error("Review capture group reviewerRunAcknowledged must be boolean"); - if (value.workspaceRoot !== undefined && typeof value.workspaceRoot !== "string") throw new Error("Review capture group workspaceRoot must be a string"); + if (unexpected !== undefined) + throw new Error(`Review capture group does not accept ${unexpected}`); + if (!isCanonicalProcessString(value.lineageId)) + throw new Error("Review capture group requires an exact non-empty lineageId"); + if ( + !Array.isArray(value.collectBindings) || + value.collectBindings.length === 0 || + value.collectBindings.some( + (binding) => typeof binding !== "string" || binding.length === 0, + ) + ) + throw new Error( + "Review capture group requires one or more JSON-serialized collectBindings", + ); + if ( + value.reviewerRunAcknowledged !== undefined && + typeof value.reviewerRunAcknowledged !== "boolean" + ) + throw new Error( + "Review capture group reviewerRunAcknowledged must be boolean", + ); + if ( + value.workspaceRoot !== undefined && + typeof value.workspaceRoot !== "string" + ) + throw new Error("Review capture group workspaceRoot must be a string"); return { lineageId: value.lineageId, collectBindings: [...value.collectBindings], - ...(value.reviewerRunAcknowledged === undefined ? {} : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }), - ...(value.workspaceRoot === undefined ? {} : { workspaceRoot: value.workspaceRoot }), + ...(value.reviewerRunAcknowledged === undefined + ? {} + : { reviewerRunAcknowledged: value.reviewerRunAcknowledged }), + ...(value.workspaceRoot === undefined + ? {} + : { workspaceRoot: value.workspaceRoot }), }; } function requiredControllerString( parameters: ReviewControllerParameters, - key: "idempotencyKey" | "transition" | "command" | "input" | "outputPath" | "inputPath" | "operationId", + key: + | "idempotencyKey" + | "transition" + | "command" + | "input" + | "outputPath" + | "inputPath" + | "operationId", ): string { const value = parameters[key]; if (typeof value !== "string" || value.trim().length === 0) { @@ -3317,21 +3888,39 @@ function requiredControllerString( return value; } -function readRepositoryControllerInput(inputPath: string, repositoryRoot: string): string { +function readRepositoryControllerInput( + inputPath: string, + repositoryRoot: string, +): string { const canonicalRoot = realpathSync(repositoryRoot); const requestedPath = resolve(canonicalRoot, inputPath); const relativePath = relative(canonicalRoot, requestedPath); - if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { - throw new Error("Review controller inputPath must be confined to the repository"); + if ( + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { + throw new Error( + "Review controller inputPath must be confined to the repository", + ); } const stat = lstatSync(requestedPath); - if (!stat.isFile() || stat.isSymbolicLink() || realpathSync(requestedPath) !== requestedPath) { - throw new Error("Review controller inputPath must be a regular non-symlink file"); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + realpathSync(requestedPath) !== requestedPath + ) { + throw new Error( + "Review controller inputPath must be a regular non-symlink file", + ); } return readFileSync(requestedPath, "utf8"); } -function parseControllerJson(input: string, operation: ReviewControllerOperation): Record { +function parseControllerJson( + input: string, + operation: ReviewControllerOperation, +): Record { let value: unknown; try { value = JSON.parse(input); @@ -3345,7 +3934,8 @@ function parseControllerJson(input: string, operation: ReviewControllerOperation `Review controller ${operation} input is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, ); } - if (!isRecord(value)) throw new Error(`Review controller ${operation} input must be a JSON object`); + if (!isRecord(value)) + throw new Error(`Review controller ${operation} input must be a JSON object`); return value; } @@ -3367,25 +3957,67 @@ async function authorizeDestructiveReviewOperation( const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET; const maintenance = nativeMaintenanceOperation(parameters.operation); if (!isReset && maintenance === undefined) return; - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); - if (maintenance !== undefined && (missingNativeMaintenanceInputs(maintenance, input).length > 0 || invalidNativeMaintenanceInput(maintenance, input))) return; + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); + if ( + maintenance !== undefined && + (missingNativeMaintenanceInputs(maintenance, input).length > 0 || + invalidNativeMaintenanceInput(maintenance, input)) + ) + return; if (isReset) { - for (const key of ["repositoryId", "commonDirHash", "inventoryHash"] as const) { - if (typeof input[key] !== "string" || input[key].length === 0) throw new Error(`Review controller ${parameters.operation} requires an exact string ${key}`); + for (const key of [ + "repositoryId", + "commonDirHash", + "inventoryHash", + ] as const) { + if (typeof input[key] !== "string" || input[key].length === 0) + throw new Error( + `Review controller ${parameters.operation} requires an exact string ${key}`, + ); } - if (typeof input.confirmation !== "string" || input.confirmation.length === 0) throw new Error(`Review controller ${parameters.operation} requires an exact string confirmation`); + if (typeof input.confirmation !== "string" || input.confirmation.length === 0) + throw new Error( + `Review controller ${parameters.operation} requires an exact string confirmation`, + ); } if (!ctx.hasUI) { - throw new Error(`Review controller ${parameters.operation.toUpperCase()} requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed`); + throw new Error( + `Review controller ${parameters.operation.toUpperCase()} requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed`, + ); } - const maintenanceAuthorization = maintenance === undefined ? undefined : nativeMaintenanceAuthorization(maintenance, input); + const maintenanceAuthorization = + maintenance === undefined + ? undefined + : nativeMaintenanceAuthorization(maintenance, input); const approved = await ctx.ui.confirm( - maintenance === undefined ? `Authorize destructive review authority ${parameters.operation.toUpperCase()}?` : `Authorize review authority ${parameters.operation.toUpperCase()}?`, maintenance === undefined - ? [`Operation: ${parameters.operation.toUpperCase()}`, `Repository: ${input.repositoryId}`, `Exact challenge: ${input.confirmation}`, "This invalidates all prior review authority for this repository."].join("\n") - : [`Operation: ${parameters.operation.toUpperCase()}`, "Exact published authorization binding:", maintenanceAuthorization!, maintenance === "abandon" ? "The native command may quarantine only an eligible pristine compact-v2 lineage." : maintenance === "quarantineLegacy" ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched."].join("\n"), + ? `Authorize destructive review authority ${parameters.operation.toUpperCase()}?` + : `Authorize review authority ${parameters.operation.toUpperCase()}?`, + maintenance === undefined + ? [ + `Operation: ${parameters.operation.toUpperCase()}`, + `Repository: ${input.repositoryId}`, + `Exact challenge: ${input.confirmation}`, + "This invalidates all prior review authority for this repository.", + ].join("\n") + : [ + `Operation: ${parameters.operation.toUpperCase()}`, + "Exact published authorization binding:", + maintenanceAuthorization!, + maintenance === "abandon" + ? "The native command may quarantine only an eligible pristine compact-v2 lineage." + : maintenance === "quarantineLegacy" + ? "The native command may quarantine only the published malformed freeze-findings legacy diagnostic." + : "The native command may quarantine only the bound invalid recovery successor; the predecessor stays untouched.", + ].join("\n"), ); - if (!approved) throw new Error(`Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`); + if (!approved) + throw new Error( + `Review controller ${parameters.operation.toUpperCase()} was not explicitly authorized`, + ); } function parseReviewBudget(value: unknown, label: string): ReviewBudgetV1 { @@ -3393,8 +4025,13 @@ function parseReviewBudget(value: unknown, label: string): ReviewBudgetV1 { return value as unknown as ReviewBudgetV1; } -function parseStartInput(value: Record): ReviewControllerStartInput { - if (value.mode !== REVIEW_MODE.ORDINARY && value.mode !== REVIEW_MODE.JUDGMENT_DAY) { +function parseStartInput( + value: Record, +): ReviewControllerStartInput { + if ( + value.mode !== REVIEW_MODE.ORDINARY && + value.mode !== REVIEW_MODE.JUDGMENT_DAY + ) { throw new Error( 'Review controller START supports only "ordinary" or "judgment-day" mode; use "ordinary" unless Judgment Day was explicitly selected. Pass input as a JSON string encoding the START object. START failed before authority access, so no lineage was created; do not call STATUS or ADVANCE for this attempted lineage.', ); @@ -3414,12 +4051,22 @@ function parseStartInput(value: Record): ReviewControllerStartI tree: value.projection.tree, }; } else { - throw new Error("Review controller start projection is unsupported or unresolved"); + throw new Error( + "Review controller start projection is unsupported or unresolved", + ); } - if (typeof value.policyHash !== "string" || typeof value.evidenceHash !== "string") { - throw new Error("Review controller start requires policyHash and evidenceHash"); + if ( + typeof value.policyHash !== "string" || + typeof value.evidenceHash !== "string" + ) { + throw new Error( + "Review controller start requires policyHash and evidenceHash", + ); } - if (value.parentLineageId !== undefined && typeof value.parentLineageId !== "string") { + if ( + value.parentLineageId !== undefined && + typeof value.parentLineageId !== "string" + ) { throw new Error("Review controller parentLineageId must be a string"); } const result: ReviewControllerStartInput = { @@ -3429,17 +4076,23 @@ function parseStartInput(value: Record): ReviewControllerStartI evidenceHash: value.evidenceHash, budget: parseReviewBudget(value.budget, "Review controller start budget"), }; - if (typeof value.parentLineageId === "string") result.parentLineageId = value.parentLineageId; + if (typeof value.parentLineageId === "string") + result.parentLineageId = value.parentLineageId; return result; } function isReviewTransition(value: string): value is ReviewTransition { - return Object.values(REVIEW_TRANSITION).some((transition) => transition === value); + return Object.values(REVIEW_TRANSITION).some( + (transition) => transition === value, + ); } function isGraphV1JudgmentDayLineage(cwd: string, lineageId: string): boolean { try { - return ReviewTransactionStore.forRepository(cwd).read(lineageId).mode === REVIEW_MODE.JUDGMENT_DAY; + return ( + ReviewTransactionStore.forRepository(cwd).read(lineageId).mode === + REVIEW_MODE.JUDGMENT_DAY + ); } catch { return false; } @@ -3492,9 +4145,13 @@ const REVIEW_MODE_DISABLED_OUTCOME = "review-mode-disabled"; // global is the only scope that can turn reviews on at all, and Pi answers the // same. Leaving this undefined would hand the single most common state a dead // end. -function reviewModeContinuation(source: NativeReviewModeSource): string | undefined { - if (source === NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL) return "Run `gentle-ai review mode enable --scope=global` if global RDD is still off, then run /gentle:review-mode enable to clear this clone-local override."; - if (source === NATIVE_REVIEW_MODE_SOURCE.GLOBAL) return "Run `gentle-ai review mode enable --scope=global` to turn reviews back on; /gentle:review-mode enable only clears the clone-local setting, which cannot override a global off."; +function reviewModeContinuation( + source: NativeReviewModeSource, +): string | undefined { + if (source === NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL) + return "Run `gentle-ai review mode enable --scope=global` if global RDD is still off, then run /gentle:review-mode enable to clear this clone-local override."; + if (source === NATIVE_REVIEW_MODE_SOURCE.GLOBAL) + return "Run `gentle-ai review mode enable --scope=global` to turn reviews back on; /gentle:review-mode enable only clears the clone-local setting, which cannot override a global off."; return "Run `gentle-ai review mode enable --scope=global` to opt in; RDD is off by default until explicitly enabled. /gentle:review-mode enable only clears a clone-local override and cannot enable global RDD."; } @@ -3503,7 +4160,10 @@ function reviewModeContinuation(source: NativeReviewModeSource): string | undefi // disabled switch never blocks here — but it must not discard which source // decided, because that is precisely the information the operator needs and // the only thing that selects a working way back on. -function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: NativeReviewModeSource): Record { +function nativeReviewModeSkipped( + operation: ReviewControllerOperation, + source: NativeReviewModeSource, +): Record { const continuation = reviewModeContinuation(source); return { operation, @@ -3525,12 +4185,22 @@ async function resolveReviewModeGate( ): Promise | undefined> { if (nativeReviewCli?.reviewMode === undefined) return undefined; try { - const mode = await nativeReviewCli.reviewMode({ cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) }); - return mode.status.effective === "off" ? nativeReviewModeSkipped(operation, mode.status.source) : undefined; - } catch (error) { - if (asNativeReviewCliError(error)?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return undefined; - throw error; - } + const mode = await nativeReviewCli.reviewMode({ + cwd, + operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, + ...(signal === undefined ? {} : { signal }), + }); + return mode.status.effective === "off" + ? nativeReviewModeSkipped(operation, mode.status.source) + : undefined; + } catch (error) { + if ( + asNativeReviewCliError(error)?.code === + NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE + ) + return undefined; + throw error; + } } // gentle-pi#185: a native CLI without negotiated STATUS support (no @@ -3540,14 +4210,19 @@ async function resolveReviewModeGate( // `next_action` was a machine token with nothing a human or an agent could // run. `remediation_command` names the exact upstream command that // re-establishes negotiated STATUS for this session's Pi host identity. -const NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND = "gentle-ai review status --cwd --contract gentle-ai.review-integration/v2 --agent pi --next-transition"; +const NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND = + "gentle-ai review status --cwd --contract gentle-ai.review-integration/v2 --agent pi --next-transition"; -function nativeStatusUnsupported(operation: ReviewControllerOperation): Record { +function nativeStatusUnsupported( + operation: ReviewControllerOperation, +): Record { return { operation, status: "blocked", outcome: "native-status-unsupported", - ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { mutation_performed: false }), + ...(operation === REVIEW_CONTROLLER_OPERATION.START + ? nativeStartPreAuthorityRejection() + : { mutation_performed: false }), inventory_complete: false, next_action: "require-upstream-read-only-native-status-inventory", remediation_command: NATIVE_STATUS_UNSUPPORTED_REMEDIATION_COMMAND, @@ -3560,29 +4235,52 @@ function nativeStatusUnsupported(operation: ReviewControllerOperation): Record { +function nativeStatusPackageBinaryMissing( + operation: ReviewControllerOperation, + diagnostics: NativeReviewProcessDiagnostics, +): Record { return { operation, status: "blocked", outcome: "native-status-package-binary-missing", - ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }), + ...(operation === REVIEW_CONTROLLER_OPERATION.START + ? nativeStartPreAuthorityRejection() + : { + lineage_created: false, + mutation_performed: false, + mutation_outcome: "none", + }), inventory_complete: false, diagnostics, reason: `The verified package-local binary is unavailable. ${GENTLE_AI_INSTALL_RECOVERY_INSTRUCTIONS} This does not prove install lifecycle scripts were disabled.`, @@ -3591,10 +4289,15 @@ function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation, }; } -function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown): Record { +function nativeStatusFailed( + operation: ReviewControllerOperation, + error: unknown, +): Record { const cliError = asNativeReviewCliError(error); - if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return nativeStatusUnsupported(operation); - if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, cliError.diagnostics); + if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) + return nativeStatusUnsupported(operation); + if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) + return nativeStatusPackageBinaryMissing(operation, cliError.diagnostics); if (cliError !== undefined) { return { ...nativeOperationFailure(operation, error), @@ -3634,42 +4337,126 @@ function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown const NATIVE_RECOVERY_INPUT = { reclaim: ["lineage", "actor", "reason"], - recover: ["predecessorLineage", "expectedPredecessorRevision", "successorLineage", "disposition", "actor", "reason"], + recover: [ + "predecessorLineage", + "expectedPredecessorRevision", + "successorLineage", + "disposition", + "actor", + "reason", + ], } as const; const NATIVE_MAINTENANCE_INPUT = { - abandon: ["lineage", "expectedRevision", "snapshotIdentity", "actor", "reason"], - quarantineLegacy: ["repository", "lineage", "expectedRevision", "diagnostic", "disposition", "actor", "reason"], - reconcileAuthority: ["predecessorLineage", "expectedPredecessorRevision", "successorLineage", "expectedSuccessorRevision", "actor", "reason"], + abandon: [ + "lineage", + "expectedRevision", + "snapshotIdentity", + "actor", + "reason", + ], + quarantineLegacy: [ + "repository", + "lineage", + "expectedRevision", + "diagnostic", + "disposition", + "actor", + "reason", + ], + reconcileAuthority: [ + "predecessorLineage", + "expectedPredecessorRevision", + "successorLineage", + "expectedSuccessorRevision", + "actor", + "reason", + ], } as const; type NativeMaintenanceOperation = keyof typeof NATIVE_MAINTENANCE_INPUT; -function nativeMaintenanceOperation(operation: ReviewControllerOperation): NativeMaintenanceOperation | undefined { +function nativeMaintenanceOperation( + operation: ReviewControllerOperation, +): NativeMaintenanceOperation | undefined { if (operation === REVIEW_CONTROLLER_OPERATION.ABANDON) return "abandon"; - if (operation === REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY) return "quarantineLegacy"; - if (operation === REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY) return "reconcileAuthority"; + if (operation === REVIEW_CONTROLLER_OPERATION.QUARANTINE_LEGACY) + return "quarantineLegacy"; + if (operation === REVIEW_CONTROLLER_OPERATION.RECONCILE_AUTHORITY) + return "reconcileAuthority"; return undefined; } -function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, input: Record): readonly string[] { - const missing = NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key])); +function missingNativeMaintenanceInputs( + operation: NativeMaintenanceOperation, + input: Record, +): readonly string[] { + const missing = NATIVE_MAINTENANCE_INPUT[operation].filter( + (key) => !isCanonicalProcessString(input[key]), + ); if (operation !== "abandon") return missing; return [ ...missing, - ...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]), + ...(Array.isArray(input.capturedLensResults) && + input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) + ? [] + : ["capturedLensResults"]), ...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]), ]; } -function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, input: Record): boolean { - if (operation === "quarantineLegacy") return input.diagnostic !== NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC || input.disposition !== NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION; - return operation === "reconcileAuthority" && input.anomalies !== undefined && input.anomalies !== NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED; +function invalidNativeMaintenanceInput( + operation: NativeMaintenanceOperation, + input: Record, +): boolean { + if (operation === "quarantineLegacy") + return ( + input.diagnostic !== NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC || + input.disposition !== NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION + ); + return ( + operation === "reconcileAuthority" && + input.anomalies !== undefined && + input.anomalies !== NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED + ); } -function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record): string { - if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason) }); - if (operation === "quarantineLegacy") return nativeReviewLegacyQuarantineAuthorization({ repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason) }); - return nativeReviewReconcileAuthorization({ predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }) }); +function nativeMaintenanceAuthorization( + operation: NativeMaintenanceOperation, + input: Record, +): string { + if (operation === "abandon") + return nativeReviewAbandonAuthorization({ + lineage: String(input.lineage), + expectedRevision: String(input.expectedRevision), + snapshotIdentity: String(input.snapshotIdentity), + capturedLensResults: (input.capturedLensResults as readonly unknown[]).map( + String, + ), + findingsPresent: input.findingsPresent === true, + actor: String(input.actor), + reason: String(input.reason), + }); + if (operation === "quarantineLegacy") + return nativeReviewLegacyQuarantineAuthorization({ + repository: String(input.repository), + lineage: String(input.lineage), + expectedRevision: String(input.expectedRevision), + diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, + disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, + actor: String(input.actor), + reason: String(input.reason), + }); + return nativeReviewReconcileAuthorization({ + predecessorLineage: String(input.predecessorLineage), + expectedPredecessorRevision: String(input.expectedPredecessorRevision), + successorLineage: String(input.successorLineage), + expectedSuccessorRevision: String(input.expectedSuccessorRevision), + actor: String(input.actor), + reason: String(input.reason), + ...(input.anomalies === undefined + ? {} + : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), + }); } async function executeNativeAuthorityMaintenance( @@ -3680,31 +4467,125 @@ async function executeNativeAuthorityMaintenance( nativeReviewCli: NativeReviewCli | null, signal: AbortSignal | undefined, ): Promise> { - const method = nativeOperation === "abandon" ? nativeReviewCli?.abandon : nativeOperation === "quarantineLegacy" ? nativeReviewCli?.quarantineLegacy : nativeReviewCli?.reconcileAuthority; - const nativeCommand = nativeOperation === "quarantineLegacy" ? "review quarantine-legacy" : nativeOperation === "reconcileAuthority" ? "review reconcile-authority" : "review abandon"; + const method = + nativeOperation === "abandon" + ? nativeReviewCli?.abandon + : nativeOperation === "quarantineLegacy" + ? nativeReviewCli?.quarantineLegacy + : nativeReviewCli?.reconcileAuthority; + const nativeCommand = + nativeOperation === "quarantineLegacy" + ? "review quarantine-legacy" + : nativeOperation === "reconcileAuthority" + ? "review reconcile-authority" + : "review abandon"; if (method === undefined) { - return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly" }; + return { + operation, + status: "blocked", + outcome: "native-maintenance-unavailable", + native_operation: nativeCommand, + mutation_performed: false, + mutation_outcome: "none", + next_action: + "install-package-local-gentle-ai-or-run-native-review-cli-directly", + }; } const missing = missingNativeMaintenanceInputs(nativeOperation, input); if (missing.length > 0) { - return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeCommand, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-exact-native-maintenance-input" }; + return { + operation, + status: "blocked", + outcome: "native-input-required", + native_operation: nativeCommand, + missing_input: missing, + mutation_performed: false, + mutation_outcome: "none", + next_action: "resubmit-with-exact-native-maintenance-input", + }; } if (invalidNativeMaintenanceInput(nativeOperation, input)) { - return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-the-exact-published-native-maintenance-binding" }; + return { + operation, + status: "blocked", + outcome: "native-input-invalid", + native_operation: nativeCommand, + mutation_performed: false, + mutation_outcome: "none", + next_action: "resubmit-with-the-exact-published-native-maintenance-binding", + }; } try { - const result = nativeOperation === "abandon" - ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), capturedLensResults: (input.capturedLensResults as readonly unknown[]).map(String), findingsPresent: input.findingsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) }) - : nativeOperation === "quarantineLegacy" - ? await nativeReviewCli.quarantineLegacy!({ cwd, repository: String(input.repository), lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) }) - : await nativeReviewCli.reconcileAuthority!({ cwd, predecessorLineage: String(input.predecessorLineage), expectedPredecessorRevision: String(input.expectedPredecessorRevision), successorLineage: String(input.successorLineage), expectedSuccessorRevision: String(input.expectedSuccessorRevision), actor: String(input.actor), reason: String(input.reason), ...(input.anomalies === undefined ? {} : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) }); - return { operation, native_operation: nativeCommand, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" }; + const result = + nativeOperation === "abandon" + ? await nativeReviewCli.abandon!({ + cwd, + lineage: String(input.lineage), + expectedRevision: String(input.expectedRevision), + snapshotIdentity: String(input.snapshotIdentity), + capturedLensResults: ( + input.capturedLensResults as readonly unknown[] + ).map(String), + findingsPresent: input.findingsPresent === true, + actor: String(input.actor), + reason: String(input.reason), + maintainerAuthorization: nativeMaintenanceAuthorization( + nativeOperation, + input, + ), + ...(signal === undefined ? {} : { signal }), + }) + : nativeOperation === "quarantineLegacy" + ? await nativeReviewCli.quarantineLegacy!({ + cwd, + repository: String(input.repository), + lineage: String(input.lineage), + expectedRevision: String(input.expectedRevision), + diagnostic: NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC, + disposition: NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION, + actor: String(input.actor), + reason: String(input.reason), + maintainerAuthorization: nativeMaintenanceAuthorization( + nativeOperation, + input, + ), + ...(signal === undefined ? {} : { signal }), + }) + : await nativeReviewCli.reconcileAuthority!({ + cwd, + predecessorLineage: String(input.predecessorLineage), + expectedPredecessorRevision: String(input.expectedPredecessorRevision), + successorLineage: String(input.successorLineage), + expectedSuccessorRevision: String(input.expectedSuccessorRevision), + actor: String(input.actor), + reason: String(input.reason), + ...(input.anomalies === undefined + ? {} + : { anomalies: NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED }), + maintainerAuthorization: nativeMaintenanceAuthorization( + nativeOperation, + input, + ), + ...(signal === undefined ? {} : { signal }), + }); + return { + operation, + native_operation: nativeCommand, + result: result.record, + mutation_performed: true, + mutation_outcome: "committed", + next_action: "inspect", + }; } catch (error) { return nativeOperationFailure(operation, error); } } -const NATIVE_LEGACY_ALIAS_REPAIR_INPUT = ["lineage", "actor", "reason"] as const; +const NATIVE_LEGACY_ALIAS_REPAIR_INPUT = [ + "lineage", + "actor", + "reason", +] as const; async function executeNativeLegacyAliasRepair( input: Record, @@ -3715,34 +4596,87 @@ async function executeNativeLegacyAliasRepair( ): Promise> { const operation = REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS; const nativeOperation = "review repair-legacy-alias"; - if (Object.keys(input).some((key) => !NATIVE_LEGACY_ALIAS_REPAIR_INPUT.includes(key as (typeof NATIVE_LEGACY_ALIAS_REPAIR_INPUT)[number]))) { - return { operation, status: "blocked", outcome: "native-input-invalid", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason-only" }; + if ( + Object.keys(input).some( + (key) => + !NATIVE_LEGACY_ALIAS_REPAIR_INPUT.includes( + key as (typeof NATIVE_LEGACY_ALIAS_REPAIR_INPUT)[number], + ), + ) + ) { + return { + operation, + status: "blocked", + outcome: "native-input-invalid", + native_operation: nativeOperation, + mutation_performed: false, + mutation_outcome: "none", + next_action: "resubmit-with-lineage-actor-and-reason-only", + }; } - const missing = NATIVE_LEGACY_ALIAS_REPAIR_INPUT.filter((key) => !isCanonicalProcessString(input[key])); + const missing = NATIVE_LEGACY_ALIAS_REPAIR_INPUT.filter( + (key) => !isCanonicalProcessString(input[key]), + ); if (missing.length > 0) { - return { operation, status: "blocked", outcome: "native-input-required", native_operation: nativeOperation, missing_input: missing, mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-with-lineage-actor-and-reason" }; + return { + operation, + status: "blocked", + outcome: "native-input-required", + native_operation: nativeOperation, + missing_input: missing, + mutation_performed: false, + mutation_outcome: "none", + next_action: "resubmit-with-lineage-actor-and-reason", + }; } - if (nativeReviewCli?.reviewStatus === undefined || nativeReviewCli.repairLegacyAlias === undefined) { - return { operation, status: "blocked", outcome: "native-maintenance-unavailable", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "install-package-local-gentle-ai-v2.1.11-or-run-native-review-cli-directly" }; + if ( + nativeReviewCli?.reviewStatus === undefined || + nativeReviewCli.repairLegacyAlias === undefined + ) { + return { + operation, + status: "blocked", + outcome: "native-maintenance-unavailable", + native_operation: nativeOperation, + mutation_performed: false, + mutation_outcome: "none", + next_action: + "install-package-local-gentle-ai-v2.1.11-or-run-native-review-cli-directly", + }; } let inventory; try { - inventory = await nativeReviewCli.reviewStatus({ cwd, ...(signal === undefined ? {} : { signal }) }); + inventory = await nativeReviewCli.reviewStatus({ + cwd, + ...(signal === undefined ? {} : { signal }), + }); } catch (error) { return nativeOperationFailure(operation, error); } const candidate = inventory.complete - ? inventory.entries.filter((entry) => - entry.version === "legacy-v1" - && entry.status === "invalid" - && entry.lineageId === input.lineage - && isCanonicalProcessString(entry.revision) - && entry.problems.length === 1 - && entry.problems[0] === NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DIAGNOSTIC, - ) + ? inventory.entries.filter( + (entry) => + entry.version === "legacy-v1" && + entry.status === "invalid" && + entry.lineageId === input.lineage && + isCanonicalProcessString(entry.revision) && + entry.problems.length === 1 && + entry.problems[0] === NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DIAGNOSTIC, + ) : []; - if (candidate.length !== 1 || !isCanonicalProcessString(inventory.repository)) { - return { operation, status: "blocked", outcome: "native-alias-repair-ineligible", native_operation: nativeOperation, mutation_performed: false, mutation_outcome: "none", next_action: "inspect-complete-native-authority-inventory" }; + if ( + candidate.length !== 1 || + !isCanonicalProcessString(inventory.repository) + ) { + return { + operation, + status: "blocked", + outcome: "native-alias-repair-ineligible", + native_operation: nativeOperation, + mutation_performed: false, + mutation_outcome: "none", + next_action: "inspect-complete-native-authority-inventory", + }; } const entry = candidate[0]!; const request = { @@ -3756,15 +4690,37 @@ async function executeNativeLegacyAliasRepair( reason: input.reason as string, }; const authorization = nativeReviewLegacyAliasRepairAuthorization(request); - if (context?.hasUI !== true) throw new Error("Review controller REPAIR_LEGACY_ALIAS requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed"); + if (context?.hasUI !== true) + throw new Error( + "Review controller REPAIR_LEGACY_ALIAS requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed", + ); const approved = await context.ui.confirm( "Authorize review authority REPAIR_LEGACY_ALIAS?", - ["Operation: REPAIR_LEGACY_ALIAS", "Exact published authorization binding:", authorization, "The native command may quarantine only this fresh, invalid legacy-v1 alias lineage; it never rewrites or validates historical authority."].join("\n"), + [ + "Operation: REPAIR_LEGACY_ALIAS", + "Exact published authorization binding:", + authorization, + "The native command may quarantine only this fresh, invalid legacy-v1 alias lineage; it never rewrites or validates historical authority.", + ].join("\n"), ); - if (!approved) throw new Error("Review controller REPAIR_LEGACY_ALIAS was not explicitly authorized"); + if (!approved) + throw new Error( + "Review controller REPAIR_LEGACY_ALIAS was not explicitly authorized", + ); try { - const result = await nativeReviewCli.repairLegacyAlias({ ...request, maintainerAuthorization: authorization, ...(signal === undefined ? {} : { signal }) }); - return { operation, native_operation: nativeOperation, result: result.record, mutation_performed: true, mutation_outcome: "committed", next_action: "inspect" }; + const result = await nativeReviewCli.repairLegacyAlias({ + ...request, + maintainerAuthorization: authorization, + ...(signal === undefined ? {} : { signal }), + }); + return { + operation, + native_operation: nativeOperation, + result: result.record, + mutation_performed: true, + mutation_outcome: "committed", + next_action: "inspect", + }; } catch (error) { return nativeOperationFailure(operation, error); } @@ -3788,7 +4744,10 @@ async function executeNativeRecoveryRoute( signal: AbortSignal | undefined, ): Promise> { const nativeCommand = `review ${nativeOperation}`; - const method = nativeOperation === "reclaim" ? nativeReviewCli?.reclaim : nativeReviewCli?.recover; + const method = + nativeOperation === "reclaim" + ? nativeReviewCli?.reclaim + : nativeReviewCli?.recover; if (nativeReviewCli === null || method === undefined) { return { operation, @@ -3797,13 +4756,17 @@ async function executeNativeRecoveryRoute( native_operation: nativeCommand, mutation_performed: false, mutation_outcome: "none", - next_action: "install-package-local-gentle-ai-or-run-native-review-cli-directly", + next_action: + "install-package-local-gentle-ai-or-run-native-review-cli-directly", }; } const missing = NATIVE_RECOVERY_INPUT[nativeOperation].filter((key) => key === "disposition" - ? input[key] !== "scope_changed" && input[key] !== "invalidated" && input[key] !== "escalated" - : typeof input[key] !== "string" || (input[key] as string).trim().length === 0, + ? input[key] !== "scope_changed" && + input[key] !== "invalidated" && + input[key] !== "escalated" + : typeof input[key] !== "string" || + (input[key] as string).trim().length === 0, ); if (missing.length > 0) { return { @@ -3818,19 +4781,31 @@ async function executeNativeRecoveryRoute( }; } try { - const result = nativeOperation === "reclaim" - ? await nativeReviewCli.reclaim!({ cwd, lineage: String(input.lineage), actor: String(input.actor), reason: String(input.reason), ...(signal === undefined ? {} : { signal }) }) - : await nativeReviewCli.recover!({ - cwd, - predecessorLineage: String(input.predecessorLineage), - expectedPredecessorRevision: String(input.expectedPredecessorRevision), - successorLineage: String(input.successorLineage), - disposition: input.disposition as "scope_changed" | "invalidated" | "escalated", - actor: String(input.actor), - reason: String(input.reason), - ...(typeof input.maintainerAuthorization === "string" ? { maintainerAuthorization: input.maintainerAuthorization } : {}), - ...(signal === undefined ? {} : { signal }), - }); + const result = + nativeOperation === "reclaim" + ? await nativeReviewCli.reclaim!({ + cwd, + lineage: String(input.lineage), + actor: String(input.actor), + reason: String(input.reason), + ...(signal === undefined ? {} : { signal }), + }) + : await nativeReviewCli.recover!({ + cwd, + predecessorLineage: String(input.predecessorLineage), + expectedPredecessorRevision: String(input.expectedPredecessorRevision), + successorLineage: String(input.successorLineage), + disposition: input.disposition as + | "scope_changed" + | "invalidated" + | "escalated", + actor: String(input.actor), + reason: String(input.reason), + ...(typeof input.maintainerAuthorization === "string" + ? { maintainerAuthorization: input.maintainerAuthorization } + : {}), + ...(signal === undefined ? {} : { signal }), + }); return { operation, native_operation: nativeCommand, @@ -3844,7 +4819,9 @@ async function executeNativeRecoveryRoute( } } -function mapNativeStartResult(result: NativeStartResult): Record { +function mapNativeStartResult( + result: NativeStartResult, +): Record { return { lineage_id: result.lineageId, state: result.state, @@ -3855,14 +4832,20 @@ function mapNativeStartResult(result: NativeStartResult): Record): Record { +function withoutRawCollectInputs( + raw: Record, +): Record { if (!isRecord(raw.next_transition)) return raw; const { collect: _collect, ...transition } = raw.next_transition; return { ...raw, next_transition: transition }; } -function mapNativeTargetStatus(operation: ReviewControllerOperation, status: ReviewStatusV3, requestedLineageId?: string): Record { +function mapNativeTargetStatus( + operation: ReviewControllerOperation, + status: ReviewStatusV3, + requestedLineageId?: string, +): Record { if ( status.nextTransition?.kind === "collect" && - (operation === REVIEW_CONTROLLER_OPERATION.START || operation === REVIEW_CONTROLLER_OPERATION.INSPECT || operation === REVIEW_CONTROLLER_OPERATION.STATUS) + (operation === REVIEW_CONTROLLER_OPERATION.START || + operation === REVIEW_CONTROLLER_OPERATION.INSPECT || + operation === REVIEW_CONTROLLER_OPERATION.STATUS) ) { const selection = reviewIntendedUntrackedInput(status); return { @@ -3905,19 +4896,26 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev provider_action: "recover", recovery_disposition: status.actionDisposition, next_action: "recover-with-provider-disposition", - required_status_action: "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.", + required_status_action: + "Use only the provider-selected recovery disposition; do not substitute scope_changed, invalidated, or escalated.", }; } // gentle-pi#627: a stale managed-asset set stops the transition with the // exact `gentle-ai sync` invocation that resolves it. Render that command // as the one actionable next step; every other reason code keeps rendering // as a plain blocked result. - if (status.nextTransition?.kind === "stop" && status.nextTransition.reasonCode === "managed_assets_outdated" && status.nextTransition.continuation !== undefined) { + if ( + status.nextTransition?.kind === "stop" && + status.nextTransition.reasonCode === "managed_assets_outdated" && + status.nextTransition.continuation !== undefined + ) { return { operation, status: "blocked", result: status.raw, - ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }), + ...(requestedLineageId === undefined + ? {} + : { requested_lineage_id: requestedLineageId }), hint: `run ${status.nextTransition.continuation.command}`, }; } @@ -3925,7 +4923,9 @@ function mapNativeTargetStatus(operation: ReviewControllerOperation, status: Rev operation, status: status.action === "start" ? "ready" : "blocked", result: status.raw, - ...(requestedLineageId === undefined ? {} : { requested_lineage_id: requestedLineageId }), + ...(requestedLineageId === undefined + ? {} + : { requested_lineage_id: requestedLineageId }), }; } @@ -3936,11 +4936,20 @@ interface NativeStartPolicyValidation { function isStrictDescendantPath(parent: string, candidate: string): boolean { const pathFromParent = relative(parent, candidate); - return pathFromParent.length > 0 && pathFromParent !== ".." && !pathFromParent.startsWith(`..${sep}`) && !isAbsolute(pathFromParent); + return ( + pathFromParent.length > 0 && + pathFromParent !== ".." && + !pathFromParent.startsWith(`..${sep}`) && + !isAbsolute(pathFromParent) + ); } -function validateNativeStartPolicyPath(cwd: string, value: unknown): NativeStartPolicyValidation { - if (typeof value !== "string" || value.trim().length === 0) return { reason: "policy-path-not-regular" }; +function validateNativeStartPolicyPath( + cwd: string, + value: unknown, +): NativeStartPolicyValidation { + if (typeof value !== "string" || value.trim().length === 0) + return { reason: "policy-path-not-regular" }; let repository: string; try { repository = realpathSync(cwd); @@ -3949,7 +4958,8 @@ function validateNativeStartPolicyPath(cwd: string, value: unknown): NativeStart } const policyRoot = join(repository, ".gentle-ai", "policies"); const candidate = resolve(repository, value); - if (!isStrictDescendantPath(policyRoot, candidate)) return { reason: "policy-path-outside-scope" }; + if (!isStrictDescendantPath(policyRoot, candidate)) + return { reason: "policy-path-outside-scope" }; const gentleDirectory = join(repository, ".gentle-ai"); for (const directory of [gentleDirectory, policyRoot]) { try { @@ -3978,7 +4988,11 @@ function validateNativeStartPolicyPath(cwd: string, value: unknown): NativeStart } try { const canonicalPath = realpathSync(candidate); - if (canonicalPath !== candidate || !isStrictDescendantPath(policyRoot, canonicalPath)) return { reason: "policy-path-symlink" }; + if ( + canonicalPath !== candidate || + !isStrictDescendantPath(policyRoot, canonicalPath) + ) + return { reason: "policy-path-symlink" }; return { policyPath: canonicalPath }; } catch { return { reason: "policy-path-not-regular" }; @@ -3991,17 +5005,22 @@ const NATIVE_START_FOCUS = { READABILITY: "readability", RELIABILITY: "reliability", } as const; -type NativeStartFocus = (typeof NATIVE_START_FOCUS)[keyof typeof NATIVE_START_FOCUS]; +type NativeStartFocus = + (typeof NATIVE_START_FOCUS)[keyof typeof NATIVE_START_FOCUS]; function isNativeStartFocus(value: unknown): value is NativeStartFocus { - return typeof value === "string" && (Object.values(NATIVE_START_FOCUS) as readonly string[]).includes(value); + return ( + typeof value === "string" && + (Object.values(NATIVE_START_FOCUS) as readonly string[]).includes(value) + ); } const NATIVE_START_UNTRACKED_SCOPE = { EXCLUDE: "exclude", SELECT: "select", } as const; -type NativeStartUntrackedScope = (typeof NATIVE_START_UNTRACKED_SCOPE)[keyof typeof NATIVE_START_UNTRACKED_SCOPE]; +type NativeStartUntrackedScope = + (typeof NATIVE_START_UNTRACKED_SCOPE)[keyof typeof NATIVE_START_UNTRACKED_SCOPE]; interface NativeStartUntrackedSelection { untrackedScope?: NativeStartUntrackedScope; @@ -4016,67 +5035,111 @@ interface RetainedNativeUntrackedSelection { readonly intendedUntracked: readonly string[]; } -interface RetainedNativeCaptureRoute { readonly workspaceRoot: string; readonly lineageId: string; readonly baseRef?: string; readonly committedOnly?: true; } +interface RetainedNativeCaptureRoute { + readonly workspaceRoot: string; + readonly lineageId: string; + readonly baseRef?: string; + readonly committedOnly?: true; +} -type RetainedNativeStatusSelection = RetainedNativeUntrackedSelection | RetainedNativeCaptureRoute; +type RetainedNativeStatusSelection = + | RetainedNativeUntrackedSelection + | RetainedNativeCaptureRoute; const MAX_RETAINED_NATIVE_STATUS_SELECTIONS = 64; class NativeCaptureRouteRegistrationError extends Error {} function isNativeStartUntrackedPath(value: unknown): value is string { - return isCanonicalProcessString(value) - && !isAbsolute(value) - && !/^[A-Za-z]:\//.test(value) - && !value.includes("\\") - && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); + return ( + isCanonicalProcessString(value) && + !isAbsolute(value) && + !/^[A-Za-z]:\//.test(value) && + !value.includes("\\") && + value + .split("/") + .every( + (segment) => segment.length > 0 && segment !== "." && segment !== "..", + ) + ); } -function validateNativeStartUntrackedSelection(value: Record): NativeStartUntrackedSelection { - const declared = "untrackedScope" in value || "expectedUntrackedInventory" in value || "intendedUntracked" in value; +function validateNativeStartUntrackedSelection( + value: Record, +): NativeStartUntrackedSelection { + const declared = + "untrackedScope" in value || + "expectedUntrackedInventory" in value || + "intendedUntracked" in value; if (!declared) return {}; const scope = value.untrackedScope; const expectedUntrackedInventory = value.expectedUntrackedInventory; const intendedUntracked = value.intendedUntracked; if ( - (scope !== NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && scope !== NATIVE_START_UNTRACKED_SCOPE.SELECT) || + (scope !== NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && + scope !== NATIVE_START_UNTRACKED_SCOPE.SELECT) || !isCanonicalProcessString(expectedUntrackedInventory) || - (intendedUntracked !== undefined && (!Array.isArray(intendedUntracked) || intendedUntracked.some((path) => !isNativeStartUntrackedPath(path) || intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path)))) - ) return { reason: "untracked-selection-invalid" }; - if (scope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) return { reason: "untracked-selection-invalid" }; - if (scope === NATIVE_START_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) return { reason: "untracked-selection-invalid" }; + (intendedUntracked !== undefined && + (!Array.isArray(intendedUntracked) || + intendedUntracked.some( + (path) => + !isNativeStartUntrackedPath(path) || + intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path), + ))) + ) + return { reason: "untracked-selection-invalid" }; + if ( + scope === NATIVE_START_UNTRACKED_SCOPE.EXCLUDE && + (intendedUntracked?.length ?? 0) > 0 + ) + return { reason: "untracked-selection-invalid" }; + if ( + scope === NATIVE_START_UNTRACKED_SCOPE.SELECT && + (intendedUntracked?.length ?? 0) === 0 + ) + return { reason: "untracked-selection-invalid" }; return { untrackedScope: scope, expectedUntrackedInventory, - intendedUntracked: intendedUntracked === undefined ? [] : [...intendedUntracked], + intendedUntracked: + intendedUntracked === undefined ? [] : [...intendedUntracked], }; } -function nativeStartRejection(reason: string, field?: string): Record { +function nativeStartRejection( + reason: string, + field?: string, +): Record { return { operation: REVIEW_CONTROLLER_OPERATION.START, status: "blocked", - outcome: reason === "legacy-policy-hash-unsupported" - ? "native-start-legacy-policy-hash-unsupported" - : reason === "base-ref-invalid" - ? "native-start-base-ref-invalid" - : reason === "base-ref-ambiguous" - ? "native-start-base-ref-ambiguous" - : reason === "base-ref-unresolvable" || reason === "base-ref-moved" - ? "native-start-base-ref-unresolvable" - : reason === "committed-only-required" - ? "native-start-committed-only-required" - : reason === "committed-only-invalid" - ? "native-start-committed-only-invalid" - : reason === "unknown-field" || reason === "focus-invalid" || reason === "untracked-selection-invalid" - ? "native-start-input-invalid" - : "native-start-policy-path-invalid", + outcome: + reason === "legacy-policy-hash-unsupported" + ? "native-start-legacy-policy-hash-unsupported" + : reason === "base-ref-invalid" + ? "native-start-base-ref-invalid" + : reason === "base-ref-ambiguous" + ? "native-start-base-ref-ambiguous" + : reason === "base-ref-unresolvable" || reason === "base-ref-moved" + ? "native-start-base-ref-unresolvable" + : reason === "committed-only-required" + ? "native-start-committed-only-required" + : reason === "committed-only-invalid" + ? "native-start-committed-only-invalid" + : reason === "unknown-field" || + reason === "focus-invalid" || + reason === "untracked-selection-invalid" + ? "native-start-input-invalid" + : "native-start-policy-path-invalid", reason, ...(field === undefined ? {} : { field }), ...nativeStartPreAuthorityRejection(), }; } -function nativeStatusInputRejection(reason: string, field?: string): Record { +function nativeStatusInputRejection( + reason: string, + field?: string, +): Record { return { operation: REVIEW_CONTROLLER_OPERATION.STATUS, status: "blocked", @@ -4090,7 +5153,8 @@ function nativeStatusInputRejection(reason: string, field?: string): Record>(); + private readonly sessions = new Map< + PendingReviewConsentSessionKey, + Map + >(); // gentle-pi#455: a binding id is globally unique (randomUUID), so its live // owner and its stale disposition are tracked by binding id alone. This // index lets answer-consent resolve and atomically take exactly once a // binding another active Pi session's START created; the per-session map // above stays authoritative for session-scoped listings and shutdown cleanup. private readonly byBinding = new Map(); - private readonly staleDispositions = new Map(); - - get(sessionKey: PendingReviewConsentSessionKey): Map | undefined { + private readonly staleDispositions = new Map< + string, + PendingReviewConsentDisposition + >(); + + get( + sessionKey: PendingReviewConsentSessionKey, + ): Map | undefined { return this.sessions.get(sessionKey); } - ensure(sessionKey: PendingReviewConsentSessionKey): Map { + ensure( + sessionKey: PendingReviewConsentSessionKey, + ): Map { let pending = this.sessions.get(sessionKey); if (pending === undefined) { pending = new Map(); @@ -4148,7 +5223,10 @@ export class PendingReviewConsentRegistry { // Registers a freshly created pending binding under its owning session and // the cross-session binding index in one step so the two never drift. - add(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void { + add( + sessionKey: PendingReviewConsentSessionKey, + pending: PendingReviewConsent, + ): void { this.ensure(sessionKey).set(pending.id, pending); this.byBinding.set(pending.id, sessionKey); } @@ -4156,57 +5234,97 @@ export class PendingReviewConsentRegistry { // Resolves a live binding to its owning session regardless of which // session asks, so answer-consent can reach a binding another session's // START created (gentle-pi#455). - resolve(bindingId: string): { sessionKey: PendingReviewConsentSessionKey; pending: PendingReviewConsent } | undefined { + resolve( + bindingId: string, + ): + | { + sessionKey: PendingReviewConsentSessionKey; + pending: PendingReviewConsent; + } + | undefined { const sessionKey = this.byBinding.get(bindingId); - const pending = sessionKey === undefined ? undefined : this.sessions.get(sessionKey)?.get(bindingId); + const pending = + sessionKey === undefined + ? undefined + : this.sessions.get(sessionKey)?.get(bindingId); return pending === undefined ? undefined : { sessionKey, pending }; } - private remove(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean { + private remove( + sessionKey: PendingReviewConsentSessionKey, + pending: PendingReviewConsent, + ): boolean { const session = this.sessions.get(sessionKey); if (session?.get(pending.id) !== pending) return false; session.delete(pending.id); if (session.size === 0) this.sessions.delete(sessionKey); - if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id); + if (this.byBinding.get(pending.id) === sessionKey) + this.byBinding.delete(pending.id); return true; } - private rememberDisposition(pending: PendingReviewConsent, disposition: PendingReviewConsentDisposition): void { + private rememberDisposition( + pending: PendingReviewConsent, + disposition: PendingReviewConsentDisposition, + ): void { this.staleDispositions.delete(pending.id); this.staleDispositions.set(pending.id, disposition); - while (this.staleDispositions.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT) this.staleDispositions.delete(this.staleDispositions.keys().next().value!); + while ( + this.staleDispositions.size > PENDING_REVIEW_CONSENT_STALE_DISPOSITION_LIMIT + ) + this.staleDispositions.delete(this.staleDispositions.keys().next().value!); } - consume(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean { + consume( + sessionKey: PendingReviewConsentSessionKey, + pending: PendingReviewConsent, + ): boolean { if (!this.remove(sessionKey, pending)) return false; - this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED); + this.rememberDisposition( + pending, + PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED, + ); return true; } - expire(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): boolean { + expire( + sessionKey: PendingReviewConsentSessionKey, + pending: PendingReviewConsent, + ): boolean { if (!this.remove(sessionKey, pending)) return false; this.rememberDisposition(pending, PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED); return true; } - discard(sessionKey: PendingReviewConsentSessionKey, pending: PendingReviewConsent): void { + discard( + sessionKey: PendingReviewConsentSessionKey, + pending: PendingReviewConsent, + ): void { this.remove(sessionKey, pending); } - staleDisposition(binding: string): PendingReviewConsentDisposition | undefined { + staleDisposition( + binding: string, + ): PendingReviewConsentDisposition | undefined { return this.staleDispositions.get(binding); } take(sessionKey: PendingReviewConsentSessionKey): PendingReviewConsent[] { const session = this.sessions.get(sessionKey); this.sessions.delete(sessionKey); - if (session !== undefined) for (const pending of session.values()) if (this.byBinding.get(pending.id) === sessionKey) this.byBinding.delete(pending.id); + if (session !== undefined) + for (const pending of session.values()) + if (this.byBinding.get(pending.id) === sessionKey) + this.byBinding.delete(pending.id); return session === undefined ? [] : [...session.values()]; } } const processPendingReviewConsentRegistry = new PendingReviewConsentRegistry(); -const processRetainedNativeStatusSelections = new Map>(); +const processRetainedNativeStatusSelections = new Map< + PendingReviewConsentSessionKey, + Map +>(); // gentle-pi#556 / gentle-ai#4051: nesting depth of named-agent (SDD phase // executor or other subagent) starts vs. ends for a session. Starts and @@ -4214,7 +5332,10 @@ const processRetainedNativeStatusSelections = new Map(); +const processAgentEndSubagentDepth = new Map< + PendingReviewConsentSessionKey, + number +>(); // gentle-pi#677: gentle-ai#4309 owns anonymous usage telemetry end to end; // Pi only nudges it once per process. This is a plain process-lifetime @@ -4228,41 +5349,70 @@ function resetTelemetryTriggerGuardForTesting(): void { processTelemetryTriggerAttempted = false; } -function pendingReviewConsentSessionKey(context: ExtensionContext | undefined, fallbackKey: symbol): PendingReviewConsentSessionKey { +function pendingReviewConsentSessionKey( + context: ExtensionContext | undefined, + fallbackKey: symbol, +): PendingReviewConsentSessionKey { try { - const sessionManager = (context as unknown as { sessionManager?: { getSessionId?: () => unknown } } | undefined)?.sessionManager; + const sessionManager = ( + context as unknown as + | { sessionManager?: { getSessionId?: () => unknown } } + | undefined + )?.sessionManager; const sessionId = sessionManager?.getSessionId?.(); if (typeof sessionId === "string") return sessionId; - } catch { /* Minimal or test contexts use the registration-local fallback. */ } + } catch { + /* Minimal or test contexts use the registration-local fallback. */ + } return fallbackKey; } -function consumePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): boolean { +function consumePendingReviewConsent( + pending: PendingReviewConsent, + registry: PendingReviewConsentRegistry, + sessionKey: PendingReviewConsentSessionKey, +): boolean { if (!registry.consume(sessionKey, pending)) return false; if (pending.expiry !== undefined) clearTimeout(pending.expiry); pending.expiry = undefined; return true; } -function discardPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { +function discardPendingReviewConsent( + pending: PendingReviewConsent, + registry: PendingReviewConsentRegistry, + sessionKey: PendingReviewConsentSessionKey, +): void { if (pending.expiry !== undefined) clearTimeout(pending.expiry); pending.expiry = undefined; registry.discard(sessionKey, pending); } -function expirePendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { +function expirePendingReviewConsent( + pending: PendingReviewConsent, + registry: PendingReviewConsentRegistry, + sessionKey: PendingReviewConsentSessionKey, +): void { if (pending.expiry !== undefined) clearTimeout(pending.expiry); pending.expiry = undefined; if (registry.expire(sessionKey, pending)) pending.cleanupCandidate(); } -function cleanupPendingReviewConsent(pending: PendingReviewConsent, registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { +function cleanupPendingReviewConsent( + pending: PendingReviewConsent, + registry: PendingReviewConsentRegistry, + sessionKey: PendingReviewConsentSessionKey, +): void { discardPendingReviewConsent(pending, registry, sessionKey); pending.cleanupCandidate(); } -function cleanupAllPendingReviewConsents(registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey): void { - for (const pending of registry.take(sessionKey)) cleanupPendingReviewConsent(pending, registry, sessionKey); +function cleanupAllPendingReviewConsents( + registry: PendingReviewConsentRegistry, + sessionKey: PendingReviewConsentSessionKey, +): void { + for (const pending of registry.take(sessionKey)) + cleanupPendingReviewConsent(pending, registry, sessionKey); } // An unused consent binding and the candidate view retained exclusively for @@ -4273,11 +5423,16 @@ function cleanupAllPendingReviewConsents(registry: PendingReviewConsentRegistry, // later START may reuse the retained view) keeps timer order from deciding // correctness: a fresh candidate retry never reuses a view whose binding // already expired, so it cannot trip `candidate-target-projection-drift`. -function pruneExpiredReviewConsents(registry: PendingReviewConsentRegistry, sessionKey: PendingReviewConsentSessionKey, now: () => number): void { +function pruneExpiredReviewConsents( + registry: PendingReviewConsentRegistry, + sessionKey: PendingReviewConsentSessionKey, + now: () => number, +): void { const pendingReviewConsents = registry.get(sessionKey); if (pendingReviewConsents === undefined) return; for (const pending of [...pendingReviewConsents.values()]) { - if (pending.expiresAt <= now()) expirePendingReviewConsent(pending, registry, sessionKey); + if (pending.expiresAt <= now()) + expirePendingReviewConsent(pending, registry, sessionKey); } } @@ -4285,11 +5440,21 @@ function reviewConsentDigest(consent: ReviewConsentEnvelope): string { return createHash("sha256").update(JSON.stringify(consent)).digest("hex"); } -function reviewSessionManagerAndId(context: ExtensionContext): { manager: object; sessionId: string } | undefined { +function reviewSessionManagerAndId( + context: ExtensionContext, +): { manager: object; sessionId: string } | undefined { try { - const manager = context.sessionManager as unknown as { getSessionId?: () => unknown }; + const manager = context.sessionManager as unknown as { + getSessionId?: () => unknown; + }; const sessionId = manager.getSessionId?.(); - if (typeof manager !== "object" || manager === null || typeof sessionId !== "string" || sessionId.length === 0) return undefined; + if ( + typeof manager !== "object" || + manager === null || + typeof sessionId !== "string" || + sessionId.length === 0 + ) + return undefined; return { manager, sessionId }; } catch { return undefined; @@ -4300,23 +5465,33 @@ function isDirectOrdinaryReviewStart(parametersValue: unknown): boolean { try { const parameters = parseReviewControllerParameters(parametersValue); if (parameters.operation !== REVIEW_CONTROLLER_OPERATION.START) return false; - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); return input.mode === REVIEW_MODE.ORDINARY; } catch { return false; } } -function isHostReviewConsentEligibleOperation(parametersValue: unknown): boolean { +function isHostReviewConsentEligibleOperation( + parametersValue: unknown, +): boolean { if (isDirectOrdinaryReviewStart(parametersValue)) return true; try { - return parseReviewControllerParameters(parametersValue).operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED; + return ( + parseReviewControllerParameters(parametersValue).operation === + REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED + ); } catch { return false; } } -function completedGrantedReviewConsent(outcome: Record): boolean { +function completedGrantedReviewConsent( + outcome: Record, +): boolean { if ( outcome.operation !== REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT || outcome.status !== undefined || @@ -4325,19 +5500,27 @@ function completedGrantedReviewConsent(outcome: Record): boolea outcome.mutation_performed === false || outcome.lineage_created === false || !isCanonicalProcessString(outcome.workspace_root) - ) return false; + ) + return false; const result = outcome.result; if (!isRecord(result)) return false; - const nonnegativeInteger = (value: unknown): boolean => Number.isInteger(value) && Number(value) >= 0; - return isCanonicalProcessString(result.lineage_id) && + const nonnegativeInteger = (value: unknown): boolean => + Number.isInteger(value) && Number(value) >= 0; + return ( + isCanonicalProcessString(result.lineage_id) && isCanonicalProcessString(result.state) && isCanonicalProcessString(result.risk_tier) && - Array.isArray(result.selected_lenses) && result.selected_lenses.every(isCanonicalProcessString) && + Array.isArray(result.selected_lenses) && + result.selected_lenses.every(isCanonicalProcessString) && nonnegativeInteger(result.changed_files) && nonnegativeInteger(result.original_changed_lines) && nonnegativeInteger(result.correction_budget) && - (result.action === "created" || result.action === "resumed" || result.action === "replayed" || result.action === "closed") && - typeof result.lenses_required === "boolean"; + (result.action === "created" || + result.action === "resumed" || + result.action === "replayed" || + result.action === "closed") && + typeof result.lenses_required === "boolean" + ); } // gentle-pi#516: a binding this session does not hold (already answered, @@ -4352,25 +5535,44 @@ const STALE_CONSENT_BINDING_DIAGNOSTIC_CODE = { UNKNOWN: "consent-binding-unknown", } as const; -type StaleConsentBindingDiagnosticCode = (typeof STALE_CONSENT_BINDING_DIAGNOSTIC_CODE)[keyof typeof STALE_CONSENT_BINDING_DIAGNOSTIC_CODE]; +type StaleConsentBindingDiagnosticCode = + (typeof STALE_CONSENT_BINDING_DIAGNOSTIC_CODE)[keyof typeof STALE_CONSENT_BINDING_DIAGNOSTIC_CODE]; interface StaleConsentBindingDiagnostics { code: StaleConsentBindingDiagnosticCode; message: string; } -function staleConsentBindingDiagnostics(binding: string, disposition: PendingReviewConsentDisposition | undefined): StaleConsentBindingDiagnostics { - const exit = "Run START again for this candidate to obtain a fresh consent envelope and answer that envelope's binding once; do not resend this binding."; +function staleConsentBindingDiagnostics( + binding: string, + disposition: PendingReviewConsentDisposition | undefined, +): StaleConsentBindingDiagnostics { + const exit = + "Run START again for this candidate to obtain a fresh consent envelope and answer that envelope's binding once; do not resend this binding."; if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED) { - return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.EXPIRED, message: `consent binding ${binding} expired after ${PENDING_REVIEW_CONSENT_TTL_MS / 60_000} minutes without an answer. ${exit}` }; + return { + code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.EXPIRED, + message: `consent binding ${binding} expired after ${PENDING_REVIEW_CONSENT_TTL_MS / 60_000} minutes without an answer. ${exit}`, + }; } if (disposition === PENDING_REVIEW_CONSENT_DISPOSITION.CONSUMED) { - return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.ALREADY_CONSUMED, message: `consent binding ${binding} was already consumed by an earlier answer. ${exit}` }; + return { + code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.ALREADY_CONSUMED, + message: `consent binding ${binding} was already consumed by an earlier answer. ${exit}`, + }; } - return { code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.UNKNOWN, message: `consent binding ${binding} is not held by this Pi session. ${exit}` }; + return { + code: STALE_CONSENT_BINDING_DIAGNOSTIC_CODE.UNKNOWN, + message: `consent binding ${binding} is not held by this Pi session. ${exit}`, + }; } -function staleConsentBindingOutcome(operation: ReviewControllerOperation, binding: string, diagnostics: ReturnType, status: ReviewStatusV3): Record { +function staleConsentBindingOutcome( + operation: ReviewControllerOperation, + binding: string, + diagnostics: ReturnType, + status: ReviewStatusV3, +): Record { const mapped = mapNativeTargetStatus(operation, status); return { ...mapped, @@ -4381,7 +5583,11 @@ function staleConsentBindingOutcome(operation: ReviewControllerOperation, bindin native_invocation_attempted: false, ...nativeStartPreAuthorityRejection(), provider_action: status.action, - ...(status.action === "start" ? { next_action: "restart-for-fresh-consent" } : mapped.next_action === undefined ? { next_action: status.action } : {}), + ...(status.action === "start" + ? { next_action: "restart-for-fresh-consent" } + : mapped.next_action === undefined + ? { next_action: status.action } + : {}), }; } @@ -4392,7 +5598,12 @@ function staleConsentBindingOutcome(operation: ReviewControllerOperation, bindin // stale-binding outcome -- it never runs the mode gate or native // answerConsent, and never consumes the binding, so it stays answerable from // the correct repository afterwards. -function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOperation, binding: string, mintingRepositoryCwd: string, answeringRepositoryCwd: string): Record { +function consentBindingRepositoryMismatchOutcome( + operation: ReviewControllerOperation, + binding: string, + mintingRepositoryCwd: string, + answeringRepositoryCwd: string, +): Record { return { operation, status: "blocked", @@ -4408,17 +5619,26 @@ function consentBindingRepositoryMismatchOutcome(operation: ReviewControllerOper }; } -function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void { +function assertNativeStartCandidateBinding( + candidateView: CandidateView, + target: ReviewStatusV3, +): void { candidateView.verify(); if ( target.projection.projection !== "workspace" || target.projection.baseTree !== candidateView.baseTree || target.projection.initialReviewTree !== candidateView.candidateTree || target.projection.currentCandidateTree !== candidateView.candidateTree || - JSON.stringify([...target.projection.paths].sort()) !== JSON.stringify([...candidateView.paths].sort()) || - (candidateView.intendedUntracked !== undefined && JSON.stringify([...target.projection.intendedUntracked].sort()) !== JSON.stringify([...candidateView.intendedUntracked].sort())) + JSON.stringify([...target.projection.paths].sort()) !== + JSON.stringify([...candidateView.paths].sort()) || + (candidateView.intendedUntracked !== undefined && + JSON.stringify([...target.projection.intendedUntracked].sort()) !== + JSON.stringify([...candidateView.intendedUntracked].sort())) ) { - throw new CandidateViewError("native START workspace target does not match the immutable reviewer candidate view", "candidate-target-projection-drift"); + throw new CandidateViewError( + "native START workspace target does not match the immutable reviewer candidate view", + "candidate-target-projection-drift", + ); } } @@ -4429,20 +5649,38 @@ function completeNativeStart( candidateView: CandidateView | undefined, candidateViews: CandidateViewRegistry | null, ): Record { - if (candidateView === undefined) return { operation, result: mapNativeStartResult(result), workspace_root: workspaceRoot }; + if (candidateView === undefined) + return { + operation, + result: mapNativeStartResult(result), + workspace_root: workspaceRoot, + }; if (candidateViews && result.lensesRequired) { - const binding = { token: candidateView.token, lineageId: result.lineageId, selectedLenses: result.selectedLenses }; - if (result.action === "resumed" && !candidateViews.hasCurrentBinding(candidateView.contributorRoot)) candidateViews.restoreCurrentFromNativeStart(binding); + const binding = { + token: candidateView.token, + lineageId: result.lineageId, + selectedLenses: result.selectedLenses, + }; + if ( + result.action === "resumed" && + !candidateViews.hasCurrentBinding(candidateView.contributorRoot) + ) + candidateViews.restoreCurrentFromNativeStart(binding); else candidateViews.bindCurrent(binding); - } else if (candidateViews && ((result.action === "created" && result.state === "reviewing") || result.action === "resumed")) candidateViews.retain(candidateView.token, result.lineageId); + } else if ( + candidateViews && + ((result.action === "created" && result.state === "reviewing") || + result.action === "resumed") + ) + candidateViews.retain(candidateView.token, result.lineageId); else candidateViews?.cleanup(candidateView.token); const actorBinding = result.lensesRequired ? { - workspace_root: workspaceRoot, - candidate_root: candidateView.root, - candidate_tree: candidateView.candidateTree, - candidate_paths: candidateView.paths, - } + workspace_root: workspaceRoot, + candidate_root: candidateView.root, + candidate_tree: candidateView.candidateTree, + candidate_paths: candidateView.paths, + } : undefined; return { operation, @@ -4452,8 +5690,26 @@ function completeNativeStart( }; } -function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record { - const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown; code?: unknown; continuation?: { command?: unknown } } }; +function nativeOperationFailure( + operation: ReviewControllerOperation | "gentle_review_capture", + error: unknown, +): Record { + const value = error as { + mutationOutcome?: unknown; + nextAction?: unknown; + diagnostics?: unknown; + auditRecord?: unknown; + launchAttempted?: unknown; + candidateViewPreNative?: unknown; + failureEnvelope?: { + raw?: unknown; + mutationOutcome?: unknown; + replayability?: unknown; + nextAction?: unknown; + code?: unknown; + continuation?: { command?: unknown }; + }; + }; if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) { const mutationOutcome = value.failureEnvelope.mutationOutcome; return { @@ -4465,12 +5721,17 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r : mutationOutcome === "unknown" ? { mutation_outcome: "unknown" } : { mutation_performed: false, mutation_outcome: "none" }), - ...(typeof value.failureEnvelope.replayability === "string" ? { replayability: value.failureEnvelope.replayability } : {}), - ...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}), + ...(typeof value.failureEnvelope.replayability === "string" + ? { replayability: value.failureEnvelope.replayability } + : {}), + ...(typeof value.failureEnvelope.nextAction === "string" + ? { next_action: value.failureEnvelope.nextAction } + : {}), // gentle-pi#627: START's preflight failure envelope for a stale // managed-asset set carries a top-level continuation; render its // `gentle-ai sync` command as the one actionable next step. - ...(value.failureEnvelope.code === "managed_assets_outdated" && typeof value.failureEnvelope.continuation?.command === "string" + ...(value.failureEnvelope.code === "managed_assets_outdated" && + typeof value.failureEnvelope.continuation?.command === "string" ? { hint: `run ${value.failureEnvelope.continuation.command}` } : {}), }; @@ -4488,7 +5749,10 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r lineage_created: false, mutation_performed: false, mutation_outcome: "none" as const, - diagnostics: { code: consentBinding.reason, message: consentBinding.message }, + diagnostics: { + code: consentBinding.reason, + message: consentBinding.message, + }, next_action: "resolve-consent-binding", }; } @@ -4501,35 +5765,63 @@ function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_r mutation_outcome: "none", }; } - const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none"; + const mutationOutcome = + value.mutationOutcome === "unknown" ? "unknown" : "none"; const nativeCliError = asNativeReviewCliError(error); - if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, nativeCliError.diagnostics); + if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) + return nativeStatusPackageBinaryMissing( + operation, + nativeCliError.diagnostics, + ); const nativeDiagnostics = nativeCliError?.diagnostics; // A target-status probe verifies `version` before it invokes `review/status`. // Preserve either already-sanitized diagnostic on every controller route rather // than relabeling an actionable failure as an opaque controller failure. - const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.VERSION || nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS; - const preservesAnswerConsentStartDiagnostic = operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT && nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.START; - const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true - ? error.diagnostics ?? { code: error.reason, message: "candidate view rejected before native START" } - : error instanceof CandidateViewError - ? { code: error.reason, message: error.message } - : nativeDiagnostics?.operation === `review/${operation}` || preservesNativeTargetStatusDiagnostic || preservesAnswerConsentStartDiagnostic - ? nativeDiagnostics - : undefined; + const preservesNativeTargetStatusDiagnostic = + nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.VERSION || + nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS; + const preservesAnswerConsentStartDiagnostic = + operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT && + nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.START; + const diagnostics = + operation === REVIEW_CONTROLLER_OPERATION.START && + error instanceof CandidateViewError && + value.candidateViewPreNative === true + ? (error.diagnostics ?? { + code: error.reason, + message: "candidate view rejected before native START", + }) + : error instanceof CandidateViewError + ? { code: error.reason, message: error.message } + : nativeDiagnostics?.operation === `review/${operation}` || + preservesNativeTargetStatusDiagnostic || + preservesAnswerConsentStartDiagnostic + ? nativeDiagnostics + : undefined; return { operation, status: "blocked", outcome: "native-operation-failed", - ...(operation === REVIEW_CONTROLLER_OPERATION.START && mutationOutcome === "none" + ...(operation === REVIEW_CONTROLLER_OPERATION.START && + mutationOutcome === "none" ? nativeStartPreAuthorityRejection() : mutationOutcome === "none" - ? { lineage_created: false, mutation_performed: false, mutation_outcome: "none" as const } + ? { + lineage_created: false, + mutation_performed: false, + mutation_outcome: "none" as const, + } : { mutation_outcome: mutationOutcome }), ...(diagnostics === undefined ? {} : { diagnostics }), - ...(isRecord(value.auditRecord) ? { native_audit_record: value.auditRecord } : {}), + ...(isRecord(value.auditRecord) + ? { native_audit_record: value.auditRecord } + : {}), ...(mutationOutcome === "unknown" || value.nextAction === "review.status" - ? { replayability: "status_required", next_action: "review.status", required_status_action: requiredStatusActionText() } + ? { + replayability: "status_required", + next_action: "review.status", + required_status_action: requiredStatusActionText(), + } : { next_action: "resolve-native-operation-failure" }), }; } @@ -4538,13 +5830,19 @@ function nativeMutationRequiresStatus(error: unknown): boolean { const value = error as { mutationOutcome?: unknown; nextAction?: unknown; - failureEnvelope?: { mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown }; + failureEnvelope?: { + mutationOutcome?: unknown; + replayability?: unknown; + nextAction?: unknown; + }; }; - return value.mutationOutcome === "unknown" || + return ( + value.mutationOutcome === "unknown" || value.nextAction === "review.status" || value.failureEnvelope?.mutationOutcome === "unknown" || value.failureEnvelope?.replayability === "status_required" || - value.failureEnvelope?.nextAction === "review.status"; + value.failureEnvelope?.nextAction === "review.status" + ); } async function reconcileNativeMutationFailure( @@ -4569,8 +5867,14 @@ async function reconcileNativeMutationFailure( } try { const status = await nativeReviewCli.targetStatus(target); - syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, target.baseRef); - const { required_status_action: staleStatusDirective, ...reconciledBase } = failure; + syncRetainedNativeStatusSelections( + retainedSelections, + canonicalRetentionRoot, + status, + target.baseRef, + ); + const { required_status_action: staleStatusDirective, ...reconciledBase } = + failure; void staleStatusDirective; // Field defect (fambig, 2026-08-16): an envelope-less mutating failure // is stamped mutationOutcome "unknown", but a reconciled authority @@ -4579,7 +5883,10 @@ async function reconcileNativeMutationFailure( // claim no replay prohibition for it. Every genuinely ambiguous result // — revision moved, no pre-operation revision held, or STATUS // unavailable — stays fail-closed exactly as before. - if (preOperationRevision !== undefined && status.authority?.revision === preOperationRevision) { + if ( + preOperationRevision !== undefined && + status.authority?.revision === preOperationRevision + ) { const { replayability: staleReplayability, ...provenBase } = reconciledBase; void staleReplayability; return { @@ -4607,7 +5914,10 @@ async function reconcileNativeMutationFailure( return { ...failure, outcome: "native-mutation-status-reconciliation-failed", - reconciliation_failure: nativeOperationFailure(REVIEW_CONTROLLER_OPERATION.STATUS, statusError), + reconciliation_failure: nativeOperationFailure( + REVIEW_CONTROLLER_OPERATION.STATUS, + statusError, + ), replayability: "status_required", next_action: "review.status", required_status_action: requiredStatusActionText(target.lineageId), @@ -4615,11 +5925,16 @@ async function reconcileNativeMutationFailure( } } -function reviewWorkspaceGitIdentity(cwd: string): { toplevel: string; commonDir: string } { +function reviewWorkspaceGitIdentity(cwd: string): { + toplevel: string; + commonDir: string; +} { const git = (...arguments_: string[]): string => execFileSync("git", arguments_, { cwd, encoding: "utf8" }).trim(); const toplevel = realpathSync(git("rev-parse", "--show-toplevel")); - const commonDir = realpathSync(resolve(cwd, git("rev-parse", "--git-common-dir"))); + const commonDir = realpathSync( + resolve(cwd, git("rev-parse", "--git-common-dir")), + ); return { toplevel, commonDir }; } @@ -4635,32 +5950,43 @@ function resolveReviewControllerWorkspaceRoot( candidateViews: CandidateViewRegistry | null, lineageId: string | undefined, ): string { - const remembered = requested === undefined && lineageId !== undefined - ? candidateViews?.resolveWorkspaceRoot(lineageId) - : undefined; + const remembered = + requested === undefined && lineageId !== undefined + ? candidateViews?.resolveWorkspaceRoot(lineageId) + : undefined; const selected = requested ?? remembered ?? sessionCwd; if (selected.trim().length === 0 || !isAbsolute(selected)) { - throw new Error(`Review controller workspaceRoot must be an absolute path to an existing Git worktree root; received ${JSON.stringify(selected)}`); + throw new Error( + `Review controller workspaceRoot must be an absolute path to an existing Git worktree root; received ${JSON.stringify(selected)}`, + ); } let resolved: string; try { resolved = realpathSync(selected); if (!lstatSync(resolved).isDirectory()) throw new Error("not a directory"); } catch { - throw new Error(`Review controller workspaceRoot ${selected} is not an existing directory; create or adopt the worktree before binding review operations to it`); + throw new Error( + `Review controller workspaceRoot ${selected} is not an existing directory; create or adopt the worktree before binding review operations to it`, + ); } let target: { toplevel: string; commonDir: string }; try { target = reviewWorkspaceGitIdentity(resolved); } catch { if (requested === undefined && remembered === undefined) return sessionCwd; - throw new Error(`Review controller workspaceRoot ${resolved} is not inside a Git worktree; review operations bind only to real worktrees of the session repository`); + throw new Error( + `Review controller workspaceRoot ${resolved} is not inside a Git worktree; review operations bind only to real worktrees of the session repository`, + ); } - if (lineageId !== undefined) candidateViews?.assertWorkspaceRoot(lineageId, target.toplevel); + if (lineageId !== undefined) + candidateViews?.assertWorkspaceRoot(lineageId, target.toplevel); return target.toplevel; } -function reviewLifecycleStorageKey(workspaceRoot: string, lineageId: string): string { +function reviewLifecycleStorageKey( + workspaceRoot: string, + lineageId: string, +): string { return `${workspaceRoot}\u0000${lineageId}`; } @@ -4668,16 +5994,26 @@ function reviewCaptureSelectionStorageKey(collectBinding: string): string { return `capture\u0000${collectBinding}`; } -function cloneRetainedNativeUntrackedSelection(selection: NativeStartUntrackedSelection): RetainedNativeUntrackedSelection | undefined { - if (selection.untrackedScope === undefined || selection.expectedUntrackedInventory === undefined) return undefined; - return Object.freeze({ +function cloneRetainedNativeUntrackedSelection( + selection: NativeStartUntrackedSelection, +): RetainedNativeUntrackedSelection | undefined { + if ( + selection.untrackedScope === undefined || + selection.expectedUntrackedInventory === undefined + ) + return undefined; + return Object.freeze({ untrackedScope: selection.untrackedScope, expectedUntrackedInventory: selection.expectedUntrackedInventory, intendedUntracked: Object.freeze([...(selection.intendedUntracked ?? [])]), }); } -function retainNativeStatusSelection(selections: Map, key: string, selection: RetainedNativeStatusSelection): void { +function retainNativeStatusSelection( + selections: Map, + key: string, + selection: RetainedNativeStatusSelection, +): void { if (!selections.has(key)) { while (selections.size >= MAX_RETAINED_NATIVE_STATUS_SELECTIONS) { const oldestKey = selections.keys().next().value; @@ -4688,61 +6024,156 @@ function retainNativeStatusSelection(selections: Map, workspaceRoot: string, lineageId: string, selection: RetainedNativeUntrackedSelection | undefined): void { - if (selection !== undefined) retainNativeStatusSelection(selections, reviewLifecycleStorageKey(workspaceRoot, lineageId), selection); +function retainNativeUntrackedSelection( + selections: Map, + workspaceRoot: string, + lineageId: string, + selection: RetainedNativeUntrackedSelection | undefined, +): void { + if (selection !== undefined) + retainNativeStatusSelection( + selections, + reviewLifecycleStorageKey(workspaceRoot, lineageId), + selection, + ); } -function readRetainedNativeUntrackedSelection(selections: Map, workspaceRoot: string, lineageId: string): NativeStartUntrackedSelection { - const selection = selections.get(reviewLifecycleStorageKey(workspaceRoot, lineageId)); +function readRetainedNativeUntrackedSelection( + selections: Map, + workspaceRoot: string, + lineageId: string, +): NativeStartUntrackedSelection { + const selection = selections.get( + reviewLifecycleStorageKey(workspaceRoot, lineageId), + ); return selection === undefined || "baseRef" in selection ? {} : { - untrackedScope: selection.untrackedScope, - expectedUntrackedInventory: selection.expectedUntrackedInventory, - intendedUntracked: [...selection.intendedUntracked], - }; + untrackedScope: selection.untrackedScope, + expectedUntrackedInventory: selection.expectedUntrackedInventory, + intendedUntracked: [...selection.intendedUntracked], + }; } -function isRetainedNativeCaptureRoute(selection: RetainedNativeStatusSelection | undefined): selection is RetainedNativeCaptureRoute { +function isRetainedNativeCaptureRoute( + selection: RetainedNativeStatusSelection | undefined, +): selection is RetainedNativeCaptureRoute { return selection !== undefined && "workspaceRoot" in selection; } -function retainNativeCaptureRoutes(selections: Map, workspaceRoot: string, status: ReviewStatusV3, baseRef: string | undefined): void { +function retainNativeCaptureRoutes( + selections: Map, + workspaceRoot: string, + status: ReviewStatusV3, + baseRef: string | undefined, +): void { const lineageId = status.authority?.lineageId; - if (status.applicability !== "current_target" || !isCanonicalProcessString(lineageId) || isTerminalReviewAuthorityState(status.authority?.state)) return; - const routes = (status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : []).map((input) => ({ key: reviewCaptureSelectionStorageKey(canonicalReviewCaptureBinding(input)), route: Object.freeze({ workspaceRoot, lineageId, ...(baseRef === undefined ? {} : { baseRef, committedOnly: true as const }) }) })); + if ( + status.applicability !== "current_target" || + !isCanonicalProcessString(lineageId) || + isTerminalReviewAuthorityState(status.authority?.state) + ) + return; + const routes = ( + status.nextTransition?.kind === "collect" + ? (status.nextTransition.collect?.inputs ?? []) + : [] + ).map((input) => ({ + key: reviewCaptureSelectionStorageKey(canonicalReviewCaptureBinding(input)), + route: Object.freeze({ + workspaceRoot, + lineageId, + ...(baseRef === undefined ? {} : { baseRef, committedOnly: true as const }), + }), + })); for (const { key, route } of routes) { const existing = selections.get(key); - if (isRetainedNativeCaptureRoute(existing) && (existing.workspaceRoot !== route.workspaceRoot || existing.lineageId !== route.lineageId || existing.baseRef !== route.baseRef || existing.committedOnly !== route.committedOnly)) throw new NativeCaptureRouteRegistrationError("Provider collectBinding collides with a different registered route"); + if ( + isRetainedNativeCaptureRoute(existing) && + (existing.workspaceRoot !== route.workspaceRoot || + existing.lineageId !== route.lineageId || + existing.baseRef !== route.baseRef || + existing.committedOnly !== route.committedOnly) + ) + throw new NativeCaptureRouteRegistrationError( + "Provider collectBinding collides with a different registered route", + ); } const current = new Set(routes.map(({ key }) => key)); - for (const [key, selection] of selections) if (isRetainedNativeCaptureRoute(selection) && selection.workspaceRoot === workspaceRoot && selection.lineageId === lineageId && !current.has(key)) selections.delete(key); - for (const { key, route } of routes) if (!selections.has(key)) retainNativeStatusSelection(selections, key, route); + for (const [key, selection] of selections) + if ( + isRetainedNativeCaptureRoute(selection) && + selection.workspaceRoot === workspaceRoot && + selection.lineageId === lineageId && + !current.has(key) + ) + selections.delete(key); + for (const { key, route } of routes) + if (!selections.has(key)) retainNativeStatusSelection(selections, key, route); } -function readRetainedNativeCaptureRoute(selections: Map, collectBinding: string): RetainedNativeCaptureRoute | undefined { - const selection = selections.get(reviewCaptureSelectionStorageKey(collectBinding)); +function readRetainedNativeCaptureRoute( + selections: Map, + collectBinding: string, +): RetainedNativeCaptureRoute | undefined { + const selection = selections.get( + reviewCaptureSelectionStorageKey(collectBinding), + ); return isRetainedNativeCaptureRoute(selection) ? selection : undefined; } -function isTerminalReviewAuthorityState(state: string | undefined): boolean { return state === "invalidated" || state === "approved" || state === "escalated"; } +function isTerminalReviewAuthorityState(state: string | undefined): boolean { + return ( + state === "invalidated" || state === "approved" || state === "escalated" + ); +} -function clearRetainedNativeUntrackedSelection(selections: Map, workspaceRoot: string, lineageId: string): void { +function clearRetainedNativeUntrackedSelection( + selections: Map, + workspaceRoot: string, + lineageId: string, +): void { selections.delete(reviewLifecycleStorageKey(workspaceRoot, lineageId)); } -function clearRetainedNativeStatusSelectionsOnTerminal(selections: Map, workspaceRoot: string, lineageId: string | undefined, state: string | undefined): void { +function clearRetainedNativeStatusSelectionsOnTerminal( + selections: Map, + workspaceRoot: string, + lineageId: string | undefined, + state: string | undefined, +): void { if (lineageId === undefined || !isTerminalReviewAuthorityState(state)) return; - if (state !== "approved") clearRetainedNativeUntrackedSelection(selections, workspaceRoot, lineageId); - for (const [key, selection] of selections) if (isRetainedNativeCaptureRoute(selection) && selection.workspaceRoot === workspaceRoot && selection.lineageId === lineageId) selections.delete(key); + if (state !== "approved") + clearRetainedNativeUntrackedSelection(selections, workspaceRoot, lineageId); + for (const [key, selection] of selections) + if ( + isRetainedNativeCaptureRoute(selection) && + selection.workspaceRoot === workspaceRoot && + selection.lineageId === lineageId + ) + selections.delete(key); } -function syncRetainedNativeStatusSelections(selections: Map, workspaceRoot: string, status: ReviewStatusV3, baseRef: string | undefined): void { - clearRetainedNativeStatusSelectionsOnTerminal(selections, workspaceRoot, status.authority?.lineageId, status.authority?.state); +function syncRetainedNativeStatusSelections( + selections: Map, + workspaceRoot: string, + status: ReviewStatusV3, + baseRef: string | undefined, +): void { + clearRetainedNativeStatusSelectionsOnTerminal( + selections, + workspaceRoot, + status.authority?.lineageId, + status.authority?.state, + ); retainNativeCaptureRoutes(selections, workspaceRoot, status, baseRef); } -function requiresExplicitTargetLifecycleRoot(requested: string | undefined, sessionCwd: string, workspaceRoot: string): boolean { +function requiresExplicitTargetLifecycleRoot( + requested: string | undefined, + sessionCwd: string, + workspaceRoot: string, +): boolean { return requested !== undefined || workspaceRoot !== sessionCwd; } @@ -4754,12 +6185,19 @@ function requiresExplicitTargetLifecycleRoot(requested: string | undefined, sess let activeReviewHostRelayRunner: ReviewHostRelayRunner = runReviewHostRelaySlot; let activeReviewHostRelayReviewerGroupRunner = runReviewHostRelayReviewerGroup; let activeReviewHostRelaySubmissionRunner = submitReviewHostRelayPreparedResult; -function setReviewHostRelayRunnerForTesting(runner?: ReviewHostRelayRunner): void { +function setReviewHostRelayRunnerForTesting( + runner?: ReviewHostRelayRunner, +): void { activeReviewHostRelayRunner = runner ?? runReviewHostRelaySlot; } -function setReviewHostRelayGroupRunnersForTesting(reviewerGroup?: typeof runReviewHostRelayReviewerGroup, submission?: typeof submitReviewHostRelayPreparedResult): void { - activeReviewHostRelayReviewerGroupRunner = reviewerGroup ?? runReviewHostRelayReviewerGroup; - activeReviewHostRelaySubmissionRunner = submission ?? submitReviewHostRelayPreparedResult; +function setReviewHostRelayGroupRunnersForTesting( + reviewerGroup?: typeof runReviewHostRelayReviewerGroup, + submission?: typeof submitReviewHostRelayPreparedResult, +): void { + activeReviewHostRelayReviewerGroupRunner = + reviewerGroup ?? runReviewHostRelayReviewerGroup; + activeReviewHostRelaySubmissionRunner = + submission ?? submitReviewHostRelayPreparedResult; } const REVIEW_HOST_RELAY_RETRY_ACTION = @@ -4770,19 +6208,24 @@ const REVIEW_HOST_RELAY_RETRY_ACTION = // problem, so the continuation is a fresh reviewer run on the reoffered slot, // not a replay and not an unknown-outcome reconciliation. const REVIEW_HOST_RELAY_REFUSED_ACTION = - "gentle-ai refused this submission at admission and did not consume the lens slot; the reason is in failure.stderr. " - + "Call fresh STATUS and run only the exact slot it reoffers so the reviewer produces a new result that satisfies that refusal; never resubmit the refused bytes."; + "gentle-ai refused this submission at admission and did not consume the lens slot; the reason is in failure.stderr. " + + "Call fresh STATUS and run only the exact slot it reoffers so the reviewer produces a new result that satisfies that refusal; never resubmit the refused bytes."; function reviewHostRelayTimeoutNextAction(error: ReviewHostRelayError): string { - const measured = error.elapsedMs === null || error.timeoutMs === null - ? "" - : ` The reviewer was killed after ${error.elapsedMs}ms against a ${error.timeoutMs}ms bound.`; - return `Do not relaunch this slot unchanged: the same bound kills the same reviewer run again and re-spends the model tokens for nothing.${measured} ` - + `Change one of two things first: export ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}= above the reviewer's real wall time (hard ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}), or reduce the candidate scope so the materialized prompt is smaller. ` - + "Then call fresh STATUS and submit only its exact reoffered slot."; + const measured = + error.elapsedMs === null || error.timeoutMs === null + ? "" + : ` The reviewer was killed after ${error.elapsedMs}ms against a ${error.timeoutMs}ms bound.`; + return ( + `Do not relaunch this slot unchanged: the same bound kills the same reviewer run again and re-spends the model tokens for nothing.${measured} ` + + `Change one of two things first: export ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}= above the reviewer's real wall time (hard ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}), or reduce the candidate scope so the materialized prompt is smaller. ` + + "Then call fresh STATUS and submit only its exact reoffered slot." + ); } -function reviewHostRelayFailureReport(error: ReviewHostRelayError): Record { +function reviewHostRelayFailureReport( + error: ReviewHostRelayError, +): Record { return { kind: error.kind, stage: error.stage, @@ -4801,9 +6244,20 @@ function mapLastEventClosure( closure: ReviewLastEventClosureV1, binding: ReviewLastEventClosureBinding, ): Record { - if (closure.lineageId !== binding.lineageId) throw new CandidateViewError("last-event closure returned a different lineage", "last-event-closure-binding-drift"); - if (binding.targetIdentity !== undefined && closure.targetIdentity !== undefined && closure.targetIdentity !== binding.targetIdentity) { - throw new CandidateViewError("last-event closure returned a different target", "last-event-closure-binding-drift"); + if (closure.lineageId !== binding.lineageId) + throw new CandidateViewError( + "last-event closure returned a different lineage", + "last-event-closure-binding-drift", + ); + if ( + binding.targetIdentity !== undefined && + closure.targetIdentity !== undefined && + closure.targetIdentity !== binding.targetIdentity + ) { + throw new CandidateViewError( + "last-event closure returned a different target", + "last-event-closure-binding-drift", + ); } return { tool: "gentle_review_capture", @@ -4816,19 +6270,33 @@ function mapLastEventClosure( state: closure.state, store_revision: closure.storeRevision, ...(closure.action === undefined ? {} : { action: closure.action }), - ...(closure.targetIdentity === undefined ? {} : { target_identity: closure.targetIdentity }), - ...(closure.requestHash === undefined ? {} : { request_hash: closure.requestHash }), - ...(closure.correctionLines === undefined ? {} : { correction_lines: closure.correctionLines }), - ...(closure.advisoryFindings === undefined ? {} : { advisory_findings: closure.advisoryFindings }), - ...(closure.statusContinuation === undefined ? {} : { status_continuation: closure.statusContinuation.raw }), + ...(closure.targetIdentity === undefined + ? {} + : { target_identity: closure.targetIdentity }), + ...(closure.requestHash === undefined + ? {} + : { request_hash: closure.requestHash }), + ...(closure.correctionLines === undefined + ? {} + : { correction_lines: closure.correctionLines }), + ...(closure.advisoryFindings === undefined + ? {} + : { advisory_findings: closure.advisoryFindings }), + ...(closure.statusContinuation === undefined + ? {} + : { status_continuation: closure.statusContinuation.raw }), // The host has to see the acknowledgement to run it: approval now // waits for that exact invocation instead of burning on its own, so // dropping it here would strand the lineage as approved forever. - ...(closure.acknowledgement === undefined ? {} : { acknowledgement: closure.acknowledgement.raw }), + ...(closure.acknowledgement === undefined + ? {} + : { acknowledgement: closure.acknowledgement.raw }), // A present-but-unreadable continuation is not the same as none: the // host is approved and cannot end it here, and silence would read as // nothing left to do. - ...(closure.acknowledgementUndecodable === undefined ? {} : { acknowledgement_undecodable: true }), + ...(closure.acknowledgementUndecodable === undefined + ? {} + : { acknowledgement_undecodable: true }), }, lineage_id: closure.lineageId, state: closure.state, @@ -4843,14 +6311,35 @@ function mapAndClearLastEventClosure( workspaceRoot: string, ): Record { const mapped = mapLastEventClosure(closure, binding); - clearRetainedNativeStatusSelectionsOnTerminal(selections, workspaceRoot, closure.lineageId, closure.state); + clearRetainedNativeStatusSelectionsOnTerminal( + selections, + workspaceRoot, + closure.lineageId, + closure.state, + ); return mapped; } -function decodeRelayLastEventClosure(submission: string): ReviewLastEventClosureV1 | undefined { +function decodeRelayLastEventClosure( + submission: string, +): ReviewLastEventClosureV1 | undefined { let body: unknown; - try { body = JSON.parse(submission); } catch { throw new CandidateViewError("host relay submission returned malformed JSON", "last-event-closure-decode-failed"); } - if (typeof body !== "object" || body === null || Array.isArray(body) || (body as { schema?: unknown }).schema !== "gentle-ai.review-last-event-closure/v1") return undefined; + try { + body = JSON.parse(submission); + } catch { + throw new CandidateViewError( + "host relay submission returned malformed JSON", + "last-event-closure-decode-failed", + ); + } + if ( + typeof body !== "object" || + body === null || + Array.isArray(body) || + (body as { schema?: unknown }).schema !== + "gentle-ai.review-last-event-closure/v1" + ) + return undefined; return decodeReviewLastEventClosureV1(body); } @@ -4864,13 +6353,26 @@ async function reconcileUnknownReviewCaptureFailure( expectedReviewCaptureSuffix?: readonly string[], agent?: "pi", ): Promise> { - const failure = error === undefined ? undefined : nativeOperationFailure("gentle_review_capture", error); - if (error !== undefined && !nativeMutationRequiresStatus(error)) return failure; + const failure = + error === undefined + ? undefined + : nativeOperationFailure("gentle_review_capture", error); + if (error !== undefined && !nativeMutationRequiresStatus(error)) + return failure; try { const selector = agent === undefined ? route : { ...route, agent }; - const status = await reconcileUnknownReviewLastEventCapture(nativeReviewCli, cwd, binding, selector); + const status = await reconcileUnknownReviewLastEventCapture( + nativeReviewCli, + cwd, + binding, + selector, + ); syncRetainedNativeStatusSelections(selections, cwd, status, route?.baseRef); - if (expectedReviewCaptureSuffix !== undefined && !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix)) return captureGroupAuthorityDrift(status); + if ( + expectedReviewCaptureSuffix !== undefined && + !hasExactReviewCaptureSuffix(status, expectedReviewCaptureSuffix) + ) + return captureGroupAuthorityDrift(status); return { tool: "gentle_review_capture", status: "reconciled", @@ -4879,12 +6381,21 @@ async function reconcileUnknownReviewCaptureFailure( lineage_id: binding.lineageId, target_identity: status.targetIdentity, provider_action: status.action, - ...(status.nextTransition === undefined ? {} : { next_transition: status.nextTransition }), + ...(status.nextTransition === undefined + ? {} + : { next_transition: status.nextTransition }), result: status.raw, }; } catch (statusError) { - const reconciliationFailure = nativeOperationFailure("gentle_review_capture", statusError); - return { ...(failure ?? reconciliationFailure), outcome: "native-capture-status-reconciliation-failed", reconciliation_failure: reconciliationFailure }; + const reconciliationFailure = nativeOperationFailure( + "gentle_review_capture", + statusError, + ); + return { + ...(failure ?? reconciliationFailure), + outcome: "native-capture-status-reconciliation-failed", + reconciliation_failure: reconciliationFailure, + }; } } @@ -4912,7 +6423,8 @@ async function executeReviewHostRelayCapture( ...(signal === undefined ? {} : { signal }), }); const closure = decodeRelayLastEventClosure(result.submission); - if (closure !== undefined) return mapAndClearLastEventClosure(closure, binding, selections, cwd); + if (closure !== undefined) + return mapAndClearLastEventClosure(closure, binding, selections, cwd); return { tool: "gentle_review_capture", status: "captured", @@ -4922,17 +6434,38 @@ async function executeReviewHostRelayCapture( transport: "pi_host_relay", ...(slot.lens === undefined ? {} : { lens: slot.lens }), ...(slot.order === undefined ? {} : { order: slot.order }), - ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }), + ...(slot.subjectHash === undefined + ? {} + : { subject_hash: slot.subjectHash }), prompt_bytes: result.promptByteLength, result_bytes: result.resultByteLength, submission: result.submission, }, }; } catch (error) { - if (!(error instanceof ReviewHostRelayError)) return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT); + if (!(error instanceof ReviewHostRelayError)) + return await reconcileUnknownReviewCaptureFailure( + error, + nativeReviewCli, + cwd, + binding, + selections, + route, + undefined, + REVIEW_HOST_AGENT, + ); if (error.mutationOutcome === "unknown") { return { - ...(await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route, undefined, REVIEW_HOST_AGENT)), + ...(await reconcileUnknownReviewCaptureFailure( + error, + nativeReviewCli, + cwd, + binding, + selections, + route, + undefined, + REVIEW_HOST_AGENT, + )), failure: reviewHostRelayFailureReport(error), reason: error.message, }; @@ -4961,16 +6494,20 @@ async function executeReviewHostRelayCapture( return { tool: "gentle_review_capture", status: "blocked", - outcome: error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT ? "pi-host-relay-timeout" : "pi-host-relay-transport-failure", + outcome: + error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT + ? "pi-host-relay-timeout" + : "pi-host-relay-transport-failure", failure: reviewHostRelayFailureReport(error), reason: error.message, mutation_performed: false, mutation_outcome: "none", - next_action: error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT - ? reviewHostRelayTimeoutNextAction(error) - : error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED - ? REVIEW_HOST_RELAY_REFUSED_ACTION - : REVIEW_HOST_RELAY_RETRY_ACTION, + next_action: + error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT + ? reviewHostRelayTimeoutNextAction(error) + : error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED + ? REVIEW_HOST_RELAY_REFUSED_ACTION + : REVIEW_HOST_RELAY_RETRY_ACTION, }; } } @@ -4992,7 +6529,8 @@ async function executeProviderRoleVectorCapture( tool: "gentle_review_capture", status: "blocked", outcome: "provider-role-capture-unsupported", - reason: "The provider issued a self-contained role capture vector, but this runtime has no native provider-role capture surface.", + reason: + "The provider issued a self-contained role capture vector, but this runtime has no native provider-role capture surface.", mutation_performed: false, mutation_outcome: "none", }; @@ -5004,7 +6542,8 @@ async function executeProviderRoleVectorCapture( cwd, ...(signal === undefined ? {} : { signal }), }); - if ("operation" in artifact) return mapAndClearLastEventClosure(artifact, binding, selections, cwd); + if ("operation" in artifact) + return mapAndClearLastEventClosure(artifact, binding, selections, cwd); return { tool: "gentle_review_capture", status: "captured", @@ -5019,10 +6558,19 @@ async function executeProviderRoleVectorCapture( }, }; } catch (error) { - const outcome = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route); + const outcome = await reconcileUnknownReviewCaptureFailure( + error, + nativeReviewCli, + cwd, + binding, + selections, + route, + ); return { ...outcome, - ...(outcome.status === "reconciled" ? {} : { retry_discipline: REVIEW_PROVIDER_ROLE_RETRY_ACTION }), + ...(outcome.status === "reconciled" + ? {} + : { retry_discipline: REVIEW_PROVIDER_ROLE_RETRY_ACTION }), }; } } @@ -5046,7 +6594,8 @@ async function executeReviewHostRelayExecuteCapture( tool: "gentle_review_capture", status: "blocked", outcome: "execute-capture-unsupported", - reason: "The provider issued a self-contained execute capture vector, but this runtime has no native execute capture surface.", + reason: + "The provider issued a self-contained execute capture vector, but this runtime has no native execute capture surface.", mutation_performed: false, mutation_outcome: "none", }; @@ -5057,7 +6606,8 @@ async function executeReviewHostRelayExecuteCapture( cwd, ...(signal === undefined ? {} : { signal }), }); - if ("operation" in result) return mapAndClearLastEventClosure(result, binding, selections, cwd); + if ("operation" in result) + return mapAndClearLastEventClosure(result, binding, selections, cwd); return { tool: "gentle_review_capture", status: "captured", @@ -5068,7 +6618,14 @@ async function executeReviewHostRelayExecuteCapture( capture_operation: "review.capture-result", }; } catch (error) { - return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, binding, selections, route); + return await reconcileUnknownReviewCaptureFailure( + error, + nativeReviewCli, + cwd, + binding, + selections, + route, + ); } } @@ -5076,10 +6633,14 @@ async function executeReviewHostRelayExecuteCapture( // pending `review.capture-result` collect input, in provider order. function pendingReviewerLenses(status: ReviewStatusV3): readonly string[] { if (status.nextTransition?.kind !== "collect") return []; - return [...new Set((status.nextTransition.collect?.inputs ?? []) - .filter((input) => input.captureOperation === "review.capture-result") - .map((input) => input.artifactSubject?.lens) - .filter((lens): lens is NonNullable => lens !== undefined))]; + return [ + ...new Set( + (status.nextTransition.collect?.inputs ?? []) + .filter((input) => input.captureOperation === "review.capture-result") + .map((input) => input.artifactSubject?.lens) + .filter((lens): lens is NonNullable => lens !== undefined), + ), + ]; } // Live defect (2026-08-16, Engram #12461): a successor lineage created by @@ -5120,19 +6681,32 @@ const REVIEW_TRANSPORT_REFUSAL_CODES = new Set([ "unsupported_agent", "unknown_flag", ]); -interface ReviewTransportRefusal { supported: false; code: string; message: string; } +interface ReviewTransportRefusal { + supported: false; + code: string; + message: string; +} interface NegotiatedHostTransportStatus { status?: ReviewStatusV3; transport?: ReviewTransportRefusal; } -const reviewTransportRefusalByProvider = new WeakMap(); +const reviewTransportRefusalByProvider = new WeakMap< + object, + ReviewTransportRefusal +>(); -function clearReviewTransportProbeForTesting(nativeReviewCli: NativeReviewCli | null): void { - if (nativeReviewCli !== null) reviewTransportRefusalByProvider.delete(nativeReviewCli as unknown as object); +function clearReviewTransportProbeForTesting( + nativeReviewCli: NativeReviewCli | null, +): void { + if (nativeReviewCli !== null) + reviewTransportRefusalByProvider.delete(nativeReviewCli as unknown as object); } function hostTransportUnavailable( - operation: ReviewControllerOperation | "gentle_review_capture" | "gentle_review_capture_group", + operation: + | ReviewControllerOperation + | "gentle_review_capture" + | "gentle_review_capture_group", transport: ReviewTransportRefusal, ): Record { // #535: a provider-printed raw `gentle-ai review ...` continuation is a dead @@ -5141,7 +6715,9 @@ function hostTransportUnavailable( // refusal therefore names the continuation that runs in this surface (the // gentle_review / gentle_review_capture wrapper tools) while the provider's // own diagnostic stays intact in relay_transport as evidence. - const isCapture = operation === "gentle_review_capture" || operation === "gentle_review_capture_group"; + const isCapture = + operation === "gentle_review_capture" || + operation === "gentle_review_capture_group"; return { ...(isCapture ? { tool: operation } : { operation }), status: "blocked", @@ -5155,7 +6731,7 @@ function hostTransportUnavailable( operation: REVIEW_CONTROLLER_OPERATION.INSPECT, ...(isCapture ? { then: operation } : {}), }, - next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${isCapture ? operation === "gentle_review_capture_group" ? " and resubmit gentle_review_capture_group with the complete exact ordered collectBindings that fresh STATUS returns" : " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns" : " and follow the transition it returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`, + next_action: `Install a native gentle-ai provider that supports \`review status --agent pi\`, then re-enter negotiated STATUS with gentle_review {"operation":"inspect"}${isCapture ? (operation === "gentle_review_capture_group" ? " and resubmit gentle_review_capture_group with the complete exact ordered collectBindings that fresh STATUS returns" : " and resubmit gentle_review_capture with the exact one-slot collectBinding that fresh STATUS returns") : " and follow the transition it returns"}. A provider-printed raw CLI continuation does not run in this runtime, and Pi never falls back to an agent-less lifecycle route.`, }; } @@ -5174,15 +6750,31 @@ async function negotiatedStatusForHostTransport( const remembered = reviewTransportRefusalByProvider.get(provider); if (remembered !== undefined) return { transport: remembered }; try { - const status = await nativeReviewCli.targetStatus!({ ...request, agent: REVIEW_HOST_AGENT }); - syncRetainedNativeStatusSelections(retainedSelections, canonicalRetentionRoot, status, request.baseRef); + const status = await nativeReviewCli.targetStatus!({ + ...request, + agent: REVIEW_HOST_AGENT, + }); + syncRetainedNativeStatusSelections( + retainedSelections, + canonicalRetentionRoot, + status, + request.baseRef, + ); return { status }; } catch (error) { - const code = error instanceof NativeReviewIntegrationError ? error.failureEnvelope.code : undefined; + const code = + error instanceof NativeReviewIntegrationError + ? error.failureEnvelope.code + : undefined; // Only the closed transport-refusal set is typed unavailable; every // other failure remains an error for the caller's normal error path. - if (code === undefined || !REVIEW_TRANSPORT_REFUSAL_CODES.has(code)) throw error; - const transport: ReviewTransportRefusal = { supported: false, code, message: error.message }; + if (code === undefined || !REVIEW_TRANSPORT_REFUSAL_CODES.has(code)) + throw error; + const transport: ReviewTransportRefusal = { + supported: false, + code, + message: error.message, + }; reviewTransportRefusalByProvider.set(provider, transport); return { transport }; } @@ -5200,19 +6792,35 @@ async function resolveNegotiatedReviewStatusForSession( ctx: ExtensionContext, sessionKey: PendingReviewConsentSessionKey, ): Promise { - if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return undefined; + if ( + nativeReviewCli?.reviewMode === undefined || + nativeReviewCli.targetStatus === undefined + ) + return undefined; if (ctx.hasUI !== true) return undefined; let modeEffective: "on" | "off"; try { - const mode = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS }); + const mode = await nativeReviewCli.reviewMode({ + cwd: ctx.cwd, + operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, + }); modeEffective = mode.status.effective; } catch { return undefined; } if (modeEffective === "off") return undefined; try { - const retainedSelections = ((key: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(key) ?? processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)(sessionKey); - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: ctx.cwd }, retainedSelections, ctx.cwd); + const retainedSelections = ((key: PendingReviewConsentSessionKey) => + processRetainedNativeStatusSelections.get(key) ?? + processRetainedNativeStatusSelections.set(key, new Map()).get(key)!)( + sessionKey, + ); + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { cwd: ctx.cwd }, + retainedSelections, + ctx.cwd, + ); return negotiated.status; } catch { return undefined; @@ -5228,10 +6836,24 @@ function renderAgentEndReviewPreflightMessage(targetIdentity: string): string { } function canonicalReviewCaptureBinding(value: unknown): string { - if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map((entry) => canonicalReviewCaptureBinding(entry)).join(",")}]`; - if (!isRecord(value)) throw new Error("Review capture collectBinding must encode a JSON object"); - return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalReviewCaptureBinding(value[key])}`).join(",")}}`; + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" || + typeof value === "number" + ) + return JSON.stringify(value); + if (Array.isArray(value)) + return `[${value.map((entry) => canonicalReviewCaptureBinding(entry)).join(",")}]`; + if (!isRecord(value)) + throw new Error("Review capture collectBinding must encode a JSON object"); + return `{${Object.keys(value) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonicalReviewCaptureBinding(value[key])}`, + ) + .join(",")}}`; } function parseCanonicalReviewCaptureBinding(input: string): string { @@ -5239,13 +6861,21 @@ function parseCanonicalReviewCaptureBinding(input: string): string { try { binding = JSON.parse(input); } catch (error) { - throw new Error(`Review capture collectBinding is not valid JSON: ${error instanceof Error ? error.message : String(error)}`); + throw new Error( + `Review capture collectBinding is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + ); } - if (!isRecord(binding)) throw new Error("Review capture collectBinding must encode exactly one collect input object"); + if (!isRecord(binding)) + throw new Error( + "Review capture collectBinding must encode exactly one collect input object", + ); return canonicalReviewCaptureBinding(binding); } -function exactCollectArgument(input: ReviewCollectInputV3, name: string): string | undefined { +function exactCollectArgument( + input: ReviewCollectInputV3, + name: string, +): string | undefined { const matches = input.arguments.filter((argument) => argument.name === name); return matches.length === 1 ? matches[0]!.value : undefined; } @@ -5254,28 +6884,59 @@ function exactCollectArgument(input: ReviewCollectInputV3, name: string): string // status version keeps it; matching one exact version left every workspace // with untracked files unable to start a review once gentle-ai answered v7 // (gentle-pi#610, gentle-ai#4187). -const INTENDED_UNTRACKED_STATUS_SCHEMA = /^gentle-ai\.review-integration\.status\/v(\d+)$/; +const INTENDED_UNTRACKED_STATUS_SCHEMA = + /^gentle-ai\.review-integration\.status\/v(\d+)$/; function statusCarriesIntendedUntrackedSelection(schema: unknown): boolean { - const match = typeof schema === "string" ? INTENDED_UNTRACKED_STATUS_SCHEMA.exec(schema) : null; + const match = + typeof schema === "string" + ? INTENDED_UNTRACKED_STATUS_SCHEMA.exec(schema) + : null; return match !== null && Number(match[1]) >= 6; } -function reviewIntendedUntrackedInput(status: ReviewStatusV3): ReviewCollectInputV3 | undefined { - if (!statusCarriesIntendedUntrackedSelection(status.raw.schema) || status.nextTransition?.kind !== "collect") return undefined; - const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => { - const value = input.submission?.values[0]; - return input.name === "intended_untracked_selection" && input.schema === "gentle-ai.review-intended-untracked-selection/v1" && input.captureOperation === "external.select_intended_untracked" && input.submission?.operationToken === "status" && input.submission.values.length === 1 && value?.slot === "intended_untracked_selection" && value.domain === "schema_bound_json"; - }); +function reviewIntendedUntrackedInput( + status: ReviewStatusV3, +): ReviewCollectInputV3 | undefined { + if ( + !statusCarriesIntendedUntrackedSelection(status.raw.schema) || + status.nextTransition?.kind !== "collect" + ) + return undefined; + const matches = (status.nextTransition.collect?.inputs ?? []).filter( + (input) => { + const value = input.submission?.values[0]; + return ( + input.name === "intended_untracked_selection" && + input.schema === "gentle-ai.review-intended-untracked-selection/v1" && + input.captureOperation === "external.select_intended_untracked" && + input.submission?.operationToken === "status" && + input.submission.values.length === 1 && + value?.slot === "intended_untracked_selection" && + value.domain === "schema_bound_json" + ); + }, + ); return matches.length === 1 ? matches[0] : undefined; } -interface PublicReviewCaptureBinding { collectBinding: string; } -function publicReviewCaptureBindings(status: ReviewStatusV3): readonly PublicReviewCaptureBinding[] { +interface PublicReviewCaptureBinding { + collectBinding: string; +} +function publicReviewCaptureBindings( + status: ReviewStatusV3, +): readonly PublicReviewCaptureBinding[] { if (status.nextTransition?.kind !== "collect") return []; - return (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation !== "external.select_intended_untracked").map((input) => ({ collectBinding: canonicalReviewCaptureBinding(input) })); + return (status.nextTransition.collect?.inputs ?? []) + .filter( + (input) => input.captureOperation !== "external.select_intended_untracked", + ) + .map((input) => ({ collectBinding: canonicalReviewCaptureBinding(input) })); } -function captureBindingRejected(reason: string, group = false): Record { +function captureBindingRejected( + reason: string, + group = false, +): Record { return { tool: group ? "gentle_review_capture_group" : "gentle_review_capture", status: "blocked", @@ -5305,16 +6966,24 @@ function selectExactReviewCapture( status.applicability !== "current_target" || statusLineageId !== lineageId ) { - return captureBindingRejected("current STATUS does not offer one non-empty matching lineage and target identity"); + return captureBindingRejected( + "current STATUS does not offer one non-empty matching lineage and target identity", + ); } if (status.nextTransition?.kind !== "collect") { - return captureBindingRejected("current STATUS does not offer a collect transition"); + return captureBindingRejected( + "current STATUS does not offer a collect transition", + ); } - const matches = (status.nextTransition.collect?.inputs ?? []).filter((input) => canonicalReviewCaptureBinding(input) === canonicalBinding); + const matches = (status.nextTransition.collect?.inputs ?? []).filter( + (input) => canonicalReviewCaptureBinding(input) === canonicalBinding, + ); if (matches.length !== 1) { - return captureBindingRejected(matches.length === 0 - ? "collectBinding is missing or stale for current STATUS" - : "collectBinding matches more than one current STATUS input"); + return captureBindingRejected( + matches.length === 0 + ? "collectBinding is missing or stale for current STATUS" + : "collectBinding matches more than one current STATUS input", + ); } const input = matches[0]!; const inputLineageId = exactCollectArgument(input, "lineage"); @@ -5322,14 +6991,17 @@ function selectExactReviewCapture( // Go's targeted-validator vector binds its capture target to the correction // target from the provider-owned validation request, rather than STATUS's // current candidate identity. All other captures remain bound to STATUS. - const expectedInputTargetIdentity = input.validationRequest?.correctionTargetIdentity ?? statusTargetIdentity; + const expectedInputTargetIdentity = + input.validationRequest?.correctionTargetIdentity ?? statusTargetIdentity; if ( !isCanonicalProcessString(inputLineageId) || !isCanonicalProcessString(inputTargetIdentity) || inputLineageId !== lineageId || inputTargetIdentity !== expectedInputTargetIdentity ) { - return captureBindingRejected("collectBinding does not carry one non-empty matching provider lineage and target token"); + return captureBindingRejected( + "collectBinding does not carry one non-empty matching provider lineage and target token", + ); } return { input, @@ -5337,21 +7009,45 @@ function selectExactReviewCapture( }; } -function isSelectedReviewCapture(value: SelectedReviewCapture | Record): value is SelectedReviewCapture { +function isSelectedReviewCapture( + value: SelectedReviewCapture | Record, +): value is SelectedReviewCapture { return "input" in value && "binding" in value; } -function captureGroupRejected(reason: string): Record { return captureBindingRejected(reason, true); } +function captureGroupRejected(reason: string): Record { + return captureBindingRejected(reason, true); +} -function hasExactReviewCaptureSuffix(status: ReviewStatusV3, expected: readonly string[]): boolean { - const current = status.nextTransition?.kind === "collect" - ? (status.nextTransition.collect?.inputs ?? []).filter((input) => input.captureOperation === "review.capture-result").map(canonicalReviewCaptureBinding) - : []; - return current.length === expected.length && current.every((binding, index) => binding === expected[index]); +function hasExactReviewCaptureSuffix( + status: ReviewStatusV3, + expected: readonly string[], +): boolean { + const current = + status.nextTransition?.kind === "collect" + ? (status.nextTransition.collect?.inputs ?? []) + .filter((input) => input.captureOperation === "review.capture-result") + .map(canonicalReviewCaptureBinding) + : []; + return ( + current.length === expected.length && + current.every((binding, index) => binding === expected[index]) + ); } -function captureGroupAuthorityDrift(status: ReviewStatusV3): Record { - return { ...captureGroupRejected("authoritative STATUS does not offer exactly the unsubmitted reviewer suffix"), outcome: "capture-group-authority-drift", reconciliation: status.raw, authority_applicability: status.applicability, provider_action: status.action, next_transition: status.nextTransition }; +function captureGroupAuthorityDrift( + status: ReviewStatusV3, +): Record { + return { + ...captureGroupRejected( + "authoritative STATUS does not offer exactly the unsubmitted reviewer suffix", + ), + outcome: "capture-group-authority-drift", + reconciliation: status.raw, + authority_applicability: status.applicability, + provider_action: status.action, + next_transition: status.nextTransition, + }; } interface SelectedReviewCaptureGroup { @@ -5364,45 +7060,114 @@ function selectExactReviewCaptureGroup( lineageId: string, canonicalBindings: readonly string[], ): SelectedReviewCaptureGroup | Record { - const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : []; + const inputs = + status.nextTransition?.kind === "collect" + ? (status.nextTransition.collect?.inputs ?? []) + : []; const slots = reviewHostRelaySlots(inputs); if (inputs.length === 0 || slots.length !== inputs.length) { - return captureGroupRejected("current STATUS does not offer an exclusively materialize reviewer capture group"); + return captureGroupRejected( + "current STATUS does not offer an exclusively materialize reviewer capture group", + ); } - const currentBindings = inputs.map((input) => canonicalReviewCaptureBinding(input)); - if (new Set(currentBindings).size !== currentBindings.length || canonicalBindings.length !== currentBindings.length || canonicalBindings.some((binding, index) => binding !== currentBindings[index])) { - return captureGroupRejected("collectBindings must be the complete distinct current reviewer group in exact provider order"); + const currentBindings = inputs.map((input) => + canonicalReviewCaptureBinding(input), + ); + if ( + new Set(currentBindings).size !== currentBindings.length || + canonicalBindings.length !== currentBindings.length || + canonicalBindings.some((binding, index) => binding !== currentBindings[index]) + ) { + return captureGroupRejected( + "collectBindings must be the complete distinct current reviewer group in exact provider order", + ); } const first = selectExactReviewCapture(status, lineageId, currentBindings[0]!); - if (!isSelectedReviewCapture(first)) return captureGroupRejected(String(first.reason ?? "current STATUS rejected a reviewer binding")); + if (!isSelectedReviewCapture(first)) + return captureGroupRejected( + String(first.reason ?? "current STATUS rejected a reviewer binding"), + ); const expectedRevision = exactCollectArgument(inputs[0]!, "expected-revision"); - const repositoryContext = exactCollectArgument(inputs[0]!, "repository-context"); + const repositoryContext = exactCollectArgument( + inputs[0]!, + "repository-context", + ); const statusTargetIdentity = status.targetIdentity; const currentRepositoryContext = status.repositoryContext; - if (!isCanonicalProcessString(expectedRevision) || !isCanonicalProcessString(repositoryContext) || currentRepositoryContext === undefined || expectedRevision !== currentRepositoryContext.revision) { - return captureGroupRejected("current STATUS does not bind one matching expected revision and repository context for the reviewer group"); + if ( + !isCanonicalProcessString(expectedRevision) || + !isCanonicalProcessString(repositoryContext) || + currentRepositoryContext === undefined || + expectedRevision !== currentRepositoryContext.revision + ) { + return captureGroupRejected( + "current STATUS does not bind one matching expected revision and repository context for the reviewer group", + ); } - if (currentRepositoryContext.handle !== repositoryContext || currentRepositoryContext.targetIdentity !== statusTargetIdentity) { - return captureGroupRejected("current STATUS repository context does not match the reviewer group binding"); + if ( + currentRepositoryContext.handle !== repositoryContext || + currentRepositoryContext.targetIdentity !== statusTargetIdentity + ) { + return captureGroupRejected( + "current STATUS repository context does not match the reviewer group binding", + ); } - const lenses = new Set(), orders = new Set(), subjectHashes = new Set(); + const lenses = new Set(), + orders = new Set(), + subjectHashes = new Set(); for (let index = 0; index < inputs.length; index += 1) { - const input = inputs[index]!, slot = slots[index]!, subject = input.artifactSubject; - const slotLineage = exactCollectArgument(input, "lineage"), target = exactCollectArgument(input, "target"); - const revision = exactCollectArgument(input, "expected-revision"), context = exactCollectArgument(input, "repository-context"); - const subjectHash = exactCollectArgument(input, "subject-hash"), order = slot.order, lens = slot.lens; + const input = inputs[index]!, + slot = slots[index]!, + subject = input.artifactSubject; + const slotLineage = exactCollectArgument(input, "lineage"), + target = exactCollectArgument(input, "target"); + const revision = exactCollectArgument(input, "expected-revision"), + context = exactCollectArgument(input, "repository-context"); + const subjectHash = exactCollectArgument(input, "subject-hash"), + order = slot.order, + lens = slot.lens; if ( - subject === undefined || slot.submission === undefined || slotLineage !== lineageId || target !== statusTargetIdentity - || revision !== expectedRevision || context !== repositoryContext || subjectHash !== subject.subjectHash || order === undefined || lens === undefined - || subject.lineageId !== lineageId || subject.authorityRevision !== expectedRevision || subject.targetIdentity !== statusTargetIdentity - || lens !== subject.lens || String(subject.selectedOrder) !== order - ) return captureGroupRejected("current STATUS carries an incomplete or mismatched materialize reviewer binding"); - try { resolveReviewHostRelaySubmission(slot.submission); } catch { return captureGroupRejected("current STATUS carries an invalid provider reviewer submission descriptor"); } + subject === undefined || + slot.submission === undefined || + slotLineage !== lineageId || + target !== statusTargetIdentity || + revision !== expectedRevision || + context !== repositoryContext || + subjectHash !== subject.subjectHash || + order === undefined || + lens === undefined || + subject.lineageId !== lineageId || + subject.authorityRevision !== expectedRevision || + subject.targetIdentity !== statusTargetIdentity || + lens !== subject.lens || + String(subject.selectedOrder) !== order + ) + return captureGroupRejected( + "current STATUS carries an incomplete or mismatched materialize reviewer binding", + ); + try { + resolveReviewHostRelaySubmission(slot.submission); + } catch { + return captureGroupRejected( + "current STATUS carries an invalid provider reviewer submission descriptor", + ); + } const value = slot.submission.values[0]; - if (slot.submission.operationToken !== "capture-result" || value?.slot !== "reviewer_result" || value.domain !== "artifact_path_or_stdin" || lenses.has(lens) || orders.has(order) || subjectHashes.has(subject.subjectHash)) { - return captureGroupRejected("current STATUS carries duplicate or invalid reviewer slot identities"); + if ( + slot.submission.operationToken !== "capture-result" || + value?.slot !== "reviewer_result" || + value.domain !== "artifact_path_or_stdin" || + lenses.has(lens) || + orders.has(order) || + subjectHashes.has(subject.subjectHash) + ) { + return captureGroupRejected( + "current STATUS carries duplicate or invalid reviewer slot identities", + ); } - lenses.add(lens); orders.add(order); subjectHashes.add(subject.subjectHash); + lenses.add(lens); + orders.add(order); + subjectHashes.add(subject.subjectHash); } return { slots, binding: first.binding }; } @@ -5416,11 +7181,21 @@ function reviewHostRelayGroupFailure( return { tool: "gentle_review_capture_group", status: "blocked", - outcome: error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE ? "pi-host-relay-unavailable" : error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED ? "pi-host-relay-handshake-refused" : error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT ? "pi-host-relay-timeout" : "pi-host-relay-transport-failure", + outcome: + error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE + ? "pi-host-relay-unavailable" + : error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED + ? "pi-host-relay-handshake-refused" + : error.kind === REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT + ? "pi-host-relay-timeout" + : "pi-host-relay-transport-failure", reason: error.message, failure: reviewHostRelayFailureReport(error), ...reviewHostRelayGroupProgress(slots, prepared, submitted), - next_action: error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED ? REVIEW_HOST_RELAY_REFUSED_ACTION : REVIEW_HOST_RELAY_RETRY_ACTION, + next_action: + error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED + ? REVIEW_HOST_RELAY_REFUSED_ACTION + : REVIEW_HOST_RELAY_RETRY_ACTION, }; } @@ -5430,7 +7205,10 @@ async function executeReviewCaptureOperation( nativeReviewCli: NativeReviewCli | null, signal?: AbortSignal, candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(), - retainedUntrackedSelections: Map = new Map(), + retainedUntrackedSelections: Map< + string, + RetainedNativeStatusSelection + > = new Map(), requireRegisteredRoute = false, ): Promise> { const parameters = parseReviewCaptureParameters(parametersValue); @@ -5443,40 +7221,85 @@ async function executeReviewCaptureOperation( mutation_outcome: "none", }; } - const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.collectBinding); - const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId); - const route = readRetainedNativeCaptureRoute(retainedUntrackedSelections, canonicalBinding); - if (requireRegisteredRoute && (route === undefined || route.workspaceRoot !== cwd || route.lineageId !== parameters.lineageId)) { - return captureBindingRejected("collectBinding is unknown, expired, or belongs to a different session route"); + const canonicalBinding = parseCanonicalReviewCaptureBinding( + parameters.collectBinding, + ); + const cwd = resolveReviewControllerWorkspaceRoot( + parameters.workspaceRoot, + sessionCwd, + candidateViews, + parameters.lineageId, + ); + const route = readRetainedNativeCaptureRoute( + retainedUntrackedSelections, + canonicalBinding, + ); + if ( + requireRegisteredRoute && + (route === undefined || + route.workspaceRoot !== cwd || + route.lineageId !== parameters.lineageId) + ) { + return captureBindingRejected( + "collectBinding is unknown, expired, or belongs to a different session route", + ); } let status: ReviewStatusV3; try { - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { + cwd, + lineageId: parameters.lineageId, + ...(route?.baseRef === undefined + ? {} + : { baseRef: route.baseRef, committedOnly: true }), + ...readRetainedNativeUntrackedSelection( + retainedUntrackedSelections, + cwd, + parameters.lineageId, + ), + ...(signal === undefined ? {} : { signal }), + }, + retainedUntrackedSelections, cwd, - lineageId: parameters.lineageId, - ...(route?.baseRef === undefined ? {} : { baseRef: route.baseRef, committedOnly: true }), - ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId), - ...(signal === undefined ? {} : { signal }), - }, retainedUntrackedSelections, cwd); - if (negotiated.transport !== undefined) return hostTransportUnavailable("gentle_review_capture", negotiated.transport); + ); + if (negotiated.transport !== undefined) + return hostTransportUnavailable( + "gentle_review_capture", + negotiated.transport, + ); status = negotiated.status!; } catch (error) { return nativeOperationFailure("gentle_review_capture", error); } - const selected = selectExactReviewCapture(status, parameters.lineageId, canonicalBinding); + const selected = selectExactReviewCapture( + status, + parameters.lineageId, + canonicalBinding, + ); if (!isSelectedReviewCapture(selected)) return selected; // During correction the flow carries both the original authority target // identity and a distinct provider-issued correction target identity // (gentle-pi#535 row 15). Echo the correction one on the capture result so // the caller never reconstructs which is which from the opaque binding. - const correctionTargetIdentity = selected.input.validationRequest?.correctionTargetIdentity ?? selected.input.artifactSubject?.correctionTargetIdentity; - const withCorrectionTarget = (result: Record): Record => - correctionTargetIdentity === undefined ? result : { ...result, correction_target_identity: correctionTargetIdentity }; + const correctionTargetIdentity = + selected.input.validationRequest?.correctionTargetIdentity ?? + selected.input.artifactSubject?.correctionTargetIdentity; + const withCorrectionTarget = ( + result: Record, + ): Record => + correctionTargetIdentity === undefined + ? result + : { ...result, correction_target_identity: correctionTargetIdentity }; const hostRelaySlots = reviewHostRelaySlots([selected.input]); if (hostRelaySlots.length === 1) { - if (parameters.correctionLines !== undefined) return captureBindingRejected("correctionLines is valid only for a correction-plan capture"); + if (parameters.correctionLines !== undefined) + return captureBindingRejected( + "correctionLines is valid only for a correction-plan capture", + ); if (parameters.reviewerRunAcknowledged !== true) { return { tool: "gentle_review_capture", @@ -5485,13 +7308,24 @@ async function executeReviewCaptureOperation( cost_forecast: { transport: "pi_host_relay", model_runs: 1, - lenses: hostRelaySlots[0]!.lens === undefined ? [] : [hostRelaySlots[0]!.lens], + lenses: + hostRelaySlots[0]!.lens === undefined ? [] : [hostRelaySlots[0]!.lens], }, mutation_performed: false, mutation_outcome: "none", }; } - return withCorrectionTarget(await executeReviewHostRelayCapture(hostRelaySlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal)); + return withCorrectionTarget( + await executeReviewHostRelayCapture( + hostRelaySlots[0]!, + nativeReviewCli, + cwd, + selected.binding, + retainedUntrackedSelections, + route, + signal, + ), + ); } // gentle-pi execute-native compat: self-contained execute vector for @@ -5500,17 +7334,39 @@ async function executeReviewCaptureOperation( // verdict. This mirrors the provider role vector pattern. const executeSlots = reviewHostRelayExecuteSlots([selected.input]); if (executeSlots.length === 1) { - if (parameters.reviewerRunAcknowledged !== undefined || parameters.correctionLines !== undefined) { - return captureBindingRejected("reviewerRunAcknowledged and correctionLines are not valid for an execute capture"); + if ( + parameters.reviewerRunAcknowledged !== undefined || + parameters.correctionLines !== undefined + ) { + return captureBindingRejected( + "reviewerRunAcknowledged and correctionLines are not valid for an execute capture", + ); } - return withCorrectionTarget(await executeReviewHostRelayExecuteCapture(executeSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal)); + return withCorrectionTarget( + await executeReviewHostRelayExecuteCapture( + executeSlots[0]!, + nativeReviewCli, + cwd, + selected.binding, + retainedUntrackedSelections, + route, + signal, + ), + ); } if (selected.input.captureOperation === "review.capture-correction-plan") { - if (parameters.reviewerRunAcknowledged !== undefined) return captureBindingRejected("reviewerRunAcknowledged is valid only for a materialize reviewer capture"); + if (parameters.reviewerRunAcknowledged !== undefined) + return captureBindingRejected( + "reviewerRunAcknowledged is valid only for a materialize reviewer capture", + ); const submission = selected.input.submission; - const value = submission?.values.length === 1 ? submission.values[0] : undefined; - if (submission === undefined || value?.slot !== "correction_lines") return captureBindingRejected("provider correction-plan capture omitted its exact correction-lines binding"); + const value = + submission?.values.length === 1 ? submission.values[0] : undefined; + if (submission === undefined || value?.slot !== "correction_lines") + return captureBindingRejected( + "provider correction-plan capture omitted its exact correction-lines binding", + ); if (parameters.correctionLines === undefined) { return { tool: "gentle_review_capture", @@ -5522,10 +7378,19 @@ async function executeReviewCaptureOperation( mutation_outcome: "none", }; } - if ((value.minimum !== undefined && parameters.correctionLines < value.minimum) || (value.maximum !== undefined && parameters.correctionLines > value.maximum)) { - return captureBindingRejected("correctionLines is outside the exact provider-issued correction-plan bounds"); + if ( + (value.minimum !== undefined && + parameters.correctionLines < value.minimum) || + (value.maximum !== undefined && parameters.correctionLines > value.maximum) + ) { + return captureBindingRejected( + "correctionLines is outside the exact provider-issued correction-plan bounds", + ); } - if (nativeReviewCli.captureCorrectionPlan === undefined) return captureBindingRejected("native correction-plan capture is unavailable"); + if (nativeReviewCli.captureCorrectionPlan === undefined) + return captureBindingRejected( + "native correction-plan capture is unavailable", + ); try { const closure = await nativeReviewCli.captureCorrectionPlan({ argumentTokens: submission.argumentTokens, @@ -5533,23 +7398,58 @@ async function executeReviewCaptureOperation( cwd, ...(signal === undefined ? {} : { signal }), }); - return withCorrectionTarget(mapAndClearLastEventClosure(closure, selected.binding, retainedUntrackedSelections, cwd)); + return withCorrectionTarget( + mapAndClearLastEventClosure( + closure, + selected.binding, + retainedUntrackedSelections, + cwd, + ), + ); } catch (error) { - return await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route); + return await reconcileUnknownReviewCaptureFailure( + error, + nativeReviewCli, + cwd, + selected.binding, + retainedUntrackedSelections, + route, + ); } } const providerRoleSlots = reviewProviderRoleVectorSlots([selected.input]); if (providerRoleSlots.length === 1) { - if (parameters.reviewerRunAcknowledged !== undefined || parameters.correctionLines !== undefined) { - return captureBindingRejected("reviewerRunAcknowledged and correctionLines are not valid for a provider role capture"); + if ( + parameters.reviewerRunAcknowledged !== undefined || + parameters.correctionLines !== undefined + ) { + return captureBindingRejected( + "reviewerRunAcknowledged and correctionLines are not valid for a provider role capture", + ); } - return withCorrectionTarget(await executeProviderRoleVectorCapture(providerRoleSlots[0]!, nativeReviewCli, cwd, selected.binding, retainedUntrackedSelections, route, signal)); + return withCorrectionTarget( + await executeProviderRoleVectorCapture( + providerRoleSlots[0]!, + nativeReviewCli, + cwd, + selected.binding, + retainedUntrackedSelections, + route, + signal, + ), + ); } - return captureBindingRejected(`unsupported provider capture operation: ${selected.input.captureOperation}`); + return captureBindingRejected( + `unsupported provider capture operation: ${selected.input.captureOperation}`, + ); } -function reviewHostRelayGroupDiagnostics(slots: readonly ReviewHostRelaySlot[], prepared: readonly ReviewHostRelayPreparedResult[], count: number): readonly Record[] { +function reviewHostRelayGroupDiagnostics( + slots: readonly ReviewHostRelaySlot[], + prepared: readonly ReviewHostRelayPreparedResult[], + count: number, +): readonly Record[] { return slots.slice(0, count).map((slot, index) => ({ ...(slot.lens === undefined ? {} : { lens: slot.lens }), ...(slot.order === undefined ? {} : { order: slot.order }), @@ -5565,12 +7465,26 @@ function reviewHostRelayGroupProgress( submitted: number, uncertain = false, ): Record { - const outcome = submitted === 0 ? uncertain ? "unknown" : "none" : uncertain ? "partial_unknown" : submitted === slots.length ? "completed" : "partial"; + const outcome = + submitted === 0 + ? uncertain + ? "unknown" + : "none" + : uncertain + ? "partial_unknown" + : submitted === slots.length + ? "completed" + : "partial"; return { prepared_reviewers: prepared.length, submitted_reviewers: submitted, - host_relay: { transport: "pi_host_relay", reviewers: reviewHostRelayGroupDiagnostics(slots, prepared, submitted) }, - ...(submitted === 0 && uncertain ? { mutation_outcome: outcome } : { mutation_performed: submitted > 0, mutation_outcome: outcome }), + host_relay: { + transport: "pi_host_relay", + reviewers: reviewHostRelayGroupDiagnostics(slots, prepared, submitted), + }, + ...(submitted === 0 && uncertain + ? { mutation_outcome: outcome } + : { mutation_performed: submitted > 0, mutation_outcome: outcome }), }; } @@ -5580,85 +7494,235 @@ async function executeReviewCaptureGroupOperation( nativeReviewCli: NativeReviewCli | null, signal?: AbortSignal, candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(), - retainedUntrackedSelections: Map = new Map(), + retainedUntrackedSelections: Map< + string, + RetainedNativeStatusSelection + > = new Map(), requireRegisteredRoute = false, ): Promise> { const parameters = parseReviewCaptureGroupParameters(parametersValue); - if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) return { ...captureGroupRejected("native target STATUS is unavailable"), outcome: "native-status-unsupported" }; - const canonicalBindings = parameters.collectBindings.map((binding) => parseCanonicalReviewCaptureBinding(binding)); - const cwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId); - const routes = canonicalBindings.map((binding) => readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding)); + if (nativeReviewCli === null || nativeReviewCli.targetStatus === undefined) + return { + ...captureGroupRejected("native target STATUS is unavailable"), + outcome: "native-status-unsupported", + }; + const canonicalBindings = parameters.collectBindings.map((binding) => + parseCanonicalReviewCaptureBinding(binding), + ); + const cwd = resolveReviewControllerWorkspaceRoot( + parameters.workspaceRoot, + sessionCwd, + candidateViews, + parameters.lineageId, + ); + const routes = canonicalBindings.map((binding) => + readRetainedNativeCaptureRoute(retainedUntrackedSelections, binding), + ); const route = routes[0]; - if (requireRegisteredRoute && (route === undefined || routes.some((candidate) => candidate === undefined || candidate.workspaceRoot !== cwd || candidate.lineageId !== parameters.lineageId || candidate.baseRef !== route.baseRef))) { - return captureGroupRejected("collectBindings are unknown, expired, or belong to different session routes"); - } - const freshStatus = () => negotiatedStatusForHostTransport(nativeReviewCli, { - cwd, lineageId: parameters.lineageId, - ...(route?.baseRef === undefined ? {} : { baseRef: route.baseRef, committedOnly: true }), - ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, cwd, parameters.lineageId), - ...(signal === undefined ? {} : { signal }), - }, retainedUntrackedSelections, cwd); + if ( + requireRegisteredRoute && + (route === undefined || + routes.some( + (candidate) => + candidate === undefined || + candidate.workspaceRoot !== cwd || + candidate.lineageId !== parameters.lineageId || + candidate.baseRef !== route.baseRef, + )) + ) { + return captureGroupRejected( + "collectBindings are unknown, expired, or belong to different session routes", + ); + } + const freshStatus = () => + negotiatedStatusForHostTransport( + nativeReviewCli, + { + cwd, + lineageId: parameters.lineageId, + ...(route?.baseRef === undefined + ? {} + : { baseRef: route.baseRef, committedOnly: true }), + ...readRetainedNativeUntrackedSelection( + retainedUntrackedSelections, + cwd, + parameters.lineageId, + ), + ...(signal === undefined ? {} : { signal }), + }, + retainedUntrackedSelections, + cwd, + ); let status: ReviewStatusV3; try { const negotiated = await freshStatus(); - if (negotiated.transport !== undefined) return hostTransportUnavailable("gentle_review_capture_group", negotiated.transport); + if (negotiated.transport !== undefined) + return hostTransportUnavailable( + "gentle_review_capture_group", + negotiated.transport, + ); status = negotiated.status!; } catch (error) { - return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed" }; + return { + ...captureGroupRejected( + error instanceof Error ? error.message : String(error), + ), + outcome: "native-status-failed", + }; } - const group = selectExactReviewCaptureGroup(status, parameters.lineageId, canonicalBindings); + const group = selectExactReviewCaptureGroup( + status, + parameters.lineageId, + canonicalBindings, + ); if (!("slots" in group && "binding" in group)) return group; if (parameters.reviewerRunAcknowledged !== true) { return { tool: "gentle_review_capture_group", status: "blocked", outcome: "reviewer-model-run-forecast", - cost_forecast: { transport: "pi_host_relay", model_runs: group.slots.length, lenses: group.slots.map((slot) => slot.lens).filter((lens): lens is string => lens !== undefined) }, + cost_forecast: { + transport: "pi_host_relay", + model_runs: group.slots.length, + lenses: group.slots + .map((slot) => slot.lens) + .filter((lens): lens is string => lens !== undefined), + }, mutation_performed: false, mutation_outcome: "none", }; } - const requests: readonly ReviewHostRelayRequest[] = group.slots.map((slot) => ({ - captureArgumentTokens: slot.captureArgumentTokens, - targetCwd: cwd, - submission: slot.submission!, - ...(signal === undefined ? {} : { signal }), - })); - let prepared: readonly ReviewHostRelayPreparedResult[]; - try { - prepared = await activeReviewHostRelayReviewerGroupRunner(requests); - if (prepared.length !== requests.length) throw new Error("Pi host relay reviewer group returned a different number of prepared results"); + const requests: readonly ReviewHostRelayRequest[] = group.slots.map( + (slot) => ({ + captureArgumentTokens: slot.captureArgumentTokens, + targetCwd: cwd, + submission: slot.submission!, + ...(signal === undefined ? {} : { signal }), + }), + ); + let prepared: readonly ReviewHostRelayPreparedResult[]; + try { + prepared = await activeReviewHostRelayReviewerGroupRunner(requests); + if (prepared.length !== requests.length) + throw new Error( + "Pi host relay reviewer group returned a different number of prepared results", + ); } catch (error) { return error instanceof ReviewHostRelayError ? reviewHostRelayGroupFailure(error, group.slots, [], 0) - : { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "pi-host-relay-reviewer-group-failed" }; + : { + ...captureGroupRejected( + error instanceof Error ? error.message : String(error), + ), + outcome: "pi-host-relay-reviewer-group-failed", + }; } for (let index = 0; index < prepared.length; index += 1) { let current: SelectedReviewCapture | Record; try { const negotiated = await freshStatus(); - if (negotiated.transport !== undefined) return { ...hostTransportUnavailable("gentle_review_capture_group", negotiated.transport), ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; - if (!hasExactReviewCaptureSuffix(negotiated.status!, canonicalBindings.slice(index))) return { ...captureGroupAuthorityDrift(negotiated.status!), ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; - current = selectExactReviewCapture(negotiated.status!, parameters.lineageId, canonicalBindings[index]!); + if (negotiated.transport !== undefined) + return { + ...hostTransportUnavailable( + "gentle_review_capture_group", + negotiated.transport, + ), + ...reviewHostRelayGroupProgress(group.slots, prepared, index), + }; + if ( + !hasExactReviewCaptureSuffix( + negotiated.status!, + canonicalBindings.slice(index), + ) + ) + return { + ...captureGroupAuthorityDrift(negotiated.status!), + ...reviewHostRelayGroupProgress(group.slots, prepared, index), + }; + current = selectExactReviewCapture( + negotiated.status!, + parameters.lineageId, + canonicalBindings[index]!, + ); } catch (error) { - return { ...captureGroupRejected(error instanceof Error ? error.message : String(error)), outcome: "native-status-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; + return { + ...captureGroupRejected( + error instanceof Error ? error.message : String(error), + ), + outcome: "native-status-failed", + ...reviewHostRelayGroupProgress(group.slots, prepared, index), + }; } - if (!isSelectedReviewCapture(current)) return { ...captureGroupRejected(String(current.reason ?? "current STATUS rejected a reviewer binding")), ...reviewHostRelayGroupProgress(group.slots, prepared, index) }; + if (!isSelectedReviewCapture(current)) + return { + ...captureGroupRejected( + String(current.reason ?? "current STATUS rejected a reviewer binding"), + ), + ...reviewHostRelayGroupProgress(group.slots, prepared, index), + }; try { const result = await activeReviewHostRelaySubmissionRunner(prepared[index]!); const closure = decodeRelayLastEventClosure(result.submission); if (closure !== undefined) { - const closed = mapAndClearLastEventClosure(closure, current.binding, retainedUntrackedSelections, cwd); - return { ...closed, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1) }; + const closed = mapAndClearLastEventClosure( + closure, + current.binding, + retainedUntrackedSelections, + cwd, + ); + return { + ...closed, + tool: "gentle_review_capture_group", + ...reviewHostRelayGroupProgress(group.slots, prepared, index + 1), + }; } } catch (error) { - if (error instanceof ReviewHostRelayError && error.mutationOutcome !== "unknown") return reviewHostRelayGroupFailure(error, group.slots, prepared, index); - const reconciled = await reconcileUnknownReviewCaptureFailure(error, nativeReviewCli, cwd, current.binding, retainedUntrackedSelections, route, undefined, REVIEW_HOST_AGENT); - return { ...reconciled, tool: "gentle_review_capture_group", ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), ...(error instanceof ReviewHostRelayError ? { failure: reviewHostRelayFailureReport(error), reason: error.message } : {}) }; + if ( + error instanceof ReviewHostRelayError && + error.mutationOutcome !== "unknown" + ) + return reviewHostRelayGroupFailure(error, group.slots, prepared, index); + const reconciled = await reconcileUnknownReviewCaptureFailure( + error, + nativeReviewCli, + cwd, + current.binding, + retainedUntrackedSelections, + route, + undefined, + REVIEW_HOST_AGENT, + ); + return { + ...reconciled, + tool: "gentle_review_capture_group", + ...reviewHostRelayGroupProgress(group.slots, prepared, index, true), + ...(error instanceof ReviewHostRelayError + ? { failure: reviewHostRelayFailureReport(error), reason: error.message } + : {}), + }; } } - const reconciled = await reconcileUnknownReviewCaptureFailure(undefined, nativeReviewCli, cwd, group.binding, retainedUntrackedSelections, route, canonicalBindings.slice(prepared.length), REVIEW_HOST_AGENT); - return { ...reconciled, tool: "gentle_review_capture_group", outcome: reconciled.outcome === "capture-group-authority-drift" ? reconciled.outcome : reconciled.status === "reconciled" ? "native-reviewer-group-status-reconciled" : "native-reviewer-group-status-reconciliation-failed", ...reviewHostRelayGroupProgress(group.slots, prepared, prepared.length) }; + const reconciled = await reconcileUnknownReviewCaptureFailure( + undefined, + nativeReviewCli, + cwd, + group.binding, + retainedUntrackedSelections, + route, + canonicalBindings.slice(prepared.length), + REVIEW_HOST_AGENT, + ); + return { + ...reconciled, + tool: "gentle_review_capture_group", + outcome: + reconciled.outcome === "capture-group-authority-drift" + ? reconciled.outcome + : reconciled.status === "reconciled" + ? "native-reviewer-group-status-reconciled" + : "native-reviewer-group-status-reconciliation-failed", + ...reviewHostRelayGroupProgress(group.slots, prepared, prepared.length), + }; } type DispatchHydrationOutcome = @@ -5666,14 +7730,32 @@ type DispatchHydrationOutcome = | { hydrated: false; lineage_id: string; reason: string; message: string } | undefined; -function hydrateDispatchBindingFromStatus(candidateViews: CandidateViewRegistry | null, contributorRoot: string, status: ReviewStatusV3): DispatchHydrationOutcome { - if (candidateViews === null || candidateViews.hasCurrentBinding(contributorRoot)) return undefined; +function hydrateDispatchBindingFromStatus( + candidateViews: CandidateViewRegistry | null, + contributorRoot: string, + status: ReviewStatusV3, +): DispatchHydrationOutcome { + if ( + candidateViews === null || + candidateViews.hasCurrentBinding(contributorRoot) + ) + return undefined; const lineageId = status.authority?.lineageId; - if (lineageId === undefined || status.applicability !== "current_target" || candidateViews.hasProjection(lineageId, contributorRoot)) return undefined; + if ( + lineageId === undefined || + status.applicability !== "current_target" || + candidateViews.hasProjection(lineageId, contributorRoot) + ) + return undefined; const lenses = pendingReviewerLenses(status); if (lenses.length === 0) return undefined; try { - candidateViews.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, status.projection, lenses); + candidateViews.restoreCurrentForDispatchFromNative( + lineageId, + contributorRoot, + status.projection, + lenses, + ); return { hydrated: true, lineage_id: lineageId, lenses }; } catch (error) { // Never fail the caller on hydration; the registry records the typed @@ -5682,7 +7764,10 @@ function hydrateDispatchBindingFromStatus(candidateViews: CandidateViewRegistry return { hydrated: false, lineage_id: lineageId, - reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid", + reason: + error instanceof CandidateViewError + ? error.reason + : "candidate-view-invalid", message: error instanceof Error ? error.message : String(error), }; } @@ -5695,19 +7780,43 @@ async function executeReviewControllerOperation( signal?: AbortSignal, candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(), context?: ExtensionContext, - retainedUntrackedSelections: Map = new Map(), + retainedUntrackedSelections: Map< + string, + RetainedNativeStatusSelection + > = new Map(), pendingReviewConsentRegistry: PendingReviewConsentRegistry = processPendingReviewConsentRegistry, - pendingReviewConsentFallbackKey: symbol = Symbol("pending-review-consent-fallback"), + pendingReviewConsentFallbackKey: symbol = Symbol( + "pending-review-consent-fallback", + ), reviewConsentNow: () => number = Date.now, - reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout, + reviewConsentScheduleTimer: ( + callback: () => void, + delayMs: number, + ) => { unref: () => void } = setTimeout, intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission, ): Promise> { const parameters = parseReviewControllerParameters(parametersValue); - const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd, candidateViews, parameters.lineageId); - const pendingReviewConsentSession = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey); - const useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot(parameters.workspaceRoot, sessionCwd, defaultCwd); - const includeWorkspaceRoot = parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd; - if (parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT) { + const defaultCwd = resolveReviewControllerWorkspaceRoot( + parameters.workspaceRoot, + sessionCwd, + candidateViews, + parameters.lineageId, + ); + const pendingReviewConsentSession = pendingReviewConsentSessionKey( + context, + pendingReviewConsentFallbackKey, + ); + const useTargetLifecycleRoot = requiresExplicitTargetLifecycleRoot( + parameters.workspaceRoot, + sessionCwd, + defaultCwd, + ); + const includeWorkspaceRoot = + parameters.workspaceRoot !== undefined || defaultCwd !== sessionCwd; + if ( + parameters.operation === REVIEW_CONTROLLER_OPERATION.EXPORT || + parameters.operation === REVIEW_CONTROLLER_OPERATION.IMPORT + ) { // Legacy bundle transport rode on the retired pre-integration graph/compact // stores. The native v2.1.11 CLI exposes no bundle equivalent, so both // operations return a structured retirement envelope; the enum members are @@ -5716,10 +7825,12 @@ async function executeReviewControllerOperation( operation: parameters.operation, status: "blocked", outcome: "legacy-operation-retired", - reason: "Legacy review bundle transport (export/import) was retired together with the pre-integration graph/compact stores; gentle-ai v2.1.11 exposes no native bundle equivalent.", + reason: + "Legacy review bundle transport (export/import) was retired together with the pre-integration graph/compact stores; gentle-ai v2.1.11 exposes no native bundle equivalent.", mutation_performed: false, mutation_outcome: "none", - next_action: "Use the native `gentle-ai review` CLI (start/finalize/validate/status/recover) against the repository review authority; receipts and canonical artifacts live in the Git common-directory store at .git/gentle-ai/reviews and travel with the repository through normal Git replication.", + next_action: + "Use the native `gentle-ai review` CLI (start/finalize/validate/status/recover) against the repository review authority; receipts and canonical artifacts live in the Git common-directory store at .git/gentle-ai/reviews and travel with the repository through normal Git replication.", }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ASSESS) { @@ -5728,32 +7839,76 @@ async function executeReviewControllerOperation( // authorizeDestructiveReviewOperation (it returns early for any // operation that is neither RESET nor a maintenance operation). const input = parseReviewAssessInput(parameters.operation, parameters.input); - const details = await resolveReviewAssessmentPlan(nativeReviewCli, defaultCwd, input, signal); - return { operation: parameters.operation, ...details, ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) }; + const details = await resolveReviewAssessmentPlan( + nativeReviewCli, + defaultCwd, + input, + signal, + ); + return { + operation: parameters.operation, + ...details, + ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), + }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR_LEGACY_ALIAS) { - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); - return await executeNativeLegacyAliasRepair(input, defaultCwd, nativeReviewCli, signal, context); + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); + return await executeNativeLegacyAliasRepair( + input, + defaultCwd, + nativeReviewCli, + signal, + context, + ); } const maintenance = nativeMaintenanceOperation(parameters.operation); if (maintenance !== undefined) { - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); - return await executeNativeAuthorityMaintenance(parameters.operation, maintenance, input, defaultCwd, nativeReviewCli, signal); + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); + return await executeNativeAuthorityMaintenance( + parameters.operation, + maintenance, + input, + defaultCwd, + nativeReviewCli, + signal, + ); } - if (parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && nativeReviewCli !== null) { + if ( + parameters.operation === REVIEW_CONTROLLER_OPERATION.INSPECT && + nativeReviewCli !== null + ) { try { if (nativeReviewCli.targetStatus !== undefined) { - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { - cwd: defaultCwd, - ...(signal === undefined ? {} : { signal }), - }, retainedUntrackedSelections, defaultCwd); + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { + cwd: defaultCwd, + ...(signal === undefined ? {} : { signal }), + }, + retainedUntrackedSelections, + defaultCwd, + ); if (negotiated.transport !== undefined) { return { ...hostTransportUnavailable(parameters.operation, negotiated.transport), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), }; } - return { ...mapNativeTargetStatus(parameters.operation, negotiated.status!, undefined, defaultCwd), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) }; + return { + ...mapNativeTargetStatus( + parameters.operation, + negotiated.status!, + undefined, + defaultCwd, + ), + ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), + }; } return nativeStatusUnsupported(parameters.operation); } catch (error) { @@ -5765,15 +7920,30 @@ async function executeReviewControllerOperation( } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER_LOCK) { - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); - if (typeof input.ownerHash !== "string") throw new Error("Lock recovery requires an exact ownerHash"); + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); + if (typeof input.ownerHash !== "string") + throw new Error("Lock recovery requires an exact ownerHash"); // A stuck legacy mutation lock is an incomplete in-flight entry; the // audited native quarantine owns its removal. Lock recovery is not a // destructive authority reset, so pending authorizations survive. - return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli, undefined, signal); + return await executeNativeRecoveryRoute( + parameters.operation, + "reclaim", + input, + defaultCwd, + nativeReviewCli, + undefined, + signal, + ); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER) { - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); // The authorization binding is Pi-derived, never caller-carried. It is // recorded verbatim as a maintainer attestation, so accepting one the // caller composed would let an unapproved actor sign the recovery edge. @@ -5790,18 +7960,37 @@ async function executeReviewControllerOperation( } const missing = NATIVE_RECOVERY_INPUT.recover.filter((key) => key === "disposition" - ? !["scope_changed", "invalidated", "escalated"].includes(input[key] as string) + ? !["scope_changed", "invalidated", "escalated"].includes( + input[key] as string, + ) : !isCanonicalProcessString(input[key]), ); - if (missing.length > 0) return await executeNativeRecoveryRoute(parameters.operation, "recover", input, defaultCwd, nativeReviewCli, signal); - if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); - const frozenTarget = candidateViews?.hasProjection(String(input.predecessorLineage), defaultCwd) - ? candidateViews.resolveProjection(String(input.predecessorLineage), defaultCwd) + if (missing.length > 0) + return await executeNativeRecoveryRoute( + parameters.operation, + "recover", + input, + defaultCwd, + nativeReviewCli, + signal, + ); + if (nativeReviewCli?.targetStatus === undefined) + return nativeStatusUnsupported(parameters.operation); + const frozenTarget = candidateViews?.hasProjection( + String(input.predecessorLineage), + defaultCwd, + ) + ? candidateViews.resolveProjection( + String(input.predecessorLineage), + defaultCwd, + ) : undefined; const statusRequest = { cwd: defaultCwd, lineageId: String(input.predecessorLineage), - ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit, committedOnly: true } : {}), + ...(frozenTarget?.committedOnly === true + ? { baseRef: frozenTarget.baseCommit, committedOnly: true } + : {}), ...(signal === undefined ? {} : { signal }), }; let status: ReviewStatusV3; @@ -5811,16 +8000,38 @@ async function executeReviewControllerOperation( return nativeStatusFailed(parameters.operation, error); } const pinnedRecoveryStatus = (candidate: ReviewStatusV3): boolean => - candidate.action === "recover" - && candidate.actionDisposition === status.actionDisposition - && candidate.authority?.lineageId === input.predecessorLineage - && candidate.authority?.revision === input.expectedPredecessorRevision - && candidate.targetIdentity === status.targetIdentity; - if (status.action !== "recover" || status.actionDisposition === undefined || status.authority?.lineageId !== input.predecessorLineage || status.authority.revision !== input.expectedPredecessorRevision || !isCanonicalProcessString(status.targetIdentity)) { - return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-status-mismatch", mutation_performed: false, mutation_outcome: "none", result: status.raw, next_action: "follow-provider-target-status" }; + candidate.action === "recover" && + candidate.actionDisposition === status.actionDisposition && + candidate.authority?.lineageId === input.predecessorLineage && + candidate.authority?.revision === input.expectedPredecessorRevision && + candidate.targetIdentity === status.targetIdentity; + if ( + status.action !== "recover" || + status.actionDisposition === undefined || + status.authority?.lineageId !== input.predecessorLineage || + status.authority.revision !== input.expectedPredecessorRevision || + !isCanonicalProcessString(status.targetIdentity) + ) { + return { + operation: parameters.operation, + status: "blocked", + outcome: "native-recovery-status-mismatch", + mutation_performed: false, + mutation_outcome: "none", + result: status.raw, + next_action: "follow-provider-target-status", + }; } if (input.disposition !== status.actionDisposition) { - return { operation: parameters.operation, status: "blocked", outcome: "native-recovery-disposition-mismatch", mutation_performed: false, mutation_outcome: "none", provider_disposition: status.actionDisposition, next_action: "resubmit-with-provider-disposition" }; + return { + operation: parameters.operation, + status: "blocked", + outcome: "native-recovery-disposition-mismatch", + mutation_performed: false, + mutation_outcome: "none", + provider_disposition: status.actionDisposition, + next_action: "resubmit-with-provider-disposition", + }; } const recoverAuthorization = nativeReviewRecoverAuthorization({ predecessorLineage: String(input.predecessorLineage), @@ -5829,7 +8040,10 @@ async function executeReviewControllerOperation( actor: String(input.actor), reason: String(input.reason), }); - if (context?.hasUI !== true) throw new Error("Review controller RECOVER requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed"); + if (context?.hasUI !== true) + throw new Error( + "Review controller RECOVER requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed", + ); const approved = await context.ui.confirm( "Authorize destructive review authority RECOVER?", [ @@ -5840,7 +8054,8 @@ async function executeReviewControllerOperation( `The native command creates one auditable successor authority (${String(input.successorLineage)}) for this exact predecessor and target identity; the predecessor stays untouched.`, ].join("\n"), ); - if (!approved) throw new Error("Review controller RECOVER was not explicitly authorized"); + if (!approved) + throw new Error("Review controller RECOVER was not explicitly authorized"); // Time-of-check to time-of-use: the human deliberates for an unbounded // interval, and the authority can advance, be recovered by someone else, or // stop being recovery-eligible while they do. The approval and the derived @@ -5864,45 +8079,129 @@ async function executeReviewControllerOperation( next_action: "reinspect-and-reauthorize-recovery", }; } - return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, signal); + return await executeNativeRecoveryRoute( + parameters.operation, + "recover", + { + ...input, + disposition: status.actionDisposition, + maintainerAuthorization: recoverAuthorization, + }, + defaultCwd, + nativeReviewCli, + signal, + ); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET) { - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); - return await executeNativeRecoveryRoute(parameters.operation, "reclaim", input, defaultCwd, nativeReviewCli, signal); + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); + return await executeNativeRecoveryRoute( + parameters.operation, + "reclaim", + input, + defaultCwd, + nativeReviewCli, + signal, + ); } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR) { - if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); - const frozenTarget = parameters.lineageId === undefined || !candidateViews?.hasProjection(parameters.lineageId, defaultCwd) ? undefined : candidateViews.resolveProjection(parameters.lineageId, defaultCwd); + if (nativeReviewCli?.targetStatus === undefined) + return nativeStatusUnsupported(parameters.operation); + const frozenTarget = + parameters.lineageId === undefined || + !candidateViews?.hasProjection(parameters.lineageId, defaultCwd) + ? undefined + : candidateViews.resolveProjection(parameters.lineageId, defaultCwd); let status: ReviewStatusV3; try { - status = await nativeReviewCli.targetStatus({ cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit, committedOnly: true } : {}), ...(signal === undefined ? {} : { signal }) }); + status = await nativeReviewCli.targetStatus({ + cwd: defaultCwd, + ...(parameters.lineageId === undefined + ? {} + : { lineageId: parameters.lineageId }), + ...(frozenTarget?.committedOnly === true + ? { baseRef: frozenTarget.baseCommit, committedOnly: true } + : {}), + ...(signal === undefined ? {} : { signal }), + }); } catch (error) { return nativeStatusFailed(parameters.operation, error); } - clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); retainNativeCaptureRoutes(retainedUntrackedSelections, defaultCwd, status, frozenTarget?.committedOnly === true ? frozenTarget.baseCommit : undefined); - if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId) }; - if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId); + clearRetainedNativeStatusSelectionsOnTerminal( + retainedUntrackedSelections, + defaultCwd, + status.authority?.lineageId, + status.authority?.state, + ); + retainNativeCaptureRoutes( + retainedUntrackedSelections, + defaultCwd, + status, + frozenTarget?.committedOnly === true ? frozenTarget.baseCommit : undefined, + ); + if (status.authority?.version === "compact-v2") + return { + operation: parameters.operation, + repaired: false, + compact_authority: "immutable-untouched", + status: mapNativeTargetStatus( + parameters.operation, + status, + parameters.lineageId, + ), + }; + if (status.authority?.version !== "legacy-v1") + return mapNativeTargetStatus( + parameters.operation, + status, + parameters.lineageId, + ); const store = ReviewTransactionStore.forRepository(defaultCwd); store.repairCurrentAuthority(); return { operation: parameters.operation, repaired: true }; } - if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED) { - const controllerOnlyInput = ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource"] - .find((key) => parameters[key as keyof ReviewControllerParameters] !== undefined); - if (controllerOnlyInput !== undefined || !isCanonicalProcessString(parameters.lineageId)) { + if ( + parameters.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED + ) { + const controllerOnlyInput = [ + "changeName", + "idempotencyKey", + "transition", + "input", + "outputPath", + "inputPath", + "operationId", + "lineageIds", + "acknowledgeUntrustedBundleSource", + ].find( + (key) => parameters[key as keyof ReviewControllerParameters] !== undefined, + ); + if ( + controllerOnlyInput !== undefined || + !isCanonicalProcessString(parameters.lineageId) + ) { return { operation: parameters.operation, status: "blocked", outcome: "native-approved-acknowledgement-input-invalid", - reason: controllerOnlyInput === undefined ? "lineage-invalid" : "controller-only-input", - ...(controllerOnlyInput === undefined ? {} : { field: controllerOnlyInput }), + reason: + controllerOnlyInput === undefined + ? "lineage-invalid" + : "controller-only-input", + ...(controllerOnlyInput === undefined + ? {} + : { field: controllerOnlyInput }), mutation_performed: false, mutation_outcome: "none", next_action: "resubmit-the-exact-lineage-without-controller-only-input", }; } - const acknowledgementCli = nativeReviewCli as NativeReviewAcknowledgementCli | null; - if (acknowledgementCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); + const acknowledgementCli = + nativeReviewCli as NativeReviewAcknowledgementCli | null; + if (acknowledgementCli?.targetStatus === undefined) + return nativeStatusUnsupported(parameters.operation); if (acknowledgementCli.acknowledgeApproved === undefined) { return { operation: parameters.operation, @@ -5913,14 +8212,23 @@ async function executeReviewControllerOperation( next_action: "install-native-acknowledge-approved-support", }; } - const frozenTarget = candidateViews?.hasProjection(parameters.lineageId, defaultCwd) + const frozenTarget = candidateViews?.hasProjection( + parameters.lineageId, + defaultCwd, + ) ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd) : undefined; const target = { cwd: defaultCwd, lineageId: parameters.lineageId, - ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit, committedOnly: true } : {}), - ...readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId), + ...(frozenTarget?.committedOnly === true + ? { baseRef: frozenTarget.baseCommit, committedOnly: true } + : {}), + ...readRetainedNativeUntrackedSelection( + retainedUntrackedSelections, + defaultCwd, + parameters.lineageId, + ), ...(signal === undefined ? {} : { signal }), }; let status: ReviewStatusV3; @@ -5929,7 +8237,10 @@ async function executeReviewControllerOperation( } catch (error) { return nativeStatusFailed(parameters.operation, error); } - const execute = status.nextTransition?.kind === "execute" ? status.nextTransition.execute : undefined; + const execute = + status.nextTransition?.kind === "execute" + ? status.nextTransition.execute + : undefined; if ( status.applicability !== "current_target" || status.authority?.lineageId !== parameters.lineageId || @@ -5966,13 +8277,25 @@ async function executeReviewControllerOperation( const acknowledged = await acknowledgementCli.acknowledgeApproved({ argumentTokens, cwd: defaultCwd, - binding: { lineageId: parameters.lineageId, targetIdentity: status.targetIdentity, revision: status.authority.revision }, + binding: { + lineageId: parameters.lineageId, + targetIdentity: status.targetIdentity, + revision: status.authority.revision, + }, ...(signal === undefined ? {} : { signal }), }); - clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId); + clearRetainedNativeUntrackedSelection( + retainedUntrackedSelections, + defaultCwd, + parameters.lineageId, + ); // The registry owns restoring writability of its 0555 views before // removal; a terminal approved cleanup keeps the lineage projection. - candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd); + candidateViews?.cleanupTerminal( + parameters.lineageId, + "approved", + defaultCwd, + ); // gentle-pi#668: `closed` is never auto-derived or recorded here -- // a parent that wants the on-path passes nativeReviewOutcome: // "closed" explicitly on its next assess call for this candidate. @@ -5982,23 +8305,54 @@ async function executeReviewControllerOperation( outcome: "native-approved-acknowledgement-completed", lineage_id: parameters.lineageId, target_identity: status.targetIdentity, - ...(acknowledged === undefined ? {} : { consumed_revision: acknowledged.consumedRevision }), + ...(acknowledged === undefined + ? {} + : { consumed_revision: acknowledged.consumedRevision }), authority: "burned", - ...(acknowledged === undefined ? {} : { burn_evidence: acknowledged.schema }), + ...(acknowledged === undefined + ? {} + : { burn_evidence: acknowledged.schema }), delivery: "ordinary-repository-policy", mutation_performed: true, mutation_outcome: "committed", }; } catch (error) { - if (!nativeMutationRequiresStatus(error)) return nativeOperationFailure(parameters.operation, error); - return await reconcileNativeMutationFailure(parameters.operation, error, acknowledgementCli, target, retainedUntrackedSelections); + if (!nativeMutationRequiresStatus(error)) + return nativeOperationFailure(parameters.operation, error); + return await reconcileNativeMutationFailure( + parameters.operation, + error, + acknowledgementCli, + target, + retainedUntrackedSelections, + ); } } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT) { - const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation); - if (Object.keys(input).some((key) => key !== "consentBinding" && key !== "answer") || Object.keys(input).length !== 2) throw new Error("Review controller answer-consent input must contain exactly consentBinding and answer"); - if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding"); - if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined"); + const input = parseControllerJson( + requiredControllerString(parameters, "input"), + parameters.operation, + ); + if ( + Object.keys(input).some( + (key) => key !== "consentBinding" && key !== "answer", + ) || + Object.keys(input).length !== 2 + ) + throw new Error( + "Review controller answer-consent input must contain exactly consentBinding and answer", + ); + if ( + typeof input.consentBinding !== "string" || + input.consentBinding.length === 0 + ) + throw new Error( + "Review controller answer-consent requires an opaque consentBinding", + ); + if (input.answer !== "granted" && input.answer !== "declined") + throw new Error( + "Review controller answer-consent answer must be granted or declined", + ); // gentle-pi#455: resolve the binding by its opaque id alone, so a // binding one active Pi session's START created is answerable from any // active session presenting it -- not only the session that created it. @@ -6006,56 +8360,126 @@ async function executeReviewControllerOperation( const pending = resolved?.pending; const owningSession = resolved?.sessionKey ?? pendingReviewConsentSession; if (pending === undefined || pending.expiresAt <= reviewConsentNow()) { - const disposition = pending === undefined - ? pendingReviewConsentRegistry.staleDisposition(input.consentBinding) - : PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED; - const stale = staleConsentBindingDiagnostics(input.consentBinding, disposition); - if (pending !== undefined) expirePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession); - if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); + const disposition = + pending === undefined + ? pendingReviewConsentRegistry.staleDisposition(input.consentBinding) + : PENDING_REVIEW_CONSENT_DISPOSITION.EXPIRED; + const stale = staleConsentBindingDiagnostics( + input.consentBinding, + disposition, + ); + if (pending !== undefined) + expirePendingReviewConsent( + pending, + pendingReviewConsentRegistry, + owningSession, + ); + if (nativeReviewCli?.targetStatus === undefined) + return nativeStatusUnsupported(parameters.operation); try { - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { - cwd: defaultCwd, - ...(signal === undefined ? {} : { signal }), - }, retainedUntrackedSelections, defaultCwd); - if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); - return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!); + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { + cwd: defaultCwd, + ...(signal === undefined ? {} : { signal }), + }, + retainedUntrackedSelections, + defaultCwd, + ); + if (negotiated.transport !== undefined) + return hostTransportUnavailable( + parameters.operation, + negotiated.transport, + ); + return staleConsentBindingOutcome( + parameters.operation, + input.consentBinding, + stale, + negotiated.status!, + ); } catch (error) { return nativeStatusFailed(parameters.operation, error); } } const answeringRepositoryCwd = realpathSync(defaultCwd); - if (answeringRepositoryCwd !== pending.repositoryCwd) return consentBindingRepositoryMismatchOutcome(parameters.operation, input.consentBinding, pending.repositoryCwd, answeringRepositoryCwd); - if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed"); + if (answeringRepositoryCwd !== pending.repositoryCwd) + return consentBindingRepositoryMismatchOutcome( + parameters.operation, + input.consentBinding, + pending.repositoryCwd, + answeringRepositoryCwd, + ); + if (reviewConsentDigest(pending.consent) !== pending.consentDigest) + throw new Error("Review controller consent envelope binding changed"); pending.verifyCandidate(); - if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable"); - if (!consumePendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession)) { + if (nativeReviewCli?.answerConsent === undefined) + throw new Error("Native review consent follow-up is unavailable"); + if ( + !consumePendingReviewConsent( + pending, + pendingReviewConsentRegistry, + owningSession, + ) + ) { const stale = staleConsentBindingDiagnostics( input.consentBinding, pendingReviewConsentRegistry.staleDisposition(input.consentBinding), ); - if (nativeReviewCli.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); + if (nativeReviewCli.targetStatus === undefined) + return nativeStatusUnsupported(parameters.operation); try { - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { - cwd: defaultCwd, - ...(signal === undefined ? {} : { signal }), - }, retainedUntrackedSelections, defaultCwd); - if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); - return staleConsentBindingOutcome(parameters.operation, input.consentBinding, stale, negotiated.status!); + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { + cwd: defaultCwd, + ...(signal === undefined ? {} : { signal }), + }, + retainedUntrackedSelections, + defaultCwd, + ); + if (negotiated.transport !== undefined) + return hostTransportUnavailable( + parameters.operation, + negotiated.transport, + ); + return staleConsentBindingOutcome( + parameters.operation, + input.consentBinding, + stale, + negotiated.status!, + ); } catch (error) { return nativeStatusFailed(parameters.operation, error); } } try { - const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal); + const gated = await resolveReviewModeGate( + nativeReviewCli, + parameters.operation, + defaultCwd, + signal, + ); if (gated !== undefined) { // gentle-pi#668: mode disabled for this exact candidate -- keyed by // its targetIdentity, never by repository alone. - recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.UNAVAILABLE); - cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession); + recordNativeReviewOutcome( + pending.authorityCwd, + pending.consent.targetIdentity, + NATIVE_REVIEW_OUTCOME.UNAVAILABLE, + ); + cleanupPendingReviewConsent( + pending, + pendingReviewConsentRegistry, + owningSession, + ); return gated; } } catch (error) { - cleanupPendingReviewConsent(pending, pendingReviewConsentRegistry, owningSession); + cleanupPendingReviewConsent( + pending, + pendingReviewConsentRegistry, + owningSession, + ); return nativeOperationFailure(parameters.operation, error); } // The one-shot binding is consumed before the first answer-path await. Any @@ -6071,7 +8495,11 @@ async function executeReviewControllerOperation( if (answered.kind === "declined") { // gentle-pi#668: candidate-scoped decline, keyed by this exact // candidate's targetIdentity, never by repository alone. - recordNativeReviewOutcome(pending.authorityCwd, pending.consent.targetIdentity, NATIVE_REVIEW_OUTCOME.DECLINED); + recordNativeReviewOutcome( + pending.authorityCwd, + pending.consent.targetIdentity, + NATIVE_REVIEW_OUTCOME.DECLINED, + ); pending.cleanupCandidate(); return { operation: parameters.operation, @@ -6081,37 +8509,140 @@ async function executeReviewControllerOperation( ...nativeStartPreAuthorityRejection(), }; } - retainNativeUntrackedSelection(retainedUntrackedSelections, pending.authorityCwd, answered.start.lineageId, pending.untrackedSelection); - completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, pending.candidateViews); + retainNativeUntrackedSelection( + retainedUntrackedSelections, + pending.authorityCwd, + answered.start.lineageId, + pending.untrackedSelection, + ); + completed = completeNativeStart( + parameters.operation, + answered.start, + pending.repositoryCwd, + pending.candidateView, + pending.candidateViews, + ); } catch (error) { const value = error as { mutationOutcome?: unknown }; if (value.mutationOutcome === "none") pending.cleanupCandidate(); - return await reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, { - cwd: pending.authorityCwd, - ...(pending.candidateView.committedOnly ? { baseRef: pending.candidateView.baseCommit, committedOnly: true } : {}), - projection: "workspace", - }, retainedUntrackedSelections); + return await reconcileNativeMutationFailure( + parameters.operation, + error, + nativeReviewCli, + { + cwd: pending.authorityCwd, + ...(pending.candidateView.committedOnly + ? { baseRef: pending.candidateView.baseCommit, committedOnly: true } + : {}), + projection: "workspace", + }, + retainedUntrackedSelections, + ); } return completed; } - if (parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED) { - if (nativeReviewCli?.targetStatus === undefined || nativeReviewCli.start === undefined) return nativeStatusUnsupported(parameters.operation); - const canonicalBinding = parseCanonicalReviewCaptureBinding(parameters.selectionBinding!); + if ( + parameters.operation === REVIEW_CONTROLLER_OPERATION.SELECT_INTENDED_UNTRACKED + ) { + if ( + nativeReviewCli?.targetStatus === undefined || + nativeReviewCli.start === undefined + ) + return nativeStatusUnsupported(parameters.operation); + const canonicalBinding = parseCanonicalReviewCaptureBinding( + parameters.selectionBinding!, + ); let status: ReviewStatusV3; try { - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, ...(signal === undefined ? {} : { signal }) }, retainedUntrackedSelections, defaultCwd); - if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { cwd: defaultCwd, ...(signal === undefined ? {} : { signal }) }, + retainedUntrackedSelections, + defaultCwd, + ); + if (negotiated.transport !== undefined) + return hostTransportUnavailable(parameters.operation, negotiated.transport); status = negotiated.status!; - } catch (error) { return nativeStatusFailed(parameters.operation, error); } - const input = reviewIntendedUntrackedInput(status), eligibleJson = input === undefined ? undefined : exactCollectArgument(input, "eligible_paths_json"), inventory = input === undefined ? undefined : exactCollectArgument(input, "expected_untracked_inventory"); + } catch (error) { + return nativeStatusFailed(parameters.operation, error); + } + const input = reviewIntendedUntrackedInput(status), + eligibleJson = + input === undefined + ? undefined + : exactCollectArgument(input, "eligible_paths_json"), + inventory = + input === undefined + ? undefined + : exactCollectArgument(input, "expected_untracked_inventory"); let eligible: unknown; - try { eligible = JSON.parse(eligibleJson ?? ""); } catch { eligible = undefined; } - const scope = parameters.intendedUntracked!.length === 0 ? NATIVE_START_UNTRACKED_SCOPE.EXCLUDE : NATIVE_START_UNTRACKED_SCOPE.SELECT; - const selected = validateNativeStartUntrackedSelection({ untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: parameters.intendedUntracked }); - const rejected = input === undefined || canonicalReviewCaptureBinding(input) !== canonicalBinding || exactCollectArgument(input, "target_identity") !== status.targetIdentity || exactCollectArgument(input, "projection") !== status.projection.projection || exactCollectArgument(input, "base_tree") !== status.projection.baseTree || exactCollectArgument(input, "candidate_tree") !== status.projection.currentCandidateTree || !Array.isArray(eligible) || selected.reason !== undefined || selected.intendedUntracked!.some((path) => !eligible.includes(path)); - if (rejected) return { operation: parameters.operation, status: "blocked", outcome: "intended-untracked-selection-binding-rejected", mutation_performed: false, mutation_outcome: "none" }; - const submission = { argumentTokens: input.submission!.argumentTokens, value: JSON.stringify({ schema: "gentle-ai.review-intended-untracked-selection/v1", untracked_scope: scope, expected_untracked_inventory: inventory, intended_untracked: selected.intendedUntracked }) }; - const result = await executeReviewControllerOperation({ operation: REVIEW_CONTROLLER_OPERATION.START, ...(parameters.workspaceRoot === undefined ? {} : { workspaceRoot: parameters.workspaceRoot }), input: JSON.stringify({ mode: REVIEW_MODE.ORDINARY, untrackedScope: scope, expectedUntrackedInventory: inventory, intendedUntracked: selected.intendedUntracked }) }, sessionCwd, nativeReviewCli, signal, candidateViews, context, retainedUntrackedSelections, pendingReviewConsentRegistry, pendingReviewConsentFallbackKey, reviewConsentNow, reviewConsentScheduleTimer, submission); + try { + eligible = JSON.parse(eligibleJson ?? ""); + } catch { + eligible = undefined; + } + const scope = + parameters.intendedUntracked!.length === 0 + ? NATIVE_START_UNTRACKED_SCOPE.EXCLUDE + : NATIVE_START_UNTRACKED_SCOPE.SELECT; + const selected = validateNativeStartUntrackedSelection({ + untrackedScope: scope, + expectedUntrackedInventory: inventory, + intendedUntracked: parameters.intendedUntracked, + }); + const rejected = + input === undefined || + canonicalReviewCaptureBinding(input) !== canonicalBinding || + exactCollectArgument(input, "target_identity") !== status.targetIdentity || + exactCollectArgument(input, "projection") !== status.projection.projection || + exactCollectArgument(input, "base_tree") !== status.projection.baseTree || + exactCollectArgument(input, "candidate_tree") !== + status.projection.currentCandidateTree || + !Array.isArray(eligible) || + selected.reason !== undefined || + selected.intendedUntracked!.some((path) => !eligible.includes(path)); + if (rejected) + return { + operation: parameters.operation, + status: "blocked", + outcome: "intended-untracked-selection-binding-rejected", + mutation_performed: false, + mutation_outcome: "none", + }; + const submission = { + argumentTokens: input.submission!.argumentTokens, + value: JSON.stringify({ + schema: "gentle-ai.review-intended-untracked-selection/v1", + untracked_scope: scope, + expected_untracked_inventory: inventory, + intended_untracked: selected.intendedUntracked, + }), + }; + const result = await executeReviewControllerOperation( + { + operation: REVIEW_CONTROLLER_OPERATION.START, + ...(parameters.workspaceRoot === undefined + ? {} + : { workspaceRoot: parameters.workspaceRoot }), + input: JSON.stringify({ + mode: REVIEW_MODE.ORDINARY, + untrackedScope: scope, + expectedUntrackedInventory: inventory, + intendedUntracked: selected.intendedUntracked, + }), + }, + sessionCwd, + nativeReviewCli, + signal, + candidateViews, + context, + retainedUntrackedSelections, + pendingReviewConsentRegistry, + pendingReviewConsentFallbackKey, + reviewConsentNow, + reviewConsentScheduleTimer, + submission, + ); return { ...result, operation: parameters.operation }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) { @@ -6120,52 +8651,118 @@ async function executeReviewControllerOperation( REVIEW_CONTROLLER_OPERATION.START, ); if (rawStart.mode === REVIEW_MODE.ORDINARY) { - if ("policyHash" in rawStart) return nativeStartRejection("legacy-policy-hash-unsupported"); - const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field)); - if (unknownField !== undefined) return nativeStartRejection("unknown-field", unknownField); + if ("policyHash" in rawStart) + return nativeStartRejection("legacy-policy-hash-unsupported"); + const unknownField = Object.keys(rawStart).find( + (field) => + ![ + "mode", + "baseRef", + "committedOnly", + "policyPath", + "focus", + "untrackedScope", + "expectedUntrackedInventory", + "intendedUntracked", + ].includes(field), + ); + if (unknownField !== undefined) + return nativeStartRejection("unknown-field", unknownField); const focus = rawStart.focus; - if (focus !== undefined && !isNativeStartFocus(focus)) return nativeStartRejection("focus-invalid"); - const policy: NativeStartPolicyValidation = rawStart.policyPath === undefined - ? {} - : validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath); + if (focus !== undefined && !isNativeStartFocus(focus)) + return nativeStartRejection("focus-invalid"); + const policy: NativeStartPolicyValidation = + rawStart.policyPath === undefined + ? {} + : validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath); if (policy.reason !== undefined) return nativeStartRejection(policy.reason); const baseRef = rawStart.baseRef; - if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStartRejection("base-ref-invalid"); - if (baseRef !== undefined && rawStart.committedOnly !== true) return nativeStartRejection("committed-only-required"); - if (baseRef === undefined && "committedOnly" in rawStart) return nativeStartRejection("committed-only-invalid"); + if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) + return nativeStartRejection("base-ref-invalid"); + if (baseRef !== undefined && rawStart.committedOnly !== true) + return nativeStartRejection("committed-only-required"); + if (baseRef === undefined && "committedOnly" in rawStart) + return nativeStartRejection("committed-only-invalid"); const untrackedSelection = validateNativeStartUntrackedSelection(rawStart); - if (untrackedSelection.reason !== undefined) return nativeStartRejection(untrackedSelection.reason); - const retainedUntrackedSelection = cloneRetainedNativeUntrackedSelection(untrackedSelection); + if (untrackedSelection.reason !== undefined) + return nativeStartRejection(untrackedSelection.reason); + const retainedUntrackedSelection = + cloneRetainedNativeUntrackedSelection(untrackedSelection); let canonicalBaseRef: string | undefined; if (baseRef !== undefined) { try { - canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit; + canonicalBaseRef = resolveCanonicalCandidateBase( + defaultCwd, + baseRef, + ).commit; } catch (error) { - if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true })); - if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason); + if (error instanceof CandidateViewError && error.diagnostics !== undefined) + return nativeOperationFailure( + parameters.operation, + Object.assign(error, { candidateViewPreNative: true }), + ); + if ( + error instanceof CandidateViewError && + (error.reason === "base-ref-ambiguous" || + error.reason === "base-ref-unresolvable" || + error.reason === "base-ref-moved") + ) + return nativeStartRejection(error.reason); return nativeStartRejection("base-ref-unresolvable"); } } try { - const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal); + const gated = await resolveReviewModeGate( + nativeReviewCli, + parameters.operation, + defaultCwd, + signal, + ); if (gated !== undefined) return gated; } catch (error) { return nativeOperationFailure(parameters.operation, error); } - if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation); + if (nativeReviewCli?.targetStatus === undefined) + return nativeStatusUnsupported(parameters.operation); let target: ReviewStatusV3; try { - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { - cwd: defaultCwd, - ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), - ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }), - ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), - ...(intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection }), - ...(signal === undefined ? {} : { signal }), - }, retainedUntrackedSelections, defaultCwd); - if (negotiated.transport !== undefined) return hostTransportUnavailable(parameters.operation, negotiated.transport); + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { + cwd: defaultCwd, + ...(parameters.lineageId === undefined + ? {} + : { lineageId: parameters.lineageId }), + ...(canonicalBaseRef === undefined + ? {} + : { baseRef: canonicalBaseRef, committedOnly: true }), + ...(untrackedSelection.untrackedScope === undefined + ? {} + : untrackedSelection), + ...(intendedUntrackedSelection === undefined + ? {} + : { intendedUntrackedSelection }), + ...(signal === undefined ? {} : { signal }), + }, + retainedUntrackedSelections, + defaultCwd, + ); + if (negotiated.transport !== undefined) + return hostTransportUnavailable( + parameters.operation, + negotiated.transport, + ); target = negotiated.status!; - if (target.nextTransition?.kind === "collect" || target.applicability !== "unrelated" || target.action !== "start") return mapNativeTargetStatus(parameters.operation, target, parameters.lineageId); + if ( + target.nextTransition?.kind === "collect" || + target.applicability !== "unrelated" || + target.action !== "start" + ) + return mapNativeTargetStatus( + parameters.operation, + target, + parameters.lineageId, + ); } catch (error) { return nativeOperationFailure(parameters.operation, error); } @@ -6178,24 +8775,53 @@ async function executeReviewControllerOperation( // recovery. Folding in currentCandidateTree makes a content change // mint a fresh replay key -- and therefore a fresh candidate view -- // instead of reusing the stale one. - const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null, candidateTree: target.projection.currentCandidateTree }); + const replayKey = JSON.stringify({ + cwd: defaultCwd, + lineageId: parameters.lineageId ?? null, + input: parameters.input ?? null, + inputPath: parameters.inputPath ?? null, + candidateTree: target.projection.currentCandidateTree, + }); // Synchronously drop any binding whose TTL has already elapsed // before reusing its retained candidate view, so a fresh-candidate // retry cannot reuse a view tied to an expired binding and trip // candidate-target-projection-drift. Timer order must not decide // correctness: the queued cleanup macrotask may not have fired yet. - pruneExpiredReviewConsents(pendingReviewConsentRegistry, pendingReviewConsentSession, reviewConsentNow); + pruneExpiredReviewConsents( + pendingReviewConsentRegistry, + pendingReviewConsentSession, + reviewConsentNow, + ); const candidateIntendedUntracked = target.projection.intendedUntracked; let candidateView: ReturnType | undefined; let nativeStartAttempted = false; try { - const candidateRequest = { contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) }; - candidateView = candidateViews?.createOrReuse({ ...candidateRequest, ...(candidateIntendedUntracked.length === 0 ? {} : { intendedUntracked: candidateIntendedUntracked }) }); - if (candidateView !== undefined && candidateIntendedUntracked.length === 0 && candidateView.candidateTree !== target.projection.currentCandidateTree) { + const candidateRequest = { + contributorRoot: defaultCwd, + replayKey, + ...(canonicalBaseRef === undefined + ? {} + : { baseRef: canonicalBaseRef, committedOnly: true }), + }; + candidateView = candidateViews?.createOrReuse({ + ...candidateRequest, + ...(candidateIntendedUntracked.length === 0 + ? {} + : { intendedUntracked: candidateIntendedUntracked }), + }); + if ( + candidateView !== undefined && + candidateIntendedUntracked.length === 0 && + candidateView.candidateTree !== target.projection.currentCandidateTree + ) { candidateView.cleanup(); - candidateView = candidateViews?.createOrReuse({ ...candidateRequest, intendedUntracked: [] }); + candidateView = candidateViews?.createOrReuse({ + ...candidateRequest, + intendedUntracked: [], + }); } - if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target); + if (candidateView !== undefined) + assertNativeStartCandidateBinding(candidateView, target); let result: NativeStartResult; try { nativeStartAttempted = true; @@ -6203,28 +8829,54 @@ async function executeReviewControllerOperation( cwd: defaultCwd, ...(canonicalBaseRef === undefined ? {} - : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }), + : { + baseRef: candidateView?.baseCommit ?? canonicalBaseRef, + committedOnly: true, + }), targetIdentity: target.targetIdentity, projection: target.projection.projection, - ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), - ...(intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection }), - ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), - ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }), + ...(untrackedSelection.untrackedScope === undefined + ? {} + : untrackedSelection), + ...(intendedUntrackedSelection === undefined + ? {} + : { intendedUntrackedSelection }), + ...(parameters.lineageId === undefined + ? {} + : { lineageId: parameters.lineageId }), + ...(policy.policyPath === undefined + ? {} + : { policyPath: policy.policyPath }), ...(focus === undefined ? {} : { focus }), ...(signal === undefined ? {} : { signal }), }); } catch (error) { if (!(error instanceof NativeReviewConsentRequiredError)) throw error; - if (candidateView === undefined) throw new CandidateViewError("native consent requires a frozen candidate view"); + if (candidateView === undefined) + throw new CandidateViewError( + "native consent requires a frozen candidate view", + ); const consentCandidateView = candidateView; const repositoryCwd = realpathSync(defaultCwd); const consentDigest = reviewConsentDigest(error.consent); - const pendingReviewConsents = pendingReviewConsentRegistry.get(pendingReviewConsentSession); - const existing = [...(pendingReviewConsents?.values() ?? [])].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > reviewConsentNow()); + const pendingReviewConsents = pendingReviewConsentRegistry.get( + pendingReviewConsentSession, + ); + const existing = [...(pendingReviewConsents?.values() ?? [])].find( + (pending) => + pending.repositoryCwd === repositoryCwd && + pending.candidateView.token === consentCandidateView.token && + pending.consentDigest === consentDigest && + pending.expiresAt > reviewConsentNow(), + ); if (existing === undefined) { for (const pending of [...(pendingReviewConsents?.values() ?? [])]) { if (pending.candidateView.token === consentCandidateView.token) { - discardPendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession); + discardPendingReviewConsent( + pending, + pendingReviewConsentRegistry, + pendingReviewConsentSession, + ); } } } @@ -6241,16 +8893,27 @@ async function executeReviewControllerOperation( cleanupCandidate: () => { if (candidateCleaned) return; candidateCleaned = true; - try { consentCandidateView.cleanup(); } catch { /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ } + try { + consentCandidateView.cleanup(); + } catch { + /* Failed ownership proof preserves the view; consent expiry/teardown still completes. */ + } }, - ...(retainedUntrackedSelection === undefined ? {} : { untrackedSelection: retainedUntrackedSelection }), + ...(retainedUntrackedSelection === undefined + ? {} + : { untrackedSelection: retainedUntrackedSelection }), consent: error.consent, consentDigest, expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS, }; pendingReviewConsentRegistry.add(pendingReviewConsentSession, pending); pending.expiry = reviewConsentScheduleTimer( - () => expirePendingReviewConsent(pending, pendingReviewConsentRegistry, pendingReviewConsentSession), + () => + expirePendingReviewConsent( + pending, + pendingReviewConsentRegistry, + pendingReviewConsentSession, + ), PENDING_REVIEW_CONSENT_TTL_MS, ); pending.expiry.unref(); @@ -6264,37 +8927,89 @@ async function executeReviewControllerOperation( ...nativeStartPreAuthorityRejection(), }; } - retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, result.lineageId, retainedUntrackedSelection); - return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews); - } catch (error) { - if (!nativeStartAttempted && error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true })); - if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason); + retainNativeUntrackedSelection( + retainedUntrackedSelections, + defaultCwd, + result.lineageId, + retainedUntrackedSelection, + ); + return completeNativeStart( + parameters.operation, + result, + defaultCwd, + candidateView, + candidateViews, + ); + } catch (error) { + if ( + !nativeStartAttempted && + error instanceof CandidateViewError && + error.diagnostics !== undefined + ) + return nativeOperationFailure( + parameters.operation, + Object.assign(error, { candidateViewPreNative: true }), + ); + if ( + error instanceof CandidateViewError && + (error.reason === "base-ref-ambiguous" || + error.reason === "base-ref-unresolvable" || + error.reason === "base-ref-moved") + ) + return nativeStartRejection(error.reason); const value = error as { mutationOutcome?: unknown; nextAction?: unknown }; const provenNoMutation = value.mutationOutcome === "none"; - const preNativeCandidateFailure = !nativeStartAttempted && error instanceof CandidateViewError; - if (candidateView && candidateViews && (provenNoMutation || preNativeCandidateFailure)) candidateViews.cleanup(candidateView.token); + const preNativeCandidateFailure = + !nativeStartAttempted && error instanceof CandidateViewError; + if ( + candidateView && + candidateViews && + (provenNoMutation || preNativeCandidateFailure) + ) + candidateViews.cleanup(candidateView.token); const failure = provenNoMutation ? error : preNativeCandidateFailure ? Object.assign(error, { candidateViewPreNative: true }) - : Object.assign(error instanceof Error ? error : new Error(String(error)), { - mutationOutcome: "unknown", - nextAction: "review.status", - }); - return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, { - cwd: defaultCwd, - ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), - ...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }), - ...(untrackedSelection.untrackedScope === undefined ? {} : untrackedSelection), - projection: "workspace", - }, retainedUntrackedSelections); + : Object.assign( + error instanceof Error ? error : new Error(String(error)), + { + mutationOutcome: "unknown", + nextAction: "review.status", + }, + ); + return reconcileNativeMutationFailure( + parameters.operation, + failure, + nativeReviewCli, + { + cwd: defaultCwd, + ...(parameters.lineageId === undefined + ? {} + : { lineageId: parameters.lineageId }), + ...(canonicalBaseRef === undefined + ? {} + : { + baseRef: candidateView?.baseCommit ?? canonicalBaseRef, + committedOnly: true, + }), + ...(untrackedSelection.untrackedScope === undefined + ? {} + : untrackedSelection), + projection: "workspace", + }, + retainedUntrackedSelections, + ); } } if (rawStart.mode === REVIEW_MODE.ORDINARY) { return nativeStatusUnsupported(parameters.operation); } const idempotencyKey = requiredControllerString(parameters, "idempotencyKey"); - if (typeof parameters.lineageId !== "string" || parameters.lineageId.trim().length === 0) { + if ( + typeof parameters.lineageId !== "string" || + parameters.lineageId.trim().length === 0 + ) { throw new Error("Judgment Day graph-v1 START requires lineageId"); } const input = parseStartInput(rawStart); @@ -6321,15 +9036,23 @@ async function executeReviewControllerOperation( try { result = store.create(state, idempotencyKey); } catch (error) { - if (!(error instanceof Error) || error.message !== "Graph lineage already exists") throw error; + if ( + !(error instanceof Error) || + error.message !== "Graph lineage already exists" + ) + throw error; const current = store.read(parameters.lineageId!); - const existing = current.request_journal.find((entry) => entry.idempotency_key === idempotencyKey); + const existing = current.request_journal.find( + (entry) => entry.idempotency_key === idempotencyKey, + ); if ( existing?.operation !== REVIEW_OPERATION.START || existing.request_hash !== canonicalHash(state) || existing.status !== JOURNAL_STATUS.COMPLETED ) { - throw new Error("Idempotency key was reused with a different START request; replay requires the same lineageId, idempotencyKey, and exact request"); + throw new Error( + "Idempotency key was reused with a different START request; replay requires the same lineageId, idempotencyKey, and exact request", + ); } result = existing.canonical_result as StartOperationResultV1; } @@ -6339,17 +9062,24 @@ async function executeReviewControllerOperation( const idempotencyKey = requiredControllerString(parameters, "idempotencyKey"); const transitionValue = requiredControllerString(parameters, "transition"); if (!isReviewTransition(transitionValue)) { - throw new Error(`Review controller transition is unsupported: ${transitionValue}`); + throw new Error( + `Review controller transition is unsupported: ${transitionValue}`, + ); } const hasInput = parameters.input !== undefined; const hasInputPath = parameters.inputPath !== undefined; if (hasInput === hasInputPath) { - throw new Error("Review controller advance requires exactly one of input or inputPath"); + throw new Error( + "Review controller advance requires exactly one of input or inputPath", + ); } const rawInput = parseControllerJson( hasInput ? requiredControllerString(parameters, "input") - : readRepositoryControllerInput(requiredControllerString(parameters, "inputPath"), defaultCwd), + : readRepositoryControllerInput( + requiredControllerString(parameters, "inputPath"), + defaultCwd, + ), REVIEW_CONTROLLER_OPERATION.ADVANCE, ); const store = ReviewTransactionStore.forRepository(defaultCwd); @@ -6369,39 +9099,89 @@ async function executeReviewControllerOperation( }; } if (parameters.operation === REVIEW_CONTROLLER_OPERATION.STATUS) { - const rawStatus = parameters.input === undefined - ? undefined - : parseControllerJson(parameters.input, REVIEW_CONTROLLER_OPERATION.STATUS); - const unknownField = rawStatus === undefined - ? undefined - : Object.keys(rawStatus).find((field) => !["baseRef", "committedOnly", "untrackedScope", "expectedUntrackedInventory", "intendedUntracked"].includes(field)); - if (unknownField !== undefined) return nativeStatusInputRejection("unknown-field", unknownField); + const rawStatus = + parameters.input === undefined + ? undefined + : parseControllerJson(parameters.input, REVIEW_CONTROLLER_OPERATION.STATUS); + const unknownField = + rawStatus === undefined + ? undefined + : Object.keys(rawStatus).find( + (field) => + ![ + "baseRef", + "committedOnly", + "untrackedScope", + "expectedUntrackedInventory", + "intendedUntracked", + ].includes(field), + ); + if (unknownField !== undefined) + return nativeStatusInputRejection("unknown-field", unknownField); const baseRef = rawStatus?.baseRef; - if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) return nativeStatusInputRejection("base-ref-invalid"); - if (baseRef !== undefined && rawStatus?.committedOnly !== true) return nativeStatusInputRejection("committed-only-required"); - if (baseRef === undefined && rawStatus !== undefined && "committedOnly" in rawStatus) return nativeStatusInputRejection("committed-only-invalid"); - const untrackedSelection = rawStatus === undefined ? {} : validateNativeStartUntrackedSelection(rawStatus); + if (baseRef !== undefined && !isCanonicalProcessString(baseRef)) + return nativeStatusInputRejection("base-ref-invalid"); + if (baseRef !== undefined && rawStatus?.committedOnly !== true) + return nativeStatusInputRejection("committed-only-required"); if ( + baseRef === undefined && rawStatus !== undefined && - (untrackedSelection.reason !== undefined || (baseRef === undefined && untrackedSelection.untrackedScope === undefined)) - ) return nativeStatusInputRejection(untrackedSelection.reason ?? "untracked-selection-invalid"); - const retainedUntrackedSelection = cloneRetainedNativeUntrackedSelection(untrackedSelection); - const effectiveUntrackedSelection = rawStatus === undefined && parameters.lineageId !== undefined - ? readRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId) - : untrackedSelection; - const retainedCommittedTarget = rawStatus === undefined && parameters.lineageId !== undefined && candidateViews?.hasProjection(parameters.lineageId, defaultCwd) - ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd) - : undefined; - const effectiveBaseRef = baseRef ?? (retainedCommittedTarget?.committedOnly === true ? retainedCommittedTarget.baseCommit : undefined); + "committedOnly" in rawStatus + ) + return nativeStatusInputRejection("committed-only-invalid"); + const untrackedSelection = + rawStatus === undefined + ? {} + : validateNativeStartUntrackedSelection(rawStatus); + if ( + rawStatus !== undefined && + (untrackedSelection.reason !== undefined || + (baseRef === undefined && untrackedSelection.untrackedScope === undefined)) + ) + return nativeStatusInputRejection( + untrackedSelection.reason ?? "untracked-selection-invalid", + ); + const retainedUntrackedSelection = + cloneRetainedNativeUntrackedSelection(untrackedSelection); + const effectiveUntrackedSelection = + rawStatus === undefined && parameters.lineageId !== undefined + ? readRetainedNativeUntrackedSelection( + retainedUntrackedSelections, + defaultCwd, + parameters.lineageId, + ) + : untrackedSelection; + const retainedCommittedTarget = + rawStatus === undefined && + parameters.lineageId !== undefined && + candidateViews?.hasProjection(parameters.lineageId, defaultCwd) + ? candidateViews.resolveProjection(parameters.lineageId, defaultCwd) + : undefined; + const effectiveBaseRef = + baseRef ?? + (retainedCommittedTarget?.committedOnly === true + ? retainedCommittedTarget.baseCommit + : undefined); if (nativeReviewCli?.targetStatus !== undefined) { try { - const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { - cwd: defaultCwd, - ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), - ...(effectiveBaseRef === undefined ? {} : { baseRef: effectiveBaseRef, committedOnly: true }), - ...(effectiveUntrackedSelection.untrackedScope === undefined ? {} : effectiveUntrackedSelection), - ...(signal === undefined ? {} : { signal }), - }, retainedUntrackedSelections, defaultCwd); + const negotiated = await negotiatedStatusForHostTransport( + nativeReviewCli, + { + cwd: defaultCwd, + ...(parameters.lineageId === undefined + ? {} + : { lineageId: parameters.lineageId }), + ...(effectiveBaseRef === undefined + ? {} + : { baseRef: effectiveBaseRef, committedOnly: true }), + ...(effectiveUntrackedSelection.untrackedScope === undefined + ? {} + : effectiveUntrackedSelection), + ...(signal === undefined ? {} : { signal }), + }, + retainedUntrackedSelections, + defaultCwd, + ); if (negotiated.transport !== undefined) { return { ...hostTransportUnavailable(parameters.operation, negotiated.transport), @@ -6414,17 +9194,38 @@ async function executeReviewControllerOperation( parameters.lineageId !== undefined && status.applicability === "current_target" && status.authority?.lineageId === parameters.lineageId - ) retainNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId, retainedUntrackedSelection); - clearRetainedNativeStatusSelectionsOnTerminal(retainedUntrackedSelections, defaultCwd, status.authority?.lineageId, status.authority?.state); + ) + retainNativeUntrackedSelection( + retainedUntrackedSelections, + defaultCwd, + parameters.lineageId, + retainedUntrackedSelection, + ); + clearRetainedNativeStatusSelectionsOnTerminal( + retainedUntrackedSelections, + defaultCwd, + status.authority?.lineageId, + status.authority?.state, + ); hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status); - return { ...mapNativeTargetStatus(parameters.operation, status, parameters.lineageId, defaultCwd), ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}) }; + return { + ...mapNativeTargetStatus( + parameters.operation, + status, + parameters.lineageId, + defaultCwd, + ), + ...(includeWorkspaceRoot ? { workspace_root: defaultCwd } : {}), + }; } catch (error) { return nativeOperationFailure(parameters.operation, error); } } return nativeStatusUnsupported(parameters.operation); } - throw new Error(`Review controller operation is unsupported: ${parameters.operation}`); + throw new Error( + `Review controller operation is unsupported: ${parameters.operation}`, + ); } /** @internal */ @@ -6479,7 +9280,12 @@ function resolveControllerSddStatus( includeInstructions: boolean, artifactStore: SddPreflightPreferences["artifactStore"] | undefined, ) { - return resolveSddStatus({ cwd, changeName, includeInstructions, artifactStore }); + return resolveSddStatus({ + cwd, + changeName, + includeInstructions, + artifactStore, + }); } function resolveStartupControllerSddStatus( @@ -6488,7 +9294,12 @@ function resolveStartupControllerSddStatus( includeInstructions: boolean, artifactStore: SddPreflightPreferences["artifactStore"] | undefined, ) { - return resolveControllerSddStatus(cwd, changeName, includeInstructions, artifactStore); + return resolveControllerSddStatus( + cwd, + changeName, + includeInstructions, + artifactStore, + ); } export interface GentleAiRuntimeDependencies { @@ -6502,14 +9313,20 @@ export interface GentleAiRuntimeDependencies { // tests inject a fake clock so expiry is observable without a 10-minute // sleep and without relying on the queued cleanup macrotask firing. now?: () => number; - scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void }; + scheduleTimer?: ( + callback: () => void, + delayMs: number, + ) => { unref: () => void }; // The environment the session's child processes inherit; tests inject a // plain object so the handshake declaration is observable without // touching the test runner's own process.env. processEnv?: NodeJS.ProcessEnv; // Package-owned children use this parent-bound channel only to ask whether // their own pending ordinary START may replay a grant locally. - childStandingReviewPermissionClient?: Pick; + childStandingReviewPermissionClient?: Pick< + ChildStandingReviewPermissionClient, + "requestAuthorization" | "close" + >; // gentle-pi#677: test-only seams for the telemetry trigger. Production // leaves both undefined: the real package-local resolveGentleAiBinary() // and the real detached child_process spawn run. @@ -6521,280 +9338,385 @@ export interface GentleAiRuntimeDependencies { telemetryExecFileAdapter?: ExecFileAdapter; } -export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void { +export function createGentleAiExtension( + dependencies: GentleAiRuntimeDependencies = {}, +): (pi: ExtensionAPI) => void { return createGentleAiExtensionForTesting(dependencies); } function createGentleAiExtensionForTesting( dependencies: GentleAiRuntimeDependencies = {}, ): (pi: ExtensionAPI) => void { - const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli; - const childStandingReviewPermissionLease = dependencies.childStandingReviewPermissionClient === undefined - ? acquireChildStandingReviewPermissionClient(dependencies.processEnv ?? process.env) - : undefined; - const childStandingReviewPermission = dependencies.childStandingReviewPermissionClient ?? childStandingReviewPermissionLease?.client; + const nativeReviewCli = + dependencies.nativeReviewCli === undefined + ? createNativeReviewCli() + : dependencies.nativeReviewCli; + const childStandingReviewPermissionLease = + dependencies.childStandingReviewPermissionClient === undefined + ? acquireChildStandingReviewPermissionClient( + dependencies.processEnv ?? process.env, + ) + : undefined; + const childStandingReviewPermission = + dependencies.childStandingReviewPermissionClient ?? + childStandingReviewPermissionLease?.client; const reviewConsentNow = dependencies.now ?? (() => Date.now()); - const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); - const pendingReviewConsentRegistry = dependencies.pendingReviewConsentRegistry ?? processPendingReviewConsentRegistry; - const resolveTelemetryTriggerBinary = dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary; - const telemetryExecFileAdapter = dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter(); + const reviewConsentScheduleTimer = + dependencies.scheduleTimer ?? + ((callback, delayMs) => setTimeout(callback, delayMs)); + const pendingReviewConsentRegistry = + dependencies.pendingReviewConsentRegistry ?? + processPendingReviewConsentRegistry; + const resolveTelemetryTriggerBinary = + dependencies.resolveTelemetryTriggerBinary ?? resolveGentleAiBinary; + const telemetryExecFileAdapter = + dependencies.telemetryExecFileAdapter ?? createNodeExecFileAdapter(); return function gentleAi(pi: ExtensionAPI): void { - declareReviewRelayHandshake(dependencies.processEnv ?? process.env); - const pendingReviewConsentFallbackKey = Symbol("pending-review-consent-fallback"); - const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews; - const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events); - const permissionEnvironment = dependencies.processEnv ?? process.env; - - const setReviewSessionPermissionStatus = (context: ExtensionContext, active: boolean): void => { - try { - (context.ui as unknown as { setStatus?: (key: string, text?: string) => void }).setStatus?.( - REVIEW_SESSION_PERMISSION_STATUS_KEY, - active ? REVIEW_SESSION_PERMISSION_STATUS_TEXT : undefined, + declareReviewRelayHandshake(dependencies.processEnv ?? process.env); + const pendingReviewConsentFallbackKey = Symbol( + "pending-review-consent-fallback", + ); + const candidateViews = + dependencies.candidateViews === undefined + ? new CandidateViewRegistry() + : dependencies.candidateViews; + const herdrLifecycle = createHerdrConfirmationLifecycle(pi.events); + const permissionEnvironment = dependencies.processEnv ?? process.env; + + const setReviewSessionPermissionStatus = ( + context: ExtensionContext, + active: boolean, + ): void => { + try { + ( + context.ui as unknown as { + setStatus?: (key: string, text?: string) => void; + } + ).setStatus?.( + REVIEW_SESSION_PERMISSION_STATUS_KEY, + active ? REVIEW_SESSION_PERMISSION_STATUS_TEXT : undefined, + ); + } catch { + /* Status is nonblocking and never permission authority. */ + } + }; + const capturePermissionIdentity = ( + context: ExtensionContext, + cwd: string = context.cwd, + ): Promise => + captureReviewSessionIdentity({ ...context, cwd }, permissionEnvironment); + const refreshReviewSessionPermissionStatus = async ( + context: ExtensionContext, + ): Promise => { + const identity = await capturePermissionIdentity(context); + setReviewSessionPermissionStatus( + context, + identity !== undefined && hasReviewSessionPermission(identity), ); - } catch { /* Status is nonblocking and never permission authority. */ } - }; - const capturePermissionIdentity = (context: ExtensionContext, cwd: string = context.cwd): Promise => - captureReviewSessionIdentity({ ...context, cwd }, permissionEnvironment); - const refreshReviewSessionPermissionStatus = async (context: ExtensionContext): Promise => { - const identity = await capturePermissionIdentity(context); - setReviewSessionPermissionStatus(context, identity !== undefined && hasReviewSessionPermission(identity)); - return identity; - }; - const revokeCurrentReviewSessionPermission = (context: ExtensionContext): boolean => { - const coordinates = reviewSessionManagerAndId(context); - const revoked = coordinates === undefined ? false : revokeReviewSessionPermissionsForSession(coordinates.manager, coordinates.sessionId); - setReviewSessionPermissionStatus(context, false); - return revoked; - }; - const revokeCurrentRepositoryReviewSessionPermission = async (context: ExtensionContext): Promise => { - const identity = await capturePermissionIdentity(context); - const revoked = identity === undefined ? false : revokeReviewSessionPermission(identity); - setReviewSessionPermissionStatus(context, false); - return revoked; - }; - - let reminderSessionActive = true; - let reminderEpoch = 0; - pi.on("session_shutdown", (event, context) => { - reminderSessionActive = false; - reminderEpoch += 1; - // Pi tears down this registry on reload as well as session replacement/quit. - try { candidateViews?.cleanupAll(); } catch { /* Preserve failed owned views for later recovery. */ } - const reason = (event as { reason?: unknown }).reason; - if (reason !== "reload") { - if (childStandingReviewPermissionLease === undefined) childStandingReviewPermission?.close(); else childStandingReviewPermissionLease.closeIfCurrent(); - revokeCurrentReviewSessionPermission(context); - } - const sessionKey = pendingReviewConsentSessionKey(context, pendingReviewConsentFallbackKey); - cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey); - processRetainedNativeStatusSelections.delete(sessionKey); - processAgentEndSubagentDepth.delete(sessionKey); - }); + return identity; + }; + const revokeCurrentReviewSessionPermission = ( + context: ExtensionContext, + ): boolean => { + const coordinates = reviewSessionManagerAndId(context); + const revoked = + coordinates === undefined + ? false + : revokeReviewSessionPermissionsForSession( + coordinates.manager, + coordinates.sessionId, + ); + setReviewSessionPermissionStatus(context, false); + return revoked; + }; + const revokeCurrentRepositoryReviewSessionPermission = async ( + context: ExtensionContext, + ): Promise => { + const identity = await capturePermissionIdentity(context); + const revoked = + identity === undefined ? false : revokeReviewSessionPermission(identity); + setReviewSessionPermissionStatus(context, false); + return revoked; + }; - pi.registerTool({ - name: "gentle_review_scope", - renderShell: "self", - label: "Gentle Review Scope", - description: "Read one bounded, integrity-checked page of the controller-owned frozen changed scope. This read-only tool never inspects the ambient or candidate tree.", - parameters: REVIEW_SCOPE_PARAMETERS, - executionMode: "parallel", - renderCall(_args, theme, context) { - return renderGentleAiLifecycleCall( - "review scope", - theme, - context as GentleAiRenderContext | undefined, + let reminderSessionActive = true; + let reminderEpoch = 0; + pi.on("session_shutdown", (event, context) => { + reminderSessionActive = false; + reminderEpoch += 1; + // Pi tears down this registry on reload as well as session replacement/quit. + try { + candidateViews?.cleanupAll(); + } catch { + /* Preserve failed owned views for later recovery. */ + } + const reason = (event as { reason?: unknown }).reason; + if (reason !== "reload") { + if (childStandingReviewPermissionLease === undefined) + childStandingReviewPermission?.close(); + else childStandingReviewPermissionLease.closeIfCurrent(); + revokeCurrentReviewSessionPermission(context); + } + const sessionKey = pendingReviewConsentSessionKey( + context, + pendingReviewConsentFallbackKey, ); - }, - renderResult(result, options, theme, context) { - return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined); - }, - async execute(_toolCallId, parameters) { - const input = parameters as ReviewScopeParameters; - const details = readCandidateContextManifestPage(input.manifest, input.sha256, input.cursor ?? 0); - return { content: [{ type: "text", text: JSON.stringify(details) }], details }; - }, - }); + cleanupAllPendingReviewConsents(pendingReviewConsentRegistry, sessionKey); + processRetainedNativeStatusSelections.delete(sessionKey); + processAgentEndSubagentDepth.delete(sessionKey); + }); - // The lens a reviewer capture runs is inside its collect binding, so the - // card can say "review capture · risk" instead of a bare operation name. - const lensLabel = (lens: unknown): string | undefined => - typeof lens === "string" && lens.length > 0 ? lens.replace(/^review-/, "") : undefined; - const collectBindingLens = (binding: unknown): string | undefined => { - if (typeof binding !== "string") return undefined; - try { - const parsed = JSON.parse(binding) as Record; - const subject = (parsed.artifactSubject ?? parsed.artifact_subject) as Record | undefined; - return lensLabel(subject?.lens); - } catch { - return undefined; - } - }; - const withLenses = (operation: string, lenses: readonly (string | undefined)[]): string => { - const named = lenses.filter((lens): lens is string => lens !== undefined); - return named.length === 0 ? operation : `${operation} · ${named.join(" · ")}`; - }; + pi.registerTool({ + name: "gentle_review_scope", + renderShell: "self", + label: "Gentle Review Scope", + description: + "Read one bounded, integrity-checked page of the controller-owned frozen changed scope. This read-only tool never inspects the ambient or candidate tree.", + parameters: REVIEW_SCOPE_PARAMETERS, + executionMode: "parallel", + renderCall(_args, theme, context) { + return renderGentleAiLifecycleCall( + "review scope", + theme, + context as GentleAiRenderContext | undefined, + ); + }, + renderResult(result, options, theme, context) { + return renderGentleAiResult( + result, + options, + theme, + context as GentleAiRenderContext | undefined, + ); + }, + async execute(_toolCallId, parameters) { + const input = parameters as ReviewScopeParameters; + const details = readCandidateContextManifestPage( + input.manifest, + input.sha256, + input.cursor ?? 0, + ); + return { + content: [{ type: "text", text: JSON.stringify(details) }], + details, + }; + }, + }); - pi.registerTool({ - name: "gentle_review_capture_group", - renderShell: "self", - label: "Gentle Review Capture Group", - description: "Capture one complete provider-issued materialize reviewer group. It validates the exact ordered current collect set, forecasts its bounded model cost, runs reviewers concurrently, and admits outputs one at a time in provider order.", - promptSnippet: "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.", - promptGuidelines: [ - "Pass only lineageId, the complete ordered collectBindings array from one current STATUS result, and reviewerRunAcknowledged after its forecast. Never mix, reorder, duplicate, or partially select bindings.", - "The group materializes and runs independent reviewers concurrently, but rechecks STATUS before every provider-ordered submission. It stops on a closure, correction, drift, or uncertain capture outcome; it never follows another transition or replays a prepared output.", - ], - parameters: REVIEW_CAPTURE_GROUP_PARAMETERS, - executionMode: "sequential", - renderCall(args, theme, context) { - const bindings = (args as { collectBindings?: unknown }).collectBindings; - const lenses = Array.isArray(bindings) ? bindings.map(collectBindingLens) : []; - return renderGentleAiLifecycleCall(withLenses("review capture group", lenses), theme, context as GentleAiRenderContext | undefined); - }, - renderResult(result, options, theme, context) { - return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined); - }, - async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { - if (signal?.aborted) throw new Error("Review capture group was cancelled"); - const details = await executeReviewCaptureGroupOperation( - parameters, - ctx.cwd, - nativeReviewCli, - signal, - candidateViews, - ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)), - true, - ); - return { content: [{ type: "text", text: JSON.stringify(details) }], details }; - }, - }); + // The lens a reviewer capture runs is inside its collect binding, so the + // card can say "review capture · risk" instead of a bare operation name. + const lensLabel = (lens: unknown): string | undefined => + typeof lens === "string" && lens.length > 0 + ? lens.replace(/^review-/, "") + : undefined; + const collectBindingLens = (binding: unknown): string | undefined => { + if (typeof binding !== "string") return undefined; + try { + const parsed = JSON.parse(binding) as Record; + const subject = (parsed.artifactSubject ?? parsed.artifact_subject) as + | Record + | undefined; + return lensLabel(subject?.lens); + } catch { + return undefined; + } + }; + const withLenses = ( + operation: string, + lenses: readonly (string | undefined)[], + ): string => { + const named = lenses.filter((lens): lens is string => lens !== undefined); + return named.length === 0 + ? operation + : `${operation} · ${named.join(" · ")}`; + }; - pi.registerTool({ - name: "gentle_review_capture", - renderShell: "self", - label: "Gentle Review Capture", - description: "Capture exactly one provider-issued ordinary native review collect slot. This is not a controller operation: it validates one opaque collect binding against current target-scoped STATUS, executes at most one capture, and never follows a transition.", - promptSnippet: "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.", - promptGuidelines: [ - "Pass only lineageId, the JSON-serialized exact collectBinding from current STATUS, and the route-specific optional acknowledgement or correctionLines value. Never compose provider argument tokens, prompts, results, verdicts, or lens arrays.", - "A materialize reviewer slot first forecasts one model run; re-submit that same exact binding with reviewerRunAcknowledged: true to authorize one host relay. Correction-plan slots require correctionLines inside the provider-issued bounds, counted in diff lines (one replaced source line is one deletion plus one addition) — a different unit from the frozen logical correction budget. Refuter and validation vectors execute exactly once as provider-rendered.", - "A native terminal closure or nonterminal capture returns directly. Do not expect automatic STATUS, FINALIZE, receipt, delivery, or another capture; call fresh STATUS before any next capture.", - ], - parameters: REVIEW_CAPTURE_PARAMETERS, - executionMode: "sequential", - renderCall(args, theme, context) { - return renderGentleAiLifecycleCall( - withLenses("review capture", [collectBindingLens((args as { collectBinding?: unknown }).collectBinding)]), - theme, - context as GentleAiRenderContext | undefined, - ); - }, - renderResult(result, options, theme, context) { - return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined); - }, - async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { - if (signal?.aborted) throw new Error("Review capture was cancelled"); - const details = await executeReviewCaptureOperation( - parameters, - ctx.cwd, - nativeReviewCli, - signal, - candidateViews, - ((sessionKey: PendingReviewConsentSessionKey) => processRetainedNativeStatusSelections.get(sessionKey) ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!)(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)), - true, - ); - return { - content: [{ type: "text", text: JSON.stringify(details) }], - details, - }; - }, - }); + pi.registerTool({ + name: "gentle_review_capture_group", + renderShell: "self", + label: "Gentle Review Capture Group", + description: + "Capture one complete provider-issued materialize reviewer group. It validates the exact ordered current collect set, forecasts its bounded model cost, runs reviewers concurrently, and admits outputs one at a time in provider order.", + promptSnippet: + "Use one complete exact current STATUS materialize reviewer group; acknowledge its forecast before the grouped run.", + promptGuidelines: [ + "Pass only lineageId, the complete ordered collectBindings array from one current STATUS result, and reviewerRunAcknowledged after its forecast. Never mix, reorder, duplicate, or partially select bindings.", + "The group materializes and runs independent reviewers concurrently, but rechecks STATUS before every provider-ordered submission. It stops on a closure, correction, drift, or uncertain capture outcome; it never follows another transition or replays a prepared output.", + ], + parameters: REVIEW_CAPTURE_GROUP_PARAMETERS, + executionMode: "sequential", + renderCall(args, theme, context) { + const bindings = (args as { collectBindings?: unknown }).collectBindings; + const lenses = Array.isArray(bindings) + ? bindings.map(collectBindingLens) + : []; + return renderGentleAiLifecycleCall( + withLenses("review capture group", lenses), + theme, + context as GentleAiRenderContext | undefined, + ); + }, + renderResult(result, options, theme, context) { + return renderGentleAiResult( + result, + options, + theme, + context as GentleAiRenderContext | undefined, + ); + }, + async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { + if (signal?.aborted) throw new Error("Review capture group was cancelled"); + const details = await executeReviewCaptureGroupOperation( + parameters, + ctx.cwd, + nativeReviewCli, + signal, + candidateViews, + ((sessionKey: PendingReviewConsentSessionKey) => + processRetainedNativeStatusSelections.get(sessionKey) ?? + processRetainedNativeStatusSelections + .set(sessionKey, new Map()) + .get(sessionKey)!)( + pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey), + ), + true, + ); + return { + content: [{ type: "text", text: JSON.stringify(details) }], + details, + }; + }, + }); - pi.registerTool({ - name: "gentle_review", - renderShell: "self", - label: "Gentle Review Controller", - description: - "Inspect and recover review authority and start native ordinary review. Ordinary capture is available only through the separate gentle_review_capture tool. Review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy. RESET/RECOVER remain destructive and are executed by the audited native CLI.", - promptSnippet: "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot", - promptGuidelines: [ - 'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\"}"; an explicit baseRef must be paired with committedOnly: true to request a committed range, while policyPath remains repository-local. policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget; the frozen correction budget counts logical corrections, while correction-plan correctionLines count diff lines (one replaced source line is one deletion plus one addition).', - "Use RECONCILE_AUTHORITY only to quarantine one invalid native recovery successor. Supply exact predecessorLineage, expectedPredecessorRevision, successorLineage, expectedSuccessorRevision, actor, and reason values; Pi derives and displays the seven-line native authorization binding for fresh UI approval. The predecessor stays untouched, native returns the durable audit record, and Pi never falls back to RESET or RECOVER.", - "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.", - "Lens, refuter, and validator verdicts are admitted natively, never Pi-authored. Use gentle_review_capture with exactly one current provider-owned collectBinding for ordinary native capture; it never follows another transition.", - "For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim`; only RESET carries the legacy repositoryId, commonDirHash, inventoryHash, and confirmation challenge. RECOVER routes to native `gentle-ai review recover` with exactly six inputs: predecessorLineage, expectedPredecessorRevision, successorLineage, disposition, actor, and reason. Never send RECOVER the reset challenge and never send it a maintainerAuthorization: Pi reads fresh native target status, pins the predecessor lineage, revision, provider-selected disposition, and target identity, derives the exact six-line native authorization binding, displays it for fresh UI approval, and re-reads status before mutating. Negotiated target status supplies the sole accepted recovery disposition, and a caller-supplied substitute is rejected. Treat a native-input-required envelope as a request for exact values, never as permission to invent them. After a committed native recovery record, INSPECT before any fresh ordinary START.", - "A consent-required START may be resolved inside the eligible interactive Pi host. Its third UI action is host-owned: it runs this envelope's exact provider grant once and allows later fresh validated envelopes only for the same live SessionManager, nonempty session ID, and canonical Git common-directory identity, including sibling worktrees; an unrelated repository requires a new explicit human grant. Revoke removes the current repository grant, while nonreload replacement, quit, and process exit remove all session grants; reload preserves them. It grants no provider mode, verdict, acknowledgement, maintenance, delivery, or cross-repository authority. A package-owned child may ask its parent only with the canonical digest of its exact pending target; the parent binds that digest to the task repository and fails closed otherwise. If the tool returns an unresolved envelope, present the original two provider choices without changing machine tokens, commands, target IDs, or invocations; never add the host action to the decoded provider envelope. After one explicit relayed human answer, call answer-consent exactly once with only consentBinding and answer (`granted` or `declined`). Never create host permission from tool arguments, model prose, child/headless responses, or an uncertain native result. A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.", - "Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.", - 'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"","committedOnly":true}), to record the writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state the native review\'s outcome for this candidate ({"nativeReviewOutcome":"closed|declined|unavailable|unknown"}). Omitting writerModelId and writerEffort is treated as a small writer profile (fail closed), never large, because the writer\'s actual profile is then unknown to this call; pass the writer\'s real model id/effort to get credit for a known large profile. The on-path (writer self-verification is the record, no separate verifier) holds only when nativeReviewOutcome is "closed" for this candidate; a decline, an unavailable review, or an omitted/unknown outcome falls back to the exact risk-gated plan RDD off would return, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below RDD off. "closed" is never inferred: pass it only right after this same caller acknowledged the approved review for this same candidate; omitting nativeReviewOutcome only ever auto-derives declined/unavailable, bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state. The result\'s outcome_source (explicit|derived|unknown) states which. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.', - ], - parameters: REVIEW_CONTROLLER_PARAMETERS, - executionMode: "sequential", - renderCall(args, theme, context) { - return renderGentleAiLifecycleCall( - reviewToolOperationPath(args), - theme, - context as GentleAiRenderContext | undefined, - ); - }, - renderResult(result, options, theme, context) { - return renderGentleAiResult(result, options, theme, context as GentleAiRenderContext | undefined); - }, - async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { - if (signal?.aborted) throw new Error("Review controller operation was cancelled"); - await authorizeDestructiveReviewOperation(parameters, ctx); - const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); - const retainedSelections = processRetainedNativeStatusSelections.get(sessionKey) - ?? processRetainedNativeStatusSelections.set(sessionKey, new Map()).get(sessionKey)!; - // Snapshot before native awaits: a concurrent own write is a new generation. - const acknowledgementEpoch = reminderEpoch; - let acknowledgementRoot: string | undefined; - let acknowledgementMutation: string | undefined; - try { - const parsed = parseReviewControllerParameters(parameters); - if (parsed.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED) { - acknowledgementRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId); - acknowledgementMutation = pendingReviewMutation(ctx.sessionManager, acknowledgementRoot); - } - } catch { /* Controller validation owns invalid parameters and unavailable roots. */ } - let details = await executeReviewControllerOperation( - parameters, - ctx.cwd, - nativeReviewCli, - signal, - candidateViews, - ctx, - retainedSelections, - pendingReviewConsentRegistry, - pendingReviewConsentFallbackKey, - reviewConsentNow, - reviewConsentScheduleTimer, - ); - if (details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED && - details.outcome === "native-approved-acknowledgement-completed" && - details.status === "closed" && details.authority === "burned" && - typeof details.target_identity === "string") { + pi.registerTool({ + name: "gentle_review_capture", + renderShell: "self", + label: "Gentle Review Capture", + description: + "Capture exactly one provider-issued ordinary native review collect slot. This is not a controller operation: it validates one opaque collect binding against current target-scoped STATUS, executes at most one capture, and never follows a transition.", + promptSnippet: + "Use one exact current STATUS collectBinding for one ordinary native capture; call fresh STATUS before every additional capture.", + promptGuidelines: [ + "Pass only lineageId, the JSON-serialized exact collectBinding from current STATUS, and the route-specific optional acknowledgement or correctionLines value. Never compose provider argument tokens, prompts, results, verdicts, or lens arrays.", + "A materialize reviewer slot first forecasts one model run; re-submit that same exact binding with reviewerRunAcknowledged: true to authorize one host relay. Correction-plan slots require correctionLines inside the provider-issued bounds, counted in diff lines (one replaced source line is one deletion plus one addition) — a different unit from the frozen logical correction budget. Refuter and validation vectors execute exactly once as provider-rendered.", + "A native terminal closure or nonterminal capture returns directly. Do not expect automatic STATUS, FINALIZE, receipt, delivery, or another capture; call fresh STATUS before any next capture.", + ], + parameters: REVIEW_CAPTURE_PARAMETERS, + executionMode: "sequential", + renderCall(args, theme, context) { + return renderGentleAiLifecycleCall( + withLenses("review capture", [ + collectBindingLens((args as { collectBinding?: unknown }).collectBinding), + ]), + theme, + context as GentleAiRenderContext | undefined, + ); + }, + renderResult(result, options, theme, context) { + return renderGentleAiResult( + result, + options, + theme, + context as GentleAiRenderContext | undefined, + ); + }, + async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { + if (signal?.aborted) throw new Error("Review capture was cancelled"); + const details = await executeReviewCaptureOperation( + parameters, + ctx.cwd, + nativeReviewCli, + signal, + candidateViews, + ((sessionKey: PendingReviewConsentSessionKey) => + processRetainedNativeStatusSelections.get(sessionKey) ?? + processRetainedNativeStatusSelections + .set(sessionKey, new Map()) + .get(sessionKey)!)( + pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey), + ), + true, + ); + return { + content: [{ type: "text", text: JSON.stringify(details) }], + details, + }; + }, + }); + + pi.registerTool({ + name: "gentle_review", + renderShell: "self", + label: "Gentle Review Controller", + description: + "Inspect and recover review authority and start native ordinary review. Ordinary capture is available only through the separate gentle_review_capture tool. Review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy. RESET/RECOVER remain destructive and are executed by the audited native CLI.", + promptSnippet: + "Inspect authority, then start native ordinary review; use gentle_review_capture for one current collect slot", + promptGuidelines: [ + 'Call {"operation":"inspect"} before START. New native ordinary START uses a JSON string such as "{\\"mode\\":\\"ordinary\\"}"; an explicit baseRef must be paired with committedOnly: true to request a committed range, while policyPath remains repository-local. policyHash is legacy compact-only. The controller derives lineage, Git/untracked scope, tier, lenses, authored lines, and budget; the frozen correction budget counts logical corrections, while correction-plan correctionLines count diff lines (one replaced source line is one deletion plus one addition).', + "Use RECONCILE_AUTHORITY only to quarantine one invalid native recovery successor. Supply exact predecessorLineage, expectedPredecessorRevision, successorLineage, expectedSuccessorRevision, actor, and reason values; Pi derives and displays the seven-line native authorization binding for fresh UI approval. The predecessor stays untouched, native returns the durable audit record, and Pi never falls back to RESET or RECOVER.", + "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, actor, and reason; QUARANTINE_LEGACY accepts only the published malformed freeze-findings diagnostic/disposition. A dual reconciliation may supply only anomalies `unchanged_target,malformed_recovery_authorization` in that exact order. Use REPAIR_LEGACY_ALIAS only with lineage, actor, and reason: Pi freshly reads native inventory and derives repository, revision, diagnostic, disposition, and the exact eight-line binding before interactive approval. `review dispose-result` is unsupported pending design.", + "Lens, refuter, and validator verdicts are admitted natively, never Pi-authored. Use gentle_review_capture with exactly one current provider-owned collectBinding for ordinary native capture; it never follows another transition.", + "For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim`; only RESET carries the legacy repositoryId, commonDirHash, inventoryHash, and confirmation challenge. RECOVER routes to native `gentle-ai review recover` with exactly six inputs: predecessorLineage, expectedPredecessorRevision, successorLineage, disposition, actor, and reason. Never send RECOVER the reset challenge and never send it a maintainerAuthorization: Pi reads fresh native target status, pins the predecessor lineage, revision, provider-selected disposition, and target identity, derives the exact six-line native authorization binding, displays it for fresh UI approval, and re-reads status before mutating. Negotiated target status supplies the sole accepted recovery disposition, and a caller-supplied substitute is rejected. Treat a native-input-required envelope as a request for exact values, never as permission to invent them. After a committed native recovery record, INSPECT before any fresh ordinary START.", + "A consent-required START may be resolved inside the eligible interactive Pi host. Its third UI action is host-owned: it runs this envelope's exact provider grant once and allows later fresh validated envelopes only for the same live SessionManager, nonempty session ID, and canonical Git common-directory identity, including sibling worktrees; an unrelated repository requires a new explicit human grant. Revoke removes the current repository grant, while nonreload replacement, quit, and process exit remove all session grants; reload preserves them. It grants no provider mode, verdict, acknowledgement, maintenance, delivery, or cross-repository authority. A package-owned child may ask its parent only with the canonical digest of its exact pending target; the parent binds that digest to the task repository and fails closed otherwise. If the tool returns an unresolved envelope, present the original two provider choices without changing machine tokens, commands, target IDs, or invocations; never add the host action to the decoded provider envelope. After one explicit relayed human answer, call answer-consent exactly once with only consentBinding and answer (`granted` or `declined`). Never create host permission from tool arguments, model prose, child/headless responses, or an uncertain native result. A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START output, the controller calls target-scoped native status once and returns only its declared action. An ambiguous gentle_review_capture outcome independently reconciles once and never replays the capture.", + "Use gentle_review only for native review authority operations; delivery commands follow ordinary repository policy.", + 'ASSESS (gentle-pi#662/#668) is read-only and needs no lineageId: after a delegated writer returns, call {"operation":"assess"} over its diff and follow the returned plan (writerSelfVerification, structuralReadbackOnly, independentVerifier, reason) instead of judging non-triviality from the task description. Pass input as JSON only to assess a committed range ({"baseRef":"","committedOnly":true}), to record the writer profile ({"writerModelId":"...", "writerEffort":"..."}), or to state the native review\'s outcome for this candidate ({"nativeReviewOutcome":"closed|declined|unavailable|unknown"}). Omitting writerModelId and writerEffort is treated as a small writer profile (fail closed), never large, because the writer\'s actual profile is then unknown to this call; pass the writer\'s real model id/effort to get credit for a known large profile. The on-path (writer self-verification is the record, no separate verifier) holds only when nativeReviewOutcome is "closed" for this candidate; a decline, an unavailable review, or an omitted/unknown outcome falls back to the exact risk-gated plan RDD off would return, re-enabling the separate verifier -- a decline is candidate-scoped and never lowers the bar below RDD off. "closed" is never inferred: pass it only right after this same caller acknowledged the approved review for this same candidate; omitting nativeReviewOutcome only ever auto-derives declined/unavailable, bound to that exact candidate\'s own target identity, never to a different candidate or to bare repository state. The result\'s outcome_source (explicit|derived|unknown) states which. A failed or unavailable native assessment reports risk "unassessable", verified exactly like "high". This never mutates review authority state.', + ], + parameters: REVIEW_CONTROLLER_PARAMETERS, + executionMode: "sequential", + renderCall(args, theme, context) { + return renderGentleAiLifecycleCall( + reviewToolOperationPath(args), + theme, + context as GentleAiRenderContext | undefined, + ); + }, + renderResult(result, options, theme, context) { + return renderGentleAiResult( + result, + options, + theme, + context as GentleAiRenderContext | undefined, + ); + }, + async execute(_toolCallId, parameters, signal, _onUpdate, ctx) { + if (signal?.aborted) + throw new Error("Review controller operation was cancelled"); + await authorizeDestructiveReviewOperation(parameters, ctx); + const sessionKey = pendingReviewConsentSessionKey( + ctx, + pendingReviewConsentFallbackKey, + ); + const retainedSelections = + processRetainedNativeStatusSelections.get(sessionKey) ?? + processRetainedNativeStatusSelections + .set(sessionKey, new Map()) + .get(sessionKey)!; + // Snapshot before native awaits: a concurrent own write is a new generation. + const acknowledgementEpoch = reminderEpoch; + let acknowledgementRoot: string | undefined; + let acknowledgementMutation: string | undefined; try { - if (reminderSessionActive && acknowledgementEpoch === reminderEpoch && acknowledgementRoot && pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) === sessionKey) { - consumeReviewMutation(pi, ctx.sessionManager, acknowledgementRoot, acknowledgementMutation, "acknowledged", details.target_identity); + const parsed = parseReviewControllerParameters(parameters); + if ( + parsed.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED + ) { + acknowledgementRoot = resolveReviewControllerWorkspaceRoot( + parsed.workspaceRoot, + ctx.cwd, + candidateViews, + parsed.lineageId, + ); + acknowledgementMutation = pendingReviewMutation( + ctx.sessionManager, + acknowledgementRoot, + ); } - } catch { /* Bookkeeping cannot hide a confirmed native burn. */ } - } - if ( - isHostReviewConsentEligibleOperation(parameters) && - details.outcome === "native-review-consent-required" && - typeof details.consent_binding === "string" - ) { - const resolved = pendingReviewConsentRegistry.resolve(details.consent_binding); - const pending = resolved?.pending; - const eligiblePending = pending !== undefined && isPiConsentV3(pending.consent) - ? pending - : undefined; - const answerPendingConsent = async (answer: "granted" | "declined") => executeReviewControllerOperation( - { - operation: REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, - input: JSON.stringify({ consentBinding: eligiblePending!.id, answer }), - workspaceRoot: eligiblePending!.authorityCwd, - }, + } catch { + /* Controller validation owns invalid parameters and unavailable roots. */ + } + let details = await executeReviewControllerOperation( + parameters, ctx.cwd, nativeReviewCli, signal, @@ -6806,626 +9728,991 @@ function createGentleAiExtensionForTesting( reviewConsentNow, reviewConsentScheduleTimer, ); - let permissionWorkspaceRoot: string | undefined; - try { - const parsed = parseReviewControllerParameters(parameters); - permissionWorkspaceRoot = resolveReviewControllerWorkspaceRoot(parsed.workspaceRoot, ctx.cwd, candidateViews, parsed.lineageId); - } catch { - // The successful native operation above remains authoritative; an - // unresolvable local binding simply cannot consume host permission. + if ( + details.operation === REVIEW_CONTROLLER_OPERATION.ACKNOWLEDGE_APPROVED && + details.outcome === "native-approved-acknowledgement-completed" && + details.status === "closed" && + details.authority === "burned" && + typeof details.target_identity === "string" + ) { + try { + if ( + reminderSessionActive && + acknowledgementEpoch === reminderEpoch && + acknowledgementRoot && + pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) === + sessionKey + ) { + consumeReviewMutation( + pi, + ctx.sessionManager, + acknowledgementRoot, + acknowledgementMutation, + "acknowledged", + details.target_identity, + ); + } + } catch { + /* Bookkeeping cannot hide a confirmed native burn. */ + } } - const initialIdentity = permissionWorkspaceRoot === undefined - ? undefined - : await capturePermissionIdentity(ctx, permissionWorkspaceRoot); - if (eligiblePending !== undefined && initialIdentity === undefined) { - // A package child has no local standing grant. It may ask its - // inherited parent only for the canonical repository identity of - // this exact pending target, then replay this local binding once. - const repositoryIdentity = permissionWorkspaceRoot === undefined - ? undefined - : await resolveCanonicalGitRepositoryIdentity(permissionWorkspaceRoot); - if (repositoryIdentity !== undefined && await childStandingReviewPermission?.requestAuthorization(repositoryIdentity) === true) details = await answerPendingConsent("granted"); - } else if (eligiblePending !== undefined && initialIdentity !== undefined) { - const initialEpoch = reviewSessionPermissionEpoch(initialIdentity); - const permissionAlreadyActive = hasReviewSessionPermission(initialIdentity); - const selection = initialEpoch === undefined - ? undefined - : permissionAlreadyActive - ? { kind: "host-session" as const } - : await presentReviewConsentUi(ctx, eligiblePending.consent); - if (selection !== undefined) { - const confirmedIdentity = await capturePermissionIdentity(ctx, permissionWorkspaceRoot); - if (initialEpoch !== undefined && confirmedIdentity !== undefined && sameReviewSessionIdentity(initialIdentity, confirmedIdentity) && reviewSessionPermissionEpoch(confirmedIdentity) === initialEpoch) { - const answer = selection.kind === "provider" ? selection.answer : "granted"; - details = await answerPendingConsent(answer); - if (!permissionAlreadyActive && selection.kind === "host-session" && completedGrantedReviewConsent(details)) { - if (grantReviewSessionPermission(confirmedIdentity, initialEpoch)) { - setReviewSessionPermissionStatus(ctx, true); - try { ctx.ui.notify("Reviews are allowed for this Pi session and this Git repository.", "info"); } catch { /* Nonblocking indication only. */ } - } else { - try { ctx.ui.notify("This review started, but the in-memory session permission registry was incompatible, so later candidates will ask again.", "warning"); } catch { /* Best effort. */ } + if ( + isHostReviewConsentEligibleOperation(parameters) && + details.outcome === "native-review-consent-required" && + typeof details.consent_binding === "string" + ) { + const resolved = pendingReviewConsentRegistry.resolve( + details.consent_binding, + ); + const pending = resolved?.pending; + const eligiblePending = + pending !== undefined && isPiConsentV3(pending.consent) + ? pending + : undefined; + const answerPendingConsent = async (answer: "granted" | "declined") => + executeReviewControllerOperation( + { + operation: REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, + input: JSON.stringify({ consentBinding: eligiblePending!.id, answer }), + workspaceRoot: eligiblePending!.authorityCwd, + }, + ctx.cwd, + nativeReviewCli, + signal, + candidateViews, + ctx, + retainedSelections, + pendingReviewConsentRegistry, + pendingReviewConsentFallbackKey, + reviewConsentNow, + reviewConsentScheduleTimer, + ); + let permissionWorkspaceRoot: string | undefined; + try { + const parsed = parseReviewControllerParameters(parameters); + permissionWorkspaceRoot = resolveReviewControllerWorkspaceRoot( + parsed.workspaceRoot, + ctx.cwd, + candidateViews, + parsed.lineageId, + ); + } catch { + // The successful native operation above remains authoritative; an + // unresolvable local binding simply cannot consume host permission. + } + const initialIdentity = + permissionWorkspaceRoot === undefined + ? undefined + : await capturePermissionIdentity(ctx, permissionWorkspaceRoot); + if (eligiblePending !== undefined && initialIdentity === undefined) { + // A package child has no local standing grant. It may ask its + // inherited parent only for the canonical repository identity of + // this exact pending target, then replay this local binding once. + const repositoryIdentity = + permissionWorkspaceRoot === undefined + ? undefined + : await resolveCanonicalGitRepositoryIdentity(permissionWorkspaceRoot); + if ( + repositoryIdentity !== undefined && + (await childStandingReviewPermission?.requestAuthorization( + repositoryIdentity, + )) === true + ) + details = await answerPendingConsent("granted"); + } else if ( + eligiblePending !== undefined && + initialIdentity !== undefined + ) { + const initialEpoch = reviewSessionPermissionEpoch(initialIdentity); + const permissionAlreadyActive = + hasReviewSessionPermission(initialIdentity); + const selection = + initialEpoch === undefined + ? undefined + : permissionAlreadyActive + ? { kind: "host-session" as const } + : await presentReviewConsentUi(ctx, eligiblePending.consent); + if (selection !== undefined) { + const confirmedIdentity = await capturePermissionIdentity( + ctx, + permissionWorkspaceRoot, + ); + if ( + initialEpoch !== undefined && + confirmedIdentity !== undefined && + sameReviewSessionIdentity(initialIdentity, confirmedIdentity) && + reviewSessionPermissionEpoch(confirmedIdentity) === initialEpoch + ) { + const answer = + selection.kind === "provider" ? selection.answer : "granted"; + details = await answerPendingConsent(answer); + if ( + !permissionAlreadyActive && + selection.kind === "host-session" && + completedGrantedReviewConsent(details) + ) { + if (grantReviewSessionPermission(confirmedIdentity, initialEpoch)) { + setReviewSessionPermissionStatus(ctx, true); + try { + ctx.ui.notify( + "Reviews are allowed for this Pi session and this Git repository.", + "info", + ); + } catch { + /* Nonblocking indication only. */ + } + } else { + try { + ctx.ui.notify( + "This review started, but the in-memory session permission registry was incompatible, so later candidates will ask again.", + "warning", + ); + } catch { + /* Best effort. */ + } + } } } } } } - } - return { - content: [{ type: "text", text: JSON.stringify(details) }], - details, - }; - }, - }); - - function runSddPreflight(ctx: ExtensionContext, promptFields: readonly SddPreflightField[] = []): Promise { - return ensureSddPreflight(ctx, { pi, installAssets: (cwd) => installPackageAssets(cwd, true, ["sdd"]), applyModelConfig: async () => applySavedModelConfig(ctx) }, { promptFields }); - } + return { + content: [{ type: "text", text: JSON.stringify(details) }], + details, + }; + }, + }); - pi.on("session_start", async (event, ctx) => { - reminderSessionActive = true; - reminderEpoch += 1; - try { candidateViews?.sweepOrphans(ctx.cwd); } catch { /* Ownership sweeping must not block startup. */ } - const reason = (event as { reason?: unknown }).reason; - if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx); - await refreshReviewSessionPermissionStatus(ctx); - // Loud, every session: an active dev-binary override means this session - // runs an unpinned gentle-ai. Announce which one before anything else. - try { - const devBinary = await describeDevBinaryOverride(); - if (ctx.hasUI && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning"); - if (ctx.hasUI && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error"); - } catch (error) { - if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning"); + function runSddPreflight( + ctx: ExtensionContext, + promptFields: readonly SddPreflightField[] = [], + ): Promise { + return ensureSddPreflight( + ctx, + { + pi, + installAssets: (cwd) => installPackageAssets(cwd, true, ["sdd"]), + applyModelConfig: async () => applySavedModelConfig(ctx), + }, + { promptFields }, + ); } - try { - const installResult = installPackageAssets(ctx.cwd, true, ["delegation", "review"]); - migrateLegacyProjectModelOverrides(ctx.cwd); - const modelResult = await applySavedModelConfig(ctx); - if (ctx.hasUI && modelResult.invalidPath) { - ctx.ui.notify( - `el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`, - "warning", - ); - return; - } - if (ctx.hasUI && modelResult.updated > 0) { - ctx.ui.notify( - `el Gentleman applied saved model config to ${modelResult.updated} agent(s). Global delegation/review assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`, - "info", - ); + + pi.on("session_start", async (event, ctx) => { + reminderSessionActive = true; + reminderEpoch += 1; + try { + candidateViews?.sweepOrphans(ctx.cwd); + } catch { + /* Ownership sweeping must not block startup. */ } - } catch (error) { - if (ctx.hasUI) { - const message = - error instanceof Error ? error.message : String(error); - ctx.ui.notify( - `el Gentleman model config sweep failed: ${message}`, - "warning", - ); + const reason = (event as { reason?: unknown }).reason; + if (reason !== "reload") revokeCurrentReviewSessionPermission(ctx); + await refreshReviewSessionPermissionStatus(ctx); + // Loud, every session: an active dev-binary override means this session + // runs an unpinned gentle-ai. Announce which one before anything else. + try { + const devBinary = await describeDevBinaryOverride(); + if (ctx.hasUI && devBinary.state === "active") + ctx.ui.notify(devBinary.line, "warning"); + if (ctx.hasUI && devBinary.state === "invalid") + ctx.ui.notify(devBinary.line, "error"); + } catch (error) { + if (ctx.hasUI) + ctx.ui.notify( + `Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, + "warning", + ); } - } - // Keep the startup transport negotiation, but do not treat its target as - // an ownership baseline: reload may have outstanding durable receipts. - try { - const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); - await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey); - } catch { - // Startup negotiation is best-effort only; never surface or throw. - } - }); - - pi.on("input", async (event, ctx) => { - if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) { - return { action: "continue" }; - } - try { await runSddPreflight(ctx); } - catch (error) { - if (ctx.hasUI) ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); - return { action: "handled" }; - } - return { action: "continue" }; - }); - - pi.on("before_agent_start", async (event, ctx) => { - const isSddAgent = isSddAgentStartEvent(event); - const isNamedAgent = isNamedAgentStartEvent(event); - const subagentDepthKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); - if (isSddAgent || isNamedAgent) { - processAgentEndSubagentDepth.set(subagentDepthKey, (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1); - } else { - processAgentEndSubagentDepth.set(subagentDepthKey, 0); - } - // gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary - // session only, reusing the exact isNamedAgent/isSddAgent predicate that - // decides the orchestrator prompt below. At most one attempt per - // process regardless of how many primary-session before_agent_start - // events this process observes; a missing/old binary or a spawn error - // must never affect activation, so every failure is swallowed silently. - if (!isNamedAgent && !isSddAgent && !processTelemetryTriggerAttempted) { - processTelemetryTriggerAttempted = true; try { - const executable = resolveTelemetryTriggerBinary(); - spawnTelemetryTrigger({ - executable, - cwd: ctx.cwd, - env: dependencies.processEnv ?? process.env, - spawn: dependencies.telemetryTriggerSpawn, - }); + const installResult = installPackageAssets(ctx.cwd, true, [ + "delegation", + "review", + ]); + migrateLegacyProjectModelOverrides(ctx.cwd); + const modelResult = await applySavedModelConfig(ctx); + if (ctx.hasUI && modelResult.invalidPath) { + ctx.ui.notify( + `el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`, + "warning", + ); + return; + } + if (ctx.hasUI && modelResult.updated > 0) { + ctx.ui.notify( + `el Gentleman applied saved model config to ${modelResult.updated} agent(s). Global delegation/review assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`, + "info", + ); + } + } catch (error) { + if (ctx.hasUI) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify( + `el Gentleman model config sweep failed: ${message}`, + "warning", + ); + } + } + // Keep the startup transport negotiation, but do not treat its target as + // an ownership baseline: reload may have outstanding durable receipts. + try { + const sessionKey = pendingReviewConsentSessionKey( + ctx, + pendingReviewConsentFallbackKey, + ); + await resolveNegotiatedReviewStatusForSession( + nativeReviewCli, + ctx, + sessionKey, + ); } catch { - // Best-effort only; never surfaced and never affects activation. + // Startup negotiation is best-effort only; never surface or throw. } - } - try { - if (isSddAgent && !getSddPreflightPreferences(ctx)) { + }); + + pi.on("input", async (event, ctx) => { + if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) { + return { action: "continue" }; + } + try { await runSddPreflight(ctx); + } catch (error) { + if (ctx.hasUI) + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "warning", + ); + return { action: "handled" }; } - } catch (error) { - // Pi logs thrown before_agent_start errors and continues. Return an - // unresolved gate instead of silently losing the preflight instructions. - return { systemPrompt: `${event.systemPrompt}\n\nSDD preflight unresolved: ${error instanceof Error ? error.message : String(error)}\nSTOP: Do not initialize the project, launch phases, write artifacts, or infer consent. Request session preflight confirmation before continuing.` }; - } - const prefs = getSddPreflightPreferences(ctx); - const sddPrompt = - prefs && (!isNamedAgent || isSddAgent) - ? `\n\n${renderSddPreflightPrompt(prefs)}` - : ""; - const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined; - const nativeStatusPrompt = phase - ? `\n\n${renderNativeSddPhasePrompt(resolveStartupControllerSddStatus( - ctx.cwd, - undefined, - true, - prefs?.artifactStore, - ), phase)}` - : ""; - // gentle-pi#661: the RDD status line (and the rest of the gentle prompt) - // is built only for the primary session, mirrored on the - // reviewContractPrompt condition below -- named/SDD agents never reach - // this branch, so no line is resolved or computed for them. - // resolveRddStatusLine never throws and never hangs past - // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native - // binary renders the fail-closed "unknown" line instead. - const gentlePrompt = isNamedAgent || isSddAgent - ? "" - : `\n\n${buildGentlePrompt( - readPersonaMode(ctx.cwd), - ctx.cwd, - readActiveToolNames(pi), - await resolveRddStatusLine(nativeReviewCli, ctx.cwd, AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS), undefined, ctx), - )}`; - // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider - // contract bundle's review execution contract for the primary session - // only, and only when a native review CLI is actually present. - const reviewContractPrompt = - !isNamedAgent && !isSddAgent && nativeReviewCli !== null - ? (() => { - const fragment = loadReviewContractPromptFragment(ctx); - return fragment === null ? "" : `\n\n${fragment}`; - })() - : ""; - return { - systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}${!isNamedAgent && !isSddAgent ? `\n\n${renderResearchCapabilities(resolveResearchCapabilities(pi))}` : ""}`, - }; - }); - - // gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish - // an authorized implementation and report completion without ever running - // the review STATUS preflight or offering the consent question. This - // handler is read-only and idempotent: it never runs START, never answers - // consent, or chooses a partial candidate. Durable own-mutation receipts - // gate STATUS and consume only the generation captured before that await. - pi.on("agent_end", async (_event, ctx) => { - if (nativeReviewCli?.reviewMode === undefined || nativeReviewCli.targetStatus === undefined) return; - if (ctx.hasUI !== true || !reminderSessionActive) return; - const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey); - const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0; - if (subagentDepth > 0) { - processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1); - return; - } - const root = resolveSessionWorktree(ctx.cwd, ctx.cwd)?.root; - if (!root) return; - let mutation: string | undefined; - try { mutation = pendingReviewMutation(ctx.sessionManager, root); } - catch { return; } - if (!mutation) return; - const epoch = reminderEpoch; - const status = await resolveNegotiatedReviewStatusForSession(nativeReviewCli, ctx, sessionKey); - if (status === undefined || !reminderSessionActive || epoch !== reminderEpoch || pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== sessionKey) return; - // Another concurrent end or ACK may already have consumed this prefix. - if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return; - if (status.nextTransition?.kind !== "execute" || status.nextTransition.execute.operation !== "review.start") return; - const targetIdentity = status.targetIdentity; - pi.sendMessage( - { - customType: "gentle-pi.review-preflight", - content: renderAgentEndReviewPreflightMessage(targetIdentity), - display: true, - }, - { triggerTurn: true, deliverAs: "followUp" }, - ); - consumeReviewMutation(pi, ctx.sessionManager, root, mutation, "nudged", targetIdentity); - }); - - pi.on("tool_result", (event, ctx) => { - if (!reminderSessionActive || event.isError !== false || (event.toolName !== "write" && event.toolName !== "edit")) return; - if (!isRecord(event.input) || typeof event.input.path !== "string" || !event.input.path.trim()) return; - try { - const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root; - if (root) recordReviewMutation(pi, ctx.sessionManager, root, { source: "direct", toolName: event.toolName, toolCallId: event.toolCallId }); - } catch { /* Receipt persistence must not change a successful tool result. */ } - }); + return { action: "continue" }; + }); - pi.on("tool_call", async (event, ctx) => { - const sensitivePathDenied = evaluateSensitivePathTool( - event.toolName, - event.input, - ); - if (sensitivePathDenied) return sensitivePathDenied; - if (event.toolName === "subagent_run") { - const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input); - if (writerScopeDenied) return writerScopeDenied; + pi.on("before_agent_start", async (event, ctx) => { + const isSddAgent = isSddAgentStartEvent(event); + const isNamedAgent = isNamedAgentStartEvent(event); + const subagentDepthKey = pendingReviewConsentSessionKey( + ctx, + pendingReviewConsentFallbackKey, + ); + if (isSddAgent || isNamedAgent) { + processAgentEndSubagentDepth.set( + subagentDepthKey, + (processAgentEndSubagentDepth.get(subagentDepthKey) ?? 0) + 1, + ); + } else { + processAgentEndSubagentDepth.set(subagentDepthKey, 0); + } + // gentle-pi#677: nudge gentle-ai's own telemetry trigger for a primary + // session only, reusing the exact isNamedAgent/isSddAgent predicate that + // decides the orchestrator prompt below. At most one attempt per + // process regardless of how many primary-session before_agent_start + // events this process observes; a missing/old binary or a spawn error + // must never affect activation, so every failure is swallowed silently. + if (!isNamedAgent && !isSddAgent && !processTelemetryTriggerAttempted) { + processTelemetryTriggerAttempted = true; + try { + const executable = resolveTelemetryTriggerBinary(); + spawnTelemetryTrigger({ + executable, + cwd: ctx.cwd, + env: dependencies.processEnv ?? process.env, + spawn: dependencies.telemetryTriggerSpawn, + }); + } catch { + // Best-effort only; never surfaced and never affects activation. + } + } try { - injectReviewCandidateView(event.input, candidateViews); - return undefined; + if (isSddAgent && !getSddPreflightPreferences(ctx)) { + await runSddPreflight(ctx); + } } catch (error) { + // Pi logs thrown before_agent_start errors and continues. Return an + // unresolved gate instead of silently losing the preflight instructions. return { - block: true, - reason: error instanceof Error ? error.message : "review subagent dispatch is invalid", + systemPrompt: `${event.systemPrompt}\n\nSDD preflight unresolved: ${error instanceof Error ? error.message : String(error)}\nSTOP: Do not initialize the project, launch phases, write artifacts, or infer consent. Request session preflight confirmation before continuing.`, }; } - } - if (event.toolName !== "bash") return undefined; - if (!isRecord(event.input) || typeof event.input.command !== "string") { - return undefined; - } - return await confirmCommand(event.input.command, ctx, pi.events, herdrLifecycle); - }); - - for (const owner of ["delegation", "review", "sdd"] as const) { - const label = owner === "sdd" ? "SDD" : owner; - pi.registerCommand(`gentle:install-${owner}`, { - description: `Repair or refresh only global Gentle AI ${label} assets.`, - handler: async (args, ctx) => { - const force = args.includes("--force"); - const result = installPackageAssets(ctx.cwd, force, [owner]); - ctx.ui.notify( - `Global Gentle AI ${label} assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`, - "info", - ); - }, + const prefs = getSddPreflightPreferences(ctx); + const sddPrompt = + prefs && (!isNamedAgent || isSddAgent) + ? `\n\n${renderSddPreflightPrompt(prefs)}` + : ""; + const phase = isSddAgent ? sddPhaseFromAgentStartEvent(event) : undefined; + const nativeStatusPrompt = phase + ? `\n\n${renderNativeSddPhasePrompt( + resolveStartupControllerSddStatus( + ctx.cwd, + undefined, + true, + prefs?.artifactStore, + ), + phase, + )}` + : ""; + // gentle-pi#661: the RDD status line (and the rest of the gentle prompt) + // is built only for the primary session, mirrored on the + // reviewContractPrompt condition below -- named/SDD agents never reach + // this branch, so no line is resolved or computed for them. + // resolveRddStatusLine never throws and never hangs past + // RDD_STATUS_TIMEOUT_MS: an absent/timed-out/aborted/failing native + // binary renders the fail-closed "unknown" line instead. + const gentlePrompt = + isNamedAgent || isSddAgent + ? "" + : `\n\n${buildGentlePrompt( + readPersonaMode(ctx.cwd), + ctx.cwd, + readActiveToolNames(pi), + await resolveRddStatusLine( + nativeReviewCli, + ctx.cwd, + AbortSignal.timeout(RDD_STATUS_TIMEOUT_MS), + undefined, + ctx, + ), + )}`; + // gentle-pi#560 / gentle-ai#4056, #4057: inject the mirrored provider + // contract bundle's review execution contract for the primary session + // only, and only when a native review CLI is actually present. + const reviewContractPrompt = + !isNamedAgent && !isSddAgent && nativeReviewCli !== null + ? (() => { + const fragment = loadReviewContractPromptFragment(ctx); + return fragment === null ? "" : `\n\n${fragment}`; + })() + : ""; + return { + systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}${reviewContractPrompt}${!isNamedAgent && !isSddAgent ? `\n\n${renderResearchCapabilities(resolveResearchCapabilities(pi))}` : ""}`, + }; }); - } - pi.registerCommand("gentle:sdd-preflight", { - description: - "Run or reuse session SDD preflight; use --edit to change preferences.", - handler: async (args, ctx) => { - if (args.trim() !== "" && args.trim() !== "--edit") { - ctx.ui.notify("Usage: /gentle:sdd-preflight [--edit]", "warning"); + // gentle-pi#556 / gentle-ai#4051: with RDD enabled, the agent could finish + // an authorized implementation and report completion without ever running + // the review STATUS preflight or offering the consent question. This + // handler is read-only and idempotent: it never runs START, never answers + // consent, or chooses a partial candidate. Durable own-mutation receipts + // gate STATUS and consume only the generation captured before that await. + pi.on("agent_end", async (_event, ctx) => { + if ( + nativeReviewCli?.reviewMode === undefined || + nativeReviewCli.targetStatus === undefined + ) + return; + if (ctx.hasUI !== true || !reminderSessionActive) return; + const sessionKey = pendingReviewConsentSessionKey( + ctx, + pendingReviewConsentFallbackKey, + ); + const subagentDepth = processAgentEndSubagentDepth.get(sessionKey) ?? 0; + if (subagentDepth > 0) { + processAgentEndSubagentDepth.set(sessionKey, subagentDepth - 1); return; } - await runSddPreflight(ctx, args.trim() === "--edit" ? SDD_PREFLIGHT_FIELDS : []); - }, - }); - - const handleSddStatusCommand = async (args: string, ctx: ExtensionContext) => { - const parsed = parseSddStatusCommandArgs(args); - const status = resolveControllerSddStatus( - ctx.cwd, - parsed.changeName, - true, - getSddPreflightPreferences(ctx)?.artifactStore, - ); - ctx.ui.notify( - parsed.json ? JSON.stringify(status, null, 2) : renderSddStatusMarkdown(status), - sddStatusSeverity(status), - ); - }; - - pi.registerCommand("gentle-sdd-status", { - description: "Show deterministic SDD change status and instructions.", - handler: async (args, ctx) => { - await handleSddStatusCommand(args, ctx); - }, - }); - - const handleSddContinueCommand = async (args: string, ctx: ExtensionContext) => { - const parsed = parseSddStatusCommandArgs(args); - const status = resolveControllerSddStatus( - ctx.cwd, - parsed.changeName, - true, - getSddPreflightPreferences(ctx)?.artifactStore, - ); - ctx.ui.notify( - parsed.json ? JSON.stringify(status, null, 2) : renderSddDispatcherMarkdown(status), - sddStatusSeverity(status), - ); - }; - - pi.registerCommand("gentle-sdd-continue", { - description: "Resolve SDD status and route the next phase deterministically.", - handler: async (args, ctx) => { - await handleSddContinueCommand(args, ctx); - }, - }); - - pi.registerCommand("gentle:models", { - description: "Configure global per-agent models for el Gentleman.", - handler: async (_args, ctx) => { - await handleModelsCommand(ctx); - }, - }); - - pi.registerCommand("gentle:persona", { - description: "Switch el Gentleman persona between gentleman and neutral.", - handler: async (_args, ctx) => { - await handlePersonaCommand(ctx); - }, - }); - - // Dev-binary override surfacing (unpinned field-test mode). While the - // override is active every diagnostic surface names the exact binary, its - // live version, and its fresh content digest, so the maintainer always - // knows which gentle-ai actually answered. An invalid override surfaces as - // a failure — it is never silently ignored, because the native resolver - // refuses to fall back to the pin while an override is declared. - const describeDevBinaryOverride = async (): Promise< - | { state: "inactive" } - | { state: "active"; line: string; override: GentleAiDevBinaryOverride } - | { state: "invalid"; line: string } - > => { - let override: GentleAiDevBinaryOverride | undefined; - try { - override = resolveGentleAiDevBinaryOverride(); - } catch (error) { - if (error instanceof GentleAiDevBinaryOverrideError) return { state: "invalid", line: `Gentle AI dev binary override invalid — ${error.message}` }; - throw error; - } - if (override === undefined) return { state: "inactive" }; - let version = "version unavailable"; - try { - const adapter = createNodeExecFileAdapter(); - const result = await adapter({ file: override.path, arguments: ["version"], cwd: dirname(override.path), timeoutMs: 10_000, maxBufferBytes: 1024 * 1024 }); - const banner = result.stdout.trim(); - if (result.exitCode === 0 && banner.startsWith("gentle-ai ")) version = banner.slice("gentle-ai ".length); - } catch { - // The doctor line still names the binary; the version stays unavailable. - } - return { - state: "active", - override, - line: `Gentle AI dev binary override active (unpinned, field-test only): ${override.path} ${version} sha256:${override.sha256.slice(0, 16)}`, - }; - }; - - pi.registerCommand("gentle:dev-binary", { - description: "Register, inspect, or clear the persistent Gentle AI dev-binary override (status | | off). Unpinned, field-test only.", - handler: async (args, ctx) => { - const argument = args.trim(); + const root = resolveSessionWorktree(ctx.cwd, ctx.cwd)?.root; + if (!root) return; + let mutation: string | undefined; try { - if (argument === "off") { - const removed = unregisterGentleAiDevBinary(); - ctx.ui.notify(removed ? "Gentle AI dev binary registration removed; the pinned binary is active again." : "No dev binary registration to remove.", "info"); - return; - } - if (argument === "" || argument === "status") { - const described = await describeDevBinaryOverride(); - if (described.state === "inactive") ctx.ui.notify("No dev binary override; the pinned Gentle AI binary is active.", "info"); - else ctx.ui.notify(described.line, described.state === "active" ? "warning" : "error"); - return; - } - registerGentleAiDevBinary(argument); - const described = await describeDevBinaryOverride(); - ctx.ui.notify(described.state === "inactive" ? "Dev binary registration written." : described.line, "warning"); - } catch (error) { - ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); + mutation = pendingReviewMutation(ctx.sessionManager, root); + } catch { + return; } - }, - }); - - pi.registerCommand("gentle:doctor", { - description: "Run read-only Gentle AI diagnostics for this Pi workspace.", - handler: async (_args, ctx) => { - const assetLines = packageAssetDiagnosticLines(ctx.cwd); - const openspecConfigured = existsSync( - join(ctx.cwd, "openspec", "config.yaml"), + if (!mutation) return; + const epoch = reminderEpoch; + const status = await resolveNegotiatedReviewStatusForSession( + nativeReviewCli, + ctx, + sessionKey, ); - const skillRegistryPresent = existsSync( - join(ctx.cwd, ".atl", "skill-registry.md"), + if ( + status === undefined || + !reminderSessionActive || + epoch !== reminderEpoch || + pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey) !== + sessionKey + ) + return; + // Another concurrent end or ACK may already have consumed this prefix. + if (!pendingReviewMutation(ctx.sessionManager, root, mutation)) return; + if ( + status.nextTransition?.kind !== "execute" || + status.nextTransition.execute.operation !== "review.start" + ) + return; + const targetIdentity = status.targetIdentity; + pi.sendMessage( + { + customType: "gentle-pi.review-preflight", + content: renderAgentEndReviewPreflightMessage(targetIdentity), + display: true, + }, + { triggerTurn: true, deliverAs: "followUp" }, ); - const modelConfig = await readSavedModelConfigAsync(ctx.cwd); - const engramActive = hasWritableEngramTool(pi); - const devBinary = await describeDevBinaryOverride(); - const lines = [ - "el Gentleman doctor", - ...assetLines, - `${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`, - `${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`, - `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`, - "pass: Sensitive-path guard active for read/write/edit tools", - `${engramActive ? "pass" : "warn"}: Engram memory tools ${engramActive ? "active" : "not active in this session"}`, - ...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []), - ...(devBinary.state === "invalid" ? [`fail: ${devBinary.line}`, "remedy: fix the dev binary override or clear it with /gentle:dev-binary off (or unset GENTLE_PI_GENTLE_AI_DEV_BINARY)"] : []), - ]; - if (modelConfig.status === "invalid") { - lines.push(`remedy: fix or remove ${modelConfig.path}`); - } - ctx.ui.notify( - lines.join("\n"), - lines.some((line) => line.startsWith("fail:")) || assetLines.some((line) => line.startsWith("warn:")) ? "warning" : "info", + consumeReviewMutation( + pi, + ctx.sessionManager, + root, + mutation, + "nudged", + targetIdentity, ); - }, - }); + }); - pi.registerCommand("gentle:review-session-permission", { - description: "Show or revoke the process-memory review permission for this exact Pi session and Git repository (status|revoke).", - handler: async (args, ctx) => { - const subAction = args.trim().length === 0 ? "status" : args.trim(); - if (subAction !== "status" && subAction !== "revoke") { - ctx.ui.notify(`Unknown /gentle:review-session-permission sub-action "${subAction}". Use status or revoke.`, "warning"); - return; - } - if (subAction === "revoke") { - const revoked = await revokeCurrentRepositoryReviewSessionPermission(ctx); - ctx.ui.notify(revoked ? "Review permission revoked for this Git repository in this Pi session. Provider review mode and authority were not changed." : "No review permission is active for this Git repository in this Pi session. Provider review mode and authority were not changed.", "info"); + pi.on("tool_result", (event, ctx) => { + if ( + !reminderSessionActive || + event.isError !== false || + (event.toolName !== "write" && event.toolName !== "edit") + ) return; - } - const identity = await refreshReviewSessionPermissionStatus(ctx); - if (identity === undefined) { - ctx.ui.notify("Review session permission is unavailable: it requires the interactive Pi TUI, a non-child session, a nonempty session ID, and a canonical Git worktree.", "info"); + if ( + !isRecord(event.input) || + typeof event.input.path !== "string" || + !event.input.path.trim() + ) return; + try { + const root = resolveSessionWorktree(event.input.path, ctx.cwd)?.root; + if (root) + recordReviewMutation(pi, ctx.sessionManager, root, { + source: "direct", + toolName: event.toolName, + toolCallId: event.toolCallId, + }); + } catch { + /* Receipt persistence must not change a successful tool result. */ } - ctx.ui.notify(hasReviewSessionPermission(identity) - ? "Reviews are allowed for this Pi session and Git repository. Use /gentle:review-session-permission revoke to ask again." - : "Reviews are not pre-authorized for this Pi session; each medium- or high-risk candidate asks normally.", "info"); - }, - }); + }); - pi.registerCommand("gentle:review-mode", { - description: "Show or set the Gentle AI receipt-driven development kill switch (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", - handler: async (args, ctx) => { - const subAction = args.trim().length === 0 ? NATIVE_REVIEW_MODE_OPERATION.STATUS : args.trim(); - if (subAction !== NATIVE_REVIEW_MODE_OPERATION.STATUS && subAction !== NATIVE_REVIEW_MODE_OPERATION.ENABLE && subAction !== NATIVE_REVIEW_MODE_OPERATION.DISABLE) { - ctx.ui.notify(`Unknown /gentle:review-mode sub-action "${subAction}". Use status, disable, or enable.`, "warning"); - return; + pi.on("tool_call", async (event, ctx) => { + const sensitivePathDenied = evaluateSensitivePathTool( + event.toolName, + event.input, + ); + if (sensitivePathDenied) return sensitivePathDenied; + if (event.toolName === "subagent_run") { + const writerScopeDenied = rejectUnscopedBoundedWriterDispatch(event.input); + if (writerScopeDenied) return writerScopeDenied; + try { + injectReviewCandidateView(event.input, candidateViews); + return undefined; + } catch (error) { + return { + block: true, + reason: + error instanceof Error + ? error.message + : "review subagent dispatch is invalid", + }; + } } - if (nativeReviewCli?.reviewMode === undefined) { - ctx.ui.notify("Gentle AI review mode is not available with the currently negotiated native version.", "info"); - return; + if (event.toolName !== "bash") return undefined; + if (!isRecord(event.input) || typeof event.input.command !== "string") { + return undefined; } - try { - const result = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: subAction as NativeReviewModeOperation }); - if (subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE && result.status.effective === "off") { - cleanupAllPendingReviewConsents( - pendingReviewConsentRegistry, - pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey), + return await confirmCommand( + event.input.command, + ctx, + pi.events, + herdrLifecycle, + ); + }); + + for (const owner of ["delegation", "review", "sdd"] as const) { + const label = owner === "sdd" ? "SDD" : owner; + pi.registerCommand(`gentle:install-${owner}`, { + description: `Repair or refresh only global Gentle AI ${label} assets.`, + handler: async (args, ctx) => { + const force = args.includes("--force"); + const result = installPackageAssets(ctx.cwd, force, [owner]); + ctx.ui.notify( + `Global Gentle AI ${label} assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`, + "info", ); - } - const report = `receipt-driven development: ${result.status.effective} (decided by ${result.status.source})`; - // A mutating sub-action that left the effective mode unchanged did - // not do what the user asked, and reporting only the resulting - // status reads as if it had. This is reachable for exactly one - // shape: `enable` against a global off. Pi always passes - // `--scope clone` (Design Decision #7), which only clears a - // clone-local override and cannot enable global RDD. The native call - // exits 0, reports operation "enable", and changes nothing. Say - // that, and name the global-scope command that resolves it. - const requested = subAction === NATIVE_REVIEW_MODE_OPERATION.ENABLE ? "on" : subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE ? "off" : result.status.effective; - if (result.status.effective !== requested) { - ctx.ui.notify(`${report}\nThat did not turn reviews back on: /gentle:review-mode enable only clears a clone-local override, which cannot override a global off. Run \`gentle-ai review mode enable --scope=global\` to turn them back on.`, "warning"); - return; - } - ctx.ui.notify(report, "info"); - } catch (error) { - if (asNativeReviewCliError(error)?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) { - ctx.ui.notify("Gentle AI review mode is not available with the currently negotiated native version.", "info"); + }, + }); + } + + pi.registerCommand("gentle:sdd-preflight", { + description: + "Run or reuse session SDD preflight; use --edit to change preferences.", + handler: async (args, ctx) => { + if (args.trim() !== "" && args.trim() !== "--edit") { + ctx.ui.notify("Usage: /gentle:sdd-preflight [--edit]", "warning"); return; } - ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); - } - }, - }); + await runSddPreflight( + ctx, + args.trim() === "--edit" ? SDD_PREFLIGHT_FIELDS : [], + ); + }, + }); - // gentle-pi#677: gentle-ai owns telemetry end to end (status, the opt-out - // switches, and rate limiting); this command only runs the corresponding - // `gentle-ai telemetry --json` in the foreground and relays its - // output, so a Pi user never has to leave Pi to check or change it. - pi.registerCommand("gentle:telemetry", { - description: "Show or change the local Gentle AI telemetry trigger (status|enable|disable|preview); gentle-ai owns the data and the opt-out.", - handler: async (args, ctx) => { - const subAction = args.trim().length === 0 ? "status" : args.trim(); - if (subAction !== "status" && subAction !== "enable" && subAction !== "disable" && subAction !== "preview") { - ctx.ui.notify(`Unknown /gentle:telemetry sub-action "${subAction}". Use status, enable, disable, or preview.`, "warning"); - return; - } - let executable: string; + const handleSddStatusCommand = async ( + args: string, + ctx: ExtensionContext, + ) => { + const parsed = parseSddStatusCommandArgs(args); + const status = resolveControllerSddStatus( + ctx.cwd, + parsed.changeName, + true, + getSddPreflightPreferences(ctx)?.artifactStore, + ); + ctx.ui.notify( + parsed.json + ? JSON.stringify(status, null, 2) + : renderSddStatusMarkdown(status), + sddStatusSeverity(status), + ); + }; + + pi.registerCommand("gentle-sdd-status", { + description: "Show deterministic SDD change status and instructions.", + handler: async (args, ctx) => { + await handleSddStatusCommand(args, ctx); + }, + }); + + const handleSddContinueCommand = async ( + args: string, + ctx: ExtensionContext, + ) => { + const parsed = parseSddStatusCommandArgs(args); + const status = resolveControllerSddStatus( + ctx.cwd, + parsed.changeName, + true, + getSddPreflightPreferences(ctx)?.artifactStore, + ); + ctx.ui.notify( + parsed.json + ? JSON.stringify(status, null, 2) + : renderSddDispatcherMarkdown(status), + sddStatusSeverity(status), + ); + }; + + pi.registerCommand("gentle-sdd-continue", { + description: + "Resolve SDD status and route the next phase deterministically.", + handler: async (args, ctx) => { + await handleSddContinueCommand(args, ctx); + }, + }); + + pi.registerCommand("gentle:models", { + description: "Configure global per-agent models for el Gentleman.", + handler: async (_args, ctx) => { + await handleModelsCommand(ctx); + }, + }); + + pi.registerCommand("gentle:persona", { + description: "Switch el Gentleman persona between gentleman and neutral.", + handler: async (_args, ctx) => { + await handlePersonaCommand(ctx); + }, + }); + + // Dev-binary override surfacing (unpinned field-test mode). While the + // override is active every diagnostic surface names the exact binary, its + // live version, and its fresh content digest, so the maintainer always + // knows which gentle-ai actually answered. An invalid override surfaces as + // a failure — it is never silently ignored, because the native resolver + // refuses to fall back to the pin while an override is declared. + const describeDevBinaryOverride = async (): Promise< + | { state: "inactive" } + | { state: "active"; line: string; override: GentleAiDevBinaryOverride } + | { state: "invalid"; line: string } + > => { + let override: GentleAiDevBinaryOverride | undefined; try { - executable = resolveTelemetryTriggerBinary(); + override = resolveGentleAiDevBinaryOverride(); } catch (error) { - ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); - return; + if (error instanceof GentleAiDevBinaryOverrideError) + return { + state: "invalid", + line: `Gentle AI dev binary override invalid — ${error.message}`, + }; + throw error; } - let result: ExecFileResult; + if (override === undefined) return { state: "inactive" }; + let version = "version unavailable"; try { - result = await telemetryExecFileAdapter({ - file: executable, - arguments: ["telemetry", subAction, "--json"], - cwd: ctx.cwd, - timeoutMs: 5_000, + const adapter = createNodeExecFileAdapter(); + const result = await adapter({ + file: override.path, + arguments: ["version"], + cwd: dirname(override.path), + timeoutMs: 10_000, maxBufferBytes: 1024 * 1024, }); - } catch (error) { - ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); - return; - } - if (result.exitCode !== 0) { - ctx.ui.notify(`gentle-ai telemetry ${subAction} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout || "no output").trim()}`, "error"); - return; - } - let relayed: string; - try { - relayed = JSON.stringify(JSON.parse(result.stdout), null, 2); + const banner = result.stdout.trim(); + if (result.exitCode === 0 && banner.startsWith("gentle-ai ")) + version = banner.slice("gentle-ai ".length); } catch { - relayed = result.stdout.trim(); + // The doctor line still names the binary; the version stays unavailable. } - if (subAction === "disable") { - ctx.ui.notify("Gentle AI telemetry disabled.", "info"); - return; - } - ctx.ui.notify(relayed, "info"); - }, - }); + return { + state: "active", + override, + line: `Gentle AI dev binary override active (unpinned, field-test only): ${override.path} ${version} sha256:${override.sha256.slice(0, 16)}`, + }; + }; - // Mirrors gentle:review-mode: a user-owned switch, never an automated one. - // It matters more here than there, because this policy governs whether - // background subagents may be launched at all, so nothing in Pi may write - // it. The only writer is this handler, reached only by explicit invocation. - pi.registerCommand("gentle:background-subagents", { - description: "Show or set the managed background-subagents policy (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", - handler: async (args, ctx) => { - const subAction = args.trim().length === 0 ? "status" : args.trim(); - if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") { - ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning"); - return; - } - try { - const wrote: BackgroundSubagentsPolicy | undefined = subAction === "enable" ? "on" : subAction === "disable" ? "off" : undefined; - if (wrote !== undefined) writeGlobalBackgroundSubagentsPolicy(wrote); - const resolution = resolveBackgroundSubagentsPolicy(ctx.cwd); - const capability = resolveBackgroundSubagentsCapability(ctx.cwd, readActiveToolNames(pi)); - const report = renderBackgroundSubagentsReport(resolution, capability, wrote); - ctx.ui.notify(report.message, report.type); - } catch (error) { - ctx.ui.notify(error instanceof Error ? error.message : String(error), "error"); - } - }, - }); + pi.registerCommand("gentle:dev-binary", { + description: + "Register, inspect, or clear the persistent Gentle AI dev-binary override (status | | off). Unpinned, field-test only.", + handler: async (args, ctx) => { + const argument = args.trim(); + try { + if (argument === "off") { + const removed = unregisterGentleAiDevBinary(); + ctx.ui.notify( + removed + ? "Gentle AI dev binary registration removed; the pinned binary is active again." + : "No dev binary registration to remove.", + "info", + ); + return; + } + if (argument === "" || argument === "status") { + const described = await describeDevBinaryOverride(); + if (described.state === "inactive") + ctx.ui.notify( + "No dev binary override; the pinned Gentle AI binary is active.", + "info", + ); + else + ctx.ui.notify( + described.line, + described.state === "active" ? "warning" : "error", + ); + return; + } + registerGentleAiDevBinary(argument); + const described = await describeDevBinaryOverride(); + ctx.ui.notify( + described.state === "inactive" + ? "Dev binary registration written." + : described.line, + "warning", + ); + } catch (error) { + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + } + }, + }); - pi.registerCommand("gentle:status", { - description: "Show Gentle AI package status for this project.", - handler: async (_args, ctx) => { - const assetLines = packageAssetDiagnosticLines(ctx.cwd); - const openspecConfigured = existsSync( - join(ctx.cwd, "openspec", "config.yaml"), - ); - const modelConfig = await readModelConfigAsync(ctx.cwd); - const devBinary = await describeDevBinaryOverride(); - ctx.ui.notify( - [ - "el Gentleman package is active.", - ...(devBinary.state === "inactive" ? [] : [devBinary.line]), - `Persona: ${readPersonaMode(ctx.cwd)}`, + pi.registerCommand("gentle:doctor", { + description: "Run read-only Gentle AI diagnostics for this Pi workspace.", + handler: async (_args, ctx) => { + const assetLines = packageAssetDiagnosticLines(ctx.cwd); + const openspecConfigured = existsSync( + join(ctx.cwd, "openspec", "config.yaml"), + ); + const skillRegistryPresent = existsSync( + join(ctx.cwd, ".atl", "skill-registry.md"), + ); + const modelConfig = await readSavedModelConfigAsync(ctx.cwd); + const engramActive = hasWritableEngramTool(pi); + const devBinary = await describeDevBinaryOverride(); + const lines = [ + "el Gentleman doctor", ...assetLines, - `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`, - `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`, - ...describeModelConfig(ctx.cwd, modelConfig), - ].join("\n"), - assetLines.some((line) => line.startsWith("warn:")) || devBinary.state !== "inactive" ? "warning" : "info", - ); - }, - }); + `${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`, + `${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`, + `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`, + "pass: Sensitive-path guard active for read/write/edit tools", + `${engramActive ? "pass" : "warn"}: Engram memory tools ${engramActive ? "active" : "not active in this session"}`, + ...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []), + ...(devBinary.state === "invalid" + ? [ + `fail: ${devBinary.line}`, + "remedy: fix the dev binary override or clear it with /gentle:dev-binary off (or unset GENTLE_PI_GENTLE_AI_DEV_BINARY)", + ] + : []), + ]; + if (modelConfig.status === "invalid") { + lines.push(`remedy: fix or remove ${modelConfig.path}`); + } + ctx.ui.notify( + lines.join("\n"), + lines.some((line) => line.startsWith("fail:")) || + assetLines.some((line) => line.startsWith("warn:")) + ? "warning" + : "info", + ); + }, + }); + + pi.registerCommand("gentle:review-session-permission", { + description: + "Show or revoke the process-memory review permission for this exact Pi session and Git repository (status|revoke).", + handler: async (args, ctx) => { + const subAction = args.trim().length === 0 ? "status" : args.trim(); + if (subAction !== "status" && subAction !== "revoke") { + ctx.ui.notify( + `Unknown /gentle:review-session-permission sub-action "${subAction}". Use status or revoke.`, + "warning", + ); + return; + } + if (subAction === "revoke") { + const revoked = await revokeCurrentRepositoryReviewSessionPermission(ctx); + ctx.ui.notify( + revoked + ? "Review permission revoked for this Git repository in this Pi session. Provider review mode and authority were not changed." + : "No review permission is active for this Git repository in this Pi session. Provider review mode and authority were not changed.", + "info", + ); + return; + } + const identity = await refreshReviewSessionPermissionStatus(ctx); + if (identity === undefined) { + ctx.ui.notify( + "Review session permission is unavailable: it requires the interactive Pi TUI, a non-child session, a nonempty session ID, and a canonical Git worktree.", + "info", + ); + return; + } + ctx.ui.notify( + hasReviewSessionPermission(identity) + ? "Reviews are allowed for this Pi session and Git repository. Use /gentle:review-session-permission revoke to ask again." + : "Reviews are not pre-authorized for this Pi session; each medium- or high-risk candidate asks normally.", + "info", + ); + }, + }); + + pi.registerCommand("gentle:review-mode", { + description: + "Show or set the Gentle AI receipt-driven development kill switch (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", + handler: async (args, ctx) => { + const subAction = + args.trim().length === 0 + ? NATIVE_REVIEW_MODE_OPERATION.STATUS + : args.trim(); + if ( + subAction !== NATIVE_REVIEW_MODE_OPERATION.STATUS && + subAction !== NATIVE_REVIEW_MODE_OPERATION.ENABLE && + subAction !== NATIVE_REVIEW_MODE_OPERATION.DISABLE + ) { + ctx.ui.notify( + `Unknown /gentle:review-mode sub-action "${subAction}". Use status, disable, or enable.`, + "warning", + ); + return; + } + if (nativeReviewCli?.reviewMode === undefined) { + ctx.ui.notify( + "Gentle AI review mode is not available with the currently negotiated native version.", + "info", + ); + return; + } + try { + const result = await nativeReviewCli.reviewMode({ + cwd: ctx.cwd, + operation: subAction as NativeReviewModeOperation, + }); + if ( + subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE && + result.status.effective === "off" + ) { + cleanupAllPendingReviewConsents( + pendingReviewConsentRegistry, + pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey), + ); + } + const report = `receipt-driven development: ${result.status.effective} (decided by ${result.status.source})`; + // A mutating sub-action that left the effective mode unchanged did + // not do what the user asked, and reporting only the resulting + // status reads as if it had. This is reachable for exactly one + // shape: `enable` against a global off. Pi always passes + // `--scope clone` (Design Decision #7), which only clears a + // clone-local override and cannot enable global RDD. The native call + // exits 0, reports operation "enable", and changes nothing. Say + // that, and name the global-scope command that resolves it. + const requested = + subAction === NATIVE_REVIEW_MODE_OPERATION.ENABLE + ? "on" + : subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE + ? "off" + : result.status.effective; + if (result.status.effective !== requested) { + ctx.ui.notify( + `${report}\nThat did not turn reviews back on: /gentle:review-mode enable only clears a clone-local override, which cannot override a global off. Run \`gentle-ai review mode enable --scope=global\` to turn them back on.`, + "warning", + ); + return; + } + ctx.ui.notify(report, "info"); + } catch (error) { + if ( + asNativeReviewCliError(error)?.code === + NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE + ) { + ctx.ui.notify( + "Gentle AI review mode is not available with the currently negotiated native version.", + "info", + ); + return; + } + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + } + }, + }); + + // gentle-pi#677: gentle-ai owns telemetry end to end (status, the opt-out + // switches, and rate limiting); this command only runs the corresponding + // `gentle-ai telemetry --json` in the foreground and relays its + // output, so a Pi user never has to leave Pi to check or change it. + pi.registerCommand("gentle:telemetry", { + description: + "Show or change the local Gentle AI telemetry trigger (status|enable|disable|preview); gentle-ai owns the data and the opt-out.", + handler: async (args, ctx) => { + const subAction = args.trim().length === 0 ? "status" : args.trim(); + if ( + subAction !== "status" && + subAction !== "enable" && + subAction !== "disable" && + subAction !== "preview" + ) { + ctx.ui.notify( + `Unknown /gentle:telemetry sub-action "${subAction}". Use status, enable, disable, or preview.`, + "warning", + ); + return; + } + let executable: string; + try { + executable = resolveTelemetryTriggerBinary(); + } catch (error) { + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + return; + } + let result: ExecFileResult; + try { + result = await telemetryExecFileAdapter({ + file: executable, + arguments: ["telemetry", subAction, "--json"], + cwd: ctx.cwd, + timeoutMs: 5_000, + maxBufferBytes: 1024 * 1024, + }); + } catch (error) { + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + return; + } + if (result.exitCode !== 0) { + ctx.ui.notify( + `gentle-ai telemetry ${subAction} failed (exit ${result.exitCode}): ${(result.stderr || result.stdout || "no output").trim()}`, + "error", + ); + return; + } + let relayed: string; + try { + relayed = JSON.stringify(JSON.parse(result.stdout), null, 2); + } catch { + relayed = result.stdout.trim(); + } + if (subAction === "disable") { + ctx.ui.notify("Gentle AI telemetry disabled.", "info"); + return; + } + ctx.ui.notify(relayed, "info"); + }, + }); + + // Mirrors gentle:review-mode: a user-owned switch, never an automated one. + // It matters more here than there, because this policy governs whether + // background subagents may be launched at all, so nothing in Pi may write + // it. The only writer is this handler, reached only by explicit invocation. + pi.registerCommand("gentle:background-subagents", { + description: + "Show or set the managed background-subagents policy (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.", + handler: async (args, ctx) => { + const subAction = args.trim().length === 0 ? "status" : args.trim(); + if ( + subAction !== "status" && + subAction !== "enable" && + subAction !== "disable" + ) { + ctx.ui.notify( + `Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, + "warning", + ); + return; + } + try { + const wrote: BackgroundSubagentsPolicy | undefined = + subAction === "enable" + ? "on" + : subAction === "disable" + ? "off" + : undefined; + if (wrote !== undefined) writeGlobalBackgroundSubagentsPolicy(wrote); + const resolution = resolveBackgroundSubagentsPolicy(ctx.cwd); + const capability = resolveBackgroundSubagentsCapability( + ctx.cwd, + readActiveToolNames(pi), + ); + const report = renderBackgroundSubagentsReport( + resolution, + capability, + wrote, + ); + ctx.ui.notify(report.message, report.type); + } catch (error) { + ctx.ui.notify( + error instanceof Error ? error.message : String(error), + "error", + ); + } + }, + }); + + pi.registerCommand("gentle:status", { + description: "Show Gentle AI package status for this project.", + handler: async (_args, ctx) => { + const assetLines = packageAssetDiagnosticLines(ctx.cwd); + const openspecConfigured = existsSync( + join(ctx.cwd, "openspec", "config.yaml"), + ); + const modelConfig = await readModelConfigAsync(ctx.cwd); + const devBinary = await describeDevBinaryOverride(); + ctx.ui.notify( + [ + "el Gentleman package is active.", + ...(devBinary.state === "inactive" ? [] : [devBinary.line]), + `Persona: ${readPersonaMode(ctx.cwd)}`, + ...assetLines, + `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`, + `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`, + ...describeModelConfig(ctx.cwd, modelConfig), + ].join("\n"), + assetLines.some((line) => line.startsWith("warn:")) || + devBinary.state !== "inactive" + ? "warning" + : "info", + ); + }, + }); }; } diff --git a/lib/native-review-cli.ts b/lib/native-review-cli.ts index 2c8f8dc63..a567500c0 100644 --- a/lib/native-review-cli.ts +++ b/lib/native-review-cli.ts @@ -3,9 +3,18 @@ import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, posix, win32 } from "node:path"; import { promisify } from "node:util"; -import { PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts"; -import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts"; -import { decodeReviewAssessmentV1, type ReviewAssessmentV1 } from "./review-risk-assessment.ts"; +import { + PackageLocalGentleAiBinaryMissingError, + resolveGentleAiBinary, +} from "./gentle-ai-binary.ts"; +import { + GENTLE_PI_REVIEW_RELAY_CONTRACT, + GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV, +} from "./review-relay-contract.ts"; +import { + decodeReviewAssessmentV1, + type ReviewAssessmentV1, +} from "./review-risk-assessment.ts"; import { REVIEW_INTEGRATION_CONTRACT, decodeReviewAcknowledgedV1, @@ -39,11 +48,15 @@ const execFileAsync = promisify(execFile); export const NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES = 16 * 1024 * 1024; const NATIVE_REVIEW_MAX_BUFFER_BYTES = 64 * 1024 * 1024; const NATIVE_REVIEW_MAX_BUFFER_BYTES_ENV = "GENTLE_PI_REVIEW_MAX_BUFFER_BYTES"; -const NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT = "Inspect native review state before any new START; GENTLE_PI_REVIEW_MAX_BUFFER_BYTES accepts a positive decimal up to 67108864."; +const NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT = + "Inspect native review state before any new START; GENTLE_PI_REVIEW_MAX_BUFFER_BYTES accepts a positive decimal up to 67108864."; -function resolveNativeReviewMaxBufferBytes(environment: NodeJS.ProcessEnv = process.env): number { +function resolveNativeReviewMaxBufferBytes( + environment: NodeJS.ProcessEnv = process.env, +): number { const value = environment[NATIVE_REVIEW_MAX_BUFFER_BYTES_ENV]; - if (value === undefined || !/^[1-9]\d*$/.test(value)) return NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES; + if (value === undefined || !/^[1-9]\d*$/.test(value)) + return NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES; const parsed = Number(value); return Number.isSafeInteger(parsed) && parsed <= NATIVE_REVIEW_MAX_BUFFER_BYTES ? parsed @@ -68,7 +81,8 @@ export const NATIVE_REVIEW_OPERATION = { CAPTURE_EXECUTE: "review/capture-execute", ACKNOWLEDGE_APPROVED: "review/acknowledge-approved", } as const; -export type NativeReviewOperation = (typeof NATIVE_REVIEW_OPERATION)[keyof typeof NATIVE_REVIEW_OPERATION]; +export type NativeReviewOperation = + (typeof NATIVE_REVIEW_OPERATION)[keyof typeof NATIVE_REVIEW_OPERATION]; export const NATIVE_REVIEW_ERROR_CODE = { UNAVAILABLE: "unavailable", @@ -86,28 +100,69 @@ export const NATIVE_REVIEW_ERROR_CODE = { PACKAGE_BINARY_MISSING: "package-local-binary-missing", UNSUPPORTED_TRANSITION_OPERATION: "unsupported-transition-operation", } as const; -export type NativeReviewErrorCode = (typeof NATIVE_REVIEW_ERROR_CODE)[keyof typeof NATIVE_REVIEW_ERROR_CODE]; +export type NativeReviewErrorCode = + (typeof NATIVE_REVIEW_ERROR_CODE)[keyof typeof NATIVE_REVIEW_ERROR_CODE]; -export interface ExecFileRequest { file: string; arguments: readonly string[]; cwd: string; timeoutMs: number | undefined; maxBufferBytes: number; signal?: AbortSignal; } -export interface ExecFileResult { stdout: string; stderr: string; exitCode: number; signal: NodeJS.Signals | null; timedOut: boolean; outputLimitExceeded: boolean; } -export type ExecFileAdapter = (request: ExecFileRequest) => Promise; +export interface ExecFileRequest { + file: string; + arguments: readonly string[]; + cwd: string; + timeoutMs: number | undefined; + maxBufferBytes: number; + signal?: AbortSignal; +} +export interface ExecFileResult { + stdout: string; + stderr: string; + exitCode: number; + signal: NodeJS.Signals | null; + timedOut: boolean; + outputLimitExceeded: boolean; +} +export type ExecFileAdapter = ( + request: ExecFileRequest, +) => Promise; export interface NativeReviewCli { start(request: NativeStartRequest): Promise; - reviewStatus(request: NativeReviewStatusRequest): Promise; + reviewStatus( + request: NativeReviewStatusRequest, + ): Promise; targetStatus?(request: NativeTargetStatusRequest): Promise; - answerConsent?(request: NativeReviewConsentAnswerRequest): Promise; - reclaim?(request: NativeReviewReclaimRequest): Promise; - recover?(request: NativeReviewRecoverRequest): Promise; - abandon?(request: NativeReviewAbandonRequest): Promise; - quarantineLegacy?(request: NativeReviewLegacyQuarantineRequest): Promise; - reconcileAuthority?(request: NativeReviewReconcileAuthorityRequest): Promise; - repairLegacyAlias?(request: NativeReviewLegacyAliasRepairRequest): Promise; + answerConsent?( + request: NativeReviewConsentAnswerRequest, + ): Promise; + reclaim?( + request: NativeReviewReclaimRequest, + ): Promise; + recover?( + request: NativeReviewRecoverRequest, + ): Promise; + abandon?( + request: NativeReviewAbandonRequest, + ): Promise; + quarantineLegacy?( + request: NativeReviewLegacyQuarantineRequest, + ): Promise; + reconcileAuthority?( + request: NativeReviewReconcileAuthorityRequest, + ): Promise; + repairLegacyAlias?( + request: NativeReviewLegacyAliasRepairRequest, + ): Promise; repair?(request: NativeReviewRepairRequest): Promise; - captureResult?(request: NativeReviewCaptureResultRequest): Promise; - captureCorrectionPlan?(request: NativeReviewCorrectionPlanCaptureRequest): Promise; - captureProviderRole?(request: NativeReviewProviderRoleCaptureRequest): Promise; - captureExecute?(request: NativeReviewCaptureExecuteRequest): Promise; + captureResult?( + request: NativeReviewCaptureResultRequest, + ): Promise; + captureCorrectionPlan?( + request: NativeReviewCorrectionPlanCaptureRequest, + ): Promise; + captureProviderRole?( + request: NativeReviewProviderRoleCaptureRequest, + ): Promise; + captureExecute?( + request: NativeReviewCaptureExecuteRequest, + ): Promise; // Dark until a negotiated version reports the `mode` capability true // (Design Decision #7, organic-rdd-parity). Plain versioned CLI operation, // outside the negotiated review-integration protocol — same shape as @@ -125,34 +180,39 @@ export const NATIVE_REVIEW_MODE_OPERATION = { ENABLE: "enable", DISABLE: "disable", } as const; -export type NativeReviewModeOperation = (typeof NATIVE_REVIEW_MODE_OPERATION)[keyof typeof NATIVE_REVIEW_MODE_OPERATION]; +export type NativeReviewModeOperation = + (typeof NATIVE_REVIEW_MODE_OPERATION)[keyof typeof NATIVE_REVIEW_MODE_OPERATION]; export const NATIVE_REVIEW_MODE_VALUE = { UNSET: "", ON: "on", OFF: "off", } as const; -export type NativeReviewModeValue = (typeof NATIVE_REVIEW_MODE_VALUE)[keyof typeof NATIVE_REVIEW_MODE_VALUE]; +export type NativeReviewModeValue = + (typeof NATIVE_REVIEW_MODE_VALUE)[keyof typeof NATIVE_REVIEW_MODE_VALUE]; export const NATIVE_REVIEW_MODE_SOURCE = { DEFAULT: "default", GLOBAL: "global", CLONE_LOCAL: "clone_local", } as const; -export type NativeReviewModeSource = (typeof NATIVE_REVIEW_MODE_SOURCE)[keyof typeof NATIVE_REVIEW_MODE_SOURCE]; +export type NativeReviewModeSource = + (typeof NATIVE_REVIEW_MODE_SOURCE)[keyof typeof NATIVE_REVIEW_MODE_SOURCE]; export const NATIVE_REVIEW_MODE_REACH = { MACHINE: "machine", THIS_BUILD: "this_build", } as const; -export type NativeReviewModeReach = (typeof NATIVE_REVIEW_MODE_REACH)[keyof typeof NATIVE_REVIEW_MODE_REACH]; +export type NativeReviewModeReach = + (typeof NATIVE_REVIEW_MODE_REACH)[keyof typeof NATIVE_REVIEW_MODE_REACH]; export const NATIVE_REVIEW_MODE_SCOPE = { GLOBAL: "global", CLONE: "clone", BOTH: "both", } as const; -export type NativeReviewModeScope = (typeof NATIVE_REVIEW_MODE_SCOPE)[keyof typeof NATIVE_REVIEW_MODE_SCOPE]; +export type NativeReviewModeScope = + (typeof NATIVE_REVIEW_MODE_SCOPE)[keyof typeof NATIVE_REVIEW_MODE_SCOPE]; export interface NativeReviewModeRequest { cwd: string; @@ -215,8 +275,14 @@ export const REVIEW_CONSENT_NOTICES = Object.freeze([ // one string only worked while exactly one notice could appear; the moment a // second legitimate line joined it, an otherwise successful START was reported // as unexpected-stderr. -function stderrIsTolerated(stderr: string, tolerated: readonly string[]): boolean { - const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); +function stderrIsTolerated( + stderr: string, + tolerated: readonly string[], +): boolean { + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); return lines.length > 0 && lines.every((line) => tolerated.includes(line)); } @@ -232,12 +298,25 @@ const FORECAST_NARRATION_LINES = Object.freeze([ /^Re-query STATUS after completing this partial head\.$/, ]); function stderrIsForecastNarration(stderr: string): boolean { - const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); - return lines.length > 0 && lines.every((line) => FORECAST_NARRATION_LINES.some((pattern) => pattern.test(line))); + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + return ( + lines.length > 0 && + lines.every((line) => + FORECAST_NARRATION_LINES.some((pattern) => pattern.test(line)), + ) + ); } -export const NATIVE_REVIEW_RECOVER_DISPOSITION = ["scope_changed", "invalidated", "escalated"] as const; -export type NativeReviewRecoverDisposition = (typeof NATIVE_REVIEW_RECOVER_DISPOSITION)[number]; +export const NATIVE_REVIEW_RECOVER_DISPOSITION = [ + "scope_changed", + "invalidated", + "escalated", +] as const; +export type NativeReviewRecoverDisposition = + (typeof NATIVE_REVIEW_RECOVER_DISPOSITION)[number]; export interface NativeReviewReclaimRequest { cwd: string; @@ -272,7 +351,8 @@ export const NATIVE_REVIEW_LEGACY_ALIAS_REPAIR = { DIAGNOSTIC: "unsupported historical v1 operation alias", DISPOSITION: "quarantine-approved-historical-alias", } as const; -export type NativeReviewReconcileAnomalies = (typeof NATIVE_REVIEW_RECONCILE_ANOMALIES)[keyof typeof NATIVE_REVIEW_RECONCILE_ANOMALIES]; +export type NativeReviewReconcileAnomalies = + (typeof NATIVE_REVIEW_RECONCILE_ANOMALIES)[keyof typeof NATIVE_REVIEW_RECONCILE_ANOMALIES]; export interface NativeReviewAbandonRequest { cwd: string; @@ -327,7 +407,9 @@ export interface NativeReviewLegacyAliasRepairRequest { } /** Raw audited native record; Pi relays it verbatim and never reinterprets it. */ -export interface NativeReviewRecoveryResult { record: Record; } +export interface NativeReviewRecoveryResult { + record: Record; +} // Net-new negotiated `review.repair` (contract v2). `repair(request)` always // runs a `--mode preflight` first; only an eligible assessment is ever @@ -367,7 +449,9 @@ export interface NativeReviewAdmittedResultManifest { } /** A non-final reviewer capture acknowledges an admitted artifact; final captures close natively. */ -export type NativeReviewCaptureResultOutcome = NativeReviewAdmittedResultManifest | ReviewLastEventClosureV1; +export type NativeReviewCaptureResultOutcome = + | NativeReviewAdmittedResultManifest + | ReviewLastEventClosureV1; // The one continuation that burns approved authority. Its tokens are rendered // by the provider in a closed order and are relayed verbatim: Pi never builds, @@ -384,7 +468,9 @@ export interface NativeReviewAcknowledgeApprovedRequest { } /** `undefined` is the pinned silent burn (every release up to v2.5.0-rc.3); an envelope is the #3947 typed burn. */ -export type NativeReviewAcknowledgeApprovedOutcome = ReviewAcknowledgedV1 | undefined; +export type NativeReviewAcknowledgeApprovedOutcome = + | ReviewAcknowledgedV1 + | undefined; export interface NativeReviewCorrectionPlanCaptureRequest { readonly argumentTokens: readonly string[]; @@ -400,7 +486,8 @@ export interface NativeReviewCorrectionPlanCaptureRequest { // exact rendered invocation verbatim, in the foreground, and Go materializes // the role prompt, spawns its own locked-down pi subprocess, and admits the // raw verdict. Nothing here authors, parses, or transports role output. -export const NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA = "gentle-ai.review-provider-role-capture/v1"; +export const NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA = + "gentle-ai.review-provider-role-capture/v1"; // Capture operations and their terminal closure operations are independently // named upstream. Keep this closed mapping explicit: capture-validation is the @@ -409,10 +496,16 @@ export const NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION = { "review.capture-refuter": "review.capture-refuter", "review.capture-validation": "review/capture-validation", } as const; -type NativeReviewProviderRoleCaptureOperation = keyof typeof NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION; - -function isNativeReviewProviderRoleCaptureOperation(operation: string): operation is NativeReviewProviderRoleCaptureOperation { - return Object.hasOwn(NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION, operation); +type NativeReviewProviderRoleCaptureOperation = + keyof typeof NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION; + +function isNativeReviewProviderRoleCaptureOperation( + operation: string, +): operation is NativeReviewProviderRoleCaptureOperation { + return Object.hasOwn( + NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION, + operation, + ); } export interface NativeReviewProviderRoleCaptureRequest { @@ -434,7 +527,9 @@ export interface NativeReviewProviderRoleCaptureArtifact { } /** Refuter and validator captures close only when they are the native last event. */ -export type NativeReviewProviderRoleCaptureOutcome = NativeReviewProviderRoleCaptureArtifact | ReviewLastEventClosureV1; +export type NativeReviewProviderRoleCaptureOutcome = + | NativeReviewProviderRoleCaptureArtifact + | ReviewLastEventClosureV1; // --------------------------------------------------------------------------- // Execute vector for review.capture-result (gentle-pi execute-native compat) @@ -444,7 +539,8 @@ export type NativeReviewProviderRoleCaptureOutcome = NativeReviewProviderRoleCap // (capture-refuter, capture-validation) but for the reviewer capture operation. // --------------------------------------------------------------------------- -export const NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA = "gentle-ai.review-capture-execute/v1"; +export const NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA = + "gentle-ai.review-capture-execute/v1"; export interface NativeReviewCaptureExecuteRequest { /** Every provider-issued argument token, verbatim, in provider order. */ @@ -462,13 +558,16 @@ export interface NativeReviewCaptureExecuteResult { } /** Execute capture outcome: either an admitted artifact or a terminal closure. */ -export type NativeReviewCaptureExecuteOutcome = NativeReviewCaptureExecuteResult | ReviewLastEventClosureV1; +export type NativeReviewCaptureExecuteOutcome = + | NativeReviewCaptureExecuteResult + | ReviewLastEventClosureV1; export const NATIVE_UNTRACKED_SCOPE = { EXCLUDE: "exclude", SELECT: "select", } as const; -export type NativeUntrackedScope = (typeof NATIVE_UNTRACKED_SCOPE)[keyof typeof NATIVE_UNTRACKED_SCOPE]; +export type NativeUntrackedScope = + (typeof NATIVE_UNTRACKED_SCOPE)[keyof typeof NATIVE_UNTRACKED_SCOPE]; export interface NativeIntendedUntrackedSelectionSubmission { readonly argumentTokens: readonly string[]; @@ -492,9 +591,18 @@ export interface NativeStartRequest extends NativeUntrackedSelectionRequest { projection?: "workspace" | "staged"; signal?: AbortSignal; } -export const NATIVE_REVIEW_CONSENT_ANSWER = { GRANTED: "granted", DECLINED: "declined" } as const; -export type NativeReviewConsentAnswer = (typeof NATIVE_REVIEW_CONSENT_ANSWER)[keyof typeof NATIVE_REVIEW_CONSENT_ANSWER]; -export interface NativeReviewConsentAnswerRequest { cwd: string; consent: ReviewConsentEnvelope; answer: NativeReviewConsentAnswer; signal?: AbortSignal; } +export const NATIVE_REVIEW_CONSENT_ANSWER = { + GRANTED: "granted", + DECLINED: "declined", +} as const; +export type NativeReviewConsentAnswer = + (typeof NATIVE_REVIEW_CONSENT_ANSWER)[keyof typeof NATIVE_REVIEW_CONSENT_ANSWER]; +export interface NativeReviewConsentAnswerRequest { + cwd: string; + consent: ReviewConsentEnvelope; + answer: NativeReviewConsentAnswer; + signal?: AbortSignal; +} export interface NativeReviewConsentDeclinedResult { kind: "declined"; targetIdentity: string; @@ -505,10 +613,19 @@ export interface NativeReviewConsentDeclinedResult { consent: "declined_this_candidate"; raw: Readonly>; } -export interface NativeReviewConsentStartedResult { kind: "started"; start: NativeStartResult; } -export type NativeReviewConsentAnswerResult = NativeReviewConsentStartedResult | NativeReviewConsentDeclinedResult; -export interface NativeReviewStatusRequest { cwd: string; signal?: AbortSignal; } -export interface NativeTargetStatusRequest extends NativeUntrackedSelectionRequest { +export interface NativeReviewConsentStartedResult { + kind: "started"; + start: NativeStartResult; +} +export type NativeReviewConsentAnswerResult = + | NativeReviewConsentStartedResult + | NativeReviewConsentDeclinedResult; +export interface NativeReviewStatusRequest { + cwd: string; + signal?: AbortSignal; +} +export interface NativeTargetStatusRequest + extends NativeUntrackedSelectionRequest { cwd: string; lineageId?: string; baseRef?: string; @@ -537,13 +654,15 @@ export const NATIVE_REVIEW_AUTHORITY_STATUS = { SAME_LINEAGE_MIXED_COLLISION: "same-lineage-mixed-collision", INVALID: "invalid", } as const; -export type NativeReviewAuthorityStatus = (typeof NATIVE_REVIEW_AUTHORITY_STATUS)[keyof typeof NATIVE_REVIEW_AUTHORITY_STATUS]; +export type NativeReviewAuthorityStatus = + (typeof NATIVE_REVIEW_AUTHORITY_STATUS)[keyof typeof NATIVE_REVIEW_AUTHORITY_STATUS]; export const NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION = { LEGACY_V1: "legacy-v1", COMPACT_V2: "compact-v2", } as const; -export type NativeReviewAuthorityEntryVersion = (typeof NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)[keyof typeof NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION]; +export type NativeReviewAuthorityEntryVersion = + (typeof NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)[keyof typeof NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION]; export const NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS = { ...NATIVE_REVIEW_AUTHORITY_STATUS, @@ -551,7 +670,8 @@ export const NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS = { HISTORICAL_PRE_RECEIPT: "historical-pre-receipt", INVALIDATED: "invalidated", } as const; -export type NativeReviewAuthorityEntryStatus = (typeof NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS)[keyof typeof NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS]; +export type NativeReviewAuthorityEntryStatus = + (typeof NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS)[keyof typeof NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS]; export const NATIVE_REVIEW_LOCK_STATUS = { OWNED: "owned", @@ -563,12 +683,14 @@ export const NATIVE_REVIEW_LOCK_STATUS = { // being tolerated like diagnostic-only metadata. RELEASED: "released", } as const; -export type NativeReviewLockStatus = (typeof NATIVE_REVIEW_LOCK_STATUS)[keyof typeof NATIVE_REVIEW_LOCK_STATUS]; +export type NativeReviewLockStatus = + (typeof NATIVE_REVIEW_LOCK_STATUS)[keyof typeof NATIVE_REVIEW_LOCK_STATUS]; export const NATIVE_REVIEW_LOCK_OWNER_SCHEMA = { V1: "gentle-ai.review-store-lock/v1", } as const; -export type NativeReviewLockOwnerSchema = (typeof NATIVE_REVIEW_LOCK_OWNER_SCHEMA)[keyof typeof NATIVE_REVIEW_LOCK_OWNER_SCHEMA]; +export type NativeReviewLockOwnerSchema = + (typeof NATIVE_REVIEW_LOCK_OWNER_SCHEMA)[keyof typeof NATIVE_REVIEW_LOCK_OWNER_SCHEMA]; export interface NativeReviewLockOwner { schema: NativeReviewLockOwnerSchema; @@ -582,7 +704,8 @@ export const NATIVE_REVIEW_RECOVERY_DISPOSITION = { INVALIDATED: "invalidated", ESCALATED: "escalated", } as const; -export type NativeReviewRecoveryDisposition = (typeof NATIVE_REVIEW_RECOVERY_DISPOSITION)[keyof typeof NATIVE_REVIEW_RECOVERY_DISPOSITION]; +export type NativeReviewRecoveryDisposition = + (typeof NATIVE_REVIEW_RECOVERY_DISPOSITION)[keyof typeof NATIVE_REVIEW_RECOVERY_DISPOSITION]; export interface NativeReviewRecovery { predecessorLineageId: string; @@ -632,11 +755,38 @@ export interface NativeReviewStatusResult { diagnostics: readonly NativeReviewAuthorityDiagnostic[]; raw: Record; } -export const NATIVE_START_ACTION = { CREATED: "created", RESUMED: "resumed", REPLAYED: "replayed", CLOSED: "closed", BLOCKED_SCOPE_ACTION: "blocked-scope-action" } as const; -export type NativeStartAction = (typeof NATIVE_START_ACTION)[keyof typeof NATIVE_START_ACTION]; -export interface NativeStartResult { lineageId: string; state: ReviewStartState; riskLevel: string; selectedLenses: readonly string[]; changedFiles: number; changedLines: number; correctionBudget: number; action: NativeStartAction; lensesRequired: boolean; riskReasons?: readonly Record[]; nextTransition?: ReviewNextTransitionV3; raw?: Readonly>; riskEvidence?: readonly string[]; hint?: string; } +export const NATIVE_START_ACTION = { + CREATED: "created", + RESUMED: "resumed", + REPLAYED: "replayed", + CLOSED: "closed", + BLOCKED_SCOPE_ACTION: "blocked-scope-action", +} as const; +export type NativeStartAction = + (typeof NATIVE_START_ACTION)[keyof typeof NATIVE_START_ACTION]; +export interface NativeStartResult { + lineageId: string; + state: ReviewStartState; + riskLevel: string; + selectedLenses: readonly string[]; + changedFiles: number; + changedLines: number; + correctionBudget: number; + action: NativeStartAction; + lensesRequired: boolean; + riskReasons?: readonly Record[]; + nextTransition?: ReviewNextTransitionV3; + raw?: Readonly>; + riskEvidence?: readonly string[]; + hint?: string; +} export function isCanonicalProcessString(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value); + return ( + typeof value === "string" && + value.length > 0 && + value.trim() === value && + !/[\u0000-\u001f\u007f]/.test(value) + ); } interface NativeUntrackedSelection { @@ -646,38 +796,72 @@ interface NativeUntrackedSelection { } function isNativeUntrackedPath(value: unknown): value is string { - return isCanonicalProcessString(value) - && !posix.isAbsolute(value) - && !win32.isAbsolute(value) - && !value.includes("\\") - && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== ".."); + return ( + isCanonicalProcessString(value) && + !posix.isAbsolute(value) && + !win32.isAbsolute(value) && + !value.includes("\\") && + value + .split("/") + .every( + (segment) => segment.length > 0 && segment !== "." && segment !== "..", + ) + ); } -function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): NativeUntrackedSelection { - const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request; - const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined; +function nativeUntrackedSelection( + request: NativeUntrackedSelectionRequest, +): NativeUntrackedSelection { + const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = + request; + const declared = + untrackedScope !== undefined || + expectedUntrackedInventory !== undefined || + intendedUntracked !== undefined; if (!declared) return {}; if ( - (untrackedScope !== NATIVE_UNTRACKED_SCOPE.EXCLUDE && untrackedScope !== NATIVE_UNTRACKED_SCOPE.SELECT) || + (untrackedScope !== NATIVE_UNTRACKED_SCOPE.EXCLUDE && + untrackedScope !== NATIVE_UNTRACKED_SCOPE.SELECT) || !isCanonicalProcessString(expectedUntrackedInventory) || - (intendedUntracked !== undefined && (!Array.isArray(intendedUntracked) || intendedUntracked.some((path) => !isNativeUntrackedPath(path) || intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path)))) + (intendedUntracked !== undefined && + (!Array.isArray(intendedUntracked) || + intendedUntracked.some( + (path) => + !isNativeUntrackedPath(path) || + intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path), + ))) ) { - throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths"); + throw new TypeError( + "Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths", + ); } - if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) { - throw new TypeError("Native exclude untracked selection cannot include paths"); + if ( + untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && + (intendedUntracked?.length ?? 0) > 0 + ) { + throw new TypeError( + "Native exclude untracked selection cannot include paths", + ); } - if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) { - throw new TypeError("Native select untracked selection requires at least one path"); + if ( + untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && + (intendedUntracked?.length ?? 0) === 0 + ) { + throw new TypeError( + "Native select untracked selection requires at least one path", + ); } return { untrackedScope, expectedUntrackedInventory, - intendedUntracked: intendedUntracked === undefined ? undefined : [...intendedUntracked], + intendedUntracked: + intendedUntracked === undefined ? undefined : [...intendedUntracked], }; } -function nativeUntrackedSelectionArguments(selection: NativeUntrackedSelection): readonly string[] { +function nativeUntrackedSelectionArguments( + selection: NativeUntrackedSelection, +): readonly string[] { if (selection.untrackedScope === undefined) return []; return [ `--untracked-scope=${selection.untrackedScope}`, @@ -707,27 +891,30 @@ const NATIVE_RISK_LEVEL = ["low", "medium", "high"] as const; // tests/native-review-parity.test.ts so a vocabulary change fails loudly. const REVIEW_EMPTY_CANDIDATE_HINT = "the candidate has no pending changes; already-committed work can be reviewed by rerunning review start with --base-ref naming the base to compare against"; -const REVIEW_MEDIUM_RISK_REASON = "this change is not purely passive documentation, so it gets one consolidated review."; +const REVIEW_MEDIUM_RISK_REASON = + "this change is not purely passive documentation, so it gets one consolidated review."; const REVIEW_EMPTY_CONTENT_CODE = "empty_content"; -const REVIEW_RISK_SUBJECT_BY_CODE: Readonly> = Object.freeze({ - service_token: "service credentials", - shell_source: "shell scripting", - process_boundary: "code that starts other processes", - process_scan_limit: "code that starts other processes", - executable_mode: "an executable permission change", - executable_change: "an executable change", - configuration_change: "a configuration change", -}); -const REVIEW_RISK_SUBJECT_BY_SIGNAL: Readonly> = Object.freeze({ - auth: "authentication", - update: "the update path", - security: "security", - payments: "payments", - data_exposure: "data exposure", - data_loss: "data loss", - permissions: "permissions", - shell_process: "shell or process execution", -}); +const REVIEW_RISK_SUBJECT_BY_CODE: Readonly> = + Object.freeze({ + service_token: "service credentials", + shell_source: "shell scripting", + process_boundary: "code that starts other processes", + process_scan_limit: "code that starts other processes", + executable_mode: "an executable permission change", + executable_change: "an executable change", + configuration_change: "a configuration change", + }); +const REVIEW_RISK_SUBJECT_BY_SIGNAL: Readonly> = + Object.freeze({ + auth: "authentication", + update: "the update path", + security: "security", + payments: "payments", + data_exposure: "data exposure", + data_loss: "data loss", + permissions: "permissions", + shell_process: "shell or process execution", + }); // The Go signal switch has no empty default: every hot_path reason speaks, and // an unmapped signal degrades to this rather than dropping the path entirely. const REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT = "a sensitive area"; @@ -742,7 +929,9 @@ function nativeRiskEvidenceSubject(reason: NativeRiskEvidenceReason): string { const code = typeof reason.code === "string" ? reason.code : ""; if (code === "hot_path") { const signal = typeof reason.signal === "string" ? reason.signal : ""; - return REVIEW_RISK_SUBJECT_BY_SIGNAL[signal] ?? REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT; + return ( + REVIEW_RISK_SUBJECT_BY_SIGNAL[signal] ?? REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT + ); } return REVIEW_RISK_SUBJECT_BY_CODE[code] ?? ""; } @@ -753,34 +942,165 @@ function nativeRiskEvidencePhrase(reason: NativeRiskEvidenceReason): string { // reads " in ", which for a file with no bytes would // assert something about content that is not there. if (reason.code === REVIEW_EMPTY_CONTENT_CODE) { - return path === "" ? "" : `${path}, an empty file whose type cannot be determined from its content`; + return path === "" + ? "" + : `${path}, an empty file whose type cannot be determined from its content`; } const subject = nativeRiskEvidenceSubject(reason); if (subject === "" || path === "") return subject; return `${subject} in ${path}`; } -export function nativeRiskEvidencePhrases(riskLevel: string, reasons: readonly NativeRiskEvidenceReason[]): readonly string[] { +export function nativeRiskEvidencePhrases( + riskLevel: string, + reasons: readonly NativeRiskEvidenceReason[], +): readonly string[] { if (riskLevel !== "high" && riskLevel !== "medium") return []; - const phrases = reasons.map((reason) => nativeRiskEvidencePhrase(reason)).filter((phrase) => phrase !== ""); - return riskLevel === "medium" ? [REVIEW_MEDIUM_RISK_REASON, ...phrases] : phrases; + const phrases = reasons + .map((reason) => nativeRiskEvidencePhrase(reason)) + .filter((phrase) => phrase !== ""); + return riskLevel === "medium" + ? [REVIEW_MEDIUM_RISK_REASON, ...phrases] + : phrases; } -const NATIVE_REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const; +const NATIVE_REVIEW_LENS = [ + "review-risk", + "review-resilience", + "review-readability", + "review-reliability", +] as const; const NATIVE_START_ACTION_VALUES = Object.values(NATIVE_START_ACTION); // The pin table is intentionally preserved unchanged while #3587 is // unpublished. Last-event capture support is exercised through the explicit // dev-binary override; no source-level pin or contract-row mutation is allowed. -const ORGANIC_PARITY_DARK = { mode: false, riskEvidence: false, hint: false, delivery: false } as const; +const ORGANIC_PARITY_DARK = { + mode: false, + riskEvidence: false, + hint: false, + delivery: false, +} as const; export const NATIVE_CLI_CONTRACTS = Object.freeze({ - "2.1.4": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: false, inventory: false, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }), - "2.1.5": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }), - "2.1.6": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }), - "2.1.7": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }), - "2.1.8": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: false, quarantineLegacy: false, reconcileAuthority: true, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }), - "2.1.9": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }), - "2.1.10": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, ...ORGANIC_PARITY_DARK }), - "2.1.11": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, ...ORGANIC_PARITY_DARK }), + "2.1.4": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: false, + inventory: false, + reclaim: false, + recover: false, + abandon: false, + quarantineLegacy: false, + reconcileAuthority: false, + repairLegacyAlias: false, + ...ORGANIC_PARITY_DARK, + }), + "2.1.5": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: false, + recover: false, + abandon: false, + quarantineLegacy: false, + reconcileAuthority: false, + repairLegacyAlias: false, + ...ORGANIC_PARITY_DARK, + }), + "2.1.6": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: false, + recover: false, + abandon: false, + quarantineLegacy: false, + reconcileAuthority: false, + repairLegacyAlias: false, + ...ORGANIC_PARITY_DARK, + }), + "2.1.7": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: false, + recover: false, + abandon: false, + quarantineLegacy: false, + reconcileAuthority: false, + repairLegacyAlias: false, + ...ORGANIC_PARITY_DARK, + }), + "2.1.8": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: false, + quarantineLegacy: false, + reconcileAuthority: true, + repairLegacyAlias: false, + ...ORGANIC_PARITY_DARK, + }), + "2.1.9": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: false, + ...ORGANIC_PARITY_DARK, + }), + "2.1.10": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + ...ORGANIC_PARITY_DARK, + }), + "2.1.11": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + ...ORGANIC_PARITY_DARK, + }), // First capability-true row, paired with the triple pin bump in this same // commit as Design Decision #1 requires. // @@ -801,7 +1121,24 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({ // needs the negotiated start envelope extended upstream, which moves a // byte-pinned fixture and therefore belongs to a gentle-ai release, not to // a Pi capability flip. - "2.2.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.2.0": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // 2.2.1 repeats 2.2.0 because the wire did not move for the lane Pi speaks. // v2.2.1 advertises capabilities/v1.5 (protocol minor 5) on // review-integration/v1, but the negotiated start envelope is still the @@ -809,16 +1146,67 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({ // they are dark on 2.2.0. The release does publish a second contract, // review-integration/v2, whose `start/v3` carries base/candidate trees -- // but Pi does not negotiate it yet, and a row must describe the lane in use. - "2.2.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.2.1": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // 2.2.2 repeats 2.2.1 for the same reason, confirmed against the released // v2.2.2 binary rather than assumed: on review-integration/v1 it still // advertises capabilities/v1.5 and the negotiated start envelope is still // the closed `start/v2`, so riskEvidence and hint still cannot arrive. - "2.2.2": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.2.2": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // Ground-truthed against the released v2.2.3 binary: the v2 lane remains // protocol 2.0 with the same operation set and closed START fields consumed // by Pi, so the existing capability columns are unchanged. - "2.2.3": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.2.3": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // Ground-truthed against the released v2.4.0 binary: the v2 lane advertises // capabilities/v2.2 and answers status/v5 and consent/v3, all of which the // existing decoders already read, and the START envelope Pi consumes is @@ -828,7 +1216,24 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({ // envelope reports, not whether it reports it. v2.2.4 and v2.3.0 shipped // while Pi stayed on 2.2.3; they were never pinned or probed, so they get // no row. - "2.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.4.0": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // Ground-truthed by driving the exact v2.5.0-rc.3 tagged build through the // gentle-ai-bench driven journey corpus (exit 0), which exercises the // start/status/capture/validate/mode/delivery lifecycles Pi consumes. The @@ -837,7 +1242,24 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({ // fields Pi consumes are unchanged, so the columns match the 2.4.0 row. // riskEvidence and hint stay dark: still not proven to reach the // negotiated path Pi reads. - "2.5.0-rc.3": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.5.0-rc.3": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // Ground-truthed against the published v2.5.0 binary installed from its // signed archive: `review capabilities` on the v2 lane advertises // capabilities/v2.4 (protocol minor 4) and answers status/v6, consent/v3, @@ -845,7 +1267,24 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({ // The closed fields Pi consumes did not change between rc.3 and stable, so // the columns match the 2.5.0-rc.3 row. riskEvidence and hint stay dark: // still not proven to reach the negotiated START path Pi reads. - "2.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.5.0": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // Ground-truthed against the published v2.6.0 binary installed from its // signed archive: `review capabilities` on the v2 lane advertises // capabilities/v2.5 (protocol minor 5) and answers status/v7, consent/v3, @@ -860,7 +1299,24 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({ // proven to reach the negotiated START path Pi reads. The closed fields Pi // consumes did not change between 2.5.0 and 2.6.0, so the columns match // the 2.5.0 row. - "2.6.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.6.0": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), // Ground-truthed against the published v2.7.0 binary installed from its // signed archive: `review capabilities` on the v2 lane still advertises // capabilities/v2.5 (protocol minor 5) and answers status/v7, consent/v3, @@ -872,7 +1328,24 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({ // they have on every row since 2.2.0: still not proven to reach the // negotiated START path Pi reads. The closed fields Pi consumes did not // change between 2.6.0 and 2.7.0, so the columns match the 2.6.0 row. - "2.7.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }), + "2.7.0": Object.freeze({ + start: true, + finalize: true, + validate: true, + bindSdd: true, + status: true, + inventory: true, + reclaim: true, + recover: true, + abandon: true, + quarantineLegacy: true, + reconcileAuthority: true, + repairLegacyAlias: true, + mode: true, + riskEvidence: false, + hint: false, + delivery: true, + }), }); export interface NativeReviewProcessDiagnostics { @@ -896,7 +1369,15 @@ export class NativeReviewCliError extends Error { readonly nextAction?: "review.status"; readonly diagnostics: NativeReviewProcessDiagnostics; readonly auditRecord?: Record; - constructor(code: NativeReviewErrorCode, operation: NativeReviewOperation, launchAttempted: boolean, mutating: boolean, message: string, diagnostics?: NativeReviewProcessDiagnostics, auditRecord?: Record) { + constructor( + code: NativeReviewErrorCode, + operation: NativeReviewOperation, + launchAttempted: boolean, + mutating: boolean, + message: string, + diagnostics?: NativeReviewProcessDiagnostics, + auditRecord?: Record, + ) { super(message); this.name = "NativeReviewCliError"; this.code = code; @@ -904,8 +1385,14 @@ export class NativeReviewCliError extends Error { this.launchAttempted = launchAttempted; this.mutating = mutating; this.mutationOutcome = launchAttempted && mutating ? "unknown" : "none"; - this.nextAction = this.mutationOutcome === "unknown" ? "review.status" : undefined; - this.diagnostics = diagnostics ?? { operation, error_code: code, timed_out: false, output_limit_exceeded: false }; + this.nextAction = + this.mutationOutcome === "unknown" ? "review.status" : undefined; + this.diagnostics = diagnostics ?? { + operation, + error_code: code, + timed_out: false, + output_limit_exceeded: false, + }; this.auditRecord = auditRecord; } } @@ -915,117 +1402,305 @@ export class NativeReviewCliError extends Error { // refuses pi admission pre-authority without it (gentle-pi#311 P4), and a // single injection point keeps the declaration impossible to forget on any // individual operation. -export function gentleAiProcessEnvironment(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - return { ...base, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT }; +export function gentleAiProcessEnvironment( + base: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + return { + ...base, + [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT, + }; } export function createNodeExecFileAdapter(): ExecFileAdapter { return async (request) => { try { - const output = await execFileAsync(request.file, [...request.arguments], { cwd: request.cwd, encoding: "utf8", shell: false, windowsHide: true, timeout: request.timeoutMs, maxBuffer: request.maxBufferBytes, signal: request.signal, env: gentleAiProcessEnvironment() }); - return { stdout: output.stdout, stderr: output.stderr, exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false }; + const output = await execFileAsync(request.file, [...request.arguments], { + cwd: request.cwd, + encoding: "utf8", + shell: false, + windowsHide: true, + timeout: request.timeoutMs, + maxBuffer: request.maxBufferBytes, + signal: request.signal, + env: gentleAiProcessEnvironment(), + }); + return { + stdout: output.stdout, + stderr: output.stderr, + exitCode: 0, + signal: null, + timedOut: false, + outputLimitExceeded: false, + }; } catch (error) { - const detail = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: string | number; signal?: NodeJS.Signals; killed?: boolean }; - if (detail.code === "ENOENT" || detail.code === "EACCES" || detail.name === "AbortError") throw error; - const outputLimitExceeded = detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; - return { stdout: detail.stdout ?? "", stderr: detail.stderr ?? "", exitCode: typeof detail.code === "number" ? detail.code : 1, signal: detail.signal ?? null, timedOut: !outputLimitExceeded && detail.killed === true, outputLimitExceeded }; + const detail = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: string | number; + signal?: NodeJS.Signals; + killed?: boolean; + }; + if ( + detail.code === "ENOENT" || + detail.code === "EACCES" || + detail.name === "AbortError" + ) + throw error; + const outputLimitExceeded = + detail.code === "ENOBUFS" || + detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"; + return { + stdout: detail.stdout ?? "", + stderr: detail.stderr ?? "", + exitCode: typeof detail.code === "number" ? detail.code : 1, + signal: detail.signal ?? null, + timedOut: !outputLimitExceeded && detail.killed === true, + outputLimitExceeded, + }; } }; } function object(value: unknown): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("expected object"); + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new Error("expected object"); return value as Record; } -function exactObject(value: unknown, required: readonly string[], optional: readonly string[] = []): Record { +function exactObject( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record { const parsed = object(value); const allowed = [...required, ...optional]; - if (required.some((key) => !(key in parsed)) || Object.keys(parsed).some((key) => !allowed.includes(key))) throw new Error("unexpected object shape"); + if ( + required.some((key) => !(key in parsed)) || + Object.keys(parsed).some((key) => !allowed.includes(key)) + ) + throw new Error("unexpected object shape"); + return parsed; +} +function requiredString(value: unknown): string { + if (typeof value !== "string" || value.length === 0) + throw new Error("expected string"); + return value; +} +function stringValue(value: unknown): string { + if (typeof value !== "string") throw new Error("expected string"); + return value; +} +function sha256Identity(value: unknown): string { + const parsed = requiredString(value); + if (!/^sha256:[0-9a-f]{64}$/.test(parsed)) + throw new Error("expected canonical SHA-256 identity"); return parsed; } -function requiredString(value: unknown): string { if (typeof value !== "string" || value.length === 0) throw new Error("expected string"); return value; } -function stringValue(value: unknown): string { if (typeof value !== "string") throw new Error("expected string"); return value; } -function sha256Identity(value: unknown): string { const parsed = requiredString(value); if (!/^sha256:[0-9a-f]{64}$/.test(parsed)) throw new Error("expected canonical SHA-256 identity"); return parsed; } -function booleanValue(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("expected boolean"); return value; } -function nonNegativeInteger(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("expected safe non-negative integer"); return value; } -function positiveInteger(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new Error("expected safe positive integer"); return value; } -function stringArray(value: unknown): readonly string[] { if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.length === 0)) throw new Error("expected string array"); return value; } -function decodeSelectedLenses(value: unknown, riskLevel: string, lensesRequired: boolean): readonly string[] { +function booleanValue(value: unknown): boolean { + if (typeof value !== "boolean") throw new Error("expected boolean"); + return value; +} +function nonNegativeInteger(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) + throw new Error("expected safe non-negative integer"); + return value; +} +function positiveInteger(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) + throw new Error("expected safe positive integer"); + return value; +} +function stringArray(value: unknown): readonly string[] { + if ( + !Array.isArray(value) || + value.some((entry) => typeof entry !== "string" || entry.length === 0) + ) + throw new Error("expected string array"); + return value; +} +function decodeSelectedLenses( + value: unknown, + riskLevel: string, + lensesRequired: boolean, +): readonly string[] { if (value === null && riskLevel === "low" && !lensesRequired) return []; return stringArray(value); } -function enumString(value: unknown, allowed: readonly string[]): string { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; } +function enumString(value: unknown, allowed: readonly string[]): string { + const parsed = stringValue(value); + if (!allowed.includes(parsed)) throw new Error("unsupported enum"); + return parsed; +} const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096; -function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT): string { +function sanitizeNativeDiagnosticText( + value: string, + limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT, +): string { const normalized = value - .replace(/\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, "[REDACTED CONTROL]") + .replace( + /\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, + "[REDACTED CONTROL]", + ) .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "[REDACTED CONTROL]") .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "[REDACTED CONTROL]") - .replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, "[REDACTED PEM]") - .replace(/("(?:token|password|secret|api_key|apikey|authorization|cookie|private_key|access_token|github_token|[a-z0-9_-]+_token)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, "$1\"[REDACTED]\"") + .replace( + /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, + "[REDACTED PEM]", + ) + .replace( + /("(?:token|password|secret|api_key|apikey|authorization|cookie|private_key|access_token|github_token|[a-z0-9_-]+_token)"\s*:\s*)"(?:\\.|[^"\\])*"/gi, + '$1"[REDACTED]"', + ) .replace(/\b(Bearer)\s+[^\s]+/gi, "$1 [REDACTED]") - .replace(/\b(token|secret|password|authorization|cookie|private_key|access_token|github_token|[a-z0-9_-]+_token|api[_-]?key)\s*([:=])\s*[^\s]+/gi, "$1$2[REDACTED]") + .replace( + /\b(token|secret|password|authorization|cookie|private_key|access_token|github_token|[a-z0-9_-]+_token|api[_-]?key)\s*([:=])\s*[^\s]+/gi, + "$1$2[REDACTED]", + ) .replace(/[\u0000-\u001f\u007f]/g, ""); - return normalized.length <= limit ? normalized : `${normalized.slice(0, limit - 14)}…[truncated]`; + return normalized.length <= limit + ? normalized + : `${normalized.slice(0, limit - 14)}…[truncated]`; } // Rebuild diagnostics from a duplicated module instance before facade output. -export function sanitizeForeignNativeReviewDiagnostics(value: unknown): NativeReviewProcessDiagnostics | undefined { +export function sanitizeForeignNativeReviewDiagnostics( + value: unknown, +): NativeReviewProcessDiagnostics | undefined { try { - const raw = exactObject(value, ["operation", "error_code", "timed_out", "output_limit_exceeded"], ["exit_code", "signal", "max_buffer_bytes", "configuration_hint", "stderr"]); - const operation = enumString(raw.operation, Object.values(NATIVE_REVIEW_OPERATION)) as NativeReviewOperation; - const errorCode = enumString(raw.error_code, Object.values(NATIVE_REVIEW_ERROR_CODE)) as NativeReviewErrorCode; - const signal = raw.signal === undefined ? undefined : requiredString(raw.signal); - const maxBufferBytes = raw.max_buffer_bytes === undefined ? undefined : positiveInteger(raw.max_buffer_bytes); - const configurationHint = raw.configuration_hint === undefined ? undefined : stringValue(raw.configuration_hint); + const raw = exactObject( + value, + ["operation", "error_code", "timed_out", "output_limit_exceeded"], + ["exit_code", "signal", "max_buffer_bytes", "configuration_hint", "stderr"], + ); + const operation = enumString( + raw.operation, + Object.values(NATIVE_REVIEW_OPERATION), + ) as NativeReviewOperation; + const errorCode = enumString( + raw.error_code, + Object.values(NATIVE_REVIEW_ERROR_CODE), + ) as NativeReviewErrorCode; + const signal = + raw.signal === undefined ? undefined : requiredString(raw.signal); + const maxBufferBytes = + raw.max_buffer_bytes === undefined + ? undefined + : positiveInteger(raw.max_buffer_bytes); + const configurationHint = + raw.configuration_hint === undefined + ? undefined + : stringValue(raw.configuration_hint); if ( (signal !== undefined && !/^SIG[A-Z0-9]{1,12}$/.test(signal)) || (maxBufferBytes === undefined) !== (configurationHint === undefined) || - (configurationHint !== undefined && (errorCode !== NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT || configurationHint !== NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT)) - ) return undefined; + (configurationHint !== undefined && + (errorCode !== NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT || + configurationHint !== NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT)) + ) + return undefined; return { operation, error_code: errorCode, - ...(raw.exit_code === undefined ? {} : { exit_code: nonNegativeInteger(raw.exit_code) }), + ...(raw.exit_code === undefined + ? {} + : { exit_code: nonNegativeInteger(raw.exit_code) }), ...(signal === undefined ? {} : { signal: signal as NodeJS.Signals }), timed_out: booleanValue(raw.timed_out), output_limit_exceeded: booleanValue(raw.output_limit_exceeded), - ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint! }), - ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }), + ...(maxBufferBytes === undefined + ? {} + : { + max_buffer_bytes: maxBufferBytes, + configuration_hint: configurationHint!, + }), + ...(raw.stderr === undefined + ? {} + : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }), }; - } catch { return undefined; } + } catch { + return undefined; + } } -function nativeProcessDiagnostics(operation: NativeReviewOperation, code: NativeReviewErrorCode, result?: ExecFileResult, maxBufferBytes?: number): NativeReviewProcessDiagnostics { +function nativeProcessDiagnostics( + operation: NativeReviewOperation, + code: NativeReviewErrorCode, + result?: ExecFileResult, + maxBufferBytes?: number, +): NativeReviewProcessDiagnostics { const outputLimitExceeded = result?.outputLimitExceeded === true; return { operation, error_code: code, ...(result === undefined ? {} : { exit_code: result.exitCode }), - ...(result?.signal === null || result?.signal === undefined ? {} : { signal: result.signal }), + ...(result?.signal === null || result?.signal === undefined + ? {} + : { signal: result.signal }), timed_out: !outputLimitExceeded && result?.timedOut === true, output_limit_exceeded: outputLimitExceeded, - ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined - ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT } + ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && + maxBufferBytes !== undefined + ? { + max_buffer_bytes: maxBufferBytes, + configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT, + } + : {}), + ...(result?.stderr.trim() + ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}), - ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}), }; } -function parseJson(stdout: string, operation: NativeReviewOperation, mutating: boolean, diagnostics: NativeReviewProcessDiagnostics): Record { - if (stdout.length === 0) throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT, operation, true, mutating, "native command returned empty output", { ...diagnostics, error_code: NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT }); - try { return object(JSON.parse(stdout)); } catch { throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON, operation, true, mutating, "native command returned malformed JSON", { ...diagnostics, error_code: NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON }); } +function parseJson( + stdout: string, + operation: NativeReviewOperation, + mutating: boolean, + diagnostics: NativeReviewProcessDiagnostics, +): Record { + if (stdout.length === 0) + throw new NativeReviewCliError( + NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT, + operation, + true, + mutating, + "native command returned empty output", + { ...diagnostics, error_code: NATIVE_REVIEW_ERROR_CODE.EMPTY_OUTPUT }, + ); + try { + return object(JSON.parse(stdout)); + } catch { + throw new NativeReviewCliError( + NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON, + operation, + true, + mutating, + "native command returned malformed JSON", + { ...diagnostics, error_code: NATIVE_REVIEW_ERROR_CODE.MALFORMED_JSON }, + ); + } } -function decodeNativeMaintenanceResult(value: unknown, expectedOperation: NativeReviewOperation): NativeReviewRecoveryResult { +function decodeNativeMaintenanceResult( + value: unknown, + expectedOperation: NativeReviewOperation, +): NativeReviewRecoveryResult { const body = exactObject(value, ["operation", "record"]); - if (body.operation !== expectedOperation) throw new Error("wrong native maintenance discriminator"); + if (body.operation !== expectedOperation) + throw new Error("wrong native maintenance discriminator"); return { record: object(body.record) }; } -function decodeLegacyReconcileAudit(value: unknown): NativeReviewRecoveryResult { - const record = exactObject(value, ["schema", "predecessor_lineage", "successor_lineage", "outcome"]); - if (record.schema !== "gentle-ai.review-reconcile-audit/v1") throw new Error("wrong legacy reconcile audit schema"); - for (const field of ["predecessor_lineage", "successor_lineage", "outcome"]) requiredString(record[field]); +function decodeLegacyReconcileAudit( + value: unknown, +): NativeReviewRecoveryResult { + const record = exactObject(value, [ + "schema", + "predecessor_lineage", + "successor_lineage", + "outcome", + ]); + if (record.schema !== "gentle-ai.review-reconcile-audit/v1") + throw new Error("wrong legacy reconcile audit schema"); + for (const field of ["predecessor_lineage", "successor_lineage", "outcome"]) + requiredString(record[field]); return { record }; } // Unimplemented next_transition.execute.operation values must never reach @@ -1034,117 +1709,346 @@ function decodeLegacyReconcileAudit(value: unknown): NativeReviewRecoveryResult // named, typed refusal instead of a generic schema-incompatible error, and // before any client ever tries to build an invocation for it (Design // Decision #6, migrate-review-integration-v2). -const NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS = new Set(["review.start", "review.recover", "review.repair", "review.acknowledge-approved"]); -function assertSupportedNextTransitionOperation(body: Record): void { +const NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS = new Set([ + "review.start", + "review.recover", + "review.repair", + "review.acknowledge-approved", +]); +function assertSupportedNextTransitionOperation( + body: Record, +): void { const nextTransition = body.next_transition; - if (typeof nextTransition !== "object" || nextTransition === null || Array.isArray(nextTransition)) return; + if ( + typeof nextTransition !== "object" || + nextTransition === null || + Array.isArray(nextTransition) + ) + return; const execute = (nextTransition as Record).execute; - if (typeof execute !== "object" || execute === null || Array.isArray(execute)) return; + if (typeof execute !== "object" || execute === null || Array.isArray(execute)) + return; const operation = (execute as Record).operation; - if (typeof operation === "string" && !NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS.has(operation)) { - throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNSUPPORTED_TRANSITION_OPERATION, NATIVE_REVIEW_OPERATION.STATUS, false, `unsupported-transition-operation: gentle-pi does not implement the next_transition operation "${operation}"; refusing rather than synthesizing an invocation for it`); + if ( + typeof operation === "string" && + !NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS.has(operation) + ) { + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNSUPPORTED_TRANSITION_OPERATION, + NATIVE_REVIEW_OPERATION.STATUS, + false, + `unsupported-transition-operation: gentle-pi does not implement the next_transition operation "${operation}"; refusing rather than synthesizing an invocation for it`, + ); } } -function decode(operation: NativeReviewOperation, mutating: boolean, callback: () => T, diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE)): T { - try { return callback(); } catch (error) { if (error instanceof NativeReviewCliError) throw error; throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, operation, true, mutating, "native response is schema incompatible", { ...diagnostics, error_code: NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE }); } +function decode( + operation: NativeReviewOperation, + mutating: boolean, + callback: () => T, + diagnostics = nativeProcessDiagnostics( + operation, + NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, + ), +): T { + try { + return callback(); + } catch (error) { + if (error instanceof NativeReviewCliError) throw error; + throw new NativeReviewCliError( + NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, + operation, + true, + mutating, + "native response is schema incompatible", + { ...diagnostics, error_code: NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE }, + ); + } } -function decodeReviewStartResponse(value: unknown): ReviewStartV3 | ReviewStartV4 { +function decodeReviewStartResponse( + value: unknown, +): ReviewStartV3 | ReviewStartV4 { const body = object(value); return body.schema === "gentle-ai.review-integration.start/v4" ? decodeReviewStartV4(body) : decodeReviewStartV3(body); } function decodeReleaseEvidence(value: unknown): void { - const release = exactObject(value, ["release_tree", "configuration_hash", "generated_artifact_hash", "provenance_hash", "publication_boundary_hash", "publication_state", "evidence_freshness_hash", "evidence_freshness_state"]); - for (const field of ["release_tree", "configuration_hash", "generated_artifact_hash", "provenance_hash", "publication_boundary_hash", "evidence_freshness_hash"]) requiredString(release[field]); - if (release.publication_state !== "sealed" || release.evidence_freshness_state !== "current") throw new Error("invalid release evidence"); + const release = exactObject(value, [ + "release_tree", + "configuration_hash", + "generated_artifact_hash", + "provenance_hash", + "publication_boundary_hash", + "publication_state", + "evidence_freshness_hash", + "evidence_freshness_state", + ]); + for (const field of [ + "release_tree", + "configuration_hash", + "generated_artifact_hash", + "provenance_hash", + "publication_boundary_hash", + "evidence_freshness_hash", + ]) + requiredString(release[field]); + if ( + release.publication_state !== "sealed" || + release.evidence_freshness_state !== "current" + ) + throw new Error("invalid release evidence"); } -function decodeNonDecidingGateContext(value: unknown, expectedGate: string): NativeGateContext { +function decodeNonDecidingGateContext( + value: unknown, + expectedGate: string, +): NativeGateContext { const context = exactObject(value, ["gate"]); const gate = enumString(context.gate, NATIVE_GATE); - if (gate !== expectedGate) throw new Error("native non-deciding gate context does not match the requested gate"); + if (gate !== expectedGate) + throw new Error( + "native non-deciding gate context does not match the requested gate", + ); return { lineageId: "", storeRevision: "", raw: context }; } function decodeGateContext(value: unknown): NativeGateContext { const context = exactObject( value, - ["gate", "lineage_id", "generation", "base_tree", "candidate_tree", "paths_digest", "fix_delta_hash", "policy_hash", "ledger_hash", "evidence_hash", "base_relationship_valid"], - ["store_revision", "genesis_revision", "chain_identity", "bundle_digest", "external_evidence", "base_advanced_compatible", "release", "pre_pr_boundary", "denial"], + [ + "gate", + "lineage_id", + "generation", + "base_tree", + "candidate_tree", + "paths_digest", + "fix_delta_hash", + "policy_hash", + "ledger_hash", + "evidence_hash", + "base_relationship_valid", + ], + [ + "store_revision", + "genesis_revision", + "chain_identity", + "bundle_digest", + "external_evidence", + "base_advanced_compatible", + "release", + "pre_pr_boundary", + "denial", + ], ); const gate = stringValue(context.gate); - if (gate !== "" && !(NATIVE_GATE as readonly string[]).includes(gate)) throw new Error("invalid gate context gate"); - for (const field of ["lineage_id", "base_tree", "candidate_tree", "paths_digest", "fix_delta_hash", "policy_hash", "ledger_hash", "evidence_hash"]) stringValue(context[field]); - for (const field of ["store_revision", "genesis_revision", "chain_identity", "bundle_digest"]) if (context[field] !== undefined) stringValue(context[field]); + if (gate !== "" && !(NATIVE_GATE as readonly string[]).includes(gate)) + throw new Error("invalid gate context gate"); + for (const field of [ + "lineage_id", + "base_tree", + "candidate_tree", + "paths_digest", + "fix_delta_hash", + "policy_hash", + "ledger_hash", + "evidence_hash", + ]) + stringValue(context[field]); + for (const field of [ + "store_revision", + "genesis_revision", + "chain_identity", + "bundle_digest", + ]) + if (context[field] !== undefined) stringValue(context[field]); nonNegativeInteger(context.generation); booleanValue(context.base_relationship_valid); - if (context.external_evidence !== undefined) enumString(context.external_evidence, ["invalidating", "escalating"]); + if (context.external_evidence !== undefined) + enumString(context.external_evidence, ["invalidating", "escalating"]); let sanitizedContext = context; if (context.denial !== undefined) { const denial = exactObject(context.denial, ["stage", "code"]); - const stage = sanitizeNativeDiagnosticText(requiredString(denial.stage), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); - const code = sanitizeNativeDiagnosticText(requiredString(denial.code), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); - if (!isCanonicalProcessString(stage) || !isCanonicalProcessString(code)) throw new Error("non-canonical denial evidence"); + const stage = sanitizeNativeDiagnosticText( + requiredString(denial.stage), + NATIVE_REVIEW_DENIAL_TEXT_LIMIT, + ); + const code = sanitizeNativeDiagnosticText( + requiredString(denial.code), + NATIVE_REVIEW_DENIAL_TEXT_LIMIT, + ); + if (!isCanonicalProcessString(stage) || !isCanonicalProcessString(code)) + throw new Error("non-canonical denial evidence"); sanitizedContext = { ...context, denial: { stage, code } }; } if (context.pre_pr_boundary !== undefined) { - const boundary = exactObject(context.pre_pr_boundary, ["source", "selector", "commit"], ["remote", "remote_ref", "remote_identity"]); - enumString(boundary.source, ["explicit", "publication-default"]); requiredString(boundary.selector); stringValue(boundary.commit); - for (const field of ["remote", "remote_ref", "remote_identity"]) if (boundary[field] !== undefined) requiredString(boundary[field]); + const boundary = exactObject( + context.pre_pr_boundary, + ["source", "selector", "commit"], + ["remote", "remote_ref", "remote_identity"], + ); + enumString(boundary.source, ["explicit", "publication-default"]); + requiredString(boundary.selector); + stringValue(boundary.commit); + for (const field of ["remote", "remote_ref", "remote_identity"]) + if (boundary[field] !== undefined) requiredString(boundary[field]); } if (context.base_advanced_compatible !== undefined) { - const proof = exactObject(context.base_advanced_compatible, ["status", "compatible", "old_base_tree", "new_base_tree", "original_patch_identity", "delivered_patch_identity", "delivered_paths_digest", "base_advance_paths_digest", "paths_disjoint", "merged_result_tree", "ci_attestation_artifact_hash", "ci_attestation_issuer", "ci_status"]); - for (const field of ["status", "old_base_tree", "new_base_tree", "original_patch_identity", "delivered_patch_identity", "delivered_paths_digest", "base_advance_paths_digest", "merged_result_tree", "ci_attestation_artifact_hash", "ci_attestation_issuer", "ci_status"]) requiredString(proof[field]); - booleanValue(proof.compatible); booleanValue(proof.paths_disjoint); + const proof = exactObject(context.base_advanced_compatible, [ + "status", + "compatible", + "old_base_tree", + "new_base_tree", + "original_patch_identity", + "delivered_patch_identity", + "delivered_paths_digest", + "base_advance_paths_digest", + "paths_disjoint", + "merged_result_tree", + "ci_attestation_artifact_hash", + "ci_attestation_issuer", + "ci_status", + ]); + for (const field of [ + "status", + "old_base_tree", + "new_base_tree", + "original_patch_identity", + "delivered_patch_identity", + "delivered_paths_digest", + "base_advance_paths_digest", + "merged_result_tree", + "ci_attestation_artifact_hash", + "ci_attestation_issuer", + "ci_status", + ]) + requiredString(proof[field]); + booleanValue(proof.compatible); + booleanValue(proof.paths_disjoint); } if (context.release !== undefined) decodeReleaseEvidence(context.release); return { lineageId: stringValue(context.lineage_id), - storeRevision: context.store_revision === undefined ? "" : stringValue(context.store_revision), + storeRevision: + context.store_revision === undefined + ? "" + : stringValue(context.store_revision), raw: sanitizedContext, }; } function decodeNativeReviewRecovery(value: unknown): NativeReviewRecovery { - const recovery = exactObject(value, ["predecessor_lineage_id", "predecessor_revision", "disposition", "reason", "actor", "recovered_at"], ["maintainer_authorization"]); + const recovery = exactObject( + value, + [ + "predecessor_lineage_id", + "predecessor_revision", + "disposition", + "reason", + "actor", + "recovered_at", + ], + ["maintainer_authorization"], + ); return { predecessorLineageId: requiredString(recovery.predecessor_lineage_id), predecessorRevision: requiredString(recovery.predecessor_revision), - disposition: enumString(recovery.disposition, Object.values(NATIVE_REVIEW_RECOVERY_DISPOSITION)) as NativeReviewRecoveryDisposition, + disposition: enumString( + recovery.disposition, + Object.values(NATIVE_REVIEW_RECOVERY_DISPOSITION), + ) as NativeReviewRecoveryDisposition, reason: requiredString(recovery.reason), actor: requiredString(recovery.actor), recoveredAt: requiredString(recovery.recovered_at), - ...(recovery.maintainer_authorization === undefined ? {} : { maintainerAuthorization: requiredString(recovery.maintainer_authorization) }), + ...(recovery.maintainer_authorization === undefined + ? {} + : { + maintainerAuthorization: requiredString(recovery.maintainer_authorization), + }), }; } -function decodeNativeReviewDiscardedWorkSummary(value: unknown): NativeReviewDiscardedWorkSummary { - const discardedWork = exactObject(value, ["captured_lens_results", "findings_present"]); +function decodeNativeReviewDiscardedWorkSummary( + value: unknown, +): NativeReviewDiscardedWorkSummary { + const discardedWork = exactObject(value, [ + "captured_lens_results", + "findings_present", + ]); return { capturedLensResults: stringArray(discardedWork.captured_lens_results), findingsPresent: booleanValue(discardedWork.findings_present), }; } -function decodeNativeReviewStatusEntry(value: unknown): NativeReviewAuthorityEntry { - const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery", "discarded_work"]); +function decodeNativeReviewStatusEntry( + value: unknown, +): NativeReviewAuthorityEntry { + const entry = exactObject( + value, + ["version", "path", "status", "problems"], + [ + "lineage_id", + "state", + "revision", + "snapshot_identity", + "chain_identity", + "recovery", + "discarded_work", + ], + ); return { - version: enumString(entry.version, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)) as NativeReviewAuthorityEntryVersion, - ...(entry.lineage_id === undefined ? {} : { lineageId: requiredString(entry.lineage_id) }), + version: enumString( + entry.version, + Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION), + ) as NativeReviewAuthorityEntryVersion, + ...(entry.lineage_id === undefined + ? {} + : { lineageId: requiredString(entry.lineage_id) }), path: requiredString(entry.path), - status: enumString(entry.status, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS)) as NativeReviewAuthorityEntryStatus, + status: enumString( + entry.status, + Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS), + ) as NativeReviewAuthorityEntryStatus, ...(entry.state === undefined ? {} : { state: requiredString(entry.state) }), - ...(entry.revision === undefined ? {} : { revision: requiredString(entry.revision) }), - ...(entry.snapshot_identity === undefined ? {} : { snapshotIdentity: sha256Identity(entry.snapshot_identity) }), - ...(entry.chain_identity === undefined ? {} : { chainIdentity: requiredString(entry.chain_identity) }), - ...(entry.recovery === undefined ? {} : { recovery: decodeNativeReviewRecovery(entry.recovery) }), - ...(entry.discarded_work === undefined ? {} : { discardedWork: decodeNativeReviewDiscardedWorkSummary(entry.discarded_work) }), + ...(entry.revision === undefined + ? {} + : { revision: requiredString(entry.revision) }), + ...(entry.snapshot_identity === undefined + ? {} + : { snapshotIdentity: sha256Identity(entry.snapshot_identity) }), + ...(entry.chain_identity === undefined + ? {} + : { chainIdentity: requiredString(entry.chain_identity) }), + ...(entry.recovery === undefined + ? {} + : { recovery: decodeNativeReviewRecovery(entry.recovery) }), + ...(entry.discarded_work === undefined + ? {} + : { + discardedWork: decodeNativeReviewDiscardedWorkSummary( + entry.discarded_work, + ), + }), problems: stringArray(entry.problems), }; } -function decodeNativeReviewStatusLock(value: unknown): NativeReviewAuthorityLock { - const lock = exactObject(value, ["version", "path", "status"], ["lineage_id", "owner", "problem"]); +function decodeNativeReviewStatusLock( + value: unknown, +): NativeReviewAuthorityLock { + const lock = exactObject( + value, + ["version", "path", "status"], + ["lineage_id", "owner", "problem"], + ); let owner: NativeReviewLockOwner | undefined; if (lock.owner !== undefined) { - const decodedOwner = exactObject(lock.owner, ["schema", "owner_id", "pid", "host", "acquired_at"]); + const decodedOwner = exactObject(lock.owner, [ + "schema", + "owner_id", + "pid", + "host", + "acquired_at", + ]); owner = { - schema: enumString(decodedOwner.schema, Object.values(NATIVE_REVIEW_LOCK_OWNER_SCHEMA)) as NativeReviewLockOwnerSchema, + schema: enumString( + decodedOwner.schema, + Object.values(NATIVE_REVIEW_LOCK_OWNER_SCHEMA), + ) as NativeReviewLockOwnerSchema, ownerId: requiredString(decodedOwner.owner_id), pid: positiveInteger(decodedOwner.pid), host: requiredString(decodedOwner.host), @@ -1152,175 +2056,491 @@ function decodeNativeReviewStatusLock(value: unknown): NativeReviewAuthorityLock }; } return { - version: enumString(lock.version, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)) as NativeReviewAuthorityEntryVersion, - ...(lock.lineage_id === undefined ? {} : { lineageId: requiredString(lock.lineage_id) }), + version: enumString( + lock.version, + Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION), + ) as NativeReviewAuthorityEntryVersion, + ...(lock.lineage_id === undefined + ? {} + : { lineageId: requiredString(lock.lineage_id) }), path: requiredString(lock.path), - status: enumString(lock.status, Object.values(NATIVE_REVIEW_LOCK_STATUS)) as NativeReviewLockStatus, + status: enumString( + lock.status, + Object.values(NATIVE_REVIEW_LOCK_STATUS), + ) as NativeReviewLockStatus, ...(owner === undefined ? {} : { owner }), - ...(lock.problem === undefined ? {} : { problem: requiredString(lock.problem) }), + ...(lock.problem === undefined + ? {} + : { problem: requiredString(lock.problem) }), }; } -function decodeNativeReviewStatusDiagnostic(value: unknown): NativeReviewAuthorityDiagnostic { +function decodeNativeReviewStatusDiagnostic( + value: unknown, +): NativeReviewAuthorityDiagnostic { const diagnostic = exactObject(value, ["path", "problem"]); - return { path: requiredString(diagnostic.path), problem: requiredString(diagnostic.problem) }; + return { + path: requiredString(diagnostic.path), + problem: requiredString(diagnostic.problem), + }; } function decodeNativeReviewModeStatus(value: unknown): NativeReviewModeStatus { - const status = exactObject(value, ["schema", "global", "clone_local", "effective", "source"], ["revision", "reach"]); - if (status.schema !== "gentle-ai.rdd-mode-status/v1") throw new Error("wrong review mode status schema"); + const status = exactObject( + value, + ["schema", "global", "clone_local", "effective", "source"], + ["revision", "reach"], + ); + if (status.schema !== "gentle-ai.rdd-mode-status/v1") + throw new Error("wrong review mode status schema"); return { - global: enumString(status.global, Object.values(NATIVE_REVIEW_MODE_VALUE)) as NativeReviewModeValue, - cloneLocal: enumString(status.clone_local, Object.values(NATIVE_REVIEW_MODE_VALUE)) as NativeReviewModeValue, + global: enumString( + status.global, + Object.values(NATIVE_REVIEW_MODE_VALUE), + ) as NativeReviewModeValue, + cloneLocal: enumString( + status.clone_local, + Object.values(NATIVE_REVIEW_MODE_VALUE), + ) as NativeReviewModeValue, effective: enumString(status.effective, ["on", "off"]) as "on" | "off", - source: enumString(status.source, Object.values(NATIVE_REVIEW_MODE_SOURCE)) as NativeReviewModeSource, - ...(status.revision === undefined ? {} : { revision: requiredString(status.revision) }), - ...(status.reach === undefined ? {} : { reach: enumString(status.reach, Object.values(NATIVE_REVIEW_MODE_REACH)) as NativeReviewModeReach }), + source: enumString( + status.source, + Object.values(NATIVE_REVIEW_MODE_SOURCE), + ) as NativeReviewModeSource, + ...(status.revision === undefined + ? {} + : { revision: requiredString(status.revision) }), + ...(status.reach === undefined + ? {} + : { + reach: enumString( + status.reach, + Object.values(NATIVE_REVIEW_MODE_REACH), + ) as NativeReviewModeReach, + }), }; } -function decodeNativeReviewMode(value: unknown, expectedOperation: NativeReviewModeOperation): NativeReviewModeResult { +function decodeNativeReviewMode( + value: unknown, + expectedOperation: NativeReviewModeOperation, +): NativeReviewModeResult { const body = exactObject(value, ["schema", "operation", "scope", "status"]); - if (body.schema !== "gentle-ai.review-mode/v1" || body.operation !== expectedOperation) throw new Error("wrong review mode discriminator"); + if ( + body.schema !== "gentle-ai.review-mode/v1" || + body.operation !== expectedOperation + ) + throw new Error("wrong review mode discriminator"); return { operation: expectedOperation, - scope: enumString(body.scope, Object.values(NATIVE_REVIEW_MODE_SCOPE)) as NativeReviewModeScope, + scope: enumString( + body.scope, + Object.values(NATIVE_REVIEW_MODE_SCOPE), + ) as NativeReviewModeScope, status: decodeNativeReviewModeStatus(body.status), }; } function decodeNativeReviewStatus(value: unknown): NativeReviewStatusResult { - const body = exactObject(value, ["schema", "operation", "repository", "complete", "authoritative", "status", "entries", "locks", "diagnostics"]); - if (body.schema !== "gentle-ai.review-authority-status/v1" || body.operation !== "review/status") throw new Error("wrong review status discriminator"); + const body = exactObject(value, [ + "schema", + "operation", + "repository", + "complete", + "authoritative", + "status", + "entries", + "locks", + "diagnostics", + ]); + if ( + body.schema !== "gentle-ai.review-authority-status/v1" || + body.operation !== "review/status" + ) + throw new Error("wrong review status discriminator"); const complete = booleanValue(body.complete); const authoritative = booleanValue(body.authoritative); - if (authoritative && !complete) throw new Error("incomplete inventory cannot be authoritative"); - if (!Array.isArray(body.entries) || !Array.isArray(body.locks)) throw new Error("invalid native status inventory"); + if (authoritative && !complete) + throw new Error("incomplete inventory cannot be authoritative"); + if (!Array.isArray(body.entries) || !Array.isArray(body.locks)) + throw new Error("invalid native status inventory"); return { repository: requiredString(body.repository), complete, authoritative, - status: enumString(body.status, Object.values(NATIVE_REVIEW_AUTHORITY_STATUS)) as NativeReviewAuthorityStatus, + status: enumString( + body.status, + Object.values(NATIVE_REVIEW_AUTHORITY_STATUS), + ) as NativeReviewAuthorityStatus, entries: body.entries.map(decodeNativeReviewStatusEntry), locks: body.locks.map(decodeNativeReviewStatusLock), diagnostics: body.diagnostics.map(decodeNativeReviewStatusDiagnostic), raw: body, }; } -function isWindowsRepositoryPath(value: string): boolean { return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); } -export function normalizeNativeReviewCwd(value: string, platform: NodeJS.Platform = process.platform): string { +function isWindowsRepositoryPath(value: string): boolean { + return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value); +} +export function normalizeNativeReviewCwd( + value: string, + platform: NodeJS.Platform = process.platform, +): string { if (platform !== "win32") return value; const gitBashDrive = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value); - const windowsPath = gitBashDrive === null - ? value - : `${gitBashDrive[1]!.toUpperCase()}:/${gitBashDrive[2] ?? ""}`; + const windowsPath = + gitBashDrive === null + ? value + : `${gitBashDrive[1]!.toUpperCase()}:/${gitBashDrive[2] ?? ""}`; if (!isWindowsRepositoryPath(windowsPath)) return windowsPath; const normalized = win32.normalize(windowsPath); - return normalized.replace(/^([a-z]):/, (_match, drive: string) => `${drive.toUpperCase()}:`); + return normalized.replace( + /^([a-z]):/, + (_match, drive: string) => `${drive.toUpperCase()}:`, + ); } async function canonicalNativeReviewCwd(value: string): Promise { const normalized = normalizeNativeReviewCwd(value); - try { return await realpath(normalized); } - catch { return normalized; } + try { + return await realpath(normalized); + } catch { + return normalized; + } } async function repositoryPathIdentity(value: string): Promise { const windowsPath = isWindowsRepositoryPath(value); - try { return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; } - catch { return `path:${windowsPath ? win32.normalize(value).toLowerCase() : posix.normalize(value)}`; } + try { + return `filesystem:${windowsPath ? (await realpath(value)).toLowerCase() : await realpath(value)}`; + } catch { + return `path:${windowsPath ? win32.normalize(value).toLowerCase() : posix.normalize(value)}`; + } } -async function repositoriesMatch(requested: string, returned: string): Promise { - return (await repositoryPathIdentity(requested)) === (await repositoryPathIdentity(returned)); +async function repositoriesMatch( + requested: string, + returned: string, +): Promise { + return ( + (await repositoryPathIdentity(requested)) === + (await repositoryPathIdentity(returned)) + ); } function decodeSnapshot(value: unknown): void { - const snapshot = exactObject(value, ["kind", "base_tree", "candidate_tree", "paths_digest", "intended_untracked", "intended_untracked_proof", "paths", "identity"], ["ledger_ids"]); - enumString(snapshot.kind, ["current-changes", "base-diff", "commit-range", "fix-diff"]); - for (const field of ["base_tree", "candidate_tree", "paths_digest", "intended_untracked_proof", "identity"]) requiredString(snapshot[field]); - stringArray(snapshot.intended_untracked); stringArray(snapshot.paths); + const snapshot = exactObject( + value, + [ + "kind", + "base_tree", + "candidate_tree", + "paths_digest", + "intended_untracked", + "intended_untracked_proof", + "paths", + "identity", + ], + ["ledger_ids"], + ); + enumString(snapshot.kind, [ + "current-changes", + "base-diff", + "commit-range", + "fix-diff", + ]); + for (const field of [ + "base_tree", + "candidate_tree", + "paths_digest", + "intended_untracked_proof", + "identity", + ]) + requiredString(snapshot[field]); + stringArray(snapshot.intended_untracked); + stringArray(snapshot.paths); if (snapshot.ledger_ids !== undefined) stringArray(snapshot.ledger_ids); } function decodeFinding(value: unknown): void { - const finding = exactObject(value, ["id"], ["lens", "location", "severity", "claim", "proof_refs"]); + const finding = exactObject( + value, + ["id"], + ["lens", "location", "severity", "claim", "proof_refs"], + ); requiredString(finding.id); - if (finding.lens !== undefined) enumString(finding.lens, ["risk", "resilience", "readability", "reliability"]); + if (finding.lens !== undefined) + enumString(finding.lens, [ + "risk", + "resilience", + "readability", + "reliability", + ]); if (finding.location !== undefined) stringValue(finding.location); - if (finding.severity !== undefined) enumString(finding.severity, ["BLOCKER", "CRITICAL", "WARNING", "SUGGESTION"]); + if (finding.severity !== undefined) + enumString(finding.severity, [ + "BLOCKER", + "CRITICAL", + "WARNING", + "SUGGESTION", + ]); if (finding.claim !== undefined) stringValue(finding.claim); if (finding.proof_refs !== undefined) stringArray(finding.proof_refs); } function decodeLensResult(value: unknown): void { - const result = exactObject(value, ["lens", "findings", "evidence", "result_hash"]); + const result = exactObject(value, [ + "lens", + "findings", + "evidence", + "result_hash", + ]); enumString(result.lens, NATIVE_REVIEW_LENS); if (!Array.isArray(result.findings)) throw new Error("invalid lens findings"); for (const finding of result.findings) decodeFinding(finding); - stringArray(result.evidence); requiredString(result.result_hash); + stringArray(result.evidence); + requiredString(result.result_hash); } function decodeFindingEvidence(value: unknown): void { - const evidence = exactObject(value, ["finding_id", "class", "proof"], ["causal_disposition"]); - requiredString(evidence.finding_id); enumString(evidence.class, ["deterministic", "inferential", "insufficient"]); requiredString(evidence.proof); - if (evidence.causal_disposition !== undefined) enumString(evidence.causal_disposition, ["introduced", "behavior-activated", "worsened", "pre-existing", "base-only", "unknown"]); + const evidence = exactObject( + value, + ["finding_id", "class", "proof"], + ["causal_disposition"], + ); + requiredString(evidence.finding_id); + enumString(evidence.class, ["deterministic", "inferential", "insufficient"]); + requiredString(evidence.proof); + if (evidence.causal_disposition !== undefined) + enumString(evidence.causal_disposition, [ + "introduced", + "behavior-activated", + "worsened", + "pre-existing", + "base-only", + "unknown", + ]); } function decodeValidationCheck(value: unknown): void { - const check = exactObject(value, ["evidence_hash", "fix_delta_hash", "passed"]); - requiredString(check.evidence_hash); requiredString(check.fix_delta_hash); booleanValue(check.passed); + const check = exactObject(value, [ + "evidence_hash", + "fix_delta_hash", + "passed", + ]); + requiredString(check.evidence_hash); + requiredString(check.fix_delta_hash); + booleanValue(check.passed); } function decodeReviewTransaction(value: unknown): void { const transaction = exactObject( value, - ["schema", "lineage_id", "mode", "generation", "state", "snapshot", "base_tree", "paths_digest", "initial_review_tree", "final_candidate_tree", "fix_delta_hash", "policy_hash", "ledger_hash", "ledger_findings_hash", "evidence_hash", "judge_proofs", "counters", "findings", "classifications", "outcomes", "fix_finding_ids", "pending_refuter_ids", "fix_caused_findings", "follow_ups"], - ["genesis_paths", "invalidation_reason", "judge_proof_hash", "judge_agreement_hash", "release", "failed_evidence_revision", "original_criteria", "correction_regression", "risk_level", "selected_lenses", "lens_results", "original_changed_lines", "correction_budget", "proposed_correction_lines", "actual_correction_lines"], + [ + "schema", + "lineage_id", + "mode", + "generation", + "state", + "snapshot", + "base_tree", + "paths_digest", + "initial_review_tree", + "final_candidate_tree", + "fix_delta_hash", + "policy_hash", + "ledger_hash", + "ledger_findings_hash", + "evidence_hash", + "judge_proofs", + "counters", + "findings", + "classifications", + "outcomes", + "fix_finding_ids", + "pending_refuter_ids", + "fix_caused_findings", + "follow_ups", + ], + [ + "genesis_paths", + "invalidation_reason", + "judge_proof_hash", + "judge_agreement_hash", + "release", + "failed_evidence_revision", + "original_criteria", + "correction_regression", + "risk_level", + "selected_lenses", + "lens_results", + "original_changed_lines", + "correction_budget", + "proposed_correction_lines", + "actual_correction_lines", + ], ); - if (transaction.schema !== "gentle-ai.review-transaction/v1") throw new Error("invalid review transaction schema"); - requiredString(transaction.lineage_id); enumString(transaction.mode, ["ordinary_4r", "ordinary_bounded", "judgment_day"]); nonNegativeInteger(transaction.generation); - enumString(transaction.state, ["unreviewed", "reviewing", "judges_confirmed", "findings_frozen", "evidence_classified", "fix_required", "fixing", "fix_validating", "ready_final_verification", "final_verifying", "approved", "escalated", "invalidated"]); + if (transaction.schema !== "gentle-ai.review-transaction/v1") + throw new Error("invalid review transaction schema"); + requiredString(transaction.lineage_id); + enumString(transaction.mode, [ + "ordinary_4r", + "ordinary_bounded", + "judgment_day", + ]); + nonNegativeInteger(transaction.generation); + enumString(transaction.state, [ + "unreviewed", + "reviewing", + "judges_confirmed", + "findings_frozen", + "evidence_classified", + "fix_required", + "fixing", + "fix_validating", + "ready_final_verification", + "final_verifying", + "approved", + "escalated", + "invalidated", + ]); decodeSnapshot(transaction.snapshot); - for (const field of ["base_tree", "paths_digest", "initial_review_tree", "final_candidate_tree", "fix_delta_hash", "policy_hash", "ledger_hash", "ledger_findings_hash", "evidence_hash"]) stringValue(transaction[field]); - for (const field of ["genesis_paths", "fix_finding_ids", "pending_refuter_ids"]) if (transaction[field] !== undefined) stringArray(transaction[field]); - for (const field of ["invalidation_reason", "judge_proof_hash", "judge_agreement_hash", "failed_evidence_revision"]) if (transaction[field] !== undefined) requiredString(transaction[field]); - if (!Array.isArray(transaction.judge_proofs)) throw new Error("invalid judge proofs"); + for (const field of [ + "base_tree", + "paths_digest", + "initial_review_tree", + "final_candidate_tree", + "fix_delta_hash", + "policy_hash", + "ledger_hash", + "ledger_findings_hash", + "evidence_hash", + ]) + stringValue(transaction[field]); + for (const field of [ + "genesis_paths", + "fix_finding_ids", + "pending_refuter_ids", + ]) + if (transaction[field] !== undefined) stringArray(transaction[field]); + for (const field of [ + "invalidation_reason", + "judge_proof_hash", + "judge_agreement_hash", + "failed_evidence_revision", + ]) + if (transaction[field] !== undefined) requiredString(transaction[field]); + if (!Array.isArray(transaction.judge_proofs)) + throw new Error("invalid judge proofs"); for (const proof of transaction.judge_proofs) { - const row = exactObject(proof, ["judge_id", "execution_hash", "result_hash", "blind", "confirmed"]); - requiredString(row.judge_id); requiredString(row.execution_hash); requiredString(row.result_hash); booleanValue(row.blind); booleanValue(row.confirmed); + const row = exactObject(proof, [ + "judge_id", + "execution_hash", + "result_hash", + "blind", + "confirmed", + ]); + requiredString(row.judge_id); + requiredString(row.execution_hash); + requiredString(row.result_hash); + booleanValue(row.blind); + booleanValue(row.confirmed); } - const counters = exactObject(transaction.counters, ["full_reviews", "refuter_batches", "fix_batches", "scoped_fix_validations", "final_verifications", "fix_rounds", "scoped_rejudgments", "judge_executions"], ["risk_executions", "resilience_executions", "readability_executions", "reliability_executions"]); + const counters = exactObject( + transaction.counters, + [ + "full_reviews", + "refuter_batches", + "fix_batches", + "scoped_fix_validations", + "final_verifications", + "fix_rounds", + "scoped_rejudgments", + "judge_executions", + ], + [ + "risk_executions", + "resilience_executions", + "readability_executions", + "reliability_executions", + ], + ); for (const value of Object.values(counters)) nonNegativeInteger(value); for (const field of ["findings", "fix_caused_findings"]) { - if (!Array.isArray(transaction[field])) throw new Error("invalid transaction findings"); + if (!Array.isArray(transaction[field])) + throw new Error("invalid transaction findings"); for (const finding of transaction[field]) decodeFinding(finding); } const classifications = object(transaction.classifications); - for (const evidence of Object.values(classifications)) decodeFindingEvidence(evidence); + for (const evidence of Object.values(classifications)) + decodeFindingEvidence(evidence); const outcomes = object(transaction.outcomes); - for (const outcome of Object.values(outcomes)) enumString(outcome, ["corroborated", "refuted", "inconclusive", "info"]); - if (!Array.isArray(transaction.follow_ups)) throw new Error("invalid follow-ups"); + for (const outcome of Object.values(outcomes)) + enumString(outcome, ["corroborated", "refuted", "inconclusive", "info"]); + if (!Array.isArray(transaction.follow_ups)) + throw new Error("invalid follow-ups"); for (const followUp of transaction.follow_ups) { const row = exactObject(followUp, ["observation", "proof_refs"]); - requiredString(row.observation); stringArray(row.proof_refs); + requiredString(row.observation); + stringArray(row.proof_refs); } - for (const field of ["original_criteria", "correction_regression"]) if (transaction[field] !== undefined) decodeValidationCheck(transaction[field]); - if (transaction.release !== undefined) decodeReleaseEvidence(transaction.release); - if (transaction.risk_level !== undefined) enumString(transaction.risk_level, NATIVE_RISK_LEVEL); - if (transaction.selected_lenses !== undefined) for (const lens of stringArray(transaction.selected_lenses)) enumString(lens, NATIVE_REVIEW_LENS); + for (const field of ["original_criteria", "correction_regression"]) + if (transaction[field] !== undefined) + decodeValidationCheck(transaction[field]); + if (transaction.release !== undefined) + decodeReleaseEvidence(transaction.release); + if (transaction.risk_level !== undefined) + enumString(transaction.risk_level, NATIVE_RISK_LEVEL); + if (transaction.selected_lenses !== undefined) + for (const lens of stringArray(transaction.selected_lenses)) + enumString(lens, NATIVE_REVIEW_LENS); if (transaction.lens_results !== undefined) { - if (!Array.isArray(transaction.lens_results)) throw new Error("invalid lens results"); + if (!Array.isArray(transaction.lens_results)) + throw new Error("invalid lens results"); for (const result of transaction.lens_results) decodeLensResult(result); } - for (const field of ["original_changed_lines", "correction_budget", "proposed_correction_lines", "actual_correction_lines"]) if (transaction[field] !== undefined) nonNegativeInteger(transaction[field]); + for (const field of [ + "original_changed_lines", + "correction_budget", + "proposed_correction_lines", + "actual_correction_lines", + ]) + if (transaction[field] !== undefined) nonNegativeInteger(transaction[field]); } -function hasCanonicalSelectedLenses(riskLevel: string, selectedLenses: readonly string[]): boolean { +function hasCanonicalSelectedLenses( + riskLevel: string, + selectedLenses: readonly string[], +): boolean { if (new Set(selectedLenses).size !== selectedLenses.length) return false; if (riskLevel === "low") return selectedLenses.length === 0; if (riskLevel === "medium") return selectedLenses.length === 1; - return selectedLenses.length === NATIVE_REVIEW_LENS.length - && NATIVE_REVIEW_LENS.every((lens) => selectedLenses.includes(lens)); + return ( + selectedLenses.length === NATIVE_REVIEW_LENS.length && + NATIVE_REVIEW_LENS.every((lens) => selectedLenses.includes(lens)) + ); } -function hasValidLensesRequired(action: NativeStartAction, state: string, riskLevel: string, lensesRequired: boolean): boolean { +function hasValidLensesRequired( + action: NativeStartAction, + state: string, + riskLevel: string, + lensesRequired: boolean, +): boolean { if (riskLevel === "low") return !lensesRequired; - if (action === NATIVE_START_ACTION.CREATED) return state === "reviewing" && lensesRequired; - if (action === NATIVE_START_ACTION.RESUMED) return !lensesRequired || state === "reviewing"; + if (action === NATIVE_START_ACTION.CREATED) + return state === "reviewing" && lensesRequired; + if (action === NATIVE_START_ACTION.RESUMED) + return !lensesRequired || state === "reviewing"; return !lensesRequired; } -function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperation, mutating: boolean, message: string, result?: ExecFileResult, launchAttempted = true, auditRecord?: Record, maxBufferBytes?: number): NativeReviewCliError { - return new NativeReviewCliError(code, operation, launchAttempted, mutating, message, nativeProcessDiagnostics(operation, code, result, maxBufferBytes), auditRecord); +function nativeError( + code: NativeReviewErrorCode, + operation: NativeReviewOperation, + mutating: boolean, + message: string, + result?: ExecFileResult, + launchAttempted = true, + auditRecord?: Record, + maxBufferBytes?: number, +): NativeReviewCliError { + return new NativeReviewCliError( + code, + operation, + launchAttempted, + mutating, + message, + nativeProcessDiagnostics(operation, code, result, maxBufferBytes), + auditRecord, + ); } interface NativeJsonExecution { @@ -1335,8 +2555,21 @@ class NativeReviewPlainCli { private readonly timeoutMs: number; private readonly maxBufferBytes: number; private readonly cleanupDirectory: (directory: string) => Promise; - constructor(adapter: ExecFileAdapter, executable: string | (() => string) = resolveGentleAiBinary, timeoutMs = 30_000, maxBufferBytes = resolveNativeReviewMaxBufferBytes(), cleanupDirectory = (directory: string) => rm(directory, { recursive: true, force: true })) { - if (typeof executable === "string" && (!isAbsolute(executable) || executable === "gentle-ai")) throw new TypeError("Native review requires an absolute package-local executable"); + constructor( + adapter: ExecFileAdapter, + executable: string | (() => string) = resolveGentleAiBinary, + timeoutMs = 30_000, + maxBufferBytes = resolveNativeReviewMaxBufferBytes(), + cleanupDirectory = (directory: string) => + rm(directory, { recursive: true, force: true }), + ) { + if ( + typeof executable === "string" && + (!isAbsolute(executable) || executable === "gentle-ai") + ) + throw new TypeError( + "Native review requires an absolute package-local executable", + ); this.adapter = adapter; this.executable = executable; this.timeoutMs = timeoutMs; @@ -1344,17 +2577,37 @@ class NativeReviewPlainCli { this.cleanupDirectory = cleanupDirectory; } - private executablePath(operation: NativeReviewOperation, mutating: boolean): string { + private executablePath( + operation: NativeReviewOperation, + mutating: boolean, + ): string { try { - const executable = typeof this.executable === "string" ? this.executable : this.executable(); - if (!isAbsolute(executable) || executable === "gentle-ai") throw new TypeError("Native review requires an absolute package-local executable"); + const executable = + typeof this.executable === "string" ? this.executable : this.executable(); + if (!isAbsolute(executable) || executable === "gentle-ai") + throw new TypeError( + "Native review requires an absolute package-local executable", + ); return executable; - } - catch (error) { + } catch (error) { if (error instanceof PackageLocalGentleAiBinaryMissingError) { - throw nativeError(NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING, operation, mutating, error.message, undefined, false); + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING, + operation, + mutating, + error.message, + undefined, + false, + ); } - throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "package-local native process could not start", undefined, false); + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, + operation, + mutating, + "package-local native process could not start", + undefined, + false, + ); } } @@ -1363,29 +2616,135 @@ class NativeReviewPlainCli { // only when the negotiated version's `mode` capability is true. A near-miss, // prefixed, or multi-line stderr is never tolerated — only byte-exact // membership in the frozen set. - private async execute(operation: NativeReviewOperation, cwd: string, arguments_: readonly string[], mutating: boolean, signal?: AbortSignal, toleratedStderr: readonly string[] = []): Promise { + private async execute( + operation: NativeReviewOperation, + cwd: string, + arguments_: readonly string[], + mutating: boolean, + signal?: AbortSignal, + toleratedStderr: readonly string[] = [], + ): Promise { let result: ExecFileResult; - try { result = await this.adapter({ file: this.executablePath(operation, mutating), arguments: arguments_, cwd, timeoutMs: mutating ? undefined : this.timeoutMs, maxBufferBytes: this.maxBufferBytes, signal }); } - catch (error) { - if (error instanceof NativeReviewCliError) throw nativeError(error.code, operation, mutating, error.message, undefined, error.launchAttempted); - if (error instanceof Error && error.name === "AbortError") throw nativeError(NATIVE_REVIEW_ERROR_CODE.CANCELLED, operation, mutating, "native process was cancelled"); - throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "native process could not start"); + try { + result = await this.adapter({ + file: this.executablePath(operation, mutating), + arguments: arguments_, + cwd, + timeoutMs: mutating ? undefined : this.timeoutMs, + maxBufferBytes: this.maxBufferBytes, + signal, + }); + } catch (error) { + if (error instanceof NativeReviewCliError) + throw nativeError( + error.code, + operation, + mutating, + error.message, + undefined, + error.launchAttempted, + ); + if (error instanceof Error && error.name === "AbortError") + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.CANCELLED, + operation, + mutating, + "native process was cancelled", + ); + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, + operation, + mutating, + "native process could not start", + ); } - const diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.NON_ZERO, result); - if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes); - if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result); - if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result); - const maintenancePartialFailure = [NATIVE_REVIEW_OPERATION.ABANDON, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS].includes(operation) && result.exitCode !== 0; + const diagnostics = nativeProcessDiagnostics( + operation, + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + result, + ); + if (result.outputLimitExceeded) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, + operation, + mutating, + "native process output exceeded limit", + result, + true, + undefined, + this.maxBufferBytes, + ); + if (result.timedOut) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.TIMEOUT, + operation, + mutating, + "native process timed out", + result, + ); + if (result.signal) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.SIGNAL, + operation, + mutating, + "native process was signalled", + result, + ); + const maintenancePartialFailure = + [ + NATIVE_REVIEW_OPERATION.ABANDON, + NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, + NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, + NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, + ].includes(operation) && result.exitCode !== 0; const toleratedNotice = stderrIsTolerated(result.stderr, toleratedStderr); - if (result.exitCode !== 0 && !maintenancePartialFailure) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native process failed", result); - if (result.stderr.trim().length > 0 && !maintenancePartialFailure && !toleratedNotice) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); - return { body: parseJson(result.stdout, operation, mutating, diagnostics), exitCode: result.exitCode, process: result }; + if (result.exitCode !== 0 && !maintenancePartialFailure) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + operation, + mutating, + "native process failed", + result, + ); + if ( + result.stderr.trim().length > 0 && + !maintenancePartialFailure && + !toleratedNotice + ) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, + operation, + mutating, + "native process wrote stderr", + result, + ); + return { + body: parseJson(result.stdout, operation, mutating, diagnostics), + exitCode: result.exitCode, + process: result, + }; } - async reviewStatus(request: NativeReviewStatusRequest): Promise { - const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.STATUS, request.cwd, ["review", "status", "--cwd", request.cwd], false, request.signal); - const status = decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeNativeReviewStatus(result)); - if (!await repositoriesMatch(request.cwd, status.repository)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.STATUS, false, "native review status repository mismatch"); + async reviewStatus( + request: NativeReviewStatusRequest, + ): Promise { + const { body: result } = await this.execute( + NATIVE_REVIEW_OPERATION.STATUS, + request.cwd, + ["review", "status", "--cwd", request.cwd], + false, + request.signal, + ); + const status = decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => + decodeNativeReviewStatus(result), + ); + if (!(await repositoriesMatch(request.cwd, status.repository))) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.STATUS, + false, + "native review status repository mismatch", + ); return status; } @@ -1394,28 +2753,68 @@ class NativeReviewPlainCli { // exact fixed argv per the design's data flow); `enable`/`disable` mutate // and always pass `--scope clone` so Pi's own kill-switch command surface // never mutates the operator's global gentle-ai state across other clones. - async reviewMode(request: NativeReviewModeRequest): Promise { + async reviewMode( + request: NativeReviewModeRequest, + ): Promise { const cwd = await canonicalNativeReviewCwd(request.cwd); const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS; const { body } = await this.execute( NATIVE_REVIEW_OPERATION.MODE, cwd, - ["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"], + [ + "review", + "mode", + request.operation, + "--cwd", + cwd, + ...(mutating ? ["--scope", "clone"] : []), + "--json", + ], mutating, request.signal, ); - return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => decodeNativeReviewMode(body, request.operation)); + return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => + decodeNativeReviewMode(body, request.operation), + ); } - async reclaim(request: NativeReviewReclaimRequest): Promise { - for (const [name, value] of [["lineage", request.lineage], ["actor", request.actor], ["reason", request.reason]] as const) { - if (!isCanonicalProcessString(value)) throw new TypeError(`Native RECLAIM ${name} must be a non-empty, trimmed, NUL-free string`); + async reclaim( + request: NativeReviewReclaimRequest, + ): Promise { + for (const [name, value] of [ + ["lineage", request.lineage], + ["actor", request.actor], + ["reason", request.reason], + ] as const) { + if (!isCanonicalProcessString(value)) + throw new TypeError( + `Native RECLAIM ${name} must be a non-empty, trimmed, NUL-free string`, + ); } - const { body } = await this.execute(NATIVE_REVIEW_OPERATION.RECLAIM, request.cwd, ["review", "reclaim", "--cwd", request.cwd, "--lineage", request.lineage, "--actor", request.actor, "--reason", request.reason], true, request.signal); + const { body } = await this.execute( + NATIVE_REVIEW_OPERATION.RECLAIM, + request.cwd, + [ + "review", + "reclaim", + "--cwd", + request.cwd, + "--lineage", + request.lineage, + "--actor", + request.actor, + "--reason", + request.reason, + ], + true, + request.signal, + ); return { record: body }; } - async recover(request: NativeReviewRecoverRequest): Promise { + async recover( + request: NativeReviewRecoverRequest, + ): Promise { for (const [name, value] of [ ["predecessorLineage", request.predecessorLineage], ["expectedPredecessorRevision", request.expectedPredecessorRevision], @@ -1423,69 +2822,208 @@ class NativeReviewPlainCli { ["actor", request.actor], ["reason", request.reason], ] as const) { - if (!isCanonicalProcessString(value)) throw new TypeError(`Native RECOVER ${name} must be a non-empty, trimmed, NUL-free string`); + if (!isCanonicalProcessString(value)) + throw new TypeError( + `Native RECOVER ${name} must be a non-empty, trimmed, NUL-free string`, + ); } // The maintainer authorization is an exact multi-line LF-only binding, so // LF is the only permitted control character. - if (request.maintainerAuthorization !== undefined && (request.maintainerAuthorization.length === 0 || /[\u0000-\u0009\u000b-\u001f\u007f]/.test(request.maintainerAuthorization))) { - throw new TypeError("Native RECOVER maintainerAuthorization must be a non-empty LF-only binding"); + if ( + request.maintainerAuthorization !== undefined && + (request.maintainerAuthorization.length === 0 || + /[\u0000-\u0009\u000b-\u001f\u007f]/.test(request.maintainerAuthorization)) + ) { + throw new TypeError( + "Native RECOVER maintainerAuthorization must be a non-empty LF-only binding", + ); } - if (!(NATIVE_REVIEW_RECOVER_DISPOSITION as readonly string[]).includes(request.disposition)) throw new TypeError("Native RECOVER disposition must be scope_changed, invalidated, or escalated"); - const { body } = await this.execute(NATIVE_REVIEW_OPERATION.RECOVER, request.cwd, [ - "review", "recover", "--cwd", request.cwd, - "--predecessor-lineage", request.predecessorLineage, - "--expected-predecessor-revision", request.expectedPredecessorRevision, - "--successor-lineage", request.successorLineage, - "--disposition", request.disposition, - "--actor", request.actor, - "--reason", request.reason, - ...(request.maintainerAuthorization === undefined ? [] : ["--maintainer-authorization", request.maintainerAuthorization]), - ], true, request.signal); + if ( + !(NATIVE_REVIEW_RECOVER_DISPOSITION as readonly string[]).includes( + request.disposition, + ) + ) + throw new TypeError( + "Native RECOVER disposition must be scope_changed, invalidated, or escalated", + ); + const { body } = await this.execute( + NATIVE_REVIEW_OPERATION.RECOVER, + request.cwd, + [ + "review", + "recover", + "--cwd", + request.cwd, + "--predecessor-lineage", + request.predecessorLineage, + "--expected-predecessor-revision", + request.expectedPredecessorRevision, + "--successor-lineage", + request.successorLineage, + "--disposition", + request.disposition, + "--actor", + request.actor, + "--reason", + request.reason, + ...(request.maintainerAuthorization === undefined + ? [] + : ["--maintainer-authorization", request.maintainerAuthorization]), + ], + true, + request.signal, + ); return { record: body }; } - async abandon(request: NativeReviewAbandonRequest): Promise { - for (const [name, value] of [["lineage", request.lineage], ["expectedRevision", request.expectedRevision], ["snapshotIdentity", request.snapshotIdentity], ["actor", request.actor], ["reason", request.reason]] as const) { - if (!isCanonicalProcessString(value)) throw new TypeError(`Native ABANDON ${name} must be a non-empty, trimmed, NUL-free string`); + async abandon( + request: NativeReviewAbandonRequest, + ): Promise { + for (const [name, value] of [ + ["lineage", request.lineage], + ["expectedRevision", request.expectedRevision], + ["snapshotIdentity", request.snapshotIdentity], + ["actor", request.actor], + ["reason", request.reason], + ] as const) { + if (!isCanonicalProcessString(value)) + throw new TypeError( + `Native ABANDON ${name} must be a non-empty, trimmed, NUL-free string`, + ); } - if (!Array.isArray(request.capturedLensResults) || request.capturedLensResults.some((entry) => !isCanonicalProcessString(entry))) throw new TypeError("Native ABANDON capturedLensResults must be an array of non-empty, trimmed, NUL-free strings"); - if (typeof request.findingsPresent !== "boolean") throw new TypeError("Native ABANDON findingsPresent must be a boolean"); - if (request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request)) throw new TypeError("Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, reason, discarded-work, and actor binding"); - const execution = await this.execute(NATIVE_REVIEW_OPERATION.ABANDON, request.cwd, [ - "review", "abandon", "--cwd", request.cwd, - "--lineage", request.lineage, - "--expected-revision", request.expectedRevision, - "--actor", request.actor, - "--reason", request.reason, - "--maintainer-authorization", request.maintainerAuthorization, - ], true, request.signal); - const result = decode(NATIVE_REVIEW_OPERATION.ABANDON, true, () => decodeNativeMaintenanceResult(execution.body, NATIVE_REVIEW_OPERATION.ABANDON)); - if (execution.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.ABANDON, true, "native authority abandonment partially failed", execution.process, true, result.record); + if ( + !Array.isArray(request.capturedLensResults) || + request.capturedLensResults.some((entry) => !isCanonicalProcessString(entry)) + ) + throw new TypeError( + "Native ABANDON capturedLensResults must be an array of non-empty, trimmed, NUL-free strings", + ); + if (typeof request.findingsPresent !== "boolean") + throw new TypeError("Native ABANDON findingsPresent must be a boolean"); + if ( + request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request) + ) + throw new TypeError( + "Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, reason, discarded-work, and actor binding", + ); + const execution = await this.execute( + NATIVE_REVIEW_OPERATION.ABANDON, + request.cwd, + [ + "review", + "abandon", + "--cwd", + request.cwd, + "--lineage", + request.lineage, + "--expected-revision", + request.expectedRevision, + "--actor", + request.actor, + "--reason", + request.reason, + "--maintainer-authorization", + request.maintainerAuthorization, + ], + true, + request.signal, + ); + const result = decode(NATIVE_REVIEW_OPERATION.ABANDON, true, () => + decodeNativeMaintenanceResult( + execution.body, + NATIVE_REVIEW_OPERATION.ABANDON, + ), + ); + if (execution.exitCode !== 0) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + NATIVE_REVIEW_OPERATION.ABANDON, + true, + "native authority abandonment partially failed", + execution.process, + true, + result.record, + ); return result; } - async quarantineLegacy(request: NativeReviewLegacyQuarantineRequest): Promise { - for (const [name, value] of [["repository", request.repository], ["lineage", request.lineage], ["expectedRevision", request.expectedRevision], ["actor", request.actor], ["reason", request.reason]] as const) { - if (!isCanonicalProcessString(value)) throw new TypeError(`Native QUARANTINE_LEGACY ${name} must be a non-empty, trimmed, NUL-free string`); + async quarantineLegacy( + request: NativeReviewLegacyQuarantineRequest, + ): Promise { + for (const [name, value] of [ + ["repository", request.repository], + ["lineage", request.lineage], + ["expectedRevision", request.expectedRevision], + ["actor", request.actor], + ["reason", request.reason], + ] as const) { + if (!isCanonicalProcessString(value)) + throw new TypeError( + `Native QUARANTINE_LEGACY ${name} must be a non-empty, trimmed, NUL-free string`, + ); } - if (request.diagnostic !== NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC || request.disposition !== NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION) throw new TypeError("Native QUARANTINE_LEGACY supports only the published malformed freeze-findings diagnostic and disposition"); - if (request.maintainerAuthorization !== nativeReviewLegacyQuarantineAuthorization(request)) throw new TypeError("Native QUARANTINE_LEGACY maintainerAuthorization must match the exact repository, lineage, revision, diagnostic, disposition, actor, and reason binding"); - const execution = await this.execute(NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, request.cwd, [ - "review", "quarantine-legacy", "--cwd", request.cwd, - "--lineage", request.lineage, - "--expected-revision", request.expectedRevision, - "--diagnostic", request.diagnostic, - "--disposition", request.disposition, - "--actor", request.actor, - "--reason", request.reason, - "--maintainer-authorization", request.maintainerAuthorization, - ], true, request.signal); - const result = decode(NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, true, () => decodeNativeMaintenanceResult(execution.body, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY)); - if (execution.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, true, "native legacy quarantine partially failed", execution.process, true, result.record); + if ( + request.diagnostic !== NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC || + request.disposition !== NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION + ) + throw new TypeError( + "Native QUARANTINE_LEGACY supports only the published malformed freeze-findings diagnostic and disposition", + ); + if ( + request.maintainerAuthorization !== + nativeReviewLegacyQuarantineAuthorization(request) + ) + throw new TypeError( + "Native QUARANTINE_LEGACY maintainerAuthorization must match the exact repository, lineage, revision, diagnostic, disposition, actor, and reason binding", + ); + const execution = await this.execute( + NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, + request.cwd, + [ + "review", + "quarantine-legacy", + "--cwd", + request.cwd, + "--lineage", + request.lineage, + "--expected-revision", + request.expectedRevision, + "--diagnostic", + request.diagnostic, + "--disposition", + request.disposition, + "--actor", + request.actor, + "--reason", + request.reason, + "--maintainer-authorization", + request.maintainerAuthorization, + ], + true, + request.signal, + ); + const result = decode(NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, true, () => + decodeNativeMaintenanceResult( + execution.body, + NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, + ), + ); + if (execution.exitCode !== 0) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, + true, + "native legacy quarantine partially failed", + execution.process, + true, + result.record, + ); return result; } - async reconcileAuthority(request: NativeReviewReconcileAuthorityRequest): Promise { + async reconcileAuthority( + request: NativeReviewReconcileAuthorityRequest, + ): Promise { for (const [name, value] of [ ["predecessorLineage", request.predecessorLineage], ["expectedPredecessorRevision", request.expectedPredecessorRevision], @@ -1494,51 +3032,156 @@ class NativeReviewPlainCli { ["actor", request.actor], ["reason", request.reason], ] as const) { - if (!isCanonicalProcessString(value)) throw new TypeError(`Native RECONCILE_AUTHORITY ${name} must be a non-empty, trimmed, NUL-free string`); + if (!isCanonicalProcessString(value)) + throw new TypeError( + `Native RECONCILE_AUTHORITY ${name} must be a non-empty, trimmed, NUL-free string`, + ); } - if (request.anomalies !== undefined && request.anomalies !== NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED) throw new TypeError("Native RECONCILE_AUTHORITY anomalies must use the published unchanged_target,malformed_recovery_authorization ordering"); + if ( + request.anomalies !== undefined && + request.anomalies !== NATIVE_REVIEW_RECONCILE_ANOMALIES.COMBINED + ) + throw new TypeError( + "Native RECONCILE_AUTHORITY anomalies must use the published unchanged_target,malformed_recovery_authorization ordering", + ); const expectedAuthorization = nativeReviewReconcileAuthorization(request); if (request.maintainerAuthorization !== expectedAuthorization) { - throw new TypeError("Native RECONCILE_AUTHORITY maintainerAuthorization must match the exact target and revision binding"); + throw new TypeError( + "Native RECONCILE_AUTHORITY maintainerAuthorization must match the exact target and revision binding", + ); } - const execution = await this.execute(NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, request.cwd, [ - "review", "reconcile-authority", "--cwd", request.cwd, - "--predecessor-lineage", request.predecessorLineage, - "--expected-predecessor-revision", request.expectedPredecessorRevision, - "--successor-lineage", request.successorLineage, - "--expected-successor-revision", request.expectedSuccessorRevision, - "--actor", request.actor, - "--reason", request.reason, - "--maintainer-authorization", request.maintainerAuthorization, - ], true, request.signal); - const result = decode(NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, true, () => decodeNativeMaintenanceResult(execution.body, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY)); - if (execution.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, true, "native authority reconciliation partially failed", execution.process, true, result.record); + const execution = await this.execute( + NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, + request.cwd, + [ + "review", + "reconcile-authority", + "--cwd", + request.cwd, + "--predecessor-lineage", + request.predecessorLineage, + "--expected-predecessor-revision", + request.expectedPredecessorRevision, + "--successor-lineage", + request.successorLineage, + "--expected-successor-revision", + request.expectedSuccessorRevision, + "--actor", + request.actor, + "--reason", + request.reason, + "--maintainer-authorization", + request.maintainerAuthorization, + ], + true, + request.signal, + ); + const result = decode(NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, true, () => + decodeNativeMaintenanceResult( + execution.body, + NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, + ), + ); + if (execution.exitCode !== 0) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, + true, + "native authority reconciliation partially failed", + execution.process, + true, + result.record, + ); return result; } - async repairLegacyAlias(request: NativeReviewLegacyAliasRepairRequest): Promise { - for (const [name, value] of [["repository", request.repository], ["lineage", request.lineage], ["expectedRevision", request.expectedRevision], ["actor", request.actor], ["reason", request.reason]] as const) { - if (!isCanonicalProcessString(value)) throw new TypeError(`Native REPAIR_LEGACY_ALIAS ${name} must be a non-empty, trimmed, NUL-free string`); + async repairLegacyAlias( + request: NativeReviewLegacyAliasRepairRequest, + ): Promise { + for (const [name, value] of [ + ["repository", request.repository], + ["lineage", request.lineage], + ["expectedRevision", request.expectedRevision], + ["actor", request.actor], + ["reason", request.reason], + ] as const) { + if (!isCanonicalProcessString(value)) + throw new TypeError( + `Native REPAIR_LEGACY_ALIAS ${name} must be a non-empty, trimmed, NUL-free string`, + ); } - if (request.diagnostic !== NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DIAGNOSTIC || request.disposition !== NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DISPOSITION) throw new TypeError("Native REPAIR_LEGACY_ALIAS supports only the published historical alias diagnostic and disposition"); - if (request.maintainerAuthorization !== nativeReviewLegacyAliasRepairAuthorization(request)) throw new TypeError("Native REPAIR_LEGACY_ALIAS maintainerAuthorization must match the exact repository, lineage, revision, diagnostic, disposition, actor, and reason binding"); - const execution = await this.execute(NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, request.cwd, [ - "review", "repair-legacy-alias", "--cwd", request.cwd, - "--lineage", request.lineage, - "--expected-revision", request.expectedRevision, - "--diagnostic", request.diagnostic, - "--disposition", request.disposition, - "--actor", request.actor, - "--reason", request.reason, - "--maintainer-authorization", request.maintainerAuthorization, - ], true, request.signal); - const result = decode(NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, true, () => decodeNativeMaintenanceResult(execution.body, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS)); - if (execution.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, true, "native historical alias repair partially failed", execution.process, true, result.record); + if ( + request.diagnostic !== NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DIAGNOSTIC || + request.disposition !== NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DISPOSITION + ) + throw new TypeError( + "Native REPAIR_LEGACY_ALIAS supports only the published historical alias diagnostic and disposition", + ); + if ( + request.maintainerAuthorization !== + nativeReviewLegacyAliasRepairAuthorization(request) + ) + throw new TypeError( + "Native REPAIR_LEGACY_ALIAS maintainerAuthorization must match the exact repository, lineage, revision, diagnostic, disposition, actor, and reason binding", + ); + const execution = await this.execute( + NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, + request.cwd, + [ + "review", + "repair-legacy-alias", + "--cwd", + request.cwd, + "--lineage", + request.lineage, + "--expected-revision", + request.expectedRevision, + "--diagnostic", + request.diagnostic, + "--disposition", + request.disposition, + "--actor", + request.actor, + "--reason", + request.reason, + "--maintainer-authorization", + request.maintainerAuthorization, + ], + true, + request.signal, + ); + const result = decode(NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, true, () => + decodeNativeMaintenanceResult( + execution.body, + NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, + ), + ); + if (execution.exitCode !== 0) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, + true, + "native historical alias repair partially failed", + execution.process, + true, + result.record, + ); return result; } } -export function nativeReviewAbandonAuthorization(request: Pick): string { +export function nativeReviewAbandonAuthorization( + request: Pick< + NativeReviewAbandonRequest, + | "lineage" + | "expectedRevision" + | "snapshotIdentity" + | "capturedLensResults" + | "findingsPresent" + | "actor" + | "reason" + >, +): string { // gentle-ai 0ed9225f removed evidence records from the discarded-work summary, // so the native v2 gate verifies an exact eight-line binding (schema, lineage, // revision, snapshot_identity, reason, captured_lens_results, findings_present, @@ -1555,7 +3198,18 @@ export function nativeReviewAbandonAuthorization(request: Pick): string { +export function nativeReviewLegacyQuarantineAuthorization( + request: Pick< + NativeReviewLegacyQuarantineRequest, + | "repository" + | "lineage" + | "expectedRevision" + | "diagnostic" + | "disposition" + | "actor" + | "reason" + >, +): string { return [ "gentle-ai.review-legacy-quarantine-authorization/v1", `repository=${request.repository}`, @@ -1583,7 +3237,12 @@ export function nativeReviewLegacyQuarantineAuthorization(request: Pick & { targetIdentity: string }): string { +export function nativeReviewRecoverAuthorization( + request: Pick< + NativeReviewRecoverRequest, + "predecessorLineage" | "expectedPredecessorRevision" | "actor" | "reason" + > & { targetIdentity: string }, +): string { return [ "gentle-ai.review-recovery-authorization/v1", `predecessor_lineage=${request.predecessorLineage}`, @@ -1594,7 +3253,18 @@ export function nativeReviewRecoverAuthorization(request: Pick): string { +export function nativeReviewReconcileAuthorization( + request: Pick< + NativeReviewReconcileAuthorityRequest, + | "predecessorLineage" + | "expectedPredecessorRevision" + | "successorLineage" + | "expectedSuccessorRevision" + | "actor" + | "reason" + | "anomalies" + >, +): string { return [ "gentle-ai.review-reconcile-authorization/v1", `predecessor_lineage=${request.predecessorLineage}`, @@ -1603,11 +3273,24 @@ export function nativeReviewReconcileAuthorization(request: Pick): string { +export function nativeReviewLegacyAliasRepairAuthorization( + request: Pick< + NativeReviewLegacyAliasRepairRequest, + | "repository" + | "lineage" + | "expectedRevision" + | "diagnostic" + | "disposition" + | "actor" + | "reason" + >, +): string { return [ "gentle-ai.review-legacy-alias-repair-authorization/v1", `repository=${request.repository}`, @@ -1686,7 +3369,12 @@ function splitNativeConsentInvocation(invocation: string): readonly string[] { // verbatim, including quoted UNC and drive-root paths. Outside quotes, // only a backslash before whitespace joins a space-containing token. const next = source[index + 1]; - if (character === "\\" && quote === undefined && next !== undefined && /\s/.test(next)) { + if ( + character === "\\" && + quote === undefined && + next !== undefined && + /\s/.test(next) + ) { escaping = true; started = true; continue; @@ -1713,54 +3401,87 @@ function splitNativeConsentInvocation(invocation: string): readonly string[] { current += character; started = true; } - if (quote !== undefined || escaping) throw new TypeError("Native consent invocation has invalid quoting"); + if (quote !== undefined || escaping) + throw new TypeError("Native consent invocation has invalid quoting"); if (started) words.push(current); return words; } -function exactConsentOption(arguments_: readonly string[], name: string): string { +function exactConsentOption( + arguments_: readonly string[], + name: string, +): string { const values: string[] = []; for (let index = 0; index < arguments_.length; index += 1) { const token = arguments_[index]!; if (token === name) { const value = arguments_[index + 1]; - if (value === undefined) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation ${name} is missing its value`); + if (value === undefined) + throw new NativeReviewConsentBindingError( + "consent-invocation-option-invalid", + `Native consent invocation ${name} is missing its value`, + ); values.push(value); index += 1; - } else if (token.startsWith(`${name}=`)) values.push(token.slice(name.length + 1)); + } else if (token.startsWith(`${name}=`)) + values.push(token.slice(name.length + 1)); } - if (values.length !== 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", `Native consent invocation requires exactly one ${name}`); + if (values.length !== 1) + throw new NativeReviewConsentBindingError( + "consent-invocation-option-invalid", + `Native consent invocation requires exactly one ${name}`, + ); return values[0]!; } // A Pi consent envelope is bound to Pi only when every exact provider replay // path carries exactly one `--agent pi` option. The outer envelope agent alone // cannot bind an omitted, embedded, duplicated, or conflicting invocation. -function validatePiConsentChoiceAgentBindings(consent: ReviewConsentEnvelope): void { +function validatePiConsentChoiceAgentBindings( + consent: ReviewConsentEnvelope, +): void { if (!("agent" in consent) || consent.agent !== "pi") return; for (const choice of consent.choices) { const arguments_ = splitNativeConsentInvocation(choice.invocation).slice(1); if (exactConsentOption(arguments_, "--agent") !== "pi") { - throw new NativeReviewConsentBindingError("consent-invocation-agent-changed", "Native Pi consent invocation agent binding changed"); + throw new NativeReviewConsentBindingError( + "consent-invocation-agent-changed", + "Native Pi consent invocation agent binding changed", + ); } } } -function optionalConsentLineageOption(arguments_: readonly string[]): string | undefined { +function optionalConsentLineageOption( + arguments_: readonly string[], +): string | undefined { const values: string[] = []; for (let index = 0; index < arguments_.length; index += 1) { const token = arguments_[index]!; if (token === "--lineage") { const value = arguments_[index + 1]; - if (value === undefined || value.startsWith("--")) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation --lineage is missing its value"); + if (value === undefined || value.startsWith("--")) + throw new NativeReviewConsentBindingError( + "consent-invocation-option-invalid", + "Native consent invocation --lineage is missing its value", + ); values.push(value); index += 1; - } else if (token.startsWith("--lineage=")) values.push(token.slice("--lineage=".length)); + } else if (token.startsWith("--lineage=")) + values.push(token.slice("--lineage=".length)); } - if (values.length > 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation permits at most one --lineage"); + if (values.length > 1) + throw new NativeReviewConsentBindingError( + "consent-invocation-option-invalid", + "Native consent invocation permits at most one --lineage", + ); if (values.length === 0) return undefined; const lineageId = values[0]!; - if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(lineageId)) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation --lineage is malformed"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(lineageId)) + throw new NativeReviewConsentBindingError( + "consent-invocation-option-invalid", + "Native consent invocation --lineage is malformed", + ); return lineageId; } @@ -1769,32 +3490,113 @@ interface ConsentInvocation { lineageId?: string; } -function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): ConsentInvocation { +function consentInvocationArguments( + request: NativeReviewConsentAnswerRequest, +): ConsentInvocation { validatePiConsentChoiceAgentBindings(request.consent); - const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer); - if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined"); + const choice = request.consent.choices.find( + (candidate) => candidate.answer === request.answer, + ); + if (choice === undefined) + throw new NativeReviewConsentBindingError( + "consent-answer-unknown", + "Native consent answer must be granted or declined", + ); const words = splitNativeConsentInvocation(choice.invocation); - if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") throw new NativeReviewConsentBindingError("consent-invocation-not-start", "Native consent invocation is not a provider review START"); + if (words[0] !== "gentle-ai" || words[1] !== "review" || words[2] !== "start") + throw new NativeReviewConsentBindingError( + "consent-invocation-not-start", + "Native consent invocation is not a provider review START", + ); const arguments_ = words.slice(1); - if (exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT) throw new NativeReviewConsentBindingError("consent-invocation-contract-changed", "Native consent invocation contract changed"); + if ( + exactConsentOption(arguments_, "--contract") !== REVIEW_INTEGRATION_CONTRACT + ) + throw new NativeReviewConsentBindingError( + "consent-invocation-contract-changed", + "Native consent invocation contract changed", + ); const providerCwd = exactConsentOption(arguments_, "--cwd"); const requestedCwd = normalizeNativeReviewCwd(request.cwd); - if (normalizeNativeReviewCwd(providerCwd) !== requestedCwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed"); - if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed"); - if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed"); + if (normalizeNativeReviewCwd(providerCwd) !== requestedCwd) + throw new NativeReviewConsentBindingError( + "consent-invocation-cwd-changed", + "Native consent invocation repository binding changed", + ); + if ( + exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity + ) + throw new NativeReviewConsentBindingError( + "consent-invocation-target-changed", + "Native consent invocation target binding changed", + ); + if ( + exactConsentOption(arguments_, "--projection") !== request.consent.projection + ) + throw new NativeReviewConsentBindingError( + "consent-invocation-projection-changed", + "Native consent invocation projection binding changed", + ); const lineageId = optionalConsentLineageOption(arguments_); - if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new NativeReviewConsentBindingError("consent-invocation-answer-changed", "Native consent invocation answer binding changed"); + if ( + exactConsentOption(arguments_, "--consent") !== request.answer || + arguments_.at(-1) !== request.answer + ) + throw new NativeReviewConsentBindingError( + "consent-invocation-answer-changed", + "Native consent invocation answer binding changed", + ); return { arguments_, ...(lineageId === undefined ? {} : { lineageId }) }; } -function decodeDeclinedConsentStart(value: unknown, expected: NativeReviewConsentAnswerRequest): NativeReviewConsentDeclinedResult { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("Native declined consent result must be an object"); +function decodeDeclinedConsentStart( + value: unknown, + expected: NativeReviewConsentAnswerRequest, +): NativeReviewConsentDeclinedResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new TypeError("Native declined consent result must be an object"); const body = value as Record; - if (body.operation !== "review/start" || body.action !== "declined" || body.consent !== "declined_this_candidate") throw new TypeError("Native declined consent result has an invalid identity"); - if (body.target_identity !== expected.consent.targetIdentity || body.projection !== expected.consent.projection || body.risk_level !== expected.consent.riskLevel) throw new TypeError("Native declined consent result target binding changed"); - if (body.lenses_required !== false || !Array.isArray(body.selected_lenses) || body.selected_lenses.length !== 0 || !Array.isArray(body.lens_bindings) || body.lens_bindings.length !== 0) throw new TypeError("Native declined consent result must create no review authority"); - if (typeof body.changed_files !== "number" || !Number.isSafeInteger(body.changed_files) || body.changed_files < 0 || typeof body.changed_lines !== "number" || !Number.isSafeInteger(body.changed_lines) || body.changed_lines < 0) throw new TypeError("Native declined consent result has invalid change counts"); - if (body.lineage_id !== "" || body.state !== "" || body.correction_budget !== 0) throw new TypeError("Native declined consent result cannot carry review authority"); + if ( + body.operation !== "review/start" || + body.action !== "declined" || + body.consent !== "declined_this_candidate" + ) + throw new TypeError("Native declined consent result has an invalid identity"); + if ( + body.target_identity !== expected.consent.targetIdentity || + body.projection !== expected.consent.projection || + body.risk_level !== expected.consent.riskLevel + ) + throw new TypeError("Native declined consent result target binding changed"); + if ( + body.lenses_required !== false || + !Array.isArray(body.selected_lenses) || + body.selected_lenses.length !== 0 || + !Array.isArray(body.lens_bindings) || + body.lens_bindings.length !== 0 + ) + throw new TypeError( + "Native declined consent result must create no review authority", + ); + if ( + typeof body.changed_files !== "number" || + !Number.isSafeInteger(body.changed_files) || + body.changed_files < 0 || + typeof body.changed_lines !== "number" || + !Number.isSafeInteger(body.changed_lines) || + body.changed_lines < 0 + ) + throw new TypeError( + "Native declined consent result has invalid change counts", + ); + if ( + body.lineage_id !== "" || + body.state !== "" || + body.correction_budget !== 0 + ) + throw new TypeError( + "Native declined consent result cannot carry review authority", + ); return { kind: "declined", targetIdentity: expected.consent.targetIdentity, @@ -1815,7 +3617,9 @@ interface NegotiatedExecution { silent?: true; } -function decodeNativeAdmittedResultManifest(value: unknown): NativeReviewAdmittedResultManifest { +function decodeNativeAdmittedResultManifest( + value: unknown, +): NativeReviewAdmittedResultManifest { // The admission answer routes through the exact-identity forward decoder // (decoder-freshness discipline): the complete live envelope — identity // constants, binding fields, and the exactly-one-locator rule — is @@ -1828,7 +3632,9 @@ function decodeNativeAdmittedResultManifest(value: unknown): NativeReviewAdmitte admissionDecision: artifact.admissionDecision, lens: artifact.lens, ...(artifact.path === undefined ? {} : { path: artifact.path }), - ...(artifact.reference === undefined ? {} : { reference: artifact.reference }), + ...(artifact.reference === undefined + ? {} + : { reference: artifact.reference }), }); } @@ -1836,51 +3642,99 @@ function decodeNativeAdmittedResultManifest(value: unknown): NativeReviewAdmitte // provider role vector. The immutable verdict bytes live in the Go-owned // compact store slot; this envelope only names the binding the capture // proved, so anything beyond that exact shape is refused. -function decodeNativeProviderRoleCaptureArtifact(value: unknown): NativeReviewProviderRoleCaptureArtifact { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native provider role capture artifact must be an object"); +function decodeNativeProviderRoleCaptureArtifact( + value: unknown, +): NativeReviewProviderRoleCaptureArtifact { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new TypeError( + "native provider role capture artifact must be an object", + ); const body = value as Record; - const allowed = new Set(["schema", "lineage_id", "target_identity", "role", "captured"]); - for (const key of Object.keys(body)) if (!allowed.has(key)) throw new TypeError(`native provider role capture artifact carries unexpected key ${key}`); + const allowed = new Set([ + "schema", + "lineage_id", + "target_identity", + "role", + "captured", + ]); + for (const key of Object.keys(body)) + if (!allowed.has(key)) + throw new TypeError( + `native provider role capture artifact carries unexpected key ${key}`, + ); const text = (key: string): string => { const found = body[key]; - if (typeof found !== "string" || found.trim() !== found || found.length === 0) throw new TypeError(`native provider role capture artifact ${key} must be a non-empty trimmed string`); + if (typeof found !== "string" || found.trim() !== found || found.length === 0) + throw new TypeError( + `native provider role capture artifact ${key} must be a non-empty trimmed string`, + ); return found; }; - if (text("schema") !== NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA) throw new TypeError(`native provider role capture artifact schema must be ${NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA}`); + if (text("schema") !== NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA) + throw new TypeError( + `native provider role capture artifact schema must be ${NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA}`, + ); const role = text("role"); - if (role !== "refuter" && role !== "targeted-validator") throw new TypeError(`native provider role capture artifact role must be refuter or targeted-validator, received ${role}`); - if (body.captured !== true) throw new TypeError("native provider role capture artifact must report captured: true"); - return Object.freeze({ - schema: NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA, - lineageId: text("lineage_id"), - targetIdentity: text("target_identity"), - role, - captured: true, - }); - } + if (role !== "refuter" && role !== "targeted-validator") + throw new TypeError( + `native provider role capture artifact role must be refuter or targeted-validator, received ${role}`, + ); + if (body.captured !== true) + throw new TypeError( + "native provider role capture artifact must report captured: true", + ); + return Object.freeze({ + schema: NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA, + lineageId: text("lineage_id"), + targetIdentity: text("target_identity"), + role, + captured: true, + }); +} // gentle-pi execute-native compat: the strict acknowledgement for one executed // reviewer capture vector. The immutable verdict bytes live in the Go-owned // compact store slot; this envelope only names the binding the capture proved. -function decodeNativeReviewCaptureExecuteResult(value: unknown): NativeReviewCaptureExecuteResult { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native review capture execute result must be an object"); +function decodeNativeReviewCaptureExecuteResult( + value: unknown, +): NativeReviewCaptureExecuteResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) + throw new TypeError("native review capture execute result must be an object"); const body = value as Record; - const allowed = new Set(["schema", "lineage_id", "target_identity", "captured"]); - for (const key of Object.keys(body)) if (!allowed.has(key)) throw new TypeError(`native review capture execute result carries unexpected key ${key}`); + const allowed = new Set([ + "schema", + "lineage_id", + "target_identity", + "captured", + ]); + for (const key of Object.keys(body)) + if (!allowed.has(key)) + throw new TypeError( + `native review capture execute result carries unexpected key ${key}`, + ); const text = (key: string): string => { const found = body[key]; - if (typeof found !== "string" || found.trim() !== found || found.length === 0) throw new TypeError(`native review capture execute result ${key} must be a non-empty trimmed string`); + if (typeof found !== "string" || found.trim() !== found || found.length === 0) + throw new TypeError( + `native review capture execute result ${key} must be a non-empty trimmed string`, + ); return found; }; - if (text("schema") !== NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA) throw new TypeError(`native review capture execute result schema must be ${NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA}`); - if (body.captured !== true) throw new TypeError("native review capture execute result must report captured: true"); + if (text("schema") !== NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA) + throw new TypeError( + `native review capture execute result schema must be ${NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA}`, + ); + if (body.captured !== true) + throw new TypeError( + "native review capture execute result must report captured: true", + ); return Object.freeze({ schema: NATIVE_REVIEW_CAPTURE_EXECUTE_SCHEMA, lineageId: text("lineage_id"), targetIdentity: text("target_identity"), captured: true, }); - } +} export class NativeReviewCliV216 implements NativeReviewCli { private readonly plain: NativeReviewPlainCli; @@ -1894,25 +3748,60 @@ export class NativeReviewCliV216 implements NativeReviewCli { executable: string | (() => string) = resolveGentleAiBinary, timeoutMs = 30_000, maxBufferBytes = resolveNativeReviewMaxBufferBytes(), - cleanupDirectory: (directory: string) => Promise = (directory) => rm(directory, { recursive: true, force: true }), + cleanupDirectory: (directory: string) => Promise = (directory) => + rm(directory, { recursive: true, force: true }), ) { - if (typeof executable === "string" && (!isAbsolute(executable) || executable === "gentle-ai")) throw new TypeError("Native review requires an absolute package-local executable"); + if ( + typeof executable === "string" && + (!isAbsolute(executable) || executable === "gentle-ai") + ) + throw new TypeError( + "Native review requires an absolute package-local executable", + ); this.adapter = adapter; this.executable = executable; this.timeoutMs = timeoutMs; this.maxBufferBytes = maxBufferBytes; this.cleanupDirectory = cleanupDirectory; - this.plain = new NativeReviewPlainCli(adapter, executable, timeoutMs, maxBufferBytes, cleanupDirectory); + this.plain = new NativeReviewPlainCli( + adapter, + executable, + timeoutMs, + maxBufferBytes, + cleanupDirectory, + ); } - private executablePath(operation: NativeReviewOperation, mutating: boolean): string { + private executablePath( + operation: NativeReviewOperation, + mutating: boolean, + ): string { try { - const path = typeof this.executable === "string" ? this.executable : this.executable(); - if (!isAbsolute(path) || path === "gentle-ai") throw new TypeError("Native review requires an absolute package-local executable"); + const path = + typeof this.executable === "string" ? this.executable : this.executable(); + if (!isAbsolute(path) || path === "gentle-ai") + throw new TypeError( + "Native review requires an absolute package-local executable", + ); return path; } catch (error) { - if (error instanceof PackageLocalGentleAiBinaryMissingError) throw nativeError(NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING, operation, mutating, error.message, undefined, false); - throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "package-local native process could not start", undefined, false); + if (error instanceof PackageLocalGentleAiBinaryMissingError) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING, + operation, + mutating, + error.message, + undefined, + false, + ); + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, + operation, + mutating, + "package-local native process could not start", + undefined, + false, + ); } } @@ -1935,17 +3824,77 @@ export class NativeReviewCliV216 implements NativeReviewCli { ): Promise { let result: ExecFileResult; try { - result = await this.adapter({ file: path, arguments: arguments_, cwd, timeoutMs: mutating ? undefined : this.timeoutMs, maxBufferBytes: this.maxBufferBytes, signal }); + result = await this.adapter({ + file: path, + arguments: arguments_, + cwd, + timeoutMs: mutating ? undefined : this.timeoutMs, + maxBufferBytes: this.maxBufferBytes, + signal, + }); } catch (error) { - if (error instanceof Error && error.name === "AbortError") throw nativeError(NATIVE_REVIEW_ERROR_CODE.CANCELLED, operation, mutating, "native process was cancelled"); - throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "native process could not start"); + if (error instanceof Error && error.name === "AbortError") + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.CANCELLED, + operation, + mutating, + "native process was cancelled", + ); + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, + operation, + mutating, + "native process could not start", + ); } - if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes); - if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result); - if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result); - const diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.NON_ZERO, result); - if (!expectsBody && result.exitCode === 0 && result.stdout.trim().length === 0) { - if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); + if (result.outputLimitExceeded) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, + operation, + mutating, + "native process output exceeded limit", + result, + true, + undefined, + this.maxBufferBytes, + ); + if (result.timedOut) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.TIMEOUT, + operation, + mutating, + "native process timed out", + result, + ); + if (result.signal) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.SIGNAL, + operation, + mutating, + "native process was signalled", + result, + ); + const diagnostics = nativeProcessDiagnostics( + operation, + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + result, + ); + if ( + !expectsBody && + result.exitCode === 0 && + result.stdout.trim().length === 0 + ) { + if ( + result.stderr.trim().length > 0 && + !stderrIsTolerated(result.stderr, toleratedStderr) + ) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, + operation, + mutating, + "native process wrote stderr", + result, + ); return { body: {}, exitCode: result.exitCode, silent: true }; } const body = parseJson(result.stdout, operation, mutating, diagnostics); @@ -1954,11 +3903,30 @@ export class NativeReviewCliV216 implements NativeReviewCli { throw new NativeReviewIntegrationError(decodeReviewFailureV2(body)); } catch (error) { if (error instanceof NativeReviewIntegrationError) throw error; - throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native negotiated operation failed without a valid failure envelope", result); + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.NON_ZERO, + operation, + mutating, + "native negotiated operation failed without a valid failure envelope", + result, + ); } } - const forecastNarrationTolerated = operation === NATIVE_REVIEW_OPERATION.STATUS && stderrIsForecastNarration(result.stderr); - if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr) && !forecastNarrationTolerated) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result); + const forecastNarrationTolerated = + operation === NATIVE_REVIEW_OPERATION.STATUS && + stderrIsForecastNarration(result.stderr); + if ( + result.stderr.trim().length > 0 && + !stderrIsTolerated(result.stderr, toleratedStderr) && + !forecastNarrationTolerated + ) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, + operation, + mutating, + "native process wrote stderr", + result, + ); return { body, exitCode: result.exitCode }; } @@ -1970,14 +3938,40 @@ export class NativeReviewCliV216 implements NativeReviewCli { signal?: AbortSignal, toleratedStderr: readonly string[] = [], ): Promise { - return this.invoke(operation, cwd, arguments_, mutating, signal, this.executablePath(operation, mutating), toleratedStderr); + return this.invoke( + operation, + cwd, + arguments_, + mutating, + signal, + this.executablePath(operation, mutating), + toleratedStderr, + ); } async start(request: NativeStartRequest): Promise { - if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string"); - if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement"); - if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef"); - if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity"); + if ( + request.baseRef !== undefined && + !isCanonicalProcessString(request.baseRef) + ) + throw new TypeError( + "Native START baseRef must be a non-empty, trimmed, NUL-free string", + ); + if (request.baseRef !== undefined && request.committedOnly !== true) + throw new TypeError( + "Native START baseRef requires explicit committedOnly acknowledgement", + ); + if (request.baseRef === undefined && request.committedOnly !== undefined) + throw new TypeError( + "Native START committedOnly requires an explicit baseRef", + ); + if ( + request.targetIdentity !== undefined && + !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity) + ) + throw new TypeError( + "Native START targetIdentity must be a canonical sha256 identity", + ); // STATUS owns the candidate binding and renders the only executable START // vector. Callers may supply a previously observed identity only to detect // drift; Pi never rebuilds that vector from request fields. @@ -1986,27 +3980,79 @@ export class NativeReviewCliV216 implements NativeReviewCli { const status = await this.targetStatus({ cwd: request.cwd, projection, - ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef, committedOnly: true }), + ...(request.baseRef === undefined + ? {} + : { baseRef: request.baseRef, committedOnly: true }), ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...selection, - ...(request.intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection: request.intendedUntrackedSelection }), + ...(request.intendedUntrackedSelection === undefined + ? {} + : { intendedUntrackedSelection: request.intendedUntrackedSelection }), agent: "pi", ...(request.signal === undefined ? {} : { signal: request.signal }), }); - const transition = status.nextTransition?.kind === "execute" && status.nextTransition.execute?.operation === "review.start" - ? status.nextTransition.execute - : undefined; - if (transition === undefined) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.START, false, "native STATUS did not offer an executable review.start transition", undefined, false); - if (status.projection.projection !== projection || transition.binding.targetIdentity !== status.targetIdentity || (request.targetIdentity !== undefined && request.targetIdentity !== status.targetIdentity)) { - throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, false, "native START transition target binding mismatch", undefined, false); + const transition = + status.nextTransition?.kind === "execute" && + status.nextTransition.execute?.operation === "review.start" + ? status.nextTransition.execute + : undefined; + if (transition === undefined) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, + NATIVE_REVIEW_OPERATION.START, + false, + "native STATUS did not offer an executable review.start transition", + undefined, + false, + ); + if ( + status.projection.projection !== projection || + transition.binding.targetIdentity !== status.targetIdentity || + (request.targetIdentity !== undefined && + request.targetIdentity !== status.targetIdentity) + ) { + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.START, + false, + "native START transition target binding mismatch", + undefined, + false, + ); } - if (request.lineageId !== undefined && transition.binding.lineageId !== undefined && transition.binding.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, false, "native START transition lineage binding mismatch", undefined, false); + if ( + request.lineageId !== undefined && + transition.binding.lineageId !== undefined && + transition.binding.lineageId !== request.lineageId + ) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.START, + false, + "native START transition lineage binding mismatch", + undefined, + false, + ); const transitionTokens = transition.arguments.map((argument) => { - if (argument.token === undefined) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.START, false, "native START transition omitted an ordered argument token", undefined, false); + if (argument.token === undefined) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, + NATIVE_REVIEW_OPERATION.START, + false, + "native START transition omitted an ordered argument token", + undefined, + false, + ); return argument.token; }); const targetIdentity = status.targetIdentity; - const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, ["review", "start", ...transitionTokens], true, request.signal); + const execution = await this.negotiated( + NATIVE_REVIEW_OPERATION.START, + request.cwd, + ["review", "start", ...transitionTokens], + true, + request.signal, + ); // A negotiated v2 START may answer a consent question (action: // "consent_required") instead of `start/v3` when the provider needs an // explicit answer it cannot infer. Discriminate before decode and surface @@ -2017,18 +4063,43 @@ export class NativeReviewCliV216 implements NativeReviewCli { // the v3 decoder's exact identity gate. if (execution.body.action === "consent_required") { const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => { - if (execution.body.schema === "gentle-ai.review-integration.consent/v2") return decodeReviewConsentV2(execution.body); + if (execution.body.schema === "gentle-ai.review-integration.consent/v2") + return decodeReviewConsentV2(execution.body); const decoded = decodeReviewConsentV3(execution.body, "pi"); validatePiConsentChoiceAgentBindings(decoded); return decoded; }); - if (consent.targetIdentity !== targetIdentity || consent.projection !== projection) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent target binding mismatch"); + if ( + consent.targetIdentity !== targetIdentity || + consent.projection !== projection + ) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.START, + true, + "native consent target binding mismatch", + ); throw new NativeReviewConsentRequiredError(consent); } - const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartResponse(execution.body)); - if (request.lineageId !== undefined && result.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start lineage mismatch"); - const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity; - if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch"); + const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => + decodeReviewStartResponse(execution.body), + ); + if (request.lineageId !== undefined && result.lineageId !== request.lineageId) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.START, + true, + "native start lineage mismatch", + ); + const resultTarget = + result.targetIdentity ?? result.repositoryContext?.targetIdentity; + if (resultTarget !== undefined && resultTarget !== targetIdentity) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.START, + true, + "native start target mismatch", + ); return { lineageId: result.lineageId, state: result.state as NativeStartResult["state"], @@ -2040,12 +4111,17 @@ export class NativeReviewCliV216 implements NativeReviewCli { action: result.action as NativeStartAction, lensesRequired: result.lensesRequired, riskReasons: result.riskReasons.map((reason) => ({ ...reason })), - ...("nextTransition" in result && result.nextTransition !== undefined ? { nextTransition: result.nextTransition } : {}), + ...("nextTransition" in result && result.nextTransition !== undefined + ? { nextTransition: result.nextTransition } + : {}), // Derived, not received. `risk_reasons` is a required start/v2 field // already recomputed against the authoritative frozen snapshot, so // these phrases describe the same candidate the lenses will review. ...(() => { - const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons); + const evidence = nativeRiskEvidencePhrases( + result.riskLevel, + result.riskReasons, + ); return evidence.length === 0 ? {} : { riskEvidence: evidence }; })(), // Only the empty-candidate recovery is reconstructed. Its sibling @@ -2053,60 +4129,150 @@ export class NativeReviewCliV216 implements NativeReviewCli { // this client already did, so relaying it would name a step that // changes nothing. A committed-only start (baseRef) is already the // recovery, and reporting zero changes there is a real answer. - ...(result.changedFiles === 0 && request.baseRef === undefined ? { hint: REVIEW_EMPTY_CANDIDATE_HINT } : {}), + ...(result.changedFiles === 0 && request.baseRef === undefined + ? { hint: REVIEW_EMPTY_CANDIDATE_HINT } + : {}), raw: result.raw, }; } - async answerConsent(request: NativeReviewConsentAnswerRequest): Promise { + async answerConsent( + request: NativeReviewConsentAnswerRequest, + ): Promise { const invocation = consentInvocationArguments(request); - const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, invocation.arguments_, true, request.signal); + const execution = await this.negotiated( + NATIVE_REVIEW_OPERATION.START, + request.cwd, + invocation.arguments_, + true, + request.signal, + ); if (request.answer === NATIVE_REVIEW_CONSENT_ANSWER.DECLINED) { - return decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeDeclinedConsentStart(execution.body, request)); + return decode(NATIVE_REVIEW_OPERATION.START, true, () => + decodeDeclinedConsentStart(execution.body, request), + ); } - const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartResponse(execution.body)); - const answeredTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity; - if (answeredTarget !== request.consent.targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer target mismatch"); - if (invocation.lineageId !== undefined && result.lineageId !== invocation.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch"); - return { kind: "started", start: { - lineageId: result.lineageId, - state: result.state as NativeStartResult["state"], - riskLevel: result.riskLevel, - selectedLenses: result.selectedLenses, - changedFiles: result.changedFiles, - changedLines: result.changedLines, - correctionBudget: result.correctionBudget, - action: result.action as NativeStartAction, - lensesRequired: result.lensesRequired, - riskReasons: result.riskReasons.map((reason) => ({ ...reason })), - ...("nextTransition" in result && result.nextTransition !== undefined ? { nextTransition: result.nextTransition } : {}), - ...(() => { - const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons); - return evidence.length === 0 ? {} : { riskEvidence: evidence }; - })(), - raw: result.raw, - } }; + const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => + decodeReviewStartResponse(execution.body), + ); + const answeredTarget = + result.targetIdentity ?? result.repositoryContext?.targetIdentity; + if (answeredTarget !== request.consent.targetIdentity) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.START, + true, + "native consent answer target mismatch", + ); + if ( + invocation.lineageId !== undefined && + result.lineageId !== invocation.lineageId + ) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.START, + true, + "native consent answer lineage mismatch", + ); + return { + kind: "started", + start: { + lineageId: result.lineageId, + state: result.state as NativeStartResult["state"], + riskLevel: result.riskLevel, + selectedLenses: result.selectedLenses, + changedFiles: result.changedFiles, + changedLines: result.changedLines, + correctionBudget: result.correctionBudget, + action: result.action as NativeStartAction, + lensesRequired: result.lensesRequired, + riskReasons: result.riskReasons.map((reason) => ({ ...reason })), + ...("nextTransition" in result && result.nextTransition !== undefined + ? { nextTransition: result.nextTransition } + : {}), + ...(() => { + const evidence = nativeRiskEvidencePhrases( + result.riskLevel, + result.riskReasons, + ); + return evidence.length === 0 ? {} : { riskEvidence: evidence }; + })(), + raw: result.raw, + }, + }; } - async targetStatus(request: NativeTargetStatusRequest): Promise { - if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native STATUS baseRef must be a non-empty, trimmed, NUL-free string"); - if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native STATUS baseRef requires explicit committedOnly acknowledgement"); - if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native STATUS committedOnly requires an explicit baseRef"); + async targetStatus( + request: NativeTargetStatusRequest, + ): Promise { + if ( + request.baseRef !== undefined && + !isCanonicalProcessString(request.baseRef) + ) + throw new TypeError( + "Native STATUS baseRef must be a non-empty, trimmed, NUL-free string", + ); + if (request.baseRef !== undefined && request.committedOnly !== true) + throw new TypeError( + "Native STATUS baseRef requires explicit committedOnly acknowledgement", + ); + if (request.baseRef === undefined && request.committedOnly !== undefined) + throw new TypeError( + "Native STATUS committedOnly requires an explicit baseRef", + ); const selection = nativeUntrackedSelection(request); const submitted = request.intendedUntrackedSelection; - if (submitted !== undefined && submitted.argumentTokens.reduce((count, token) => count + token.split("{{value}}").length - 1, 0) !== 1) throw new TypeError("Native intended-untracked selection requires exactly one provider-issued {{value}} token"); - const statusArguments = submitted === undefined ? [ - "review", "status", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd, - "--projection", request.projection ?? "workspace", - ...nativeUntrackedSelectionArguments(selection), - ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), - ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]), - ...(request.agent === undefined ? [] : ["--agent", request.agent]), - "--next-transition", - ] : ["review", "status", "--cwd", request.cwd, ...submitted.argumentTokens.map((token) => token.replaceAll("{{value}}", submitted.value))]; - const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.STATUS, request.cwd, statusArguments, false, request.signal); + if ( + submitted !== undefined && + submitted.argumentTokens.reduce( + (count, token) => count + token.split("{{value}}").length - 1, + 0, + ) !== 1 + ) + throw new TypeError( + "Native intended-untracked selection requires exactly one provider-issued {{value}} token", + ); + const statusArguments = + submitted === undefined + ? [ + "review", + "status", + "--contract", + REVIEW_INTEGRATION_CONTRACT, + "--cwd", + request.cwd, + "--projection", + request.projection ?? "workspace", + ...nativeUntrackedSelectionArguments(selection), + ...(request.baseRef === undefined + ? [] + : ["--base-ref", request.baseRef, "--committed-only"]), + ...(request.lineageId === undefined + ? [] + : ["--lineage", request.lineageId]), + ...(request.agent === undefined ? [] : ["--agent", request.agent]), + "--next-transition", + ] + : [ + "review", + "status", + "--cwd", + request.cwd, + ...submitted.argumentTokens.map((token) => + token.replaceAll("{{value}}", submitted.value), + ), + ]; + const execution = await this.negotiated( + NATIVE_REVIEW_OPERATION.STATUS, + request.cwd, + statusArguments, + false, + request.signal, + ); assertSupportedNextTransitionOperation(execution.body); - return decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeReviewStatusV3(execution.body)); + return decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => + decodeReviewStatusV3(execution.body), + ); } // Net-new negotiated `review.repair`: preflight first, execute only when @@ -2116,25 +4282,68 @@ export class NativeReviewCliV216 implements NativeReviewCli { // names — no repair/v2 fixture is mirrored upstream to ground-truth it // against (design.md Open Questions); this is a documented risk. async repair(request: NativeReviewRepairRequest): Promise { - const preflightExecution = await this.negotiated(NATIVE_REVIEW_OPERATION.REPAIR, request.cwd, [ - "review", "repair", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd, "--mode", "preflight", - ], false, request.signal); - const preflight = decode(NATIVE_REVIEW_OPERATION.REPAIR, false, () => decodeReviewRepairV2(preflightExecution.body)); - if (preflight.mode !== "preflight") throw new Error("wrong repair preflight discriminator"); - if (preflight.assessment.status !== "eligible" || preflight.providerInputs === undefined) return preflight; + const preflightExecution = await this.negotiated( + NATIVE_REVIEW_OPERATION.REPAIR, + request.cwd, + [ + "review", + "repair", + "--contract", + REVIEW_INTEGRATION_CONTRACT, + "--cwd", + request.cwd, + "--mode", + "preflight", + ], + false, + request.signal, + ); + const preflight = decode(NATIVE_REVIEW_OPERATION.REPAIR, false, () => + decodeReviewRepairV2(preflightExecution.body), + ); + if (preflight.mode !== "preflight") + throw new Error("wrong repair preflight discriminator"); + if ( + preflight.assessment.status !== "eligible" || + preflight.providerInputs === undefined + ) + return preflight; const providerInputs = preflight.providerInputs; - const executeExecution = await this.negotiated(NATIVE_REVIEW_OPERATION.REPAIR, request.cwd, [ - "review", "repair", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd, "--mode", "execute", - "--lineage", providerInputs.lineageId, - "--expected-revision", providerInputs.expectedRevision, - "--cause", providerInputs.cause, - "--disposition", providerInputs.disposition, - "--repository-binding", providerInputs.repositoryBinding, - "--actor", request.actor, - "--reason", request.reason, - "--maintainer-authorization", request.maintainerAuthorization, - ], true, request.signal); - return decode(NATIVE_REVIEW_OPERATION.REPAIR, true, () => decodeReviewRepairV2(executeExecution.body)); + const executeExecution = await this.negotiated( + NATIVE_REVIEW_OPERATION.REPAIR, + request.cwd, + [ + "review", + "repair", + "--contract", + REVIEW_INTEGRATION_CONTRACT, + "--cwd", + request.cwd, + "--mode", + "execute", + "--lineage", + providerInputs.lineageId, + "--expected-revision", + providerInputs.expectedRevision, + "--cause", + providerInputs.cause, + "--disposition", + providerInputs.disposition, + "--repository-binding", + providerInputs.repositoryBinding, + "--actor", + request.actor, + "--reason", + request.reason, + "--maintainer-authorization", + request.maintainerAuthorization, + ], + true, + request.signal, + ); + return decode(NATIVE_REVIEW_OPERATION.REPAIR, true, () => + decodeReviewRepairV2(executeExecution.body), + ); } // `review capture-evidence`: the evidence-first correction lifecycle's @@ -2144,30 +4353,72 @@ export class NativeReviewCliV216 implements NativeReviewCli { // --contract, and the verification-evidence/v2 record returned DIRECTLY — // not wrapped in an operation/v2 envelope. Evidence is staged through the // same 0o600 tmpfile discipline FINALIZE uses. - async captureResult(request: NativeReviewCaptureResultRequest): Promise { - if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_RESULT requires the provider-issued argument tokens"); - if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_RESULT argument tokens must all be non-empty strings"); - if (request.resultDocument.length === 0) throw new TypeError("Native CAPTURE_RESULT result document must contain at least one byte"); - const carriesContext = request.argumentTokens.some((token) => token === "--repository-context" || token.startsWith("--repository-context=")); - if (carriesContext && request.cwd !== undefined) throw new TypeError("Native CAPTURE_RESULT takes a repository context or --cwd, never both"); + async captureResult( + request: NativeReviewCaptureResultRequest, + ): Promise { + if (request.argumentTokens.length === 0) + throw new TypeError( + "Native CAPTURE_RESULT requires the provider-issued argument tokens", + ); + if ( + request.argumentTokens.some( + (token) => typeof token !== "string" || token.length === 0, + ) + ) + throw new TypeError( + "Native CAPTURE_RESULT argument tokens must all be non-empty strings", + ); + if (request.resultDocument.length === 0) + throw new TypeError( + "Native CAPTURE_RESULT result document must contain at least one byte", + ); + const carriesContext = request.argumentTokens.some( + (token) => + token === "--repository-context" || + token.startsWith("--repository-context="), + ); + if (carriesContext && request.cwd !== undefined) + throw new TypeError( + "Native CAPTURE_RESULT takes a repository context or --cwd, never both", + ); const directory = await mkdtemp(join(tmpdir(), "gentle-ai-capture-result-")); try { await chmod(directory, 0o700); const resultFile = join(directory, "result.json"); - await writeFile(resultFile, request.resultDocument, { encoding: "utf8", mode: 0o600 }); + await writeFile(resultFile, request.resultDocument, { + encoding: "utf8", + mode: 0o600, + }); await chmod(resultFile, 0o600); - const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true); - const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, request.cwd ?? process.cwd(), [ - "review", "capture-result", - ...request.argumentTokens, - ...(carriesContext || request.cwd === undefined ? [] : ["--cwd", request.cwd]), - "--input", resultFile, - ], true, request.signal, executable); + const executable = this.executablePath( + NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, + true, + ); + const execution = await this.invoke( + NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, + request.cwd ?? process.cwd(), + [ + "review", + "capture-result", + ...request.argumentTokens, + ...(carriesContext || request.cwd === undefined + ? [] + : ["--cwd", request.cwd]), + "--input", + resultFile, + ], + true, + request.signal, + executable, + ); return decode(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true, () => { const body = object(execution.body); if (body.schema === "gentle-ai.review-last-event-closure/v1") { const closure = decodeReviewLastEventClosureV1(body); - if (closure.operation !== "review/capture-result") throw new TypeError("capture-result returned a closure for another operation"); + if (closure.operation !== "review/capture-result") + throw new TypeError( + "capture-result returned a closure for another operation", + ); return closure; } return decodeNativeAdmittedResultManifest(body); @@ -2187,32 +4438,102 @@ export class NativeReviewCliV216 implements NativeReviewCli { // output on a zero exit is schema-incompatible with the mutation outcome // unknown, exactly like a malformed terminal closure: the caller // reconciles through STATUS and never infers the burn from prose. - async acknowledgeApproved(request: NativeReviewAcknowledgeApprovedRequest): Promise { - if (request.argumentTokens.length === 0) throw new TypeError("Native ACKNOWLEDGE_APPROVED requires the provider-issued argument tokens"); - if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native ACKNOWLEDGE_APPROVED argument tokens must all be non-empty strings"); - if (request.argumentTokens.some((token) => token.includes("{{value}}"))) throw new TypeError("Native ACKNOWLEDGE_APPROVED argument tokens carry no caller-substituted value"); - const executable = this.executablePath(NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, true); - const execution = await this.invoke(NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, request.cwd, [ - "review", "acknowledge-approved", ...request.argumentTokens, - ], true, request.signal, executable, [], false); + async acknowledgeApproved( + request: NativeReviewAcknowledgeApprovedRequest, + ): Promise { + if (request.argumentTokens.length === 0) + throw new TypeError( + "Native ACKNOWLEDGE_APPROVED requires the provider-issued argument tokens", + ); + if ( + request.argumentTokens.some( + (token) => typeof token !== "string" || token.length === 0, + ) + ) + throw new TypeError( + "Native ACKNOWLEDGE_APPROVED argument tokens must all be non-empty strings", + ); + if (request.argumentTokens.some((token) => token.includes("{{value}}"))) + throw new TypeError( + "Native ACKNOWLEDGE_APPROVED argument tokens carry no caller-substituted value", + ); + const executable = this.executablePath( + NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, + true, + ); + const execution = await this.invoke( + NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, + request.cwd, + ["review", "acknowledge-approved", ...request.argumentTokens], + true, + request.signal, + executable, + [], + false, + ); if (execution.silent) return undefined; - return decode(NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, true, () => decodeReviewAcknowledgedV1(execution.body, request.binding ?? {})); + return decode(NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, true, () => + decodeReviewAcknowledgedV1(execution.body, request.binding ?? {}), + ); } - async captureCorrectionPlan(request: NativeReviewCorrectionPlanCaptureRequest): Promise { - if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_CORRECTION_PLAN requires the provider-issued argument tokens"); - if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_CORRECTION_PLAN argument tokens must all be non-empty strings"); - if (!Number.isSafeInteger(request.correctionLines) || request.correctionLines < 1 || request.correctionLines > 200) throw new TypeError("Native CAPTURE_CORRECTION_PLAN correction lines must be a positive integer up to 200"); - const valueIndex = request.argumentTokens.findIndex((token) => token.includes("{{value}}")); - if (valueIndex < 0 || request.argumentTokens.filter((token) => token.includes("{{value}}")).length !== 1) throw new TypeError("Native CAPTURE_CORRECTION_PLAN requires exactly one provider-issued {{value}} token"); - const argumentTokens = request.argumentTokens.map((token, index) => index === valueIndex ? token.replaceAll("{{value}}", String(request.correctionLines)) : token); - const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, true); - const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, request.cwd, [ - "review", "capture-correction-plan", ...argumentTokens, - ], true, request.signal, executable); + async captureCorrectionPlan( + request: NativeReviewCorrectionPlanCaptureRequest, + ): Promise { + if (request.argumentTokens.length === 0) + throw new TypeError( + "Native CAPTURE_CORRECTION_PLAN requires the provider-issued argument tokens", + ); + if ( + request.argumentTokens.some( + (token) => typeof token !== "string" || token.length === 0, + ) + ) + throw new TypeError( + "Native CAPTURE_CORRECTION_PLAN argument tokens must all be non-empty strings", + ); + if ( + !Number.isSafeInteger(request.correctionLines) || + request.correctionLines < 1 || + request.correctionLines > 200 + ) + throw new TypeError( + "Native CAPTURE_CORRECTION_PLAN correction lines must be a positive integer up to 200", + ); + const valueIndex = request.argumentTokens.findIndex((token) => + token.includes("{{value}}"), + ); + if ( + valueIndex < 0 || + request.argumentTokens.filter((token) => token.includes("{{value}}")) + .length !== 1 + ) + throw new TypeError( + "Native CAPTURE_CORRECTION_PLAN requires exactly one provider-issued {{value}} token", + ); + const argumentTokens = request.argumentTokens.map((token, index) => + index === valueIndex + ? token.replaceAll("{{value}}", String(request.correctionLines)) + : token, + ); + const executable = this.executablePath( + NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, + true, + ); + const execution = await this.invoke( + NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, + request.cwd, + ["review", "capture-correction-plan", ...argumentTokens], + true, + request.signal, + executable, + ); return decode(NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, true, () => { const closure = decodeReviewLastEventClosureV1(execution.body); - if (closure.operation !== "review.capture-correction-plan") throw new TypeError("capture-correction-plan returned a closure for another operation"); + if (closure.operation !== "review.capture-correction-plan") + throw new TypeError( + "capture-correction-plan returned a closure for another operation", + ); return closure; }); } @@ -2225,22 +4546,50 @@ export class NativeReviewCliV216 implements NativeReviewCli { // --cwd: the vector's --repository-context is authoritative and mutually // exclusive with a path). The central adapter injects the relay handshake // environment on this spawn like on every other gentle-ai invocation. - async captureProviderRole(request: NativeReviewProviderRoleCaptureRequest): Promise { - if (!isNativeReviewProviderRoleCaptureOperation(request.captureOperation)) throw new TypeError(`Native CAPTURE_PROVIDER_ROLE supports only review.capture-refuter and review.capture-validation, received ${JSON.stringify(request.captureOperation)}`); + async captureProviderRole( + request: NativeReviewProviderRoleCaptureRequest, + ): Promise { + if (!isNativeReviewProviderRoleCaptureOperation(request.captureOperation)) + throw new TypeError( + `Native CAPTURE_PROVIDER_ROLE supports only review.capture-refuter and review.capture-validation, received ${JSON.stringify(request.captureOperation)}`, + ); const verb = request.captureOperation.slice("review.".length); - const expectedClosureOperation = NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION[request.captureOperation]; - if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_PROVIDER_ROLE requires the provider-rendered argument tokens"); - if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_PROVIDER_ROLE argument tokens must all be non-empty strings"); - const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, true); - const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, request.cwd, [ - "review", verb, - ...request.argumentTokens, - ], true, request.signal, executable); + const expectedClosureOperation = + NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION[ + request.captureOperation + ]; + if (request.argumentTokens.length === 0) + throw new TypeError( + "Native CAPTURE_PROVIDER_ROLE requires the provider-rendered argument tokens", + ); + if ( + request.argumentTokens.some( + (token) => typeof token !== "string" || token.length === 0, + ) + ) + throw new TypeError( + "Native CAPTURE_PROVIDER_ROLE argument tokens must all be non-empty strings", + ); + const executable = this.executablePath( + NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, + true, + ); + const execution = await this.invoke( + NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, + request.cwd, + ["review", verb, ...request.argumentTokens], + true, + request.signal, + executable, + ); return decode(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, true, () => { const body = object(execution.body); if (body.schema === "gentle-ai.review-last-event-closure/v1") { const closure = decodeReviewLastEventClosureV1(body); - if (closure.operation !== expectedClosureOperation) throw new TypeError("provider role capture returned a closure for another operation"); + if (closure.operation !== expectedClosureOperation) + throw new TypeError( + "provider role capture returned a closure for another operation", + ); return closure; } return decodeNativeProviderRoleCaptureArtifact(body); @@ -2254,14 +4603,33 @@ export class NativeReviewCliV216 implements NativeReviewCli { // pi subprocess, and admits the raw verdict. Pi never adds, removes, or reorders // a single token (not even --cwd: the vector's --repository-context is // authoritative and mutually exclusive with a path). - async captureExecute(request: NativeReviewCaptureExecuteRequest): Promise { - if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_EXECUTE requires the provider-rendered argument tokens"); - if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_EXECUTE argument tokens must all be non-empty strings"); - const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, true); - const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, request.cwd, [ - "review", "capture-result", - ...request.argumentTokens, - ], true, request.signal, executable); + async captureExecute( + request: NativeReviewCaptureExecuteRequest, + ): Promise { + if (request.argumentTokens.length === 0) + throw new TypeError( + "Native CAPTURE_EXECUTE requires the provider-rendered argument tokens", + ); + if ( + request.argumentTokens.some( + (token) => typeof token !== "string" || token.length === 0, + ) + ) + throw new TypeError( + "Native CAPTURE_EXECUTE argument tokens must all be non-empty strings", + ); + const executable = this.executablePath( + NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, + true, + ); + const execution = await this.invoke( + NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, + request.cwd, + ["review", "capture-result", ...request.argumentTokens], + true, + request.signal, + executable, + ); return decode(NATIVE_REVIEW_OPERATION.CAPTURE_EXECUTE, true, () => { const body = object(execution.body); if (body.schema === "gentle-ai.review-last-event-closure/v1") { @@ -2271,7 +4639,6 @@ export class NativeReviewCliV216 implements NativeReviewCli { }); } - // gentle-pi#311 P5: executes one provider-rendered `review.finalize` // execute transition exactly as rendered. The tokens come verbatim from // the negotiated next_transition (e.g. `--lineage= @@ -2289,8 +4656,6 @@ export class NativeReviewCliV216 implements NativeReviewCli { // {{value}} substitution: a literal for correction_lines, a staged 0o600 // artifact path for a validation document. - - // Field defect (fambig, 2026-08-16): at every evidence-pending sub-state // the collect slot renders the identity native demands — for a correction // that is the fix-diff `--target`, not the live workspace snapshot — so the @@ -2299,7 +4664,9 @@ export class NativeReviewCliV216 implements NativeReviewCli { // discipline as captureResult; --repository-context is authoritative and // mutually exclusive with a path. - async reviewStatus(request: NativeReviewStatusRequest): Promise { + async reviewStatus( + request: NativeReviewStatusRequest, + ): Promise { const execution = await this.invoke( NATIVE_REVIEW_OPERATION.STATUS, request.cwd, @@ -2308,23 +4675,43 @@ export class NativeReviewCliV216 implements NativeReviewCli { request.signal, this.executablePath(NATIVE_REVIEW_OPERATION.STATUS, false), ); - const status = decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeNativeReviewStatus(execution.body)); - if (!await repositoriesMatch(request.cwd, status.repository)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.STATUS, false, "native review status repository mismatch"); + const status = decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => + decodeNativeReviewStatus(execution.body), + ); + if (!(await repositoriesMatch(request.cwd, status.repository))) + throw nativeError( + NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, + NATIVE_REVIEW_OPERATION.STATUS, + false, + "native review status repository mismatch", + ); return status; } - async reviewMode(request: NativeReviewModeRequest): Promise { + async reviewMode( + request: NativeReviewModeRequest, + ): Promise { const cwd = await canonicalNativeReviewCwd(request.cwd); const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS; const execution = await this.invoke( NATIVE_REVIEW_OPERATION.MODE, cwd, - ["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"], + [ + "review", + "mode", + request.operation, + "--cwd", + cwd, + ...(mutating ? ["--scope", "clone"] : []), + "--json", + ], mutating, request.signal, this.executablePath(NATIVE_REVIEW_OPERATION.MODE, mutating), ); - return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => decodeNativeReviewMode(execution.body, request.operation)); + return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => + decodeNativeReviewMode(execution.body, request.operation), + ); } // Read-only risk assessment (gentle-ai#4295, gentle-pi#662). Never mutates; @@ -2332,48 +4719,89 @@ export class NativeReviewCliV216 implements NativeReviewCli { // rejects -- callers (the `gentle_review` tool's `assess` operation) fail // closed to `high`. async assess(request: NativeReviewAssessRequest): Promise { - if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string"); - if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement"); - if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef"); + if ( + request.baseRef !== undefined && + !isCanonicalProcessString(request.baseRef) + ) + throw new TypeError( + "Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string", + ); + if (request.baseRef !== undefined && request.committedOnly !== true) + throw new TypeError( + "Native ASSESS baseRef requires explicit committedOnly acknowledgement", + ); + if (request.baseRef === undefined && request.committedOnly !== undefined) + throw new TypeError( + "Native ASSESS committedOnly requires an explicit baseRef", + ); const cwd = await canonicalNativeReviewCwd(request.cwd); const execution = await this.invoke( NATIVE_REVIEW_OPERATION.ASSESS, cwd, - ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"], + [ + "review", + "assess", + "--cwd", + cwd, + ...(request.baseRef === undefined + ? [] + : ["--base-ref", request.baseRef, "--committed-only"]), + "--json", + ], false, request.signal, this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false), ); - return decode(NATIVE_REVIEW_OPERATION.ASSESS, false, () => decodeReviewAssessmentV1(execution.body)); + return decode(NATIVE_REVIEW_OPERATION.ASSESS, false, () => + decodeReviewAssessmentV1(execution.body), + ); } // Recovery commands are version-gated plain CLI operations outside the // negotiated integration-v1 contract, exactly like reviewStatus. - reclaim(request: NativeReviewReclaimRequest): Promise { + reclaim( + request: NativeReviewReclaimRequest, + ): Promise { return this.plain.reclaim(request); } - recover(request: NativeReviewRecoverRequest): Promise { + recover( + request: NativeReviewRecoverRequest, + ): Promise { return this.plain.recover(request); } - abandon(request: NativeReviewAbandonRequest): Promise { + abandon( + request: NativeReviewAbandonRequest, + ): Promise { return this.plain.abandon(request); } - quarantineLegacy(request: NativeReviewLegacyQuarantineRequest): Promise { + quarantineLegacy( + request: NativeReviewLegacyQuarantineRequest, + ): Promise { return this.plain.quarantineLegacy(request); } - reconcileAuthority(request: NativeReviewReconcileAuthorityRequest): Promise { + reconcileAuthority( + request: NativeReviewReconcileAuthorityRequest, + ): Promise { return this.plain.reconcileAuthority(request); } - repairLegacyAlias(request: NativeReviewLegacyAliasRepairRequest): Promise { + repairLegacyAlias( + request: NativeReviewLegacyAliasRepairRequest, + ): Promise { return this.plain.repairLegacyAlias(request); } } -export function createNativeReviewCli(adapter?: ExecFileAdapter, executable: string | (() => string) = resolveGentleAiBinary): NativeReviewCli { - return new NativeReviewCliV216(adapter ?? createNodeExecFileAdapter(), executable); +export function createNativeReviewCli( + adapter?: ExecFileAdapter, + executable: string | (() => string) = resolveGentleAiBinary, +): NativeReviewCli { + return new NativeReviewCliV216( + adapter ?? createNodeExecFileAdapter(), + executable, + ); } diff --git a/lib/review-host-relay.ts b/lib/review-host-relay.ts index 5fe8333a4..97c6ae105 100644 --- a/lib/review-host-relay.ts +++ b/lib/review-host-relay.ts @@ -36,8 +36,16 @@ import { runOpaquePiReviewer, type OpaquePiReviewerResult, } from "./opaque-pi-reviewer-adapter.ts"; -import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts"; -import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts"; +import { + REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, + REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, + type ReviewCaptureSubmissionV1, + type ReviewCollectInputV3, +} from "./review-integration-v2.ts"; +import { + GENTLE_PI_REVIEW_RELAY_CONTRACT, + GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV, +} from "./review-relay-contract.ts"; // Compatibility export for existing relay consumers. The pure adapter owns the // fixed Pi process boundary and its locked-down argv. @@ -62,7 +70,8 @@ export const REVIEW_HOST_RELAY_FAILURE = { PI_EMPTY_OUTPUT: "pi-empty-output", SUBMISSION_REFUSED: "submission-refused", } as const; -export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE]; +export type ReviewHostRelayFailureKind = + (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE]; export type ReviewHostRelayStage = "binding" | "materialize" | "pi" | "submit"; @@ -90,7 +99,19 @@ export class ReviewHostRelayError extends Error { // "none" again: the provider states that the lens slot was not consumed // (gentle-pi#522 / #524). readonly mutationOutcome: "none" | "unknown"; - constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) { + constructor( + kind: ReviewHostRelayFailureKind, + stage: ReviewHostRelayStage, + message: string, + details?: { + exitCode?: number | null; + stderr?: string; + timedOut?: boolean; + elapsedMs?: number; + timeoutMs?: number; + mutationOutcome?: "none" | "unknown"; + }, + ) { super(message); this.name = "ReviewHostRelayError"; this.kind = kind; @@ -100,7 +121,8 @@ export class ReviewHostRelayError extends Error { this.timedOut = details?.timedOut ?? false; this.elapsedMs = details?.elapsedMs ?? null; this.timeoutMs = details?.timeoutMs ?? null; - this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none"); + this.mutationOutcome = + details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none"); } } @@ -111,8 +133,13 @@ export class ReviewHostRelayError extends Error { // typed shape; it never parses the reason, and it never retries. const ADMISSION_REFUSAL = /\[invalid_request\]/; -export function isReviewHostRelayAdmissionRefusal(capture: { exitCode: number | null; timedOut: boolean }, stderr: string): boolean { - return capture.exitCode === 1 && !capture.timedOut && ADMISSION_REFUSAL.test(stderr); +export function isReviewHostRelayAdmissionRefusal( + capture: { exitCode: number | null; timedOut: boolean }, + stderr: string, +): boolean { + return ( + capture.exitCode === 1 && !capture.timedOut && ADMISSION_REFUSAL.test(stderr) + ); } // Refusal classification for the materialize invocation. The installed @@ -125,7 +152,8 @@ export function isReviewHostRelayAdmissionRefusal(capture: { exitCode: number | // untouched. // handshake the provider's pre-authority pi admission refusal — always // surfaced verbatim, never worked around. -const UNKNOWN_FLAG_REFUSAL = /flag provided but not defined: -{1,2}(?:materialize|agent)\b/; +const UNKNOWN_FLAG_REFUSAL = + /flag provided but not defined: -{1,2}(?:materialize|agent)\b/; const HANDSHAKE_REFUSAL = new RegExp( [ GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV, @@ -134,7 +162,9 @@ const HANDSHAKE_REFUSAL = new RegExp( ].join("|"), ); -export function classifyReviewHostRelayRefusal(stderr: string): "unknown-flag" | "handshake" | "other" { +export function classifyReviewHostRelayRefusal( + stderr: string, +): "unknown-flag" | "handshake" | "other" { if (UNKNOWN_FLAG_REFUSAL.test(stderr)) return "unknown-flag"; if (HANDSHAKE_REFUSAL.test(stderr)) return "handshake"; return "other"; @@ -161,29 +191,50 @@ export interface ReviewHostRelaySlot { readonly subjectHash?: string; } -function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined { +function argumentValue( + input: ReviewCollectInputV3, + name: string, +): string | undefined { const matches = input.arguments.filter((argument) => argument.name === name); return matches.length === 1 ? matches[0]!.value : undefined; } -function renderToken(argument: ReviewCollectInputV3["arguments"][number]): string { +function renderToken( + argument: ReviewCollectInputV3["arguments"][number], +): string { return argument.token ?? `--${argument.name}=${argument.value}`; } -export function isReviewHostRelayCollectInput(input: ReviewCollectInputV3): boolean { - return input.captureOperation === "review.capture-result" - && argumentValue(input, "materialize") === "true" - && argumentValue(input, "agent") === "pi"; +export function isReviewHostRelayCollectInput( + input: ReviewCollectInputV3, +): boolean { + return ( + input.captureOperation === "review.capture-result" && + argumentValue(input, "materialize") === "true" && + argumentValue(input, "agent") === "pi" + ); } -export function reviewHostRelaySlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelaySlot[] { - return inputs.filter((input) => isReviewHostRelayCollectInput(input)).map((input) => ({ - captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)), - ...(input.submission === undefined ? {} : { submission: input.submission }), - ...(argumentValue(input, "lens") === undefined ? {} : { lens: argumentValue(input, "lens") }), - ...(argumentValue(input, "order") === undefined ? {} : { order: argumentValue(input, "order") }), - ...(input.artifactSubject === undefined ? {} : { subjectHash: input.artifactSubject.subjectHash }), - })); +export function reviewHostRelaySlots( + inputs: readonly ReviewCollectInputV3[], +): readonly ReviewHostRelaySlot[] { + return inputs + .filter((input) => isReviewHostRelayCollectInput(input)) + .map((input) => ({ + captureArgumentTokens: input.arguments.map((argument) => + renderToken(argument), + ), + ...(input.submission === undefined ? {} : { submission: input.submission }), + ...(argumentValue(input, "lens") === undefined + ? {} + : { lens: argumentValue(input, "lens") }), + ...(argumentValue(input, "order") === undefined + ? {} + : { order: argumentValue(input, "order") }), + ...(input.artifactSubject === undefined + ? {} + : { subjectHash: input.artifactSubject.subjectHash }), + })); } // --------------------------------------------------------------------------- @@ -206,65 +257,92 @@ export interface ReviewProviderRoleVectorSlot { readonly name: string; } -export function isReviewProviderRoleVectorInput(input: ReviewCollectInputV3): boolean { - return (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(input.captureOperation) - && argumentValue(input, "execute") === "true" - && argumentValue(input, "agent") === "pi"; +export function isReviewProviderRoleVectorInput( + input: ReviewCollectInputV3, +): boolean { + return ( + (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes( + input.captureOperation, + ) && + argumentValue(input, "execute") === "true" && + argumentValue(input, "agent") === "pi" + ); +} + +export function reviewProviderRoleVectorSlots( + inputs: readonly ReviewCollectInputV3[], +): readonly ReviewProviderRoleVectorSlot[] { + return inputs + .filter((input) => isReviewProviderRoleVectorInput(input)) + .map((input) => ({ + captureOperation: + input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"], + argumentTokens: input.arguments.map((argument) => renderToken(argument)), + name: input.name, + })); +} + +// --------------------------------------------------------------------------- +// Execute vector for review.capture-result (gentle-pi execute-native compat) +// +// A self-contained execute vector for `review.capture-result` with +// `--agent=pi --execute=true`. Unlike the materialize path (which materializes +// a prompt, runs Pi, and submits the result), the execute path is handled +// by the native CLI directly: Go materializes the prompt, spawns its locked-down +// pi subprocess, and admits the raw verdict. This mirrors the provider role +// vector pattern but for the reviewer capture operation. +// --------------------------------------------------------------------------- + +export interface ReviewHostRelayExecuteSlot { + /** Every provider-issued argument token, verbatim, in provider order. */ + readonly captureArgumentTokens: readonly string[]; + readonly lens?: string; + readonly order?: string; + readonly subjectHash?: string; +} + +/** + * Classifies a collect input as an execute vector for review.capture-result. + * Matches: captureOperation === "review.capture-result" && agent === "pi" && execute === "true" + * + * This is distinct from the materialize path which requires materialize === "true". + */ +export function isReviewHostRelayExecuteInput( + input: ReviewCollectInputV3, +): boolean { + return ( + input.captureOperation === "review.capture-result" && + argumentValue(input, "agent") === "pi" && + argumentValue(input, "execute") === "true" + ); } - export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewProviderRoleVectorSlot[] { - return inputs.filter((input) => isReviewProviderRoleVectorInput(input)).map((input) => ({ - captureOperation: input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"], - argumentTokens: input.arguments.map((argument) => renderToken(argument)), - name: input.name, - })); - } - - // --------------------------------------------------------------------------- - // Execute vector for review.capture-result (gentle-pi execute-native compat) - // - // A self-contained execute vector for `review.capture-result` with - // `--agent=pi --execute=true`. Unlike the materialize path (which materializes - // a prompt, runs Pi, and submits the result), the execute path is handled - // by the native CLI directly: Go materializes the prompt, spawns its locked-down - // pi subprocess, and admits the raw verdict. This mirrors the provider role - // vector pattern but for the reviewer capture operation. - // --------------------------------------------------------------------------- - - export interface ReviewHostRelayExecuteSlot { - /** Every provider-issued argument token, verbatim, in provider order. */ - readonly captureArgumentTokens: readonly string[]; - readonly lens?: string; - readonly order?: string; - readonly subjectHash?: string; - } - - /** - * Classifies a collect input as an execute vector for review.capture-result. - * Matches: captureOperation === "review.capture-result" && agent === "pi" && execute === "true" - * - * This is distinct from the materialize path which requires materialize === "true". - */ - export function isReviewHostRelayExecuteInput(input: ReviewCollectInputV3): boolean { - return input.captureOperation === "review.capture-result" - && argumentValue(input, "agent") === "pi" - && argumentValue(input, "execute") === "true"; - } - - /** - * Extracts execute vector slots from collect inputs. - * Returns slots for review.capture-result with agent=pi and execute=true. - */ - export function reviewHostRelayExecuteSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelayExecuteSlot[] { - return inputs.filter((input) => isReviewHostRelayExecuteInput(input)).map((input) => ({ - captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)), - ...(argumentValue(input, "lens") === undefined ? {} : { lens: argumentValue(input, "lens") }), - ...(argumentValue(input, "order") === undefined ? {} : { order: argumentValue(input, "order") }), - ...(input.artifactSubject === undefined ? {} : { subjectHash: input.artifactSubject.subjectHash }), - })); - } - - // Resolves the provider-owned submission form into an executable binding. +/** + * Extracts execute vector slots from collect inputs. + * Returns slots for review.capture-result with agent=pi and execute=true. + */ +export function reviewHostRelayExecuteSlots( + inputs: readonly ReviewCollectInputV3[], +): readonly ReviewHostRelayExecuteSlot[] { + return inputs + .filter((input) => isReviewHostRelayExecuteInput(input)) + .map((input) => ({ + captureArgumentTokens: input.arguments.map((argument) => + renderToken(argument), + ), + ...(argumentValue(input, "lens") === undefined + ? {} + : { lens: argumentValue(input, "lens") }), + ...(argumentValue(input, "order") === undefined + ? {} + : { order: argumentValue(input, "order") }), + ...(input.artifactSubject === undefined + ? {} + : { subjectHash: input.artifactSubject.subjectHash }), + })); +} + +// Resolves the provider-owned submission form into an executable binding. // Fails closed with a typed contract-mismatch error whenever the completing // form is absent or cannot bind exactly one artifact value; the relay never // repairs, filters, or synthesizes it. @@ -274,25 +352,65 @@ export interface ReviewHostRelaySubmissionBinding { readonly substitutionLocation: number; } -export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmissionV1 | undefined): ReviewHostRelaySubmissionBinding { +export function resolveReviewHostRelaySubmission( + submission: ReviewCaptureSubmissionV1 | undefined, +): ReviewHostRelaySubmissionBinding { if (submission === undefined) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, + "binding", + REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, + ); } - if (submission.operationToken.length === 0 || submission.argumentTokens.length === 0 || submission.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission form carries an empty operation or argument token"); + if ( + submission.operationToken.length === 0 || + submission.argumentTokens.length === 0 || + submission.argumentTokens.some( + (token) => typeof token !== "string" || token.length === 0, + ) + ) { + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, + "binding", + "provider contract mismatch: the submission form carries an empty operation or argument token", + ); } if (submission.values.length !== 1) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission form must bind exactly one artifact value, received ${submission.values.length}`); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, + "binding", + `provider contract mismatch: the submission form must bind exactly one artifact value, received ${submission.values.length}`, + ); } const value = submission.values[0]!; const location = value.substitutionLocation; - if (!Number.isSafeInteger(location) || location < 0 || location >= submission.argumentTokens.length) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission substitution location is outside its argument tokens"); + if ( + !Number.isSafeInteger(location) || + location < 0 || + location >= submission.argumentTokens.length + ) { + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, + "binding", + "provider contract mismatch: the submission substitution location is outside its argument tokens", + ); } - if (!submission.argumentTokens[location]!.includes(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT)) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission token at location ${location} carries no ${REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT} slot`); + if ( + !submission.argumentTokens[location]!.includes( + REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT, + ) + ) { + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, + "binding", + `provider contract mismatch: the submission token at location ${location} carries no ${REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT} slot`, + ); } - return { operationToken: submission.operationToken, argumentTokens: submission.argumentTokens, substitutionLocation: location }; + return { + operationToken: submission.operationToken, + argumentTokens: submission.argumentTokens, + substitutionLocation: location, + }; } // --------------------------------------------------------------------------- @@ -336,11 +454,20 @@ export interface ReviewHostRelayPreparedResult { readonly resultByteLength: number; } -const preparedResultBytes = new WeakMap(); +const preparedResultBytes = new WeakMap< + ReviewHostRelayPreparedResult, + Buffer +>(); -export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise; -export type ReviewHostRelayPreparationRunner = (request: ReviewHostRelayRequest) => Promise; -export type ReviewHostRelaySubmissionRunner = (prepared: ReviewHostRelayPreparedResult) => Promise; +export type ReviewHostRelayRunner = ( + request: ReviewHostRelayRequest, +) => Promise; +export type ReviewHostRelayPreparationRunner = ( + request: ReviewHostRelayRequest, +) => Promise; +export type ReviewHostRelaySubmissionRunner = ( + prepared: ReviewHostRelayPreparedResult, +) => Promise; const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000; @@ -370,29 +497,47 @@ const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000; // turn a foreground FINALIZE into an unbounded child process. // --------------------------------------------------------------------------- -export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS"; +export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = + "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS"; export const REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS = 900_000; export const REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS = 900_000; export const REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS = 7_200_000; const BYTES_PER_MEBIBYTE = 1024 * 1024; -export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, environment: NodeJS.ProcessEnv = process.env): number { +export function resolveReviewHostRelayPiTimeoutMs( + promptByteLength: number, + environment: NodeJS.ProcessEnv = process.env, +): number { const configured = environment[REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]; if (configured !== undefined && /^[1-9]\d*$/.test(configured)) { const parsed = Number(configured); - if (Number.isSafeInteger(parsed)) return Math.min(parsed, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS); + if (Number.isSafeInteger(parsed)) + return Math.min(parsed, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS); } - const bytes = Number.isSafeInteger(promptByteLength) && promptByteLength > 0 ? promptByteLength : 0; - const scaled = REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + Math.ceil((bytes / BYTES_PER_MEBIBYTE) * REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS); + const bytes = + Number.isSafeInteger(promptByteLength) && promptByteLength > 0 + ? promptByteLength + : 0; + const scaled = + REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + + Math.ceil( + (bytes / BYTES_PER_MEBIBYTE) * REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS, + ); return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS); } // The reviewer ran out of time; it did not crash. The message states both // measurements and names the two things that can change the outcome, because // the one thing that cannot is relaunching the identical slot. -export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string { - return `pi reviewer subprocess exceeded the relay bound: killed after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. ` - + `Relaunching the same slot unchanged reaches the same wall. Raise ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV} above the reviewer's real wall time (ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}ms) or reduce the candidate scope so the materialized prompt is smaller.`; +export function reviewHostRelayPiTimeoutMessage( + elapsedMs: number, + timeoutMs: number, + promptByteLength: number, +): string { + return ( + `pi reviewer subprocess exceeded the relay bound: killed after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. ` + + `Relaunching the same slot unchanged reaches the same wall. Raise ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV} above the reviewer's real wall time (ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}ms) or reduce the candidate scope so the materialized prompt is smaller.` + ); } interface ProcessCapture { @@ -406,7 +551,13 @@ interface ProcessCapture { function collectGentleAiProcess( file: string, arguments_: readonly string[], - options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal }, + options: { + cwd: string; + env: NodeJS.ProcessEnv; + stdin?: Buffer; + timeoutMs: number; + signal?: AbortSignal; + }, ): Promise { return new Promise((resolve, reject) => { const startedAt = Date.now(); @@ -422,12 +573,13 @@ function collectGentleAiProcess( const stderr: Buffer[] = []; let timedOut = false; let settled = false; - const timer = options.timeoutMs > 0 - ? setTimeout(() => { - timedOut = true; - child.kill("SIGKILL"); - }, options.timeoutMs) - : undefined; + const timer = + options.timeoutMs > 0 + ? setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, options.timeoutMs) + : undefined; timer?.unref(); child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); @@ -441,7 +593,13 @@ function collectGentleAiProcess( if (settled) return; settled = true; if (timer !== undefined) clearTimeout(timer); - resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut, elapsedMs: Date.now() - startedAt }); + resolve({ + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + exitCode: code, + timedOut, + elapsedMs: Date.now() - startedAt, + }); }); if (options.stdin === undefined) { child.stdin.end(); @@ -452,7 +610,11 @@ function collectGentleAiProcess( }); } -function relayPiTransportError(error: unknown, promptByteLength: number, piTimeoutMs: number): ReviewHostRelayError { +function relayPiTransportError( + error: unknown, + promptByteLength: number, + piTimeoutMs: number, +): ReviewHostRelayError { if (!(error instanceof OpaquePiReviewerTransportError)) { return new ReviewHostRelayError( REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, @@ -468,50 +630,92 @@ function relayPiTransportError(error: unknown, promptByteLength: number, piTimeo ...(error.timeoutMs === null ? {} : { timeoutMs: error.timeoutMs }), }; if ( - error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT - && error.elapsedMs !== null - && error.timeoutMs !== null + error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT && + error.elapsedMs !== null && + error.timeoutMs !== null ) { return new ReviewHostRelayError( REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", - reviewHostRelayPiTimeoutMessage(error.elapsedMs, error.timeoutMs, promptByteLength), - { ...details, timedOut: true, elapsedMs: error.elapsedMs, timeoutMs: error.timeoutMs }, + reviewHostRelayPiTimeoutMessage( + error.elapsedMs, + error.timeoutMs, + promptByteLength, + ), + { + ...details, + timedOut: true, + elapsedMs: error.elapsedMs, + timeoutMs: error.timeoutMs, + }, ); } if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) { - return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", details); + return new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, + "pi", + "pi subprocess produced no output bytes", + details, + ); } if ( - error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED - || error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED + error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED || + error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED ) { - return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error.message}`, details); + return new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, + "pi", + `pi subprocess could not start: ${error.message}`, + details, + ); } - return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details); + return new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.PI_FAILED, + "pi", + "pi subprocess failed", + details, + ); } function assertTokens(name: string, tokens: readonly string[]): void { - if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`); + if (tokens.length === 0) + throw new TypeError( + `Pi host relay requires the provider-issued ${name} tokens`, + ); if (tokens.some((token) => typeof token !== "string" || token.length === 0)) { - throw new TypeError(`Pi host relay ${name} tokens must all be non-empty strings`); + throw new TypeError( + `Pi host relay ${name} tokens must all be non-empty strings`, + ); } } -function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): ReviewHostRelayRequest { +function snapshotReviewHostRelayRequest( + request: ReviewHostRelayRequest, +): ReviewHostRelayRequest { assertTokens("capture", request.captureArgumentTokens); // The completing form is validated before any process launches: a materialize // slot without a provider-owned submission is a typed contract mismatch, // never a synthesized invocation. resolveReviewHostRelaySubmission(request.submission); - const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary(); - if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path"); - const environment = Object.freeze({ ...(request.environment ?? process.env) }) as NodeJS.ProcessEnv; - const submission = request.submission === undefined ? undefined : Object.freeze({ - operationToken: request.submission.operationToken, - argumentTokens: Object.freeze([...request.submission.argumentTokens]), - values: Object.freeze(request.submission.values.map((value) => Object.freeze({ ...value }))), - }); + const gentleAiExecutable = + request.gentleAiExecutable ?? resolveGentleAiBinary(); + if (!isAbsolute(gentleAiExecutable)) + throw new TypeError( + "Pi host relay requires an absolute gentle-ai executable path", + ); + const environment = Object.freeze({ + ...(request.environment ?? process.env), + }) as NodeJS.ProcessEnv; + const submission = + request.submission === undefined + ? undefined + : Object.freeze({ + operationToken: request.submission.operationToken, + argumentTokens: Object.freeze([...request.submission.argumentTokens]), + values: Object.freeze( + request.submission.values.map((value) => Object.freeze({ ...value })), + ), + }); return Object.freeze({ ...request, captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]), @@ -541,62 +745,112 @@ export async function prepareReviewHostRelaySlot( // surface is available. No version sniffing or prompt reconstruction occurs. let materialized: ProcessCapture; try { - materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", "capture-result", ...preparedRequest.captureArgumentTokens], { - cwd: preparedRequest.targetCwd!, - env: { ...preparedRequest.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT }, - timeoutMs: preparedRequest.gentleAiTimeoutMs!, - ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }), - }); + materialized = await collectGentleAiProcess( + preparedRequest.gentleAiExecutable!, + ["review", "capture-result", ...preparedRequest.captureArgumentTokens], + { + cwd: preparedRequest.targetCwd!, + env: { + ...preparedRequest.environment!, + [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT, + }, + timeoutMs: preparedRequest.gentleAiTimeoutMs!, + ...(preparedRequest.signal === undefined + ? {} + : { signal: preparedRequest.signal }), + }, + ); } catch (error) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, + "materialize", + `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`, + ); } if (materialized.exitCode !== 0 || materialized.timedOut) { const stderr = materialized.stderr.toString("utf8"); const refusal = classifyReviewHostRelayRefusal(stderr); - const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: preparedRequest.gentleAiTimeoutMs! }; + const timing = { + elapsedMs: materialized.elapsedMs, + timeoutMs: preparedRequest.gentleAiTimeoutMs!, + }; if (refusal === "unknown-flag") { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { - exitCode: materialized.exitCode, - stderr, - timedOut: materialized.timedOut, - ...timing, - }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, + "materialize", + REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, + { + exitCode: materialized.exitCode, + stderr, + timedOut: materialized.timedOut, + ...timing, + }, + ); } if (refusal === "handshake") { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, + "materialize", + stderr, + { + exitCode: materialized.exitCode, + stderr, + timedOut: materialized.timedOut, + ...timing, + }, + ); + } + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, + "materialize", + materialized.timedOut + ? `gentle-ai prompt materialization exceeded its ${preparedRequest.gentleAiTimeoutMs!}ms bound after ${materialized.elapsedMs}ms` + : "gentle-ai prompt materialization failed", + { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing, - }); - } - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", materialized.timedOut - ? `gentle-ai prompt materialization exceeded its ${preparedRequest.gentleAiTimeoutMs!}ms bound after ${materialized.elapsedMs}ms` - : "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing }); + }, + ); } const promptBytes = materialized.stdout; if (promptBytes.length === 0) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { - exitCode: 0, - stderr: materialized.stderr.toString("utf8"), - elapsedMs: materialized.elapsedMs, - timeoutMs: preparedRequest.gentleAiTimeoutMs!, - }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, + "materialize", + "gentle-ai prompt materialization produced no bytes", + { + exitCode: 0, + stderr: materialized.stderr.toString("utf8"), + elapsedMs: materialized.elapsedMs, + timeoutMs: preparedRequest.gentleAiTimeoutMs!, + }, + ); } // The reviewer bound is derived from the prompt the provider actually // materialized. The explicit request timeout is a test seam that wins over // both the user-owned environment override and the scale-derived bound. - const piTimeoutMs = preparedRequest.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, preparedRequest.environment); + const piTimeoutMs = + preparedRequest.piTimeoutMs ?? + resolveReviewHostRelayPiTimeoutMs( + promptBytes.length, + preparedRequest.environment, + ); // The pure adapter owns the fresh isolated Pi process. Its input and output // are opaque bytes; this coordinator only maps transport failures. let piResult: OpaquePiReviewerResult; try { piResult = await reviewer(promptBytes, { - ...(preparedRequest.piExecutable === undefined ? {} : { piExecutable: preparedRequest.piExecutable }), + ...(preparedRequest.piExecutable === undefined + ? {} + : { piExecutable: preparedRequest.piExecutable }), environment: preparedRequest.environment, timeoutMs: piTimeoutMs, - ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }), + ...(preparedRequest.signal === undefined + ? {} + : { signal: preparedRequest.signal }), }); } catch (error) { throw relayPiTransportError(error, promptBytes.length, piTimeoutMs); @@ -620,25 +874,37 @@ export async function runReviewHostRelayReviewerGroup( prepare: ReviewHostRelayPreparationRunner = prepareReviewHostRelaySlot, ): Promise { if (requests.length === 0) { - throw new TypeError("Pi host relay reviewer group requires at least one provider-bound request"); + throw new TypeError( + "Pi host relay reviewer group requires at least one provider-bound request", + ); } - const settled = await Promise.allSettled(requests.map(async (request) => await prepare(request))); + const settled = await Promise.allSettled( + requests.map(async (request) => await prepare(request)), + ); const failed = settled.find((result) => result.status === "rejected"); if (failed?.status === "rejected") throw failed.reason; - return settled.map((result) => (result as PromiseFulfilledResult).value); + return settled.map( + (result) => + (result as PromiseFulfilledResult).value, + ); } /** * Submits one already-reviewed opaque result through the exact provider-owned * completing form. Only the provider-declared artifact slot is substituted. */ -export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRelayPreparedResult): Promise { +export async function submitReviewHostRelayPreparedResult( + prepared: ReviewHostRelayPreparedResult, +): Promise { const resultBytes = preparedResultBytes.get(prepared); - if (resultBytes === undefined) throw new TypeError("Pi host relay requires a recognized prepared result"); + if (resultBytes === undefined) + throw new TypeError("Pi host relay requires a recognized prepared result"); const { request } = prepared; assertTokens("capture", request.captureArgumentTokens); const submissionBinding = resolveReviewHostRelaySubmission(request.submission); - const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-")); + const stagingDirectory = await mkdtemp( + join(tmpdir(), "gentle-pi-host-relay-result-"), + ); let primaryFailure = false; try { await chmod(stagingDirectory, 0o700); @@ -652,29 +918,60 @@ export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRe ); let submission: ProcessCapture; try { - submission = await collectGentleAiProcess(request.gentleAiExecutable!, ["review", submissionBinding.operationToken, ...submitTokens], { - cwd: request.targetCwd!, - env: { ...request.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT }, - timeoutMs: request.gentleAiTimeoutMs!, - ...(request.signal === undefined ? {} : { signal: request.signal }), - }); + submission = await collectGentleAiProcess( + request.gentleAiExecutable!, + ["review", submissionBinding.operationToken, ...submitTokens], + { + cwd: request.targetCwd!, + env: { + ...request.environment!, + [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT, + }, + timeoutMs: request.gentleAiTimeoutMs!, + ...(request.signal === undefined ? {} : { signal: request.signal }), + }, + ); } catch (error) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `gentle-ai capture submission could not start: ${error instanceof Error ? error.message : String(error)}`); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, + "submit", + `gentle-ai capture submission could not start: ${error instanceof Error ? error.message : String(error)}`, + ); } - if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) { + if ( + submission.exitCode !== 0 || + submission.timedOut || + submission.stdout.length === 0 + ) { const stderr = submission.stderr.toString("utf8"); - const details = { exitCode: submission.exitCode, stderr, timedOut: submission.timedOut, elapsedMs: submission.elapsedMs, timeoutMs: request.gentleAiTimeoutMs! }; + const details = { + exitCode: submission.exitCode, + stderr, + timedOut: submission.timedOut, + elapsedMs: submission.elapsedMs, + timeoutMs: request.gentleAiTimeoutMs!, + }; // A typed admission refusal proves the provider consumed no slot. // Every other launched submission stays unknown pending fresh STATUS. if (isReviewHostRelayAdmissionRefusal(submission, stderr)) { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", stderr.trim(), { - ...details, - mutationOutcome: "none", - }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, + "submit", + stderr.trim(), + { + ...details, + mutationOutcome: "none", + }, + ); } - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", submission.timedOut - ? `gentle-ai capture submission exceeded its ${request.gentleAiTimeoutMs!}ms bound after ${submission.elapsedMs}ms` - : "gentle-ai refused the relayed capture submission", details); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, + "submit", + submission.timedOut + ? `gentle-ai capture submission exceeded its ${request.gentleAiTimeoutMs!}ms bound after ${submission.elapsedMs}ms` + : "gentle-ai refused the relayed capture submission", + details, + ); } return { promptByteLength: prepared.promptByteLength, @@ -703,6 +1000,10 @@ export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRe * Compatibility one-binding path: materialize → opaque Pi adapter → submit. * It preserves the established API and its typed failure behavior exactly. */ -export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise { - return await submitReviewHostRelayPreparedResult(await prepareReviewHostRelaySlot(request)); +export async function runReviewHostRelaySlot( + request: ReviewHostRelayRequest, +): Promise { + return await submitReviewHostRelayPreparedResult( + await prepareReviewHostRelaySlot(request), + ); } diff --git a/tests/review-host-relay-routing.test.ts b/tests/review-host-relay-routing.test.ts index bf3d0b293..9f39a0112 100644 --- a/tests/review-host-relay-routing.test.ts +++ b/tests/review-host-relay-routing.test.ts @@ -6,8 +6,22 @@ import { join } from "node:path"; import test from "node:test"; import { __testing } from "../extensions/gentle-ai.ts"; import type { NativeReviewCli } from "../lib/native-review-cli.ts"; -import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts"; -import { decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts"; +import { + REVIEW_HOST_RELAY_FAILURE, + REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, + REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, + REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, + REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, + ReviewHostRelayError, + type ReviewHostRelayRequest, +} from "../lib/review-host-relay.ts"; +import { + decodeReviewStatusV3, + type ReviewArtifactSubjectV2, + type ReviewCaptureSubmissionV1, + type ReviewCollectInputV3, + type ReviewStatusV3, +} from "../lib/review-integration-v2.ts"; // One-slot capture routing: the host relay runs only when the selected // provider-returned collect input carries the --materialize token. Every @@ -23,80 +37,189 @@ function repository(t: test.TestContext): string { execFileSync("git", ["init", "-b", "main"], { cwd }); writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n"); execFileSync("git", ["add", "."], { cwd }); - execFileSync("git", ["-c", "user.name=Relay Test", "-c", "user.email=relay@example.invalid", "commit", "-m", "initial"], { cwd }); + execFileSync( + "git", + [ + "-c", + "user.name=Relay Test", + "-c", + "user.email=relay@example.invalid", + "commit", + "-m", + "initial", + ], + { cwd }, + ); return cwd; } -function bindingArguments(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCollectInputV3["arguments"] { +function bindingArguments( + lineageId: string, + lens: ReviewArtifactSubjectV2["lens"], + order: number, + revision = SHA, +): ReviewCollectInputV3["arguments"] { return [ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` }, - { name: "expected-revision", value: revision, token: `--expected-revision=${revision}` }, + { + name: "expected-revision", + value: revision, + token: `--expected-revision=${revision}`, + }, { name: "target", value: SHA, token: `--target=${SHA}` }, - { name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` }, + { + name: "repository-context", + value: `rctx1_${"e".repeat(64)}`, + token: `--repository-context=rctx1_${"e".repeat(64)}`, + }, { name: "lens", value: lens, token: `--lens=${lens}` }, { name: "order", value: String(order), token: `--order=${order}` }, - { name: "subject-hash", value: `sha256:${String(order).repeat(64)}`, token: `--subject-hash=sha256:${String(order).repeat(64)}` }, + { + name: "subject-hash", + value: `sha256:${String(order).repeat(64)}`, + token: `--subject-hash=sha256:${String(order).repeat(64)}`, + }, ]; } -function providerSubmission(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCaptureSubmissionV1 { - const bindingTokens = bindingArguments(lineageId, lens, order, revision).map((argument) => argument.token!); +function providerSubmission( + lineageId: string, + lens: ReviewArtifactSubjectV2["lens"], + order: number, + revision = SHA, +): ReviewCaptureSubmissionV1 { + const bindingTokens = bindingArguments(lineageId, lens, order, revision).map( + (argument) => argument.token!, + ); return { operationToken: "capture-result", argumentTokens: [...bindingTokens, "--input={{value}}"], - values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: bindingTokens.length }], + values: [ + { + slot: "reviewer_result", + domain: "artifact_path_or_stdin", + substitutionLocation: bindingTokens.length, + }, + ], }; } -function relayCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, materialize = true, submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider", revision = SHA): ReviewCollectInputV3 { +function relayCollectInput( + lineageId: string, + lens: ReviewArtifactSubjectV2["lens"], + order: number, + materialize = true, + submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider", + revision = SHA, +): ReviewCollectInputV3 { return { name: "reviewer_result", schema: "https://gentle-ai.dev/schema/review/reviewer/v1", captureOperation: "review.capture-result", arguments: [ ...bindingArguments(lineageId, lens, order, revision), - ...(materialize ? [ - { name: "agent", value: "pi", token: "--agent=pi" }, - { name: "materialize", value: "true", token: "--materialize=true" }, - ] : []), + ...(materialize + ? [ + { name: "agent", value: "pi", token: "--agent=pi" }, + { name: "materialize", value: "true", token: "--materialize=true" }, + ] + : []), ], artifactSubject: { - schema: "gentle-ai.review-artifact-subject/v2", subjectHash: `sha256:${String(order).repeat(64)}`, - lineageId, authorityRevision: revision, targetIdentity: SHA, baseTree: TREE, candidateTree: TREE, - changedPathManifestSha256: SHA, lens, selectedOrder: order, + schema: "gentle-ai.review-artifact-subject/v2", + subjectHash: `sha256:${String(order).repeat(64)}`, + lineageId, + authorityRevision: revision, + targetIdentity: SHA, + baseTree: TREE, + candidateTree: TREE, + changedPathManifestSha256: SHA, + lens, + selectedOrder: order, }, - baseTree: TREE, candidateTree: TREE, changedPathManifest: [], - ...(materialize && submission !== "absent" ? { submission: submission === "provider" ? providerSubmission(lineageId, lens, order, revision) : submission } : {}), + baseTree: TREE, + candidateTree: TREE, + changedPathManifest: [], + ...(materialize && submission !== "absent" + ? { + submission: + submission === "provider" + ? providerSubmission(lineageId, lens, order, revision) + : submission, + } + : {}), }; } -function capturedGroupedStatus(lineageId: string, authorityRevision = SHA): ReviewStatusV3 { - const raw = JSON.parse(readFileSync(new URL("./fixtures/devbinary/status-v5-capture-result-submission.captured.json", import.meta.url), "utf8")) as Record; +function capturedGroupedStatus( + lineageId: string, + authorityRevision = SHA, +): ReviewStatusV3 { + const raw = JSON.parse( + readFileSync( + new URL( + "./fixtures/devbinary/status-v5-capture-result-submission.captured.json", + import.meta.url, + ), + "utf8", + ), + ) as Record; // The captured binary's forward-only `finalize` action is not accepted by // this checked-out decoder; retain the v5 capture and normalize only that // unrelated routing action before decoding its provider-owned group. raw.action = "stop"; - const authority = raw.authority as Record, repositoryContext = raw.repository_context as Record; + const authority = raw.authority as Record, + repositoryContext = raw.repository_context as Record; authority.lineage_id = lineageId; authority.revision = authorityRevision; - const phaseRevision = String(repositoryContext.revision), targetIdentity = String(raw.target_identity); - const source = ((((raw.next_transition as Record).collect as Record).inputs as Array>)[0]!); - const lenses = ["review-risk", "review-resilience", "review-readability", "review-reliability"]; - ((raw.next_transition as Record).collect as Record).inputs = lenses.map((lens, order) => { + const phaseRevision = String(repositoryContext.revision), + targetIdentity = String(raw.target_identity); + const source = ( + ( + (raw.next_transition as Record).collect as Record< + string, + unknown + > + ).inputs as Array> + )[0]!; + const lenses = [ + "review-risk", + "review-resilience", + "review-readability", + "review-reliability", + ]; + ( + (raw.next_transition as Record).collect as Record< + string, + unknown + > + ).inputs = lenses.map((lens, order) => { const input = JSON.parse(JSON.stringify(source)) as Record; const subjectHash = `sha256:${String(order + 1).repeat(64)}`; const arguments_ = input.arguments as Array>; for (const argument of arguments_) { - const value = argument.name === "lineage" ? lineageId - : argument.name === "lens" ? lens - : argument.name === "order" ? String(order) - : argument.name === "subject-hash" ? subjectHash - : argument.value; + const value = + argument.name === "lineage" + ? lineageId + : argument.name === "lens" + ? lens + : argument.name === "order" + ? String(order) + : argument.name === "subject-hash" + ? subjectHash + : argument.value; argument.value = value; argument.token = `--${String(argument.name)}=${String(value)}`; } const submission = input.submission as Record; - submission.argument_tokens = [...arguments_.filter((argument) => argument.name !== "agent" && argument.name !== "materialize").map((argument) => argument.token), "--input={{value}}"]; + submission.argument_tokens = [ + ...arguments_ + .filter( + (argument) => argument.name !== "agent" && argument.name !== "materialize", + ) + .map((argument) => argument.token), + "--input={{value}}", + ]; const artifactSubject = input.artifact_subject as Record; artifactSubject.subject_hash = subjectHash; artifactSubject.lineage_id = lineageId; @@ -109,15 +232,32 @@ function capturedGroupedStatus(lineageId: string, authorityRevision = SHA): Revi return decodeReviewStatusV3(raw); } -function replaceArgument(input: ReviewCollectInputV3, name: string, value: string): void { - input.arguments = input.arguments.map((argument) => argument.name === name ? { ...argument, value, token: `--${name}=${value}` } : argument); +function replaceArgument( + input: ReviewCollectInputV3, + name: string, + value: string, +): void { + input.arguments = input.arguments.map((argument) => + argument.name === name + ? { ...argument, value, token: `--${name}=${value}` } + : argument, + ); } -function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV3[]): ReviewStatusV3 { +function finalizeStatus( + lineageId: string, + inputs?: readonly ReviewCollectInputV3[], +): ReviewStatusV3 { return { contract: "gentle-ai.review-integration/v2", applicability: "current_target", - authority: { version: "compact-v2", lineageId, state: "reviewing", generation: 1, revision: SHA }, + authority: { + version: "compact-v2", + lineageId, + state: "reviewing", + generation: 1, + revision: SHA, + }, action: "stop", replayability: "unknown", targetIdentity: SHA, @@ -136,9 +276,26 @@ function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV currentSnapshotIdentity: SHA, }, candidates: [], - repositoryContext: { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA }, - ...(inputs === undefined ? {} : { nextTransition: { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } } }), - raw: { schema: "gentle-ai.review-integration.status/v3", action: "stop", lineage_id: lineageId }, + repositoryContext: { + capability: "review.opaque_repository_context", + handle: `rctx1_${"e".repeat(64)}`, + revision: SHA, + targetIdentity: SHA, + }, + ...(inputs === undefined + ? {} + : { + nextTransition: { + kind: "collect", + reasonCode: "reviewer_results_required", + collect: { inputs: [...inputs] }, + }, + }), + raw: { + schema: "gentle-ai.review-integration.status/v3", + action: "stop", + lineage_id: lineageId, + }, } as unknown as ReviewStatusV3; } @@ -151,7 +308,11 @@ function providerRefuterRequiredStatus(lineageId: string): ReviewStatusV3 { }; return { ...finalizeStatus(lineageId), - nextTransition: { kind: "collect", reasonCode: "provider_refuter_required", collect: { inputs: [refuter] } }, + nextTransition: { + kind: "collect", + reasonCode: "provider_refuter_required", + collect: { inputs: [refuter] }, + }, }; } @@ -169,7 +330,13 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness { }; harness.native = { targetStatus: async (request) => { - harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent }) }); + harness.statusCalls.push({ + cwd: request.cwd, + ...(request.lineageId === undefined + ? {} + : { lineageId: request.lineageId }), + ...(request.agent === undefined ? {} : { agent: request.agent }), + }); const next = harness.statusQueue.shift(); if (next === undefined) throw new Error("status queue exhausted"); return next; @@ -178,14 +345,20 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness { return harness; } -async function runCapture(cwd: string, harness: RoutingHarness, lineageId: string, input: Record = { reviewerRunAcknowledged: true }): Promise> { +async function runCapture( + cwd: string, + harness: RoutingHarness, + lineageId: string, + input: Record = { reviewerRunAcknowledged: true }, +): Promise> { const selected = harness.statusQueue[0]?.nextTransition?.collect?.inputs[0]; - if (selected === undefined) throw new Error("capture test requires one current collect input"); - return await __testing.executeReviewCaptureOperation( + if (selected === undefined) + throw new Error("capture test requires one current collect input"); + return (await __testing.executeReviewCaptureOperation( { lineageId, collectBinding: JSON.stringify(selected), ...input }, cwd, harness.native, - ) as Record; + )) as Record; } test("one materialize binding routes exactly one provider slot through the host relay", async (t) => { @@ -195,19 +368,38 @@ test("one materialize binding routes exactly one provider slot through the host const input = relayCollectInput(lineageId, "review-risk", 0); const harness = nativeHarness([finalizeStatus(lineageId, [input])]); const relayed: ReviewHostRelayRequest[] = []; - __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => { - relayed.push(request); - return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' }; - }); + __testing.setReviewHostRelayRunnerForTesting( + async (request: ReviewHostRelayRequest) => { + relayed.push(request); + return { + promptByteLength: 64, + resultByteLength: 32, + submission: '{"admission_decision":"completed"}', + }; + }, + ); const result = await runCapture(cwd, harness, lineageId); assert.equal(relayed.length, 1); - assert.deepEqual(relayed[0]!.captureArgumentTokens, input.arguments.map((argument) => argument.token)); - assert.deepEqual(relayed[0]!.submission, providerSubmission(lineageId, "review-risk", 0)); - assert.equal(harness.statusCalls.length, 1, "a nonterminal capture does not auto-follow STATUS"); + assert.deepEqual( + relayed[0]!.captureArgumentTokens, + input.arguments.map((argument) => argument.token), + ); + assert.deepEqual( + relayed[0]!.submission, + providerSubmission(lineageId, "review-risk", 0), + ); + assert.equal( + harness.statusCalls.length, + 1, + "a nonterminal capture does not auto-follow STATUS", + ); assert.equal(result.status, "captured"); - assert.equal((result.host_relay as { transport: string }).transport, "pi_host_relay"); + assert.equal( + (result.host_relay as { transport: string }).transport, + "pi_host_relay", + ); }); test("Pi-authored review documents are rejected at the capture input boundary", async (t) => { @@ -216,7 +408,11 @@ test("Pi-authored review documents are rejected at the capture input boundary", t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]); + const harness = nativeHarness([ + finalizeStatus(lineageId, [ + relayCollectInput(lineageId, "review-reliability", 0), + ]), + ]); let relayCalls = 0; __testing.setReviewHostRelayRunnerForTesting(async () => { relayCalls += 1; @@ -224,137 +420,381 @@ test("Pi-authored review documents are rejected at the capture input boundary", }); await assert.rejects( - () => runCapture(cwd, harness, lineageId, { review_result: { lens_results: [{ findings: [], evidence: ["reviewed"] }] } }), + () => + runCapture(cwd, harness, lineageId, { + review_result: { lens_results: [{ findings: [], evidence: ["reviewed"] }] }, + }), /does not accept review_result/, ); assert.equal(relayCalls, 0); }); -function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); } +function groupInputs( + lineageId: string, + revision = SHA, +): ReviewCollectInputV3[] { + return [ + "review-risk", + "review-resilience", + "review-readability", + "review-reliability", + ].map((lens, order) => + relayCollectInput(lineageId, lens, order, true, "provider", revision), + ); +} -async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true): Promise> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native) as Record; } +async function runCaptureGroup( + cwd: string, + harness: RoutingHarness, + lineageId: string, + inputs: readonly ReviewCollectInputV3[], + reviewerRunAcknowledged = true, +): Promise> { + return (await __testing.executeReviewCaptureGroupOperation( + { + lineageId, + collectBindings: inputs.map((input) => JSON.stringify(input)), + reviewerRunAcknowledged, + }, + cwd, + harness.native, + )) as Record; +} -function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; } +function prepared(request: ReviewHostRelayRequest) { + return { request, promptByteLength: 64, resultByteLength: 32 }; +} test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => { t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting()); - const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value); - const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, index === 1 ? [...inputs.slice(index), { name: "unrelated", schema: "example", captureOperation: "external.example", arguments: [] }] : inputs.slice(index))), providerRefuterRequiredStatus(lineageId)]); + const cwd = repository(t), + lineageId = "relay-group", + inputs = groupInputs(lineageId), + lenses = inputs.map( + (input) => + input.arguments.find((argument) => argument.name === "lens")!.value, + ); + const harness = nativeHarness([ + finalizeStatus(lineageId, inputs), + finalizeStatus(lineageId, inputs), + ...inputs.map((_input, index) => + finalizeStatus( + lineageId, + index === 1 + ? [ + ...inputs.slice(index), + { + name: "unrelated", + schema: "example", + captureOperation: "external.example", + arguments: [], + }, + ] + : inputs.slice(index), + ), + ), + providerRefuterRequiredStatus(lineageId), + ]); let ready!: () => void; - const allStarted = new Promise((resolve) => { ready = resolve; }); - const releases = new Map void>(), completed: string[] = [], submitted: string[] = []; - const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => await Promise.all(requests.map(async (request) => { - const lens = request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7); - await new Promise((resolve) => { releases.set(lens, resolve); if (releases.size === requests.length) ready(); }); - completed.push(lens); return prepared(request); - })); - __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { - submitted.push(result.request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7)); - return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }; + const allStarted = new Promise((resolve) => { + ready = resolve; }); + const releases = new Map void>(), + completed: string[] = [], + submitted: string[] = []; + const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => + await Promise.all( + requests.map(async (request) => { + const lens = request.captureArgumentTokens + .find((token) => token.startsWith("--lens="))! + .slice(7); + await new Promise((resolve) => { + releases.set(lens, resolve); + if (releases.size === requests.length) ready(); + }); + completed.push(lens); + return prepared(request); + }), + ); + __testing.setReviewHostRelayGroupRunnersForTesting( + reviewerGroup, + async (result) => { + submitted.push( + result.request.captureArgumentTokens + .find((token) => token.startsWith("--lens="))! + .slice(7), + ); + return { + promptByteLength: result.promptByteLength, + resultByteLength: result.resultByteLength, + submission: "{}", + }; + }, + ); const forecast = await runCaptureGroup(cwd, harness, lineageId, inputs, false); - assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses }); + assert.deepEqual(forecast.cost_forecast, { + transport: "pi_host_relay", + model_runs: 4, + lenses, + }); const run = runCaptureGroup(cwd, harness, lineageId, inputs); await allStarted; - assert.deepEqual([...releases.keys()], lenses, "all reviewers start before the group waits"); + assert.deepEqual( + [...releases.keys()], + lenses, + "all reviewers start before the group waits", + ); for (const lens of [...lenses].reverse()) releases.get(lens!)!(); const result = await run; - assert.deepEqual(completed, [...lenses].reverse(), "reviewer completion order is not submission order"); + assert.deepEqual( + completed, + [...lenses].reverse(), + "reviewer completion order is not submission order", + ); assert.deepEqual(submitted, lenses); - assert.deepEqual({ outcome: result.outcome, statusCalls: harness.statusCalls.length, providerAction: result.provider_action }, { outcome: "native-reviewer-group-status-reconciled", statusCalls: 7, providerAction: "stop" }); - assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "post-last-capture reconciliation preserves the Pi host runtime"); - assert.equal((result.next_transition as { kind?: string; reasonCode?: string } | undefined)?.kind, "collect"); - assert.equal((result.next_transition as { kind?: string; reasonCode?: string } | undefined)?.reasonCode, "provider_refuter_required"); + assert.deepEqual( + { + outcome: result.outcome, + statusCalls: harness.statusCalls.length, + providerAction: result.provider_action, + }, + { + outcome: "native-reviewer-group-status-reconciled", + statusCalls: 7, + providerAction: "stop", + }, + ); + assert.equal( + harness.statusCalls.at(-1)?.agent, + "pi", + "post-last-capture reconciliation preserves the Pi host runtime", + ); + assert.equal( + (result.next_transition as { kind?: string; reasonCode?: string } | undefined) + ?.kind, + "collect", + ); + assert.equal( + (result.next_transition as { kind?: string; reasonCode?: string } | undefined) + ?.reasonCode, + "provider_refuter_required", + ); }); test("captured v5 STATUS accepts capture-phase Pn when authority has advanced to Rn at the forecast boundary", async (t) => { t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting()); - const cwd = repository(t), lineageId = "relay-group-phase-revision", status = capturedGroupedStatus(lineageId, SHA), inputs = status.nextTransition!.collect!.inputs!; + const cwd = repository(t), + lineageId = "relay-group-phase-revision", + status = capturedGroupedStatus(lineageId, SHA), + inputs = status.nextTransition!.collect!.inputs!; let launches = 0; - __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); }); + __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { + launches += requests.length; + return requests.map(prepared); + }); - assert.notEqual(status.authority!.revision, status.repositoryContext!.revision, "captured v5 group keeps capture-phase Pn distinct from authority Rn"); - const forecast = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs, false); + assert.notEqual( + status.authority!.revision, + status.repositoryContext!.revision, + "captured v5 group keeps capture-phase Pn distinct from authority Rn", + ); + const forecast = await runCaptureGroup( + cwd, + nativeHarness([status]), + lineageId, + inputs, + false, + ); assert.equal(forecast.outcome, "reviewer-model-run-forecast"); - assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"] }); - assert.equal(launches, 0, "decoder-valid capture-phase Pn must pass admission before any reviewer model launch"); + assert.deepEqual(forecast.cost_forecast, { + transport: "pi_host_relay", + model_runs: 4, + lenses: [ + "review-risk", + "review-resilience", + "review-readability", + "review-reliability", + ], + }); + assert.equal( + launches, + 0, + "decoder-valid capture-phase Pn must pass admission before any reviewer model launch", + ); }); - test("group rejects STATUS repository target mismatch and per-slot revision, context, or target drift before launch", async (t) => { +test("group rejects STATUS repository target mismatch and per-slot revision, context, or target drift before launch", async (t) => { t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting()); - const cwd = repository(t), lineageId = "relay-group-slot-consistency"; + const cwd = repository(t), + lineageId = "relay-group-slot-consistency"; const repositoryTargetMismatch = capturedGroupedStatus(lineageId, SHA); repositoryTargetMismatch.repositoryContext!.targetIdentity = SHA; const revisionMismatch = capturedGroupedStatus(lineageId, SHA); - replaceArgument(revisionMismatch.nextTransition!.collect!.inputs![1]!, "expected-revision", SHA); + replaceArgument( + revisionMismatch.nextTransition!.collect!.inputs![1]!, + "expected-revision", + SHA, + ); const contextMismatch = capturedGroupedStatus(lineageId, SHA); - replaceArgument(contextMismatch.nextTransition!.collect!.inputs![1]!, "repository-context", `rctx1_${"f".repeat(64)}`); + replaceArgument( + contextMismatch.nextTransition!.collect!.inputs![1]!, + "repository-context", + `rctx1_${"f".repeat(64)}`, + ); const targetMismatch = capturedGroupedStatus(lineageId, SHA); - replaceArgument(targetMismatch.nextTransition!.collect!.inputs![1]!, "target", SHA); + replaceArgument( + targetMismatch.nextTransition!.collect!.inputs![1]!, + "target", + SHA, + ); let launches = 0; - __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); }); + __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { + launches += requests.length; + return requests.map(prepared); + }); // Each call forwards this STATUS's own current set, so rejection proves an // internal STATUS guard rather than stale or caller/canonical mismatch. - const repositoryTargetResult = await runCaptureGroup(cwd, nativeHarness([repositoryTargetMismatch]), lineageId, repositoryTargetMismatch.nextTransition!.collect!.inputs!); - assert.deepEqual({ outcome: repositoryTargetResult.outcome, reason: repositoryTargetResult.reason }, { - outcome: "capture-group-rejected", - reason: "current STATUS repository context does not match the reviewer group binding", - }); - const revisionResult = await runCaptureGroup(cwd, nativeHarness([revisionMismatch]), lineageId, revisionMismatch.nextTransition!.collect!.inputs!); - assert.deepEqual({ outcome: revisionResult.outcome, reason: revisionResult.reason }, { - outcome: "capture-group-rejected", - reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding", - }); - const contextResult = await runCaptureGroup(cwd, nativeHarness([contextMismatch]), lineageId, contextMismatch.nextTransition!.collect!.inputs!); - assert.deepEqual({ outcome: contextResult.outcome, reason: contextResult.reason }, { - outcome: "capture-group-rejected", - reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding", - }); - const targetResult = await runCaptureGroup(cwd, nativeHarness([targetMismatch]), lineageId, targetMismatch.nextTransition!.collect!.inputs!); - assert.deepEqual({ outcome: targetResult.outcome, reason: targetResult.reason }, { - outcome: "capture-group-rejected", - reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding", - }); - assert.equal(launches, 0, "current STATUS consistency checks reject before reviewer model launch"); + const repositoryTargetResult = await runCaptureGroup( + cwd, + nativeHarness([repositoryTargetMismatch]), + lineageId, + repositoryTargetMismatch.nextTransition!.collect!.inputs!, + ); + assert.deepEqual( + { + outcome: repositoryTargetResult.outcome, + reason: repositoryTargetResult.reason, + }, + { + outcome: "capture-group-rejected", + reason: + "current STATUS repository context does not match the reviewer group binding", + }, + ); + const revisionResult = await runCaptureGroup( + cwd, + nativeHarness([revisionMismatch]), + lineageId, + revisionMismatch.nextTransition!.collect!.inputs!, + ); + assert.deepEqual( + { outcome: revisionResult.outcome, reason: revisionResult.reason }, + { + outcome: "capture-group-rejected", + reason: + "current STATUS carries an incomplete or mismatched materialize reviewer binding", + }, + ); + const contextResult = await runCaptureGroup( + cwd, + nativeHarness([contextMismatch]), + lineageId, + contextMismatch.nextTransition!.collect!.inputs!, + ); + assert.deepEqual( + { outcome: contextResult.outcome, reason: contextResult.reason }, + { + outcome: "capture-group-rejected", + reason: + "current STATUS carries an incomplete or mismatched materialize reviewer binding", + }, + ); + const targetResult = await runCaptureGroup( + cwd, + nativeHarness([targetMismatch]), + lineageId, + targetMismatch.nextTransition!.collect!.inputs!, + ); + assert.deepEqual( + { outcome: targetResult.outcome, reason: targetResult.reason }, + { + outcome: "capture-group-rejected", + reason: + "current STATUS carries an incomplete or mismatched materialize reviewer binding", + }, + ); + assert.equal( + launches, + 0, + "current STATUS consistency checks reject before reviewer model launch", + ); }); test("group rejects absent or mismatched current repository context before launch", async (t) => { t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting()); - const cwd = repository(t), lineageId = "relay-group-context-reject", inputs = groupInputs(lineageId, PHASE_REVISION); + const cwd = repository(t), + lineageId = "relay-group-context-reject", + inputs = groupInputs(lineageId, PHASE_REVISION); const revisionMismatch = finalizeStatus(lineageId, inputs); const handleMismatch = finalizeStatus(lineageId, inputs); const absent = finalizeStatus(lineageId, inputs); - handleMismatch.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"f".repeat(64)}`, revision: PHASE_REVISION, targetIdentity: SHA }; + handleMismatch.repositoryContext = { + capability: "review.opaque_repository_context", + handle: `rctx1_${"f".repeat(64)}`, + revision: PHASE_REVISION, + targetIdentity: SHA, + }; delete absent.repositoryContext; let launches = 0; - __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); }); + __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { + launches += requests.length; + return requests.map(prepared); + }); for (const status of [revisionMismatch, handleMismatch, absent]) { - const result = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs); + const result = await runCaptureGroup( + cwd, + nativeHarness([status]), + lineageId, + inputs, + ); assert.equal(result.outcome, "capture-group-rejected"); } - assert.equal(launches, 0, "repository-context validation must reject before reviewer model launch"); + assert.equal( + launches, + 0, + "repository-context validation must reject before reviewer model launch", + ); }); test("group rejects partial, duplicate, reordered, stale, mixed, and late-invalid bindings before launch", async (t) => { t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting()); - const cwd = repository(t), lineageId = "relay-group-reject", inputs = groupInputs(lineageId); + const cwd = repository(t), + lineageId = "relay-group-reject", + inputs = groupInputs(lineageId); const stale = JSON.parse(JSON.stringify(inputs[3]!)) as ReviewCollectInputV3; - stale.arguments = [...stale.arguments, { name: "replacement", value: "stale", token: "--replacement=stale" }]; - const mixed = [...inputs.slice(0, 3), relayCollectInput(lineageId, "review-reliability", 3, false)]; + stale.arguments = [ + ...stale.arguments, + { name: "replacement", value: "stale", token: "--replacement=stale" }, + ]; + const mixed = [ + ...inputs.slice(0, 3), + relayCollectInput(lineageId, "review-reliability", 3, false), + ]; const malformed = JSON.parse(JSON.stringify(inputs)) as ReviewCollectInputV3[]; malformed[3]!.submission!.argumentTokens = ["--input={{value}}"]; const cases = [ - { bindings: inputs.slice(0, 3), current: inputs }, { bindings: [...inputs].reverse(), current: inputs }, - { bindings: [...inputs.slice(0, 3), inputs[2]!], current: inputs }, { bindings: [...inputs.slice(0, 3), stale], current: inputs }, - { bindings: mixed, current: mixed }, { bindings: malformed, current: malformed }, + { bindings: inputs.slice(0, 3), current: inputs }, + { bindings: [...inputs].reverse(), current: inputs }, + { bindings: [...inputs.slice(0, 3), inputs[2]!], current: inputs }, + { bindings: [...inputs.slice(0, 3), stale], current: inputs }, + { bindings: mixed, current: mixed }, + { bindings: malformed, current: malformed }, ]; let launches = 0; - __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); }); + __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { + launches += requests.length; + return requests.map(prepared); + }); for (const entry of cases) { - const result = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, entry.current)]), lineageId, entry.bindings); + const result = await runCaptureGroup( + cwd, + nativeHarness([finalizeStatus(lineageId, entry.current)]), + lineageId, + entry.bindings, + ); assert.equal(result.outcome, "capture-group-rejected"); } assert.equal(launches, 0); @@ -362,34 +802,154 @@ test("group rejects partial, duplicate, reordered, stale, mixed, and late-invali test("group preserves earlier admission when a later STATUS drifts, and stops on closure or unknown outcome", async (t) => { t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting()); - const cwd = repository(t), lineageId = "relay-group-stop", inputs = groupInputs(lineageId); - const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => requests.map(prepared); + const cwd = repository(t), + lineageId = "relay-group-stop", + inputs = groupInputs(lineageId); + const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => + requests.map(prepared); let submissions = 0; - __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }; }); - const staleStatus = finalizeStatus(lineageId, inputs), stale = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), staleStatus]), lineageId, inputs); - assert.deepEqual({ outcome: stale.outcome, submissions, mutation: stale.mutation_performed, mutationOutcome: stale.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" }); + __testing.setReviewHostRelayGroupRunnersForTesting( + reviewerGroup, + async (result) => { + submissions += 1; + return { + promptByteLength: result.promptByteLength, + resultByteLength: result.resultByteLength, + submission: "{}", + }; + }, + ); + const staleStatus = finalizeStatus(lineageId, inputs), + stale = await runCaptureGroup( + cwd, + nativeHarness([ + finalizeStatus(lineageId, inputs), + finalizeStatus(lineageId, inputs), + staleStatus, + ]), + lineageId, + inputs, + ); + assert.deepEqual( + { + outcome: stale.outcome, + submissions, + mutation: stale.mutation_performed, + mutationOutcome: stale.mutation_outcome, + }, + { + outcome: "capture-group-authority-drift", + submissions: 1, + mutation: true, + mutationOutcome: "partial", + }, + ); assert.equal(stale.reconciliation, staleStatus.raw); submissions = 0; - const drift = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(2))]), lineageId, inputs); - assert.deepEqual({ outcome: drift.outcome, submissions, mutation: drift.mutation_performed, mutationOutcome: drift.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" }); - assert.deepEqual(((drift.host_relay as { reviewers: Array<{ lens: string }> }).reviewers).map((reviewer) => reviewer.lens), ["review-risk"]); + const drift = await runCaptureGroup( + cwd, + nativeHarness([ + finalizeStatus(lineageId, inputs), + finalizeStatus(lineageId, inputs), + finalizeStatus(lineageId, inputs.slice(2)), + ]), + lineageId, + inputs, + ); + assert.deepEqual( + { + outcome: drift.outcome, + submissions, + mutation: drift.mutation_performed, + mutationOutcome: drift.mutation_outcome, + }, + { + outcome: "capture-group-authority-drift", + submissions: 1, + mutation: true, + mutationOutcome: "partial", + }, + ); + assert.deepEqual( + (drift.host_relay as { reviewers: Array<{ lens: string }> }).reviewers.map( + (reviewer) => reviewer.lens, + ), + ["review-risk"], + ); submissions = 0; - __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result", lineage_id: lineageId, state: "approved", store_revision: SHA, action: "native last event closed the review" }) }; }); - const terminal = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs); - assert.deepEqual({ status: terminal.status, submissions }, { status: "closed", submissions: 1 }); + __testing.setReviewHostRelayGroupRunnersForTesting( + reviewerGroup, + async (result) => { + submissions += 1; + return { + promptByteLength: result.promptByteLength, + resultByteLength: result.resultByteLength, + submission: JSON.stringify({ + schema: "gentle-ai.review-last-event-closure/v1", + operation: "review/capture-result", + lineage_id: lineageId, + state: "approved", + store_revision: SHA, + action: "native last event closed the review", + }), + }; + }, + ); + const terminal = await runCaptureGroup( + cwd, + nativeHarness([ + finalizeStatus(lineageId, inputs), + finalizeStatus(lineageId, inputs), + ]), + lineageId, + inputs, + ); + assert.deepEqual( + { status: terminal.status, submissions }, + { status: "closed", submissions: 1 }, + ); submissions = 0; - __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async () => { submissions += 1; throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "unknown", { timedOut: true }); }); - const unknown = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs); - assert.deepEqual({ status: unknown.status, submissions }, { status: "reconciled", submissions: 1 }); + __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async () => { + submissions += 1; + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, + "submit", + "unknown", + { timedOut: true }, + ); + }); + const unknown = await runCaptureGroup( + cwd, + nativeHarness([ + finalizeStatus(lineageId, inputs), + finalizeStatus(lineageId, inputs), + finalizeStatus(lineageId, inputs), + ]), + lineageId, + inputs, + ); + assert.deepEqual( + { status: unknown.status, submissions }, + { status: "reconciled", submissions: 1 }, + ); }); test("an old binary reports the relay as unavailable without touching existing behavior", async (t) => { t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]); + const harness = nativeHarness([ + finalizeStatus(lineageId, [ + relayCollectInput(lineageId, "review-reliability", 0), + ]), + ]); __testing.setReviewHostRelayRunnerForTesting(async () => { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: 2, stderr: "flag provided but not defined: -materialize" }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, + "materialize", + REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, + { exitCode: 2, stderr: "flag provided but not defined: -materialize" }, + ); }); const result = await runCapture(cwd, harness, lineageId); @@ -405,10 +965,20 @@ test("a handshake refusal surfaces the provider refusal verbatim through the con t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const refusal = "the active runtime is not eligible for immutable receipt review; supported immutable review runtimes: claude-code, codex, opencode"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]); + const refusal = + "the active runtime is not eligible for immutable receipt review; supported immutable review runtimes: claude-code, codex, opencode"; + const harness = nativeHarness([ + finalizeStatus(lineageId, [ + relayCollectInput(lineageId, "review-reliability", 0), + ]), + ]); __testing.setReviewHostRelayRunnerForTesting(async () => { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", refusal, { exitCode: 1, stderr: refusal }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, + "materialize", + refusal, + { exitCode: 1, stderr: refusal }, + ); }); const result = await runCapture(cwd, harness, lineageId); @@ -422,18 +992,34 @@ test("a transport failure stops the selected capture without auto-follow", async t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]); + const harness = nativeHarness([ + finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)]), + ]); __testing.setReviewHostRelayRunnerForTesting(async () => { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: 4 }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.PI_FAILED, + "pi", + "pi subprocess failed", + { exitCode: 4 }, + ); }); const result = await runCapture(cwd, harness, lineageId); assert.equal(result.status, "blocked"); assert.equal(result.outcome, "pi-host-relay-transport-failure"); - assert.deepEqual(result.failure, { kind: "pi-failed", stage: "pi", exit_code: 4, timed_out: false }); + assert.deepEqual(result.failure, { + kind: "pi-failed", + stage: "pi", + exit_code: 4, + timed_out: false, + }); assert.match(String(result.next_action), /fresh STATUS/); - assert.equal(harness.statusCalls.length, 1, "no automatic relaunch after transport failure"); + assert.equal( + harness.statusCalls.length, + 1, + "no automatic relaunch after transport failure", + ); }); // gentle-pi#522 / #524: a submission Go refused at admission is a proven @@ -444,65 +1030,139 @@ test("an admission refusal reaches the model as a proven non-mutation carrying t t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]); - const refusal = "Error: reviewer artifact admission binding_mismatch: reviewer result echoed a different artifact subject: the rejected admission did not consume the lens slot, so re-run the lens and invoke gentle-ai review capture-result again on the same lineage with a result that echoes the binding's top-level subject_hash [invalid_request]\n"; + const harness = nativeHarness([ + finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)]), + ]); + const refusal = + "Error: reviewer artifact admission binding_mismatch: reviewer result echoed a different artifact subject: the rejected admission did not consume the lens slot, so re-run the lens and invoke gentle-ai review capture-result again on the same lineage with a result that echoes the binding's top-level subject_hash [invalid_request]\n"; __testing.setReviewHostRelayRunnerForTesting(async () => { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", refusal.trim(), { exitCode: 1, stderr: refusal, elapsedMs: 40, timeoutMs: 120_000, mutationOutcome: "none" }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, + "submit", + refusal.trim(), + { + exitCode: 1, + stderr: refusal, + elapsedMs: 40, + timeoutMs: 120_000, + mutationOutcome: "none", + }, + ); }); const result = await runCapture(cwd, harness, lineageId); assert.equal(result.status, "blocked"); assert.equal(result.outcome, "pi-host-relay-transport-failure"); - assert.deepEqual(result.failure, { kind: "submission-refused", stage: "submit", exit_code: 1, timed_out: false, elapsed_ms: 40, timeout_ms: 120_000, stderr: refusal }); + assert.deepEqual(result.failure, { + kind: "submission-refused", + stage: "submit", + exit_code: 1, + timed_out: false, + elapsed_ms: 40, + timeout_ms: 120_000, + stderr: refusal, + }); assert.equal(result.reason, refusal.trim()); assert.equal(result.mutation_performed, false); assert.equal(result.mutation_outcome, "none"); assert.match(String(result.next_action), /did not consume the lens slot/); assert.match(String(result.next_action), /fresh STATUS/); - assert.equal(harness.statusCalls.length, 1, "a proven non-mutation needs no STATUS reconciliation and no relaunch"); + assert.equal( + harness.statusCalls.length, + 1, + "a proven non-mutation needs no STATUS reconciliation and no relaunch", + ); }); test("a submission whose outcome is genuinely indeterminate still reconciles through STATUS and carries its evidence", async (t) => { t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const pending = finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)]); + const pending = finalizeStatus(lineageId, [ + relayCollectInput(lineageId, "review-risk", 0), + ]); const harness = nativeHarness([pending, pending]); __testing.setReviewHostRelayRunnerForTesting(async () => { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "gentle-ai capture submission exceeded its 120000ms bound after 120004ms", { exitCode: null, stderr: "", timedOut: true, elapsedMs: 120_004, timeoutMs: 120_000 }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, + "submit", + "gentle-ai capture submission exceeded its 120000ms bound after 120004ms", + { + exitCode: null, + stderr: "", + timedOut: true, + elapsedMs: 120_004, + timeoutMs: 120_000, + }, + ); }); const result = await runCapture(cwd, harness, lineageId); assert.equal(result.status, "reconciled"); assert.equal(result.outcome, "native-capture-outcome-unknown"); - assert.deepEqual(result.failure, { kind: "submission-refused", stage: "submit", exit_code: null, timed_out: true, elapsed_ms: 120_004, timeout_ms: 120_000 }); + assert.deepEqual(result.failure, { + kind: "submission-refused", + stage: "submit", + exit_code: null, + timed_out: true, + elapsed_ms: 120_004, + timeout_ms: 120_000, + }); assert.match(String(result.reason), /exceeded its 120000ms bound/); - assert.equal(harness.statusCalls.length, 2, "an indeterminate submission reconciles exactly once through STATUS"); - assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "ordinary host-relay reconciliation preserves the Pi host runtime"); + assert.equal( + harness.statusCalls.length, + 2, + "an indeterminate submission reconciles exactly once through STATUS", + ); + assert.equal( + harness.statusCalls.at(-1)?.agent, + "pi", + "ordinary host-relay reconciliation preserves the Pi host runtime", + ); }); test("a relay timeout reports its one-slot measurements and no auto-follow", async (t) => { t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]); + const harness = nativeHarness([ + finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)]), + ]); __testing.setReviewHostRelayRunnerForTesting(async () => { throw new ReviewHostRelayError( REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", - { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 }, + { + exitCode: null, + timedOut: true, + elapsedMs: 2_256_004, + timeoutMs: 2_256_000, + }, ); }); const result = await runCapture(cwd, harness, lineageId); assert.equal(result.status, "blocked"); assert.equal(result.outcome, "pi-host-relay-timeout"); - assert.deepEqual(result.failure, { kind: "pi-timed-out", stage: "pi", exit_code: null, timed_out: true, elapsed_ms: 2_256_004, timeout_ms: 2_256_000 }); - assert.match(String(result.next_action), new RegExp(`${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}=`)); - assert.match(String(result.next_action), new RegExp(String(REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS))); + assert.deepEqual(result.failure, { + kind: "pi-timed-out", + stage: "pi", + exit_code: null, + timed_out: true, + elapsed_ms: 2_256_004, + timeout_ms: 2_256_000, + }); + assert.match( + String(result.next_action), + new RegExp(`${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}=`), + ); + assert.match( + String(result.next_action), + new RegExp(String(REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS)), + ); assert.equal(harness.statusCalls.length, 1); }); @@ -510,15 +1170,31 @@ test("a non-timeout transport failure keeps its generic continuation and now car t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]); + const harness = nativeHarness([ + finalizeStatus(lineageId, [ + relayCollectInput(lineageId, "review-reliability", 0), + ]), + ]); __testing.setReviewHostRelayRunnerForTesting(async () => { - throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: 4, elapsedMs: 1_200, timeoutMs: 900_000 }); + throw new ReviewHostRelayError( + REVIEW_HOST_RELAY_FAILURE.PI_FAILED, + "pi", + "pi subprocess failed", + { exitCode: 4, elapsedMs: 1_200, timeoutMs: 900_000 }, + ); }); const result = await runCapture(cwd, harness, lineageId); assert.equal(result.outcome, "pi-host-relay-transport-failure"); - assert.deepEqual(result.failure, { kind: "pi-failed", stage: "pi", exit_code: 4, timed_out: false, elapsed_ms: 1_200, timeout_ms: 900_000 }); + assert.deepEqual(result.failure, { + kind: "pi-failed", + stage: "pi", + exit_code: 4, + timed_out: false, + elapsed_ms: 1_200, + timeout_ms: 900_000, + }); assert.match(String(result.next_action), /fresh STATUS/); }); @@ -526,7 +1202,11 @@ test("materialize tokens without a provider submission fail closed as a typed co t.after(() => __testing.setReviewHostRelayRunnerForTesting()); const cwd = repository(t); const lineageId = "relay-lineage"; - const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0, true, "absent")])]); + const harness = nativeHarness([ + finalizeStatus(lineageId, [ + relayCollectInput(lineageId, "review-reliability", 0, true, "absent"), + ]), + ]); let relayCalls = 0; __testing.setReviewHostRelayRunnerForTesting(async () => { relayCalls += 1; @@ -535,14 +1215,27 @@ test("materialize tokens without a provider submission fail closed as a typed co const result = await runCapture(cwd, harness, lineageId); - assert.equal(relayCalls, 0, "the completing form is never synthesized, so nothing launches"); + assert.equal( + relayCalls, + 0, + "the completing form is never synthesized, so nothing launches", + ); assert.equal(result.status, "blocked"); assert.equal(result.outcome, "pi-host-relay-transport-failure"); - assert.deepEqual(result.failure, { kind: "submission-contract-mismatch", stage: "binding", exit_code: null, timed_out: false }); + assert.deepEqual(result.failure, { + kind: "submission-contract-mismatch", + stage: "binding", + exit_code: null, + timed_out: false, + }); assert.equal(result.reason, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE); assert.equal(result.mutation_performed, false); assert.equal(result.mutation_outcome, "none"); - assert.equal(harness.statusCalls.length, 1, "no relaunch and no post-capture STATUS after the contract mismatch"); + assert.equal( + harness.statusCalls.length, + 1, + "no relaunch and no post-capture STATUS after the contract mismatch", + ); }); test("collect inputs without the provider-issued materialize token never reach the relay", async (t) => { @@ -550,7 +1243,9 @@ test("collect inputs without the provider-issued materialize token never reach t const cwd = repository(t); const lineageId = "relay-lineage"; const harness = nativeHarness([ - finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0, false)]), + finalizeStatus(lineageId, [ + relayCollectInput(lineageId, "review-reliability", 0, false), + ]), finalizeStatus(lineageId), ]); let relayCalls = 0; @@ -577,7 +1272,12 @@ test("collect inputs without the provider-issued materialize token never reach t // verbatim and re-queries negotiated STATUS. // --------------------------------------------------------------------------- -function executeCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCollectInputV3 { +function executeCollectInput( + lineageId: string, + lens: ReviewArtifactSubjectV2["lens"], + order: number, + revision = SHA, +): ReviewCollectInputV3 { return { name: "reviewer_result", schema: "https://gentle-ai.dev/schema/review/reviewer/v1", @@ -588,11 +1288,20 @@ function executeCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["l { name: "execute", value: "true", token: "--execute=true" }, ], artifactSubject: { - schema: "gentle-ai.review-artifact-subject/v2", subjectHash: `sha256:${String(order).repeat(64)}`, - lineageId, authorityRevision: revision, targetIdentity: SHA, baseTree: TREE, candidateTree: TREE, - changedPathManifestSha256: SHA, lens, selectedOrder: order, + schema: "gentle-ai.review-artifact-subject/v2", + subjectHash: `sha256:${String(order).repeat(64)}`, + lineageId, + authorityRevision: revision, + targetIdentity: SHA, + baseTree: TREE, + candidateTree: TREE, + changedPathManifestSha256: SHA, + lens, + selectedOrder: order, }, - baseTree: TREE, candidateTree: TREE, changedPathManifest: [], + baseTree: TREE, + candidateTree: TREE, + changedPathManifest: [], }; } @@ -604,7 +1313,10 @@ test("one execute binding routes exactly one provider slot through the native ex let executeCalls = 0; harness.native.captureExecute = async (request) => { executeCalls += 1; - assert.deepEqual(request.argumentTokens, input.arguments.map((argument) => argument.token)); + assert.deepEqual( + request.argumentTokens, + input.arguments.map((argument) => argument.token), + ); return { schema: "gentle-ai.review-capture-execute/v1", lineageId, @@ -659,10 +1371,14 @@ test("execute capture rejects reviewerRunAcknowledged and correctionLines", asyn throw new Error("should not be called"); }; - const resultWithAck = await runCapture(cwd, harness, lineageId, { reviewerRunAcknowledged: true }); + const resultWithAck = await runCapture(cwd, harness, lineageId, { + reviewerRunAcknowledged: true, + }); assert.equal(resultWithAck.outcome, "capture-binding-rejected"); - const resultWithCorrection = await runCapture(cwd, harness, lineageId, { correctionLines: 5 }); + const resultWithCorrection = await runCapture(cwd, harness, lineageId, { + correctionLines: 5, + }); assert.equal(resultWithCorrection.outcome, "capture-binding-rejected"); }); @@ -675,8 +1391,18 @@ test("execute capture without native surface reports unsupported", async (t) => statusQueue, statusCalls: [] as Array<{ cwd: string; lineageId?: string; agent?: "pi" }>, native: { - targetStatus: async (request: { cwd: string; lineageId?: string; agent?: string }) => { - harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent as "pi" }) }); + targetStatus: async (request: { + cwd: string; + lineageId?: string; + agent?: string; + }) => { + harness.statusCalls.push({ + cwd: request.cwd, + ...(request.lineageId === undefined + ? {} + : { lineageId: request.lineageId }), + ...(request.agent === undefined ? {} : { agent: request.agent as "pi" }), + }); const next = harness.statusQueue.shift(); if (next === undefined) throw new Error("status queue exhausted"); return next; @@ -700,13 +1426,26 @@ test("execute capture failure is reported as native operation failure", async (t const cwd = repository(t); const lineageId = "execute-fail-lineage"; const input = executeCollectInput(lineageId, "review-risk", 0); - const statusQueue = [finalizeStatus(lineageId, [input]), finalizeStatus(lineageId)]; + const statusQueue = [ + finalizeStatus(lineageId, [input]), + finalizeStatus(lineageId), + ]; const harness = { statusQueue, statusCalls: [] as Array<{ cwd: string; lineageId?: string; agent?: "pi" }>, native: { - targetStatus: async (request: { cwd: string; lineageId?: string; agent?: string }) => { - harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }), ...(request.agent === undefined ? {} : { agent: request.agent as "pi" }) }); + targetStatus: async (request: { + cwd: string; + lineageId?: string; + agent?: string; + }) => { + harness.statusCalls.push({ + cwd: request.cwd, + ...(request.lineageId === undefined + ? {} + : { lineageId: request.lineageId }), + ...(request.agent === undefined ? {} : { agent: request.agent as "pi" }), + }); const next = harness.statusQueue.shift(); if (next === undefined) throw new Error("status queue exhausted"); return next;