gentle-pi 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { execFile, execFileSync } from "node:child_process";
2
- import { createHash } from "node:crypto";
2
+ import { createHash, randomUUID } from "node:crypto";
3
3
  import {
4
4
  existsSync,
5
5
  lstatSync,
@@ -96,7 +96,7 @@ import {
96
96
  type ReviewProjectionV1,
97
97
  } from "../lib/review-snapshot.ts";
98
98
  import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
99
- import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, resolveCanonicalCandidateBase, type NativeCandidateProjectionDescriptor } from "../lib/review-candidate-view.ts";
99
+ import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, resolveCanonicalCandidateBase, type CandidateView, type NativeCandidateProjectionDescriptor } from "../lib/review-candidate-view.ts";
100
100
  import {
101
101
  createNativeReviewCli,
102
102
  isCanonicalProcessString,
@@ -104,7 +104,10 @@ import {
104
104
  nativeReviewLegacyAliasRepairAuthorization,
105
105
  nativeReviewLegacyQuarantineAuthorization,
106
106
  nativeReviewReconcileAuthorization,
107
+ normalizeNativeReviewCwd,
107
108
  NativeReviewCliError,
109
+ NativeReviewConsentBindingError,
110
+ NativeReviewConsentRequiredError,
108
111
  NATIVE_REVIEW_ERROR_CODE,
109
112
  NATIVE_REVIEW_LEGACY_QUARANTINE,
110
113
  NATIVE_REVIEW_LEGACY_ALIAS_REPAIR,
@@ -120,8 +123,7 @@ import {
120
123
  type NativeStartResult,
121
124
  type NativeValidateResult,
122
125
  } from "../lib/native-review-cli.ts";
123
- import { readReviewConsentLatch, recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
124
- import type { ReviewStatusV3 } from "../lib/review-integration-v2.ts";
126
+ import type { ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
125
127
  import { assertDistinctCorrectionEvidence, resolveCorrectionStep, type CorrectionEvidence, type CorrectionOutcome, type CorrectionStep } from "../lib/review-correction-lifecycle.ts";
126
128
 
127
129
  const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
@@ -2106,6 +2108,7 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
2106
2108
 
2107
2109
  const REVIEW_CONTROLLER_OPERATION = {
2108
2110
  START: "start",
2111
+ ANSWER_CONSENT: "answer-consent",
2109
2112
  FINALIZE: "finalize",
2110
2113
  ADVANCE: "advance",
2111
2114
  STATUS: "status",
@@ -2161,7 +2164,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2161
2164
  },
2162
2165
  input: {
2163
2166
  type: "string",
2164
- description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; an explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy compact START retains policyHash. FINALIZE supplies reviewer results, correction forecast, targeted validation, final evidence, and an explicit final_verification_passed boolean. Judgment Day retains graph-v1 input.",
2167
+ description: "A JSON-serialized object string, not a nested object. New native ordinary START uses {\"mode\":\"ordinary\"}; answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. Legacy compact START retains policyHash. FINALIZE supplies reviewer results, correction forecast, targeted validation, final evidence, and an explicit final_verification_passed boolean. Judgment Day retains graph-v1 input.",
2165
2168
  },
2166
2169
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
2167
2170
  inputPath: { type: "string", description: "Repository-local JSON input file for finalize/advance (alternative to input). Legacy bundle import is retired." },
@@ -2461,7 +2464,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
2461
2464
  // VALIDATE defers its lineage requirement to execution time: the proven
2462
2465
  // release-from-protected-main fast path needs no receipt lineage, while
2463
2466
  // every other validation still requires one before receipt validation.
2464
- const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.FINALIZE, 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.VALIDATE, REVIEW_CONTROLLER_OPERATION.BIND_SDD].includes(value.operation as ReviewControllerOperation);
2467
+ const needsLineage = ![REVIEW_CONTROLLER_OPERATION.START, REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT, REVIEW_CONTROLLER_OPERATION.FINALIZE, 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.VALIDATE, REVIEW_CONTROLLER_OPERATION.BIND_SDD].includes(value.operation as ReviewControllerOperation);
2465
2468
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
2466
2469
  throw new Error("Review controller requires a lineageId");
2467
2470
  }
@@ -2871,7 +2874,7 @@ export function resolveReviewLifecycleCommand(
2871
2874
  }
2872
2875
  if (words[0] !== "git") return null;
2873
2876
  const gitGlobalArgs: string[] = [];
2874
- let resolvedCwd = resolve(defaultCwd);
2877
+ let resolvedCwd = resolve(normalizeNativeReviewCwd(defaultCwd));
2875
2878
  let index = 1;
2876
2879
  while (index < words.length) {
2877
2880
  const option = words[index];
@@ -2879,7 +2882,7 @@ export function resolveReviewLifecycleCommand(
2879
2882
  const value = words[index + 1];
2880
2883
  if (value === undefined) return null;
2881
2884
  gitGlobalArgs.push(option, value);
2882
- if (option === "-C") resolvedCwd = resolve(resolvedCwd, value);
2885
+ if (option === "-C") resolvedCwd = resolve(resolvedCwd, normalizeNativeReviewCwd(value));
2883
2886
  else return null;
2884
2887
  index += 2;
2885
2888
  continue;
@@ -3866,6 +3869,7 @@ function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: N
3866
3869
  operation,
3867
3870
  status: "skipped",
3868
3871
  outcome: REVIEW_MODE_DISABLED_OUTCOME,
3872
+ delivery: "disabled/unmanaged",
3869
3873
  mode_source: source,
3870
3874
  reason: `receipt-driven development is disabled: ${operation} is skipped because the ${source} mode source keeps it off`,
3871
3875
  ...(continuation === undefined ? {} : { next_action: continuation }),
@@ -3915,6 +3919,14 @@ function asNativeReviewCliError(error: unknown): { code: string; diagnostics: Na
3915
3919
  return diagnostics === undefined || value.code !== diagnostics.error_code ? undefined : { code: value.code, diagnostics };
3916
3920
  }
3917
3921
 
3922
+ // Same coexisting-module-instance caveat as asNativeReviewCliError above.
3923
+ function asNativeReviewConsentBindingError(error: unknown): { reason: string; message: string } | undefined {
3924
+ if (error instanceof NativeReviewConsentBindingError) return { reason: error.reason, message: error.message };
3925
+ if (!(error instanceof Error) || error.name !== "NativeReviewConsentBindingError") return undefined;
3926
+ const reason = (error as unknown as { reason?: unknown }).reason;
3927
+ return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message };
3928
+ }
3929
+
3918
3930
  function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
3919
3931
  const cliError = asNativeReviewCliError(error);
3920
3932
  if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return nativeStatusUnsupported(operation);
@@ -4326,12 +4338,33 @@ function assertFrozenPreCommitProjection(
4326
4338
  return projection.candidateTree;
4327
4339
  }
4328
4340
 
4341
+ function reproveNativePreCommitTree(
4342
+ derived: DerivedReviewGateTarget,
4343
+ lineageId: string,
4344
+ candidateViews: CandidateViewRegistry | null,
4345
+ ): string | undefined {
4346
+ return assertFrozenPreCommitProjection(derived, lineageId, candidateViews) ??
4347
+ (derived.command.event === "pre-commit" ? derived.actualIntendedCommitTree : undefined);
4348
+ }
4349
+
4329
4350
  function authorizationTargetHash(derived: DerivedReviewGateTarget): string {
4330
4351
  return derived.nativePublication === undefined
4331
4352
  ? canonicalHash(derived.target)
4332
4353
  : canonicalHash({ target: derived.target, native_publication: derived.nativePublication });
4333
4354
  }
4334
4355
 
4356
+ function nativeAuthorizationConsumptionIdentity(authorization: PendingReviewAuthorization | undefined): string | undefined {
4357
+ if (authorization?.native_gate === undefined) return undefined;
4358
+ return canonicalHash({
4359
+ command_hash: authorization.command_hash,
4360
+ target_hash: authorization.target_hash,
4361
+ lineage_id: authorization.native_gate.lineage_id,
4362
+ store_revision: authorization.native_gate.store_revision,
4363
+ fingerprint: authorization.native_gate.fingerprint,
4364
+ intended_tree: authorization.native_gate.intended_tree ?? null,
4365
+ });
4366
+ }
4367
+
4335
4368
  function assertNativePublicationBinding(result: NativeValidateResult, derived: DerivedReviewGateTarget): void {
4336
4369
  const returnedGate = result.gateContext.raw.gate;
4337
4370
  if (returnedGate !== requestedNativeGate(derived) && (result.allowed || returnedGate !== "")) {
@@ -4383,6 +4416,64 @@ async function rederiveNativePublicationTarget(
4383
4416
  return fresh;
4384
4417
  }
4385
4418
 
4419
+ interface NativePreCommitReceiptConsumption {
4420
+ authorization?: PendingReviewAuthorization;
4421
+ delivery?: "disabled/unmanaged";
4422
+ }
4423
+
4424
+ async function consumeNativePreCommitReceipt(
4425
+ command: string,
4426
+ defaultCwd: string,
4427
+ derivedTarget: DerivedReviewGateTarget,
4428
+ nativeReviewCli: NativeReviewCli,
4429
+ publicationProbe: PublicationProbe,
4430
+ publicationProbeTimeoutMs: number,
4431
+ signal?: AbortSignal,
4432
+ ): Promise<NativePreCommitReceiptConsumption> {
4433
+ const derived = await deriveNativePublicationTarget(
4434
+ derivedTarget,
4435
+ publicationProbe,
4436
+ publicationProbeTimeoutMs,
4437
+ signal,
4438
+ );
4439
+ if (derived.command.event !== "pre-commit" || derived.actualIntendedCommitTree === undefined) return {};
4440
+ const result = await nativeReviewCli.validate({
4441
+ cwd: derived.command.cwd,
4442
+ gate: "pre-commit",
4443
+ ...(signal === undefined ? {} : { signal }),
4444
+ });
4445
+ assertNativePublicationBinding(result, derived);
4446
+ const fresh = await rederiveNativePublicationTarget(
4447
+ derived,
4448
+ command,
4449
+ defaultCwd,
4450
+ publicationProbe,
4451
+ publicationProbeTimeoutMs,
4452
+ signal,
4453
+ );
4454
+ if (result.delivery === "disabled/unmanaged") return { delivery: result.delivery };
4455
+ if (!result.allowed || result.result !== "allow") return {};
4456
+ if (
4457
+ result.gateContext.lineageId.length === 0 ||
4458
+ result.gateContext.raw.candidate_tree !== derived.actualIntendedCommitTree ||
4459
+ fresh.actualIntendedCommitTree !== derived.actualIntendedCommitTree
4460
+ ) throw new Error("Native approved receipt does not bind the exact current pre-commit tree");
4461
+ const commandHash = reviewAuthorizationKey(command, fresh.command.cwd);
4462
+ return {
4463
+ authorization: {
4464
+ command_hash: commandHash,
4465
+ target_hash: authorizationTargetHash(fresh),
4466
+ receipt_hash: null,
4467
+ native_gate: {
4468
+ lineage_id: result.gateContext.lineageId,
4469
+ store_revision: result.gateContext.storeRevision,
4470
+ fingerprint: nativeGateFingerprint(result, fresh),
4471
+ intended_tree: derived.actualIntendedCommitTree,
4472
+ },
4473
+ },
4474
+ };
4475
+ }
4476
+
4386
4477
  interface NativeStartPolicyValidation {
4387
4478
  policyPath?: string;
4388
4479
  reason?: string;
@@ -4464,71 +4555,79 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
4464
4555
  };
4465
4556
  }
4466
4557
 
4467
- // Organic-rdd-parity Phase 4 (Design Decisions #3-#5): Pi's own two-option
4468
- // consent question, distinct from and in addition to gentle-ai's own
4469
- // internal headless notice tolerated in lib/native-review-cli.ts. Pi always
4470
- // spawns gentle-ai without a TTY, so gentle-ai's own interactive prompt never
4471
- // fires from Pi — this is Pi's own UI-layer question, asked once per clone.
4472
- const REVIEW_CONSENT_TITLE = "Run the review now?";
4473
- const REVIEW_CONSENT_VALUE_LINE = "Reviewing takes a bit longer, and it makes the result substantially safer.";
4474
- const REVIEW_CONSENT_ANSWERS_LINE = "Yes = run the review now / No = not now, just this once";
4475
- const REVIEW_CONSENT_OFF_PATH_LINE = "To turn reviews off for good, run /gentle:review-mode disable.";
4476
- const REVIEW_CONSENT_HEADLESS_NOTICE = "Gentle AI reviewed this change without asking, because this session has no interface to answer on. Run /gentle:review-mode disable to turn reviews off, or /gentle:review-mode status to see the current setting.";
4477
- const REVIEW_CONSENT_UNREADABLE_NOTICE = "Gentle AI could not read an answer, so it reviewed this change and will ask again next time.";
4558
+ const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
4478
4559
 
4479
- function reviewConsentBody(riskEvidence: readonly string[]): string {
4480
- return [
4481
- `Why: ${riskEvidence.join(", ")}`,
4482
- REVIEW_CONSENT_VALUE_LINE,
4483
- REVIEW_CONSENT_ANSWERS_LINE,
4484
- REVIEW_CONSENT_OFF_PATH_LINE,
4485
- ].join("\n");
4486
- }
4487
-
4488
- interface ReviewConsentDecision {
4489
- proceed: boolean;
4490
- consentNotice?: string;
4491
- }
4492
-
4493
- // Ask after native START returns, before actor_binding — only when
4494
- // lensesRequired is true AND riskEvidence is present (capability-gated: dark
4495
- // whenever the negotiated version's riskEvidence capability is false).
4496
- // Accept records the per-clone latch and proceeds. Decline persists nothing
4497
- // and withholds actor_binding for this work unit only — the next work unit
4498
- // (even the same candidate re-started) is asked again. Headless and an
4499
- // unreadable answer both proceed with a notice, never blocking and never
4500
- // consuming the latch.
4501
- async function requestReviewConsent(
4502
- cwd: string,
4503
- riskEvidence: readonly string[],
4504
- context: ExtensionContext | undefined,
4505
- ): Promise<ReviewConsentDecision> {
4506
- let latched: boolean;
4507
- try {
4508
- latched = readReviewConsentLatch(cwd);
4509
- } catch {
4510
- // Unresolvable/shallow repository: no latch write, review proceeds
4511
- // (Threat Matrix, organic-rdd-parity).
4512
- latched = false;
4513
- }
4514
- if (latched) return { proceed: true };
4515
- if (context?.hasUI !== true) {
4516
- context?.ui.notify(REVIEW_CONSENT_HEADLESS_NOTICE, "info");
4517
- return { proceed: true, consentNotice: REVIEW_CONSENT_HEADLESS_NOTICE };
4518
- }
4519
- let accepted: boolean;
4520
- try {
4521
- accepted = await context.ui.confirm(REVIEW_CONSENT_TITLE, reviewConsentBody(riskEvidence));
4522
- } catch {
4523
- return { proceed: true, consentNotice: REVIEW_CONSENT_UNREADABLE_NOTICE };
4524
- }
4525
- if (!accepted) return { proceed: false };
4526
- try {
4527
- recordReviewConsentLatch(cwd);
4528
- } catch {
4529
- // Unresolvable repository: proceed without persisting (Threat Matrix).
4560
+ interface PendingReviewConsent {
4561
+ id: string;
4562
+ repositoryCwd: string;
4563
+ authorityCwd: string;
4564
+ candidateView: CandidateView;
4565
+ consent: ReviewConsentV2;
4566
+ consentDigest: string;
4567
+ expiresAt: number;
4568
+ expiry?: ReturnType<typeof setTimeout>;
4569
+ }
4570
+
4571
+ function consumePendingReviewConsent(pending: PendingReviewConsent, pendingReviewConsents: Map<string, PendingReviewConsent>): void {
4572
+ if (pending.expiry !== undefined) clearTimeout(pending.expiry);
4573
+ pending.expiry = undefined;
4574
+ if (pendingReviewConsents.get(pending.id) === pending) pendingReviewConsents.delete(pending.id);
4575
+ }
4576
+
4577
+ function cleanupPendingReviewConsent(pending: PendingReviewConsent, pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null): void {
4578
+ consumePendingReviewConsent(pending, pendingReviewConsents);
4579
+ if (![...pendingReviewConsents.values()].some((current) => current.candidateView.token === pending.candidateView.token)) candidateViews?.cleanup(pending.candidateView.token);
4580
+ }
4581
+
4582
+ function cleanupAllPendingReviewConsents(pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null): void {
4583
+ for (const pending of [...pendingReviewConsents.values()]) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
4584
+ }
4585
+
4586
+ function reviewConsentDigest(consent: ReviewConsentV2): string {
4587
+ return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
4588
+ }
4589
+
4590
+ function assertNativeStartCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
4591
+ candidateView.verify();
4592
+ if (
4593
+ target.projection.projection !== "workspace" ||
4594
+ target.projection.baseTree !== candidateView.baseTree ||
4595
+ target.projection.initialReviewTree !== candidateView.candidateTree ||
4596
+ target.projection.currentCandidateTree !== candidateView.candidateTree ||
4597
+ JSON.stringify([...target.projection.paths].sort()) !== JSON.stringify([...candidateView.paths].sort())
4598
+ ) {
4599
+ throw new CandidateViewError("native START workspace target does not match the immutable reviewer candidate view", "candidate-target-projection-drift");
4530
4600
  }
4531
- return { proceed: true };
4601
+ }
4602
+
4603
+ function completeNativeStart(
4604
+ operation: ReviewControllerOperation,
4605
+ result: NativeStartResult,
4606
+ workspaceRoot: string,
4607
+ candidateView: CandidateView | undefined,
4608
+ candidateViews: CandidateViewRegistry | null,
4609
+ ): Record<string, unknown> {
4610
+ if (candidateView === undefined) return { operation, result: mapNativeStartResult(result), workspace_root: workspaceRoot };
4611
+ if (candidateViews && result.lensesRequired) {
4612
+ const binding = { token: candidateView.token, lineageId: result.lineageId, selectedLenses: result.selectedLenses };
4613
+ if (result.action === "resumed" && !candidateViews.hasCurrentBinding()) candidateViews.restoreCurrentFromNativeStart(binding);
4614
+ else candidateViews.bindCurrent(binding);
4615
+ } else if (candidateViews && ((result.action === "created" && result.state === "reviewing") || result.action === "resumed" || result.action === "reuse-receipt")) candidateViews.retain(candidateView.token, result.lineageId);
4616
+ else candidateViews?.cleanup(candidateView.token);
4617
+ const actorBinding = result.lensesRequired
4618
+ ? {
4619
+ workspace_root: workspaceRoot,
4620
+ candidate_root: candidateView.root,
4621
+ candidate_tree: candidateView.candidateTree,
4622
+ candidate_paths: candidateView.paths,
4623
+ }
4624
+ : undefined;
4625
+ return {
4626
+ operation,
4627
+ result: mapNativeStartResult(result),
4628
+ workspace_root: workspaceRoot,
4629
+ ...(actorBinding === undefined ? {} : { actor_binding: actorBinding }),
4630
+ };
4532
4631
  }
4533
4632
 
4534
4633
  function nativeOperationFailure(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
@@ -4548,6 +4647,23 @@ function nativeOperationFailure(operation: ReviewControllerOperation, error: unk
4548
4647
  ...(typeof value.failureEnvelope.nextAction === "string" ? { next_action: value.failureEnvelope.nextAction } : {}),
4549
4648
  };
4550
4649
  }
4650
+ // Every consent binding guard runs before the provider is launched, so this
4651
+ // is a local mismatch with nothing to reconcile. Reporting it as a native
4652
+ // operation failure hides the one fact that makes it fixable.
4653
+ const consentBinding = asNativeReviewConsentBindingError(error);
4654
+ if (consentBinding !== undefined) {
4655
+ return {
4656
+ operation,
4657
+ status: "blocked",
4658
+ outcome: "consent-binding-invalid",
4659
+ native_invocation_attempted: false,
4660
+ lineage_created: false,
4661
+ mutation_performed: false,
4662
+ mutation_outcome: "none" as const,
4663
+ diagnostics: { code: consentBinding.reason, message: consentBinding.message },
4664
+ next_action: "resolve-consent-binding",
4665
+ };
4666
+ }
4551
4667
  const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none";
4552
4668
  const nativeDiagnostics = asNativeReviewCliError(error)?.diagnostics;
4553
4669
  const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
@@ -4826,6 +4942,7 @@ async function executeReviewControllerOperation(
4826
4942
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
4827
4943
  context?: ExtensionContext,
4828
4944
  correctionEvidenceByLineage: Map<string, CorrectionEvidence> = new Map(),
4945
+ pendingReviewConsents: Map<string, PendingReviewConsent> = new Map(),
4829
4946
  ): Promise<Record<string, unknown>> {
4830
4947
  const parameters = parseReviewControllerParameters(parametersValue);
4831
4948
  const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd);
@@ -4974,6 +5091,60 @@ async function executeReviewControllerOperation(
4974
5091
  });
4975
5092
  }
4976
5093
  }
5094
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT) {
5095
+ const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5096
+ 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");
5097
+ if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
5098
+ if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
5099
+ const pending = pendingReviewConsents.get(input.consentBinding);
5100
+ if (pending === undefined || pending.expiresAt <= Date.now()) {
5101
+ if (pending !== undefined) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
5102
+ throw new Error("Review controller consent binding is unknown, expired, or already consumed");
5103
+ }
5104
+ if (realpathSync(defaultCwd) !== pending.repositoryCwd) throw new Error("Review controller consent repository binding changed");
5105
+ if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
5106
+ pending.candidateView.verify();
5107
+ if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
5108
+ try {
5109
+ const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
5110
+ if (gated !== undefined) {
5111
+ cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
5112
+ return gated;
5113
+ }
5114
+ } catch (error) {
5115
+ return nativeOperationFailure(parameters.operation, error);
5116
+ }
5117
+ // The one-shot binding is consumed before the provider mutation. Any
5118
+ // ambiguous result reconciles through STATUS and can never be replayed.
5119
+ consumePendingReviewConsent(pending, pendingReviewConsents);
5120
+ try {
5121
+ const answered = await nativeReviewCli.answerConsent({
5122
+ cwd: pending.authorityCwd,
5123
+ consent: pending.consent,
5124
+ answer: input.answer,
5125
+ ...(signal === undefined ? {} : { signal }),
5126
+ });
5127
+ if (answered.kind === "declined") {
5128
+ candidateViews?.cleanup(pending.candidateView.token);
5129
+ return {
5130
+ operation: parameters.operation,
5131
+ status: "skipped",
5132
+ outcome: "consent-declined-this-candidate",
5133
+ consent: answered.raw,
5134
+ ...nativeStartPreAuthorityRejection(),
5135
+ };
5136
+ }
5137
+ return completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, candidateViews);
5138
+ } catch (error) {
5139
+ const value = error as { mutationOutcome?: unknown };
5140
+ if (value.mutationOutcome === "none") candidateViews?.cleanup(pending.candidateView.token);
5141
+ return await reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, {
5142
+ cwd: pending.authorityCwd,
5143
+ ...(pending.candidateView.committedOnly ? { baseRef: pending.candidateView.baseCommit } : {}),
5144
+ projection: "workspace",
5145
+ });
5146
+ }
5147
+ }
4977
5148
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
4978
5149
  const rawStart = parseControllerJson(
4979
5150
  requiredControllerString(parameters, "input"),
@@ -5007,8 +5178,9 @@ async function executeReviewControllerOperation(
5007
5178
  return nativeStartRejection("base-ref-unresolvable");
5008
5179
  }
5009
5180
  }
