gentle-pi 2.0.0 → 2.1.0

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,
@@ -105,6 +105,7 @@ import {
105
105
  nativeReviewLegacyQuarantineAuthorization,
106
106
  nativeReviewReconcileAuthorization,
107
107
  NativeReviewCliError,
108
+ NativeReviewConsentRequiredError,
108
109
  NATIVE_REVIEW_ERROR_CODE,
109
110
  NATIVE_REVIEW_LEGACY_QUARANTINE,
110
111
  NATIVE_REVIEW_LEGACY_ALIAS_REPAIR,
@@ -120,8 +121,7 @@ import {
120
121
  type NativeStartResult,
121
122
  type NativeValidateResult,
122
123
  } 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";
124
+ import type { ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
125
125
  import { assertDistinctCorrectionEvidence, resolveCorrectionStep, type CorrectionEvidence, type CorrectionOutcome, type CorrectionStep } from "../lib/review-correction-lifecycle.ts";
126
126
 
127
127
  const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
@@ -2106,6 +2106,7 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
2106
2106
 
2107
2107
  const REVIEW_CONTROLLER_OPERATION = {
2108
2108
  START: "start",
2109
+ ANSWER_CONSENT: "answer-consent",
2109
2110
  FINALIZE: "finalize",
2110
2111
  ADVANCE: "advance",
2111
2112
  STATUS: "status",
@@ -2161,7 +2162,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2161
2162
  },
2162
2163
  input: {
2163
2164
  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.",
2165
+ 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
2166
  },
2166
2167
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
2167
2168
  inputPath: { type: "string", description: "Repository-local JSON input file for finalize/advance (alternative to input). Legacy bundle import is retired." },
@@ -2461,7 +2462,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
2461
2462
  // VALIDATE defers its lineage requirement to execution time: the proven
2462
2463
  // release-from-protected-main fast path needs no receipt lineage, while
2463
2464
  // 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);
2465
+ 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
2466
  if (needsLineage && (typeof value.lineageId !== "string" || value.lineageId.trim().length === 0)) {
2466
2467
  throw new Error("Review controller requires a lineageId");
2467
2468
  }
@@ -3866,6 +3867,7 @@ function nativeReviewModeSkipped(operation: ReviewControllerOperation, source: N
3866
3867
  operation,
3867
3868
  status: "skipped",
3868
3869
  outcome: REVIEW_MODE_DISABLED_OUTCOME,
3870
+ delivery: "disabled/unmanaged",
3869
3871
  mode_source: source,
3870
3872
  reason: `receipt-driven development is disabled: ${operation} is skipped because the ${source} mode source keeps it off`,
3871
3873
  ...(continuation === undefined ? {} : { next_action: continuation }),
@@ -4326,12 +4328,33 @@ function assertFrozenPreCommitProjection(
4326
4328
  return projection.candidateTree;
4327
4329
  }
4328
4330
 
4331
+ function reproveNativePreCommitTree(
4332
+ derived: DerivedReviewGateTarget,
4333
+ lineageId: string,
4334
+ candidateViews: CandidateViewRegistry | null,
4335
+ ): string | undefined {
4336
+ return assertFrozenPreCommitProjection(derived, lineageId, candidateViews) ??
4337
+ (derived.command.event === "pre-commit" ? derived.actualIntendedCommitTree : undefined);
4338
+ }
4339
+
4329
4340
  function authorizationTargetHash(derived: DerivedReviewGateTarget): string {
4330
4341
  return derived.nativePublication === undefined
4331
4342
  ? canonicalHash(derived.target)
4332
4343
  : canonicalHash({ target: derived.target, native_publication: derived.nativePublication });
4333
4344
  }
4334
4345
 
4346
+ function nativeAuthorizationConsumptionIdentity(authorization: PendingReviewAuthorization | undefined): string | undefined {
4347
+ if (authorization?.native_gate === undefined) return undefined;
4348
+ return canonicalHash({
4349
+ command_hash: authorization.command_hash,
4350
+ target_hash: authorization.target_hash,
4351
+ lineage_id: authorization.native_gate.lineage_id,
4352
+ store_revision: authorization.native_gate.store_revision,
4353
+ fingerprint: authorization.native_gate.fingerprint,
4354
+ intended_tree: authorization.native_gate.intended_tree ?? null,
4355
+ });
4356
+ }
4357
+
4335
4358
  function assertNativePublicationBinding(result: NativeValidateResult, derived: DerivedReviewGateTarget): void {
4336
4359
  const returnedGate = result.gateContext.raw.gate;
4337
4360
  if (returnedGate !== requestedNativeGate(derived) && (result.allowed || returnedGate !== "")) {
@@ -4383,6 +4406,64 @@ async function rederiveNativePublicationTarget(
4383
4406
  return fresh;
4384
4407
  }
4385
4408
 
4409
+ interface NativePreCommitReceiptConsumption {
4410
+ authorization?: PendingReviewAuthorization;
4411
+ delivery?: "disabled/unmanaged";
4412
+ }
4413
+
4414
+ async function consumeNativePreCommitReceipt(
4415
+ command: string,
4416
+ defaultCwd: string,
4417
+ derivedTarget: DerivedReviewGateTarget,
4418
+ nativeReviewCli: NativeReviewCli,
4419
+ publicationProbe: PublicationProbe,
4420
+ publicationProbeTimeoutMs: number,
4421
+ signal?: AbortSignal,
4422
+ ): Promise<NativePreCommitReceiptConsumption> {
4423
+ const derived = await deriveNativePublicationTarget(
4424
+ derivedTarget,
4425
+ publicationProbe,
4426
+ publicationProbeTimeoutMs,
4427
+ signal,
4428
+ );
4429
+ if (derived.command.event !== "pre-commit" || derived.actualIntendedCommitTree === undefined) return {};
4430
+ const result = await nativeReviewCli.validate({
4431
+ cwd: derived.command.cwd,
4432
+ gate: "pre-commit",
4433
+ ...(signal === undefined ? {} : { signal }),
4434
+ });
4435
+ assertNativePublicationBinding(result, derived);
4436
+ const fresh = await rederiveNativePublicationTarget(
4437
+ derived,
4438
+ command,
4439
+ defaultCwd,
4440
+ publicationProbe,
4441
+ publicationProbeTimeoutMs,
4442
+ signal,
4443
+ );
4444
+ if (result.delivery === "disabled/unmanaged") return { delivery: result.delivery };
4445
+ if (!result.allowed || result.result !== "allow") return {};
4446
+ if (
4447
+ result.gateContext.lineageId.length === 0 ||
4448
+ result.gateContext.raw.candidate_tree !== derived.actualIntendedCommitTree ||
4449
+ fresh.actualIntendedCommitTree !== derived.actualIntendedCommitTree
4450
+ ) throw new Error("Native approved receipt does not bind the exact current pre-commit tree");
4451
+ const commandHash = reviewAuthorizationKey(command, fresh.command.cwd);
4452
+ return {
4453
+ authorization: {
4454
+ command_hash: commandHash,
4455
+ target_hash: authorizationTargetHash(fresh),
4456
+ receipt_hash: null,
4457
+ native_gate: {
4458
+ lineage_id: result.gateContext.lineageId,
4459
+ store_revision: result.gateContext.storeRevision,
4460
+ fingerprint: nativeGateFingerprint(result, fresh),
4461
+ intended_tree: derived.actualIntendedCommitTree,
4462
+ },
4463
+ },
4464
+ };
4465
+ }
4466
+
4386
4467
  interface NativeStartPolicyValidation {
4387
4468
  policyPath?: string;
4388
4469
  reason?: string;
@@ -4464,71 +4545,65 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
4464
4545
  };
4465
4546
  }
4466
4547
 
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.";
4548
+ const PENDING_REVIEW_CONSENT_TTL_MS = 10 * 60 * 1000;
4478
4549
 
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).
4530
- }
4531
- return { proceed: true };
4550
+ interface PendingReviewConsent {
4551
+ id: string;
4552
+ repositoryCwd: string;
4553
+ candidateView: CandidateView;
4554
+ consent: ReviewConsentV2;
4555
+ consentDigest: string;
4556
+ expiresAt: number;
4557
+ expiry?: ReturnType<typeof setTimeout>;
4558
+ }
4559
+
4560
+ function consumePendingReviewConsent(pending: PendingReviewConsent, pendingReviewConsents: Map<string, PendingReviewConsent>): void {
4561
+ if (pending.expiry !== undefined) clearTimeout(pending.expiry);
4562
+ pending.expiry = undefined;
4563
+ if (pendingReviewConsents.get(pending.id) === pending) pendingReviewConsents.delete(pending.id);
4564
+ }
4565
+
4566
+ function cleanupPendingReviewConsent(pending: PendingReviewConsent, pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null): void {
4567
+ consumePendingReviewConsent(pending, pendingReviewConsents);
4568
+ if (![...pendingReviewConsents.values()].some((current) => current.candidateView.token === pending.candidateView.token)) candidateViews?.cleanup(pending.candidateView.token);
4569
+ }
4570
+
4571
+ function cleanupAllPendingReviewConsents(pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null): void {
4572
+ for (const pending of [...pendingReviewConsents.values()]) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
4573
+ }
4574
+
4575
+ function reviewConsentDigest(consent: ReviewConsentV2): string {
4576
+ return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
4577
+ }
4578
+
4579
+ function completeNativeStart(
4580
+ operation: ReviewControllerOperation,
4581
+ result: NativeStartResult,
4582
+ workspaceRoot: string,
4583
+ candidateView: CandidateView | undefined,
4584
+ candidateViews: CandidateViewRegistry | null,
4585
+ ): Record<string, unknown> {
4586
+ if (candidateView === undefined) return { operation, result: mapNativeStartResult(result), workspace_root: workspaceRoot };
4587
+ if (candidateViews && result.lensesRequired) {
4588
+ const binding = { token: candidateView.token, lineageId: result.lineageId, selectedLenses: result.selectedLenses };
4589
+ if (result.action === "resumed" && !candidateViews.hasCurrentBinding()) candidateViews.restoreCurrentFromNativeStart(binding);
4590
+ else candidateViews.bindCurrent(binding);
4591
+ } else if (candidateViews && ((result.action === "created" && result.state === "reviewing") || result.action === "resumed" || result.action === "reuse-receipt")) candidateViews.retain(candidateView.token, result.lineageId);
4592
+ else candidateViews?.cleanup(candidateView.token);
4593
+ const actorBinding = result.lensesRequired
4594
+ ? {
4595
+ workspace_root: workspaceRoot,
4596
+ candidate_root: candidateView.root,
4597
+ candidate_tree: candidateView.candidateTree,
4598
+ candidate_paths: candidateView.paths,
4599
+ }
4600
+ : undefined;
4601
+ return {
4602
+ operation,
4603
+ result: mapNativeStartResult(result),
4604
+ workspace_root: workspaceRoot,
4605
+ ...(actorBinding === undefined ? {} : { actor_binding: actorBinding }),
4606
+ };
4532
4607
  }
4533
4608
 
4534
4609
  function nativeOperationFailure(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
@@ -4826,6 +4901,7 @@ async function executeReviewControllerOperation(
4826
4901
  candidateViews: CandidateViewRegistry | null = new CandidateViewRegistry(),
4827
4902
  context?: ExtensionContext,
4828
4903
  correctionEvidenceByLineage: Map<string, CorrectionEvidence> = new Map(),
4904
+ pendingReviewConsents: Map<string, PendingReviewConsent> = new Map(),
4829
4905
  ): Promise<Record<string, unknown>> {
4830
4906
  const parameters = parseReviewControllerParameters(parametersValue);
4831
4907
  const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd);
@@ -4974,6 +5050,60 @@ async function executeReviewControllerOperation(
4974
5050
  });
4975
5051
  }
4976
5052
  }