5181
+ let target: ReviewStatusV3;
5010
5182
  try {
5011
- const target = await nativeReviewCli.targetStatus({
5183
+ target = await nativeReviewCli.targetStatus({
5012
5184
  cwd: defaultCwd,
5013
5185
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5014
5186
  ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef }),
@@ -5023,51 +5195,46 @@ async function executeReviewControllerOperation(
5023
5195
  let nativeStartAttempted = false;
5024
5196
  try {
5025
5197
  candidateView = candidateViews?.createOrReuse({ contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) });
5026
- nativeStartAttempted = true;
5027
- const result = await nativeReviewCli.start({
5028
- cwd: candidateView?.root ?? defaultCwd,
5029
- ...(canonicalBaseRef === undefined
5030
- ? {}
5031
- : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }),
5032
- ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5033
- ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
5034
- ...(signal === undefined ? {} : { signal }),
5035
- });
5036
- let consentNotice: string | undefined;
5037
- let consentDeclined = false;
5038
- if (result.lensesRequired && result.riskEvidence !== undefined) {
5039
- const decision = await requestReviewConsent(candidateView?.root ?? defaultCwd, result.riskEvidence, context);
5040
- consentNotice = decision.consentNotice;
5041
- consentDeclined = !decision.proceed;
5042
- }
5043
- if (candidateView && candidateViews && result.lensesRequired) {
5044
- const binding = { token: candidateView.token, lineageId: result.lineageId, selectedLenses: result.selectedLenses };
5045
- if (result.action === "resumed" && !candidateViews.hasCurrentBinding()) candidateViews.restoreCurrentFromNativeStart(binding);
5046
- else candidateViews.bindCurrent(binding);
5047
- } else if (candidateView && candidateViews && ((result.action === "created" && result.state === "reviewing") || result.action === "resumed" || result.action === "reuse-receipt")) candidateViews.retain(candidateView.token, result.lineageId);
5048
- else if (candidateView && candidateViews) candidateViews.cleanup(candidateView.token);
5049
- // The envelope always names the workspace root the lineage is bound
5050
- // to, and — when lenses must run — the exact frozen candidate the
5051
- // actors must read, so the caller can never silently launch review
5052
- // actors against a stale previously-active worktree (#166, #169).
5053
- // A declined organic-parity consent withholds actor_binding for this
5054
- // work unit only (Design Data Flow, organic-rdd-parity): the native
5055
- // START already ran and its result is still reported.
5056
- const actorBinding = candidateView !== undefined && result.lensesRequired && !consentDeclined
5057
- ? {
5058
- workspace_root: defaultCwd,
5059
- candidate_root: candidateView.root,
5060
- candidate_tree: candidateView.candidateTree,
5061
- candidate_paths: candidateView.paths,
5198
+ if (candidateView !== undefined) assertNativeStartCandidateBinding(candidateView, target);
5199
+ let result: NativeStartResult;
5200
+ try {
5201
+ nativeStartAttempted = true;
5202
+ result = await nativeReviewCli.start({
5203
+ cwd: defaultCwd,
5204
+ ...(canonicalBaseRef === undefined
5205
+ ? {}
5206
+ : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }),
5207
+ targetIdentity: target.targetIdentity,
5208
+ projection: target.projection.projection,
5209
+ ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5210
+ ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
5211
+ ...(signal === undefined ? {} : { signal }),
5212
+ });
5213
+ } catch (error) {
5214
+ if (!(error instanceof NativeReviewConsentRequiredError)) throw error;
5215
+ if (candidateView === undefined) throw new CandidateViewError("native consent requires a frozen candidate view");
5216
+ const consentCandidateView = candidateView;
5217
+ const repositoryCwd = realpathSync(defaultCwd);
5218
+ const consentDigest = reviewConsentDigest(error.consent);
5219
+ const existing = [...pendingReviewConsents.values()].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > Date.now());
5220
+ if (existing === undefined) for (const pending of [...pendingReviewConsents.values()]) if (pending.candidateView.token === consentCandidateView.token) consumePendingReviewConsent(pending, pendingReviewConsents);
5221
+ const id = existing?.id ?? randomUUID();
5222
+ if (existing === undefined) {
5223
+ const pending: PendingReviewConsent = { id, repositoryCwd, authorityCwd: defaultCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt: Date.now() + PENDING_REVIEW_CONSENT_TTL_MS };
5224
+ pendingReviewConsents.set(id, pending);
5225
+ pending.expiry = setTimeout(() => cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews), PENDING_REVIEW_CONSENT_TTL_MS);
5226
+ pending.expiry.unref();
5062
5227
  }
5063
- : undefined;
5064
- return {
5065
- operation: parameters.operation,
5066
- result: mapNativeStartResult(result),
5067
- workspace_root: defaultCwd,
5068
- ...(actorBinding === undefined ? {} : { actor_binding: actorBinding }),
5069
- ...(consentNotice === undefined ? {} : { consent_notice: consentNotice }),
5070
- };
5228
+ return {
5229
+ operation: parameters.operation,
5230
+ status: "blocked",
5231
+ outcome: "native-review-consent-required",
5232
+ consent: error.consent.raw,
5233
+ consent_binding: id,
5234
+ ...nativeStartPreAuthorityRejection(),
5235
+ };
5236
+ }
5237
+ return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
5071
5238
  } catch (error) {
5072
5239
  if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
5073
5240
  const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
@@ -5083,7 +5250,7 @@ async function executeReviewControllerOperation(
5083
5250
  nextAction: "review.status",
5084
5251
  });