5053
+ if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT) {
5054
+ const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5055
+ 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");
5056
+ if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
5057
+ if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
5058
+ const pending = pendingReviewConsents.get(input.consentBinding);
5059
+ if (pending === undefined || pending.expiresAt <= Date.now()) {
5060
+ if (pending !== undefined) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
5061
+ throw new Error("Review controller consent binding is unknown, expired, or already consumed");
5062
+ }
5063
+ if (realpathSync(defaultCwd) !== pending.repositoryCwd) throw new Error("Review controller consent repository binding changed");
5064
+ if (reviewConsentDigest(pending.consent) !== pending.consentDigest) throw new Error("Review controller consent envelope binding changed");
5065
+ pending.candidateView.verify();
5066
+ if (nativeReviewCli?.answerConsent === undefined) throw new Error("Native review consent follow-up is unavailable");
5067
+ try {
5068
+ const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
5069
+ if (gated !== undefined) {
5070
+ cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
5071
+ return gated;
5072
+ }
5073
+ } catch (error) {
5074
+ return nativeOperationFailure(parameters.operation, error);
5075
+ }
5076
+ // The one-shot binding is consumed before the provider mutation. Any
5077
+ // ambiguous result reconciles through STATUS and can never be replayed.
5078
+ consumePendingReviewConsent(pending, pendingReviewConsents);
5079
+ try {
5080
+ const answered = await nativeReviewCli.answerConsent({
5081
+ cwd: pending.candidateView.root,
5082
+ consent: pending.consent,
5083
+ answer: input.answer,
5084
+ ...(signal === undefined ? {} : { signal }),
5085
+ });
5086
+ if (answered.kind === "declined") {
5087
+ candidateViews?.cleanup(pending.candidateView.token);
5088
+ return {
5089
+ operation: parameters.operation,
5090
+ status: "skipped",
5091
+ outcome: "consent-declined-this-candidate",
5092
+ consent: answered.raw,
5093
+ ...nativeStartPreAuthorityRejection(),
5094
+ };
5095
+ }
5096
+ return completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, candidateViews);
5097
+ } catch (error) {
5098
+ const value = error as { mutationOutcome?: unknown };
5099
+ if (value.mutationOutcome === "none") candidateViews?.cleanup(pending.candidateView.token);
5100
+ return await reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, {
5101
+ cwd: pending.candidateView.root,
5102
+ ...(pending.candidateView.committedOnly ? { baseRef: pending.candidateView.baseCommit } : {}),
5103
+ projection: "workspace",
5104
+ });
5105
+ }
5106
+ }
4977
5107
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
4978
5108
  const rawStart = parseControllerJson(
4979
5109
  requiredControllerString(parameters, "input"),
@@ -5024,50 +5154,42 @@ async function executeReviewControllerOperation(
5024
5154
  try {
5025
5155
  candidateView = candidateViews?.createOrReuse({ contributorRoot: defaultCwd, replayKey, ...(canonicalBaseRef === undefined ? {} : { baseRef: canonicalBaseRef, committedOnly: true }) });
5026
5156
  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,
5157
+ let result: NativeStartResult;
5158
+ try {
5159
+ result = await nativeReviewCli.start({
5160
+ cwd: candidateView?.root ?? defaultCwd,
5161
+ ...(canonicalBaseRef === undefined
5162
+ ? {}
5163
+ : { baseRef: candidateView?.baseCommit ?? canonicalBaseRef, committedOnly: true }),
5164
+ ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5165
+ ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
5166
+ ...(signal === undefined ? {} : { signal }),
5167
+ });
5168
+ } catch (error) {
5169
+ if (!(error instanceof NativeReviewConsentRequiredError)) throw error;
5170
+ if (candidateView === undefined) throw new CandidateViewError("native consent requires a frozen candidate view");
5171
+ const consentCandidateView = candidateView;
5172
+ const repositoryCwd = realpathSync(defaultCwd);
5173
+ const consentDigest = reviewConsentDigest(error.consent);
5174
+ const existing = [...pendingReviewConsents.values()].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > Date.now());
5175
+ if (existing === undefined) for (const pending of [...pendingReviewConsents.values()]) if (pending.candidateView.token === consentCandidateView.token) consumePendingReviewConsent(pending, pendingReviewConsents);
5176
+ const id = existing?.id ?? randomUUID();
5177
+ if (existing === undefined) {
5178
+ const pending: PendingReviewConsent = { id, repositoryCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt: Date.now() + PENDING_REVIEW_CONSENT_TTL_MS };
5179
+ pendingReviewConsents.set(id, pending);
5180
+ pending.expiry = setTimeout(() => cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews), PENDING_REVIEW_CONSENT_TTL_MS);
5181
+ pending.expiry.unref();
5062
5182
  }
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
- };
5183
+ return {
5184
+ operation: parameters.operation,
5185
+ status: "blocked",
5186
+ outcome: "native-review-consent-required",
5187
+ consent: error.consent.raw,
5188
+ consent_binding: id,
5189
+ ...nativeStartPreAuthorityRejection(),
5190
+ };
5191
+ }
5192
+ return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
5071
5193
  } catch (error) {
5072
5194
  if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
5073
5195
  const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
@@ -5577,6 +5699,19 @@ async function gateLifecycleCommand(
5577
5699
  if (!inspection.command) {
5578
5700
  return { block: true, reason: inspection.failClosedReason ?? "Lifecycle command failed closed." };
5579
5701
  }
5702
+ if (nativeReviewCli?.reviewMode !== undefined) {
5703
+ try {
5704
+ const mode = await nativeReviewCli.reviewMode({ cwd: inspection.command.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(signal === undefined ? {} : { signal }) });
5705
+ if (mode.status.effective === "off") {
5706
+ pendingAuthorizations.clear();
5707
+ return undefined;
5708
+ }
5709
+ } catch (error) {
5710
+ if (asNativeReviewCliError(error)?.code !== NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) {
5711
+ return { block: true, reason: `Gentle AI ${inspection.event} gate could not reconsult review mode and failed closed.` };
5712
+ }
5713
+ }
5714
+ }
5580
5715
  let derived: DerivedReviewGateTarget;
5581
5716
  try {
5582
5717
  derived = deriveReviewGateTarget(command, defaultCwd);
@@ -5616,7 +5751,7 @@ async function gateLifecycleCommand(
5616
5751
  try {
5617
5752
  const intendedTree = authorization.native_gate === undefined
5618
5753
  ? undefined
5619
- : assertFrozenPreCommitProjection(derived, authorization.native_gate.lineage_id, candidateViews);
5754
+ : reproveNativePreCommitTree(derived, authorization.native_gate.lineage_id, candidateViews);
5620
5755
  if (authorization.native_gate?.intended_tree !== undefined && intendedTree !== authorization.native_gate.intended_tree) {
5621
5756
  return {
5622
5757
  block: true,
@@ -5672,7 +5807,7 @@ async function gateLifecycleCommand(
5672
5807
  publicationProbeTimeoutMs,
5673
5808
  signal,
5674
5809
  );
5675
- const postNativeIntendedTree = assertFrozenPreCommitProjection(postNativeDerived, authorization.native_gate.lineage_id, candidateViews);
5810
+ const postNativeIntendedTree = reproveNativePreCommitTree(postNativeDerived, authorization.native_gate.lineage_id, candidateViews);
5676
5811
  if (authorization.native_gate.intended_tree !== undefined && postNativeIntendedTree !== authorization.native_gate.intended_tree) throw new CandidateViewError("staged projection changed during native validation");
5677
5812
  assertNativePublicationBinding(fresh, postNativeDerived);
5678
5813
  if (nativeGateFingerprint(fresh, postNativeDerived) !== authorization.native_gate.fingerprint) {
@@ -5746,7 +5881,6 @@ export async function enforceReviewGateAndCommandSafety(
5746
5881
  /** @internal */
5747
5882
  export const __testing = {
5748
5883
  resolveReviewModeGate,
5749
- requestReviewConsent,
5750
5884
  listAgentsFromDir,
5751
5885
  listAgentsFromDirAsync,
5752
5886
  listDiscoverableAgents,
@@ -5833,10 +5967,16 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5833
5967
  if (!Number.isSafeInteger(bashTimeRevalidationTimeoutMs) || bashTimeRevalidationTimeoutMs <= 0) throw new TypeError("Bash-time revalidation timeout must be a positive safe integer");
5834
5968
  return function gentleAi(pi: ExtensionAPI): void {
5835
5969
  const pendingReviewAuthorizations = new Map<string, PendingReviewAuthorization>();
5970
+ const pendingReviewConsents = new Map<string, PendingReviewConsent>();
5971
+ const consumedNativeAuthorizations = new Set<string>();
5836
5972
  const pendingCommitTransactions = new Map<string, { cwd: string; transactionId: string }>();
5837
5973
  const correctionEvidenceByLineage = new Map<string, CorrectionEvidence>();
5838
5974
  const candidateViews = dependencies.candidateViews === undefined ? new CandidateViewRegistry() : dependencies.candidateViews;
5839
5975
 
5976
+ pi.on("session_shutdown", () => {
5977
+ cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
5978
+ });
5979
+
5840
5980
  pi.registerTool({
5841
5981
  name: "gentle_review",
5842
5982
  label: "Gentle Review Controller",
@@ -5849,7 +5989,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5849
5989
  "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
5990
  "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
5991
  "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.",
5992
+ "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
5993
  "Use gentle_review for bounded review transaction operations and exact lifecycle validation; never fabricate bash tool metadata or a separate gate target.",
5854
5994
  ],
5855
5995
  parameters: REVIEW_CONTROLLER_PARAMETERS,
@@ -5868,6 +6008,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5868
6008
  candidateViews,
5869
6009
  ctx,
5870
6010
  correctionEvidenceByLineage,
6011
+ pendingReviewConsents,
5871
6012
  );
5872
6013
  return {
5873
6014
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -5991,12 +6132,60 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
5991
6132
  return undefined;
5992
6133
  const originalCommand = event.input.command;
5993
6134
  const inspection = inspectReviewLifecycleCommand(originalCommand, ctx.cwd);
5994
- const nativeCommitAuthorization = inspection.command?.event === "pre-commit"
5995
- ? pendingReviewAuthorizations.get(reviewAuthorizationKey(originalCommand, inspection.command.cwd))
6135
+ let reviewModeDisabled = false;
6136
+ if (inspection.command?.event === "pre-commit" && nativeReviewCli?.reviewMode !== undefined) {
6137
+ try {
6138
+ const mode = await nativeReviewCli.reviewMode({ cwd: inspection.command.cwd, operation: NATIVE_REVIEW_MODE_OPERATION.STATUS, ...(ctx.signal === undefined ? {} : { signal: ctx.signal }) });
6139
+ reviewModeDisabled = mode.status.effective === "off";
6140
+ if (reviewModeDisabled) pendingReviewAuthorizations.clear();
6141
+ } catch (error) {
6142
+ 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." };
6143
+ }
6144
+ }
6145
+ const commandAuthorizationKey = inspection.command?.event === "pre-commit"
6146
+ ? reviewAuthorizationKey(originalCommand, inspection.command.cwd)
5996
6147
  : undefined;
6148
+ let nativeCommitAuthorization = commandAuthorizationKey === undefined
6149
+ ? undefined
6150
+ : pendingReviewAuthorizations.get(commandAuthorizationKey);
6151
+ let authorizationConsumptionIdentity = nativeAuthorizationConsumptionIdentity(nativeCommitAuthorization);
6152
+ if (authorizationConsumptionIdentity !== undefined && consumedNativeAuthorizations.has(authorizationConsumptionIdentity)) {
6153
+ if (commandAuthorizationKey !== undefined) pendingReviewAuthorizations.delete(commandAuthorizationKey);
6154
+ nativeCommitAuthorization = undefined;
6155
+ authorizationConsumptionIdentity = undefined;
6156
+ }
6157
+ let unmanagedPreCommit = reviewModeDisabled && inspection.command?.event === "pre-commit";
6158
+ if (!unmanagedPreCommit && inspection.command?.event === "pre-commit" && commandAuthorizationKey !== undefined && nativeCommitAuthorization === undefined && nativeReviewCli !== null) {
6159
+ let derived: DerivedReviewGateTarget;
6160
+ try {
6161
+ derived = deriveReviewGateTarget(originalCommand, ctx.cwd);
6162
+ } catch (error) {
6163
+ return {
6164
+ block: true,
6165
+ reason: `Gentle AI pre-commit gate could not exactly derive the command target and failed closed: ${error instanceof Error ? error.message : String(error)}`,
6166
+ };
6167
+ }
6168
+ const deadline = AbortSignal.timeout(bashTimeRevalidationTimeoutMs);
6169
+ const signal = ctx.signal === undefined ? deadline : AbortSignal.any([ctx.signal, deadline]);
6170
+ try {
6171
+ const consumed = await consumeNativePreCommitReceipt(originalCommand, ctx.cwd, derived, nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, signal);
6172
+ nativeCommitAuthorization = consumed.authorization;
6173
+ unmanagedPreCommit = consumed.delivery === "disabled/unmanaged";
6174
+ authorizationConsumptionIdentity = nativeAuthorizationConsumptionIdentity(nativeCommitAuthorization);
6175
+ if (authorizationConsumptionIdentity !== undefined && consumedNativeAuthorizations.has(authorizationConsumptionIdentity)) {
6176
+ nativeCommitAuthorization = undefined;
6177
+ authorizationConsumptionIdentity = undefined;
6178
+ } else if (nativeCommitAuthorization !== undefined) {
6179
+ pendingReviewAuthorizations.set(nativeCommitAuthorization.command_hash, nativeCommitAuthorization);
6180
+ }
6181
+ } catch (error) {
6182
+ return { block: true, reason: `Gentle AI pre-commit receipt consumption failed closed: ${error instanceof Error ? error.message : String(error)}` };
6183
+ }
6184
+ }
5997
6185
  const gateResult = await enforceReviewGateAndCommandSafety(
5998
6186
  originalCommand,
5999
6187
  (command) => {
6188
+ if (unmanagedPreCommit) return Promise.resolve(undefined);
6000
6189
  const deadline = AbortSignal.timeout(bashTimeRevalidationTimeoutMs);
6001
6190
  const signal = ctx.signal === undefined ? deadline : AbortSignal.any([ctx.signal, deadline]);
6002
6191
  return gateLifecycleCommand(command, ctx.cwd, pendingReviewAuthorizations, nativeReviewCli, publicationProbe, publicationProbeTimeoutMs, signal, candidateViews);
@@ -6004,6 +6193,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6004
6193
  (command) => confirmCommand(command, ctx),
6005
6194
  );
6006
6195
  if (gateResult) return gateResult;
6196
+ if (authorizationConsumptionIdentity !== undefined) consumedNativeAuthorizations.add(authorizationConsumptionIdentity);
6007
6197
  if (inspection.command?.event !== "pre-commit" || nativeCommitAuthorization?.native_gate === undefined) return undefined;
6008
6198
  if (nativeReviewCli?.targetStatus === undefined) return undefined;
6009
6199
  if (nativeCommitAuthorization.native_gate.intended_tree === undefined) {
@@ -6209,6 +6399,10 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6209
6399
  }
6210
6400
  try {
6211
6401
  const result = await nativeReviewCli.reviewMode({ cwd: ctx.cwd, operation: subAction as NativeReviewModeOperation });
6402
+ if (subAction === NATIVE_REVIEW_MODE_OPERATION.DISABLE && result.status.effective === "off") {
6403
+ pendingReviewAuthorizations.clear();
6404
+ cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
6405
+ }
6212
6406
  const report = `receipt-driven development: ${result.status.effective} (decided by ${result.status.source})`;
6213
6407
  // A mutating sub-action that left the effective mode unchanged did
6214
6408
  // not do what the user asked, and reporting only the resulting