5085
5252
  return reconcileNativeMutationFailure(parameters.operation, failure, nativeReviewCli, {
5086
- cwd: candidateView?.root ?? defaultCwd,
5253
+ cwd: defaultCwd,
5087
5254
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5088
5255
  ...(canonicalBaseRef === undefined ? {} : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef }),
5089
5256
  projection: "workspace",
@@ -5577,6 +5744,19 @@ async function gateLifecycleCommand(
5577
5744
  if (!inspection.command) {
5578
5745
  return { block: true, reason: inspection.failClosedReason ?? "Lifecycle command failed closed." };
5579
5746
  }
5747
+ if (nativeReviewCli?.reviewMode !== undefined) {
5748
+ try {
5749
+ const mode = await nativeReviewCli.reviewMode({ cwd: inspection.command.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) });
5750
+ if (mode.status.effective === "off") {
5751
+ pendingAuthorizations.clear();
5752
+ return undefined;
5753
+ }
5754
+ } catch (error) {
5755
+ if (asNativeReviewCliError(error)?.code !== NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) {
5756
+ return { block: true, reason: `Gentle AI ${inspection.event} gate could not reconsult review mode and failed closed.` };
5757
+ }
5758
+ }
5759
+ }
5580
5760
  let derived: DerivedReviewGateTarget;
5581
5761
  try {
5582
5762
  derived = deriveReviewGateTarget(command, defaultCwd);
@@ -5616,7 +5796,7 @@ async function gateLifecycleCommand(
5616
5796
  try {
5617
5797
  const intendedTree = authorization.native_gate === undefined
5618
5798
  ? undefined
5619
- : assertFrozenPreCommitProjection(derived, authorization.native_gate.lineage_id, candidateViews);
5799
+ : reproveNativePreCommitTree(derived, authorization.native_gate.lineage_id, candidateViews);
5620
5800
  if (authorization.native_gate?.intended_tree !== undefined && intendedTree !== authorization.native_gate.intended_tree) {
5621
5801
  return {
5622
5802
  block: true,
@@ -5672,7 +5852,7 @@ async function gateLifecycleCommand(
5672
5852
  publicationProbeTimeoutMs,
5673
5853
  signal,
5674
5854
  );
5675
- const postNativeIntendedTree = assertFrozenPreCommitProjection(postNativeDerived, authorization.native_gate.lineage_id, candidateViews);
5855
+ const postNativeIntendedTree = reproveNativePreCommitTree(postNativeDerived, authorization.native_gate.lineage_id, candidateViews);
5676
5856
  if (authorization.native_gate.intended_tree !== undefined && postNativeIntendedTree !== authorization.native_gate.intended_tree) throw new CandidateViewError("staged projection changed during native validation");
5677
5857
  assertNativePublicationBinding(fresh, postNativeDerived);
5678
5858
  if (nativeGateFingerprint(fresh, postNativeDerived) !== authorization.native_gate.fingerprint) {
@@ -5746,7 +5926,6 @@ export async function enforceReviewGateAndCommandSafety(
5746
5926
  /** @internal */
5747
5927
  export const __testing = {
5748
5928
  resolveReviewModeGate,
5749
- requestReviewConsent,
5750
5929
  listAgentsFromDir,
5751
5930
  listAgentsFromDirAsync,
5752
5931
  listDiscoverableAgents,
@@ -5833,10 +6012,16 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5833
6012
  if (!Number.isSafeInteger(bashTimeRevalidationTimeoutMs) || bashTimeRevalidationTimeoutMs <= 0) throw new TypeError("Bash-time revalidation timeout must be a positive safe integer");
5834
6013
  return function gentleAi(pi: ExtensionAPI): void {
5835
6014
  const pendingReviewAuthorizations = new Map<string, PendingReviewAuthorization>();
6015
+ const pendingReviewConsents = new Map<string, PendingReviewConsent>();
6016
+ const consumedNativeAuthorizations = new Set<string>();
5836
6017
  const pendingCommitTransactions = new Map<string, { cwd: string; transactionId: string }>();
5837
6018
  const correctionEvidenceByLineage = new Map<string, CorrectionEvidence>();
5838
6019
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
5839
6020
 
6021
+ pi.on("session_shutdown", () => {
6022
+ cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
6023
+ });
6024
+
5840
6025
  pi.registerTool({
5841
6026
  name: "gentle_review",
5842
6027
  label: "Gentle Review Controller",
@@ -5849,7 +6034,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5849
6034
  "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, 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.",
5850
6035
  "Run selected lenses once, then call FINALIZE with a JSON string containing review_result.lens_results entries for every START-selected lens. Each entry has lens, findings, and non-empty evidence; clean lenses use findings: []. Pair final_evidence with exactly one of final_verification_passed or final_verification_outcome (passed, verification_failed, procedural_tooling_failed). Correction evidence is captured natively before STATUS can expose targeted validation. This Pi wrapper shape differs from native CLI --result, --refuter, --validation, and --evidence files. Use ADVANCE only for explicit graph-v1 Judgment Day.",
5851
6036
  "For blocked-legacy or blocked-mixed, do not call START repeatedly. Explain invalidation, request explicit user authorization for the exact reset_request challenge, then call RESET or RECOVER only after authorization. RESET and RECOVER_LOCK route to audited native `gentle-ai review reclaim` and RECOVER routes to native `gentle-ai review recover`; 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.",
5852
- "A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START or FINALIZE output, the controller calls target-scoped native status first and returns only its declared action. Never infer or prescribe replay unless native explicitly reports exact_replay_safe for the same canonical request and required lineage.",
6037
+ "A consent-required START returns the complete provider envelope and an opaque consent_binding, then stops. The parent presents and localizes that envelope without changing machine tokens, commands, target IDs, or invocations. After one explicit human answer, call answer-consent exactly once with a JSON string containing only consentBinding and answer (`granted` or `declined`). A reported lineage_created false or pre-authority validation error proves no lineage was created. After ambiguous START, answer-consent, or FINALIZE output, the controller calls target-scoped native status first and returns only its declared action. Never infer or prescribe replay unless native explicitly reports exact_replay_safe for the same canonical request and required lineage.",
5853
6038
  "Use gentle_review for bounded review transaction operations and exact lifecycle validation; never fabricate bash tool metadata or a separate gate target.",
5854
6039
  ],
5855
6040
  parameters: REVIEW_CONTROLLER_PARAMETERS,
@@ -5868,6 +6053,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5868
6053
  candidateViews,
5869
6054
  ctx,
5870
6055
  correctionEvidenceByLineage,
6056
+ pendingReviewConsents,
5871
6057
  );
5872
6058
  return {
5873
6059
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -5991,12 +6177,60 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5991
6177
  return undefined;
5992
6178
  const originalCommand = event.input.command;
5993
6179
  const inspection = inspectReviewLifecycleCommand(originalCommand, ctx.cwd);
5994
- const nativeCommitAuthorization = inspection.command?.event === "pre-commit"
5995
- ? pendingReviewAuthorizations.get(reviewAuthorizationKey(originalCommand, inspection.command.cwd))
6180
+ let reviewModeDisabled = false;
6181
+ if (inspection.command?.event === "pre-commit" && nativeReviewCli?.reviewMode !== undefined) {
6182
+ try {
6183
+ const mode = await nativeReviewCli.reviewMode({ cwd: inspection.command.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(ctx.signal === undefined ? {} : { signal: ctx.signal }) });
6184
+ reviewModeDisabled = mode.status.effective === "off";
6185
+ if (reviewModeDisabled) pendingReviewAuthorizations.clear();
6186
+ } catch (error) {
6187
+ if (asNativeReviewCliError(error)?.code !== NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return { block: true, reason: "Gentle AI lifecycle gate could not reconsult review mode and failed closed." };
6188
+ }
6189
+ }
6190
+ const commandAuthorizationKey = inspection.command?.event === "pre-commit"
6191
+ ? reviewAuthorizationKey(originalCommand, inspection.command.cwd)
5996
6192
  : undefined;
6193
+ let nativeCommitAuthorization = commandAuthorizationKey === undefined
6194
+ ? undefined
6195
+ : pendingReviewAuthorizations.get(commandAuthorizationKey);
6196
+ let authorizationConsumptionIdentity = nativeAuthorizationConsumptionIdentity(nativeCommitAuthorization);
6197
+ if (authorizationConsumptionIdentity !== undefined && consumedNativeAuthorizations.has(authorizationConsumptionIdentity)) {
6198
+ if (commandAuthorizationKey !== undefined) pendingReviewAuthorizations.delete(commandAuthorizationKey);
6199
+ nativeCommitAuthorization = undefined;
6200
+ authorizationConsumptionIdentity = undefined;
6201
+ }
6202
+ let unmanagedPreCommit = reviewModeDisabled && inspection.command?.event === "pre-commit";
6203
+ if (!unmanagedPreCommit && inspection.command?.event === "pre-commit" && commandAuthorizationKey !== undefined && nativeCommitAuthorization === undefined && nativeReviewCli !== null) {
6204
+ let derived: DerivedReviewGateTarget;
6205
+ try {
6206
+ derived = deriveReviewGateTarget(originalCommand, ctx.cwd);
6207
+ } catch (error) {
6208
+ return {
6209
+ block: true,
6210
+ reason: `Gentle AI pre-commit gate could not exactly derive the command target and failed closed: ${error instanceof Error ? error.message : String(error)}`,
6211
+ };
6212
+ }
6213
+ const deadline = AbortSignal.timeout(bashTimeRevalidationTimeoutMs);
6214
+ const signal = ctx.signal === undefined ? deadline : AbortSignal.any([ctx.signal, deadline]);
6215
+ try {
6216
+ const consumed = await consumeNativePreCommitReceipt(originalCommand, ctx.cwd, derived, nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, signal);
6217
+ nativeCommitAuthorization = consumed.authorization;
6218
+ unmanagedPreCommit = consumed.delivery === "disabled/unmanaged";
6219
+ authorizationConsumptionIdentity = nativeAuthorizationConsumptionIdentity(nativeCommitAuthorization);
6220
+ if (authorizationConsumptionIdentity !== undefined && consumedNativeAuthorizations.has(authorizationConsumptionIdentity)) {
6221
+ nativeCommitAuthorization = undefined;
6222
+ authorizationConsumptionIdentity = undefined;
6223
+ } else if (nativeCommitAuthorization !== undefined) {
6224
+ pendingReviewAuthorizations.set(nativeCommitAuthorization.command_hash, nativeCommitAuthorization);
6225
+ }
6226
+ } catch (error) {
6227
+ return { block: true, reason: `Gentle AI pre-commit receipt consumption failed closed: ${error instanceof Error ? error.message : String(error)}` };
6228
+ }
6229
+ }
5997
6230
  const gateResult = await enforceReviewGateAndCommandSafety(
5998
6231
  originalCommand,
5999
6232
  (command) => {
6233
+ if (unmanagedPreCommit) return Promise.resolve(undefined);
6000
6234
  const deadline = AbortSignal.timeout(bashTimeRevalidationTimeoutMs);
6001
6235
  const signal = ctx.signal === undefined ? deadline : AbortSignal.any([ctx.signal, deadline]);
6002
6236
  return gateLifecycleCommand(command, ctx.cwd, pendingReviewAuthorizations, nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, signal, candidateViews);
@@ -6004,6 +6238,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6004
6238
  (command) => confirmCommand(command, ctx),
6005
6239
  );
6006
6240
  if (gateResult) return gateResult;
6241
+ if (authorizationConsumptionIdentity !== undefined) consumedNativeAuthorizations.add(authorizationConsumptionIdentity);
6007
6242
  if (inspection.command?.event !== "pre-commit" || nativeCommitAuthorization?.native_gate === undefined) return undefined;
6008
6243
  if (nativeReviewCli?.targetStatus === undefined) return undefined;
6009
6244
  if (nativeCommitAuthorization.native_gate.intended_tree === undefined) {
@@ -6209,6 +6444,10 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6209
6444
  }
6210
6445
  try {
6211
6446
  const result = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: subAction as NativeReviewModeOperation });
6447
+ if (subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE && result.status.effective === "off") {
6448
+ pendingReviewAuthorizations.clear();
6449
+ cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
6450
+ }
6212
6451
  const report = `receipt-driven development: ${result.status.effective} (decided by ${result.status.source})`;
6213
6452
  // A mutating sub-action that left the effective mode unchanged did
6214
6453
  // not do what the user asked, and reporting only the resulting