gentle-pi 2.1.2 → 2.2.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.
Files changed (171) hide show
  1. package/README.md +70 -12
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -51,14 +51,24 @@ import {
51
51
  } from "../lib/sdd-status.ts";
52
52
  import type { TriggerEvent } from "../lib/review-triggers.ts";
53
53
  import { canonicalJsonV1, domainHashV1 } from "../lib/review-canonical.ts";
54
- import { CompactReviewContractError, deriveNativeRefuterRequest, parseNativeCompactFinalizeInput, toNativeReviewerDocument, toNativeValidatorDocument } from "../lib/review-compact-contract.ts";
55
- import { toNativeRefuterDocument } from "../lib/review-refuter-adapter.ts";
54
+ import { parseNativeCompactFinalizeInput, toNativeValidatorDocument } from "../lib/review-compact-contract.ts";
55
+ import {
56
+ REVIEW_HOST_RELAY_FAILURE,
57
+ REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
58
+ REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
59
+ ReviewHostRelayError,
60
+ reviewHostRelaySlots,
61
+ reviewProviderRoleVectorSlots,
62
+ runReviewHostRelaySlot,
63
+ type ReviewHostRelayRunner,
64
+ type ReviewHostRelaySlot,
65
+ type ReviewProviderRoleVectorSlot,
66
+ } from "../lib/review-host-relay.ts";
56
67
  import {
57
68
  inheritedUnsafeGitEnvironmentKeys,
58
69
  publicationProbeGitEnvironment,
59
70
  resolveRepositoryAuthorityV1,
60
71
  } from "../lib/review-repository.ts";
61
- import { captureLiveReviewCandidateBinding } from "../lib/review-snapshot.ts";
62
72
  import {
63
73
  EXTERNAL_RELEASE_EVIDENCE,
64
74
  GATE_RESULT,
@@ -96,19 +106,30 @@ import {
96
106
  type ReviewProjectionV1,
97
107
  } from "../lib/review-snapshot.ts";
98
108
  import { sanitizeTerminalText, stripAnsi } from "../lib/terminal-theme.ts";
99
- import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, resolveCanonicalCandidateBase, type CandidateView, type NativeCandidateProjectionDescriptor } from "../lib/review-candidate-view.ts";
109
+ import { CandidateViewError, CandidateViewRegistry, injectReviewCandidateView, readCandidateContextManifestPage, resolveCanonicalCandidateBase, type CandidateView } from "../lib/review-candidate-view.ts";
110
+ import {
111
+ GentleAiDevBinaryOverrideError,
112
+ registerGentleAiDevBinary,
113
+ resolveGentleAiDevBinaryOverride,
114
+ unregisterGentleAiDevBinary,
115
+ type GentleAiDevBinaryOverride,
116
+ } from "../lib/gentle-ai-binary.ts";
100
117
  import {
101
118
  createNativeReviewCli,
119
+ createNodeExecFileAdapter,
102
120
  isCanonicalProcessString,
103
121
  nativeReviewAbandonAuthorization,
104
122
  nativeReviewLegacyAliasRepairAuthorization,
105
123
  nativeReviewLegacyQuarantineAuthorization,
106
124
  nativeReviewReconcileAuthorization,
125
+ nativeReviewRecoverAuthorization,
107
126
  normalizeNativeReviewCwd,
108
127
  NativeReviewCliError,
109
128
  NativeReviewConsentBindingError,
110
129
  NativeReviewConsentRequiredError,
130
+ NativeReviewIntegrationError,
111
131
  NATIVE_REVIEW_ERROR_CODE,
132
+ NATIVE_REVIEW_OPERATION,
112
133
  NATIVE_REVIEW_LEGACY_QUARANTINE,
113
134
  NATIVE_REVIEW_LEGACY_ALIAS_REPAIR,
114
135
  NATIVE_REVIEW_MODE_OPERATION,
@@ -116,15 +137,18 @@ import {
116
137
  NATIVE_REVIEW_RECONCILE_ANOMALIES,
117
138
  sanitizeForeignNativeReviewDiagnostics,
118
139
  type NativeReviewCli,
140
+ type NativeTargetStatusRequest,
119
141
  type NativeFinalizeResult,
142
+ type NativeReviewVerificationEvidenceV2,
120
143
  type NativeReviewModeOperation,
121
144
  type NativeReviewModeSource,
122
145
  type NativeReviewProcessDiagnostics,
123
146
  type NativeStartResult,
124
147
  type NativeValidateResult,
125
148
  } from "../lib/native-review-cli.ts";
126
- import type { ReviewConsentV2, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
149
+ import type { ReviewCollectInputV3, ReviewConsentEnvelope, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
127
150
  import { assertDistinctCorrectionEvidence, resolveCorrectionStep, type CorrectionEvidence, type CorrectionOutcome, type CorrectionStep } from "../lib/review-correction-lifecycle.ts";
151
+ import { recordReviewConsentLatch } from "../lib/review-consent-latch.ts";
128
152
 
129
153
  const GRAPH_V1_ORDINARY_READ_ONLY = "Graph-v1 ordinary review authority is read-only; use native compact-v2 review operations";
130
154
  import {
@@ -214,15 +238,353 @@ function sddLocalAgentOverrideCount(cwd: string): number {
214
238
  return count;
215
239
  }
216
240
 
217
- let orchestratorPromptCache: string | null = null;
218
- function getOrchestratorPrompt(): string {
219
- if (orchestratorPromptCache === null) {
220
- orchestratorPromptCache = renderOrchestratorPrompt(ASSETS_DIR);
241
+ // ---------------------------------------------------------------------------
242
+ // Background subagents policy — project > global > env > default off
243
+ // ---------------------------------------------------------------------------
244
+
245
+ type BackgroundSubagentsPolicy = "on" | "off";
246
+ type BackgroundSubagentsCapability = "ready" | "absent";
247
+
248
+ interface BackgroundSubagentsRendering {
249
+ policy: BackgroundSubagentsPolicy;
250
+ capability: BackgroundSubagentsCapability;
251
+ }
252
+
253
+ /** Which of the four sources decided the effective policy. */
254
+ type BackgroundSubagentsSource =
255
+ | "project_file"
256
+ | "global_file"
257
+ | "environment"
258
+ | "default";
259
+
260
+ interface BackgroundSubagentsResolution {
261
+ policy: BackgroundSubagentsPolicy;
262
+ source: BackgroundSubagentsSource;
263
+ /** The deciding file was present but failed the strict decode. */
264
+ malformed: boolean;
265
+ projectFile: string;
266
+ globalFile: string;
267
+ projectFileExists: boolean;
268
+ globalFileExists: boolean;
269
+ /** The raw env value, reported even when it is unrecognized and inert. */
270
+ envValue: string | undefined;
271
+ }
272
+
273
+ interface LoadBackgroundSubagentsOptions {
274
+ /** Override the config home directory (used in tests to avoid touching ~/.pi). */
275
+ gentlePiConfigHome?: string;
276
+ /** Override the environment lookup (used in tests). */
277
+ env?: Record<string, string | undefined>;
278
+ }
279
+
280
+ const BACKGROUND_SUBAGENTS_SCHEMA = "gentle-pi.background-subagents/v1";
281
+ const BACKGROUND_SUBAGENTS_FILE = "background-subagents.json";
282
+
283
+ const DEFAULT_BACKGROUND_SUBAGENTS_RENDERING: BackgroundSubagentsRendering = {
284
+ policy: "off",
285
+ capability: "absent",
286
+ };
287
+
288
+ /**
289
+ * Strict decode of {"schema":"gentle-pi.background-subagents/v1","policy":"on"|"off"}.
290
+ * Any malformed shape (bad JSON, wrong schema, unknown keys, invalid policy)
291
+ * returns undefined so the caller fails closed to "off".
292
+ */
293
+ function parseBackgroundSubagentsPolicyFile(
294
+ raw: string,
295
+ ): BackgroundSubagentsPolicy | undefined {
296
+ let parsed: unknown;
297
+ try {
298
+ parsed = JSON.parse(raw);
299
+ } catch {
300
+ return undefined;
301
+ }
302
+ if (!isRecord(parsed)) return undefined;
303
+ if (parsed.schema !== BACKGROUND_SUBAGENTS_SCHEMA) return undefined;
304
+ if (parsed.policy !== "on" && parsed.policy !== "off") return undefined;
305
+ if (Object.keys(parsed).length !== 2) return undefined;
306
+ return parsed.policy;
307
+ }
308
+
309
+ /**
310
+ * Resolve the background-subagents policy AND the source that decided it.
311
+ *
312
+ * Resolution order (first hit wins, mirroring loadRuntimeGuardrailsConfig):
313
+ * 1. Project file `${cwd}/.pi/gentle-ai/background-subagents.json`
314
+ * 2. Global file `${configHome}/background-subagents.json`
315
+ * (configHome honors GENTLE_PI_CONFIG_HOME, default ~/.pi/gentle-ai)
316
+ * 3. Env var GENTLE_PI_BACKGROUND_SUBAGENTS ("on" | "off")
317
+ * 4. Default "off"
318
+ *
319
+ * A present-but-malformed file fails closed to "off" instead of falling
320
+ * through to a lower-priority source, and it stays attributed to that file:
321
+ * "off decided by a broken project file" and "off by default" are different
322
+ * situations, and only the first one is a mistake to fix.
323
+ *
324
+ * Four sources with first-hit-wins is exactly the shape that makes an edit
325
+ * look like it did nothing, so the deciding source is part of the result
326
+ * rather than something a caller has to re-derive.
327
+ */
328
+ function resolveBackgroundSubagentsPolicy(
329
+ cwd: string,
330
+ options: LoadBackgroundSubagentsOptions = {},
331
+ ): BackgroundSubagentsResolution {
332
+ const env = options.env ?? process.env;
333
+ const envValue = env.GENTLE_PI_BACKGROUND_SUBAGENTS;
334
+ let projectFile = "";
335
+ let globalFile = "";
336
+ try {
337
+ const configHome = options.gentlePiConfigHome ?? gentleAiConfigHome();
338
+ projectFile = join(cwd, ".pi", "gentle-ai", BACKGROUND_SUBAGENTS_FILE);
339
+ globalFile = join(configHome, BACKGROUND_SUBAGENTS_FILE);
340
+ const projectFileExists = existsSync(projectFile);
341
+ const globalFileExists = existsSync(globalFile);
342
+ const locations = { projectFile, globalFile, projectFileExists, globalFileExists, envValue };
343
+ for (const [source, path, present] of [
344
+ ["project_file", projectFile, projectFileExists],
345
+ ["global_file", globalFile, globalFileExists],
346
+ ] as const) {
347
+ if (!present) continue;
348
+ let decoded: BackgroundSubagentsPolicy | undefined;
349
+ try {
350
+ decoded = parseBackgroundSubagentsPolicyFile(readFileSync(path, "utf8"));
351
+ } catch {
352
+ // Unreadable is indistinguishable from unusable at this layer, and
353
+ // both must fail closed on the file that claimed the decision.
354
+ decoded = undefined;
355
+ }
356
+ return decoded === undefined
357
+ ? { policy: "off", source, malformed: true, ...locations }
358
+ : { policy: decoded, source, malformed: false, ...locations };
359
+ }
360
+ if (envValue === "on" || envValue === "off") {
361
+ return { policy: envValue, source: "environment", malformed: false, ...locations };
362
+ }
363
+ return { policy: "off", source: "default", malformed: false, ...locations };
364
+ } catch {
365
+ return {
366
+ policy: "off",
367
+ source: "default",
368
+ malformed: false,
369
+ projectFile,
370
+ globalFile,
371
+ projectFileExists: false,
372
+ globalFileExists: false,
373
+ envValue,
374
+ };
375
+ }
376
+ }
377
+
378
+ /**
379
+ * The effective policy alone, for callers that do not report a source.
380
+ * It delegates so the loader and the resolver can never disagree.
381
+ */
382
+ function loadBackgroundSubagentsPolicy(
383
+ cwd: string,
384
+ options: LoadBackgroundSubagentsOptions = {},
385
+ ): BackgroundSubagentsPolicy {
386
+ return resolveBackgroundSubagentsPolicy(cwd, options).policy;
387
+ }
388
+
389
+ /** Write the global policy file, creating the config home when needed. */
390
+ function writeGlobalBackgroundSubagentsPolicy(
391
+ policy: BackgroundSubagentsPolicy,
392
+ configHome: string = gentleAiConfigHome(),
393
+ ): string {
394
+ const path = join(configHome, BACKGROUND_SUBAGENTS_FILE);
395
+ mkdirSync(configHome, { recursive: true });
396
+ writeFileSync(
397
+ path,
398
+ `${JSON.stringify({ schema: BACKGROUND_SUBAGENTS_SCHEMA, policy }, null, 2)}\n`,
399
+ );
400
+ return path;
401
+ }
402
+
403
+ function describeBackgroundSubagentsSource(
404
+ resolution: BackgroundSubagentsResolution,
405
+ ): string {
406
+ switch (resolution.source) {
407
+ case "project_file":
408
+ return `project file ${resolution.projectFile}`;
409
+ case "global_file":
410
+ return `global file ${resolution.globalFile}`;
411
+ case "environment":
412
+ return "GENTLE_PI_BACKGROUND_SUBAGENTS";
413
+ default:
414
+ return "built-in default";
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Report the effective policy, the source that decided it, and the resolved
420
+ * capability, plus whatever the user needs to know about the sources that did
421
+ * NOT decide. `wrote` names a policy this invocation just wrote to the global
422
+ * file; a write that a higher-priority file outranks must never be reported as
423
+ * if it had taken effect.
424
+ */
425
+ function renderBackgroundSubagentsReport(
426
+ resolution: BackgroundSubagentsResolution,
427
+ capability: BackgroundSubagentsCapability,
428
+ wrote?: BackgroundSubagentsPolicy,
429
+ ): { message: string; type: "info" | "warning" } {
430
+ const lines = [
431
+ `background subagents: ${resolution.policy} (decided by ${describeBackgroundSubagentsSource(resolution)}; capability: ${capability})`,
432
+ ];
433
+ if (wrote !== undefined) {
434
+ lines.push(`Wrote ${wrote} to the global file ${resolution.globalFile}.`);
435
+ }
436
+ if (resolution.malformed) {
437
+ const path =
438
+ resolution.source === "project_file" ? resolution.projectFile : resolution.globalFile;
439
+ lines.push(
440
+ `${path} is present but malformed, so the policy fails closed to off and no lower-priority source is consulted.`,
441
+ );
442
+ }
443
+ const outranksTheWrite = wrote !== undefined && resolution.source === "project_file";
444
+ if (outranksTheWrite) {
445
+ lines.push(
446
+ `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.`,
447
+ );
448
+ } else if (
449
+ wrote === undefined &&
450
+ resolution.source === "project_file" &&
451
+ resolution.globalFileExists
452
+ ) {
453
+ lines.push(
454
+ `The global file ${resolution.globalFile} exists but is outranked by that project file.`,
455
+ );
456
+ }
457
+ if (resolution.envValue !== undefined && resolution.source !== "environment") {
458
+ lines.push(
459
+ resolution.envValue === "on" || resolution.envValue === "off"
460
+ ? `GENTLE_PI_BACKGROUND_SUBAGENTS=${resolution.envValue} is set, but both files outrank it and it outranks the built-in default; it decides only when neither file exists.`
461
+ : `GENTLE_PI_BACKGROUND_SUBAGENTS="${resolution.envValue}" is not a recognized value ("on" or "off"), so it is ignored.`,
462
+ );
463
+ }
464
+ lines.push(
465
+ "Resolution order (first hit wins): project file, global file, GENTLE_PI_BACKGROUND_SUBAGENTS, built-in default off.",
466
+ );
467
+ return {
468
+ message: lines.join("\n"),
469
+ type: resolution.malformed || outranksTheWrite ? "warning" : "info",
470
+ };
471
+ }
472
+
473
+ const SUBAGENTS_PACKAGE_NAMES = ["pi-subagents-j0k3r", "pi-subagents"] as const;
474
+ const SUBAGENT_RUN_TOOL = "subagent_run";
475
+
476
+ /**
477
+ * Roots where an installed subagents package may live. These are the same
478
+ * roots builtinAgentDirs() walks, minus its `/agents` suffix.
479
+ *
480
+ * builtinAgentDirs() looks for markdown agent definitions, which the package
481
+ * legitimately may not ship. Capability is a different question, so it must
482
+ * not reuse that path: pi-subagents-j0k3r v1.5.2 ships index.ts, src/, skills/
483
+ * and scripts/ and no agents/ directory at all, so an agents-dir probe reports
484
+ * "absent" on every real install and leaves the background policy inert.
485
+ */
486
+ function subagentsPackageRoots(cwd: string): string[] {
487
+ return SUBAGENTS_PACKAGE_NAMES.flatMap((packageName) => [
488
+ join(PACKAGE_ROOT, "..", packageName),
489
+ join(cwd, ".pi", "npm", "node_modules", packageName),
490
+ join(homedir(), ".local", "lib", "node_modules", packageName),
491
+ ]);
492
+ }
493
+
494
+ /** A package root counts as installed only when it carries its own manifest. */
495
+ function hasInstalledSubagentsPackage(cwd: string): boolean {
496
+ return subagentsPackageRoots(cwd).some((root) =>
497
+ existsSync(join(root, "package.json")),
498
+ );
499
+ }
500
+
501
+ function hasSubagentRunTool(activeTools: readonly string[]): boolean {
502
+ return activeTools.some(
503
+ (name) => name === SUBAGENT_RUN_TOOL || name.endsWith(`.${SUBAGENT_RUN_TOOL}`),
504
+ );
505
+ }
506
+
507
+ /**
508
+ * Read the live pi tool registry, or undefined when it carries no signal.
509
+ *
510
+ * An absent handle, a non-array result, a throwing registry, and an empty list
511
+ * are all "no signal" rather than "no subagents": reporting absent from an
512
+ * uninformative registry would reproduce the very defect this probe fixes.
513
+ */
514
+ function readActiveToolNames(pi: unknown): readonly string[] | undefined {
515
+ try {
516
+ const getActiveTools = (pi as { getActiveTools?: () => unknown })
517
+ ?.getActiveTools;
518
+ if (typeof getActiveTools !== "function") return undefined;
519
+ const tools = getActiveTools.call(pi);
520
+ if (!Array.isArray(tools)) return undefined;
521
+ const names = tools
522
+ .map((tool) =>
523
+ typeof tool === "string"
524
+ ? tool
525
+ : isRecord(tool) && typeof tool.name === "string"
526
+ ? tool.name
527
+ : "",
528
+ )
529
+ .filter((name) => name.length > 0);
530
+ return names.length > 0 ? names : undefined;
531
+ } catch {
532
+ return undefined;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * `subagent_run` availability probe.
538
+ *
539
+ * The live tool registry answers the question directly and wins whenever it
540
+ * carries any signal. Without it -- prompt rendering outside a session, or a
541
+ * runtime with no getActiveTools -- capability falls back to the presence of
542
+ * an installed subagents package.
543
+ */
544
+ function resolveBackgroundSubagentsCapability(
545
+ cwd: string,
546
+ activeTools?: readonly string[],
547
+ ): BackgroundSubagentsCapability {
548
+ try {
549
+ if (activeTools !== undefined && activeTools.length > 0) {
550
+ return hasSubagentRunTool(activeTools) ? "ready" : "absent";
551
+ }
552
+ return hasInstalledSubagentsPackage(cwd) ? "ready" : "absent";
553
+ } catch {
554
+ return "absent";
555
+ }
556
+ }
557
+
558
+ function renderBackgroundSubagentsStatusLine(
559
+ background: BackgroundSubagentsRendering,
560
+ ): string {
561
+ return `Background subagent policy: ${background.policy} (capability: ${background.capability})`;
562
+ }
563
+
564
+ // Rendered prompts are memoized per background policy/capability key for the
565
+ // process lifetime; the assets bytes themselves are read once per key.
566
+ const orchestratorPromptCache = new Map<string, string>();
567
+ function getOrchestratorPrompt(
568
+ cwd: string = process.cwd(),
569
+ activeTools?: readonly string[],
570
+ ): string {
571
+ const background: BackgroundSubagentsRendering = {
572
+ policy: loadBackgroundSubagentsPolicy(cwd),
573
+ capability: resolveBackgroundSubagentsCapability(cwd, activeTools),
574
+ };
575
+ const cacheKey = `${background.policy}:${background.capability}`;
576
+ let prompt = orchestratorPromptCache.get(cacheKey);
577
+ if (prompt === undefined) {
578
+ prompt = renderOrchestratorPrompt(ASSETS_DIR, background);
579
+ orchestratorPromptCache.set(cacheKey, prompt);
221
580
  }
222
- return orchestratorPromptCache;
581
+ return prompt;
223
582
  }
224
583
 
225
- function renderOrchestratorPrompt(assetsDir: string): string {
584
+ function renderOrchestratorPrompt(
585
+ assetsDir: string,
586
+ background: BackgroundSubagentsRendering = DEFAULT_BACKGROUND_SUBAGENTS_RENDERING,
587
+ ): string {
226
588
  return readFileSync(join(assetsDir, "orchestrator.md"), "utf8")
227
589
  .replaceAll(
228
590
  "{{GENTLE_PI_SDD_WORKFLOW_PATH}}",
@@ -240,6 +602,10 @@ function renderOrchestratorPrompt(assetsDir: string): string {
240
602
  "{{GENTLE_PI_SKILLS_PATH}}",
241
603
  join(assetsDir, "orchestrator-skills.md"),
242
604
  )
605
+ .replaceAll(
606
+ "{{GENTLE_PI_BACKGROUND_POLICY}}",
607
+ renderBackgroundSubagentsStatusLine(background),
608
+ )
243
609
  .trim();
244
610
  }
245
611
 
@@ -275,7 +641,11 @@ const NEUTRAL_PERSONA_PROMPT = `Persona:
275
641
  - Push back when the user asks for code without enough context or understanding.
276
642
  - Correct errors directly, explain why, and show the better path.`;
277
643
 
278
- function buildGentlePrompt(persona: PersonaMode): string {
644
+ function buildGentlePrompt(
645
+ persona: PersonaMode,
646
+ cwd: string = process.cwd(),
647
+ activeTools?: readonly string[],
648
+ ): string {
279
649
  const personaPrompt =
280
650
  persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
281
651
  const languageBoundary =
@@ -309,7 +679,7 @@ Harness principles:
309
679
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
310
680
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
311
681
 
312
- ${getOrchestratorPrompt()}`;
682
+ ${getOrchestratorPrompt(cwd, activeTools)}`;
313
683
  }
314
684
 
315
685
  // Matches `git [global-flags] push` — tolerates flags like -C /repo or --work-tree=/tmp
@@ -555,7 +925,16 @@ const CORE_MODEL_AGENT_NAMES = [
555
925
  ] as const;
556
926
  const CORE_MODEL_AGENT_NAME_SET = new Set<string>(CORE_MODEL_AGENT_NAMES);
557
927
 
558
- type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
928
+ const THINKING_LEVELS = [
929
+ "off",
930
+ "minimal",
931
+ "low",
932
+ "medium",
933
+ "high",
934
+ "xhigh",
935
+ "max",
936
+ ] as const;
937
+ type ThinkingLevel = (typeof THINKING_LEVELS)[number];
559
938
  interface AgentRoutingEntry {
560
939
  model?: string;
561
940
  thinking?: ThinkingLevel;
@@ -579,12 +958,7 @@ const CUSTOM_MODEL = "Custom model id";
579
958
  const INHERIT_THINKING = "Inherit effort";
580
959
  const THINKING_OPTIONS: (ThinkingLevel | typeof INHERIT_THINKING)[] = [
581
960
  INHERIT_THINKING,
582
- "off",
583
- "minimal",
584
- "low",
585
- "medium",
586
- "high",
587
- "xhigh",
961
+ ...THINKING_LEVELS,
588
962
  ];
589
963
 
590
964
  const MODEL_CONTROL_OPTIONS = [
@@ -804,12 +1178,8 @@ function writePersonaMode(cwd: string, mode: PersonaMode): string[] {
804
1178
 
805
1179
  function isThinkingLevel(value: unknown): value is ThinkingLevel {
806
1180
  return (
807
- value === "off" ||
808
- value === "minimal" ||
809
- value === "low" ||
810
- value === "medium" ||
811
- value === "high" ||
812
- value === "xhigh"
1181
+ typeof value === "string" &&
1182
+ (THINKING_LEVELS as readonly string[]).includes(value)
813
1183
  );
814
1184
  }
815
1185
 
@@ -2164,7 +2534,7 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2164
2534
  },
2165
2535
  input: {
2166
2536
  type: "string",
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.",
2537
+ 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 only the negotiated collection answers: correction forecast, targeted validation, final evidence, and an explicit final_verification_passed boolean; reviewer, refuter, and validator verdicts are admitted natively and never Pi-authored. Judgment Day retains graph-v1 input.",
2168
2538
  },
2169
2539
  outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
2170
2540
  inputPath: { type: "string", description: "Repository-local JSON input file for finalize/advance (alternative to input). Legacy bundle import is retired." },
@@ -2177,6 +2547,23 @@ const REVIEW_CONTROLLER_PARAMETERS = {
2177
2547
  },
2178
2548
  } as const;
2179
2549
 
2550
+ const REVIEW_SCOPE_PARAMETERS = {
2551
+ type: "object",
2552
+ additionalProperties: false,
2553
+ required: ["manifest", "sha256"],
2554
+ properties: {
2555
+ manifest: { type: "string", maxLength: 4_096, description: "Exact controller-supplied gzip/base64url frozen changed-scope manifest." },
2556
+ sha256: { type: "string", pattern: "^[0-9a-f]{64}$", description: "Exact controller-supplied SHA-256 of the decompressed canonical manifest bytes." },
2557
+ cursor: { type: "integer", minimum: 0, description: "Pagination cursor. Start at 0 and continue with nextCursor until absent." },
2558
+ },
2559
+ } as const;
2560
+
2561
+ interface ReviewScopeParameters {
2562
+ manifest: string;
2563
+ sha256: string;
2564
+ cursor?: number;
2565
+ }
2566
+
2180
2567
  interface ReviewControllerParameters {
2181
2568
  operation: ReviewControllerOperation;
2182
2569
  lineageId?: string;
@@ -2533,7 +2920,17 @@ async function authorizeDestructiveReviewOperation(
2533
2920
  ctx: ExtensionContext,
2534
2921
  ): Promise<void> {
2535
2922
  const parameters = parseReviewControllerParameters(parametersValue);
2536
- const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET || parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER;
2923
+ // RESET alone carries the legacy repository-wide challenge. Native compact-v2
2924
+ // RECOVER has its own six-field contract and its own derived
2925
+ // `gentle-ai.review-recovery-authorization/v1` binding, neither of which the
2926
+ // legacy `repositoryId`/`commonDirHash`/`inventoryHash`/`confirmation` quartet
2927
+ // can express. Native INSPECT never publishes that quartet either, so
2928
+ // demanding it here made the only supported recovery flow unreachable
2929
+ // (issue #212).
2930
+ // RECOVER authorizes itself in `executeReviewControllerOperation`, the way
2931
+ // REPAIR_LEGACY_ALIAS does, because its binding can only be derived from a
2932
+ // fresh native target-status read.
2933
+ const isReset = parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET;
2537
2934
  const maintenance = nativeMaintenanceOperation(parameters.operation);
2538
2935
  if (!isReset && maintenance === undefined) return;
2539
2936
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
@@ -3849,13 +4246,21 @@ const REVIEW_MODE_DISABLED_OUTCOME = "review-mode-disabled";
3849
4246
  // and changes nothing — ground-truthed against a real build. Naming it here
3850
4247
  // would be naming a dead end, which is worse than naming nothing.
3851
4248
  //
3852
- // The default source expresses no opinion and can never be what keeps reviews
3853
- // off, so it gets no guessed continuation the same reason gentle-ai returns
3854
- // an empty scope for RDDModeSourceDefault.
4249
+ // The default branch changed with the pinned v2.4.0 runtime, which made
4250
+ // receipt-driven development opt-in. It used to be unreachable as a reason for
4251
+ // reviews being off — an all-sources-unset install resolved to ON with source
4252
+ // `default` — so naming a continuation for it would have been a guess, and
4253
+ // gentle-ai returned an empty scope to say exactly that. v2.4.0 resolves the
4254
+ // same install to OFF with source `default`, which makes it the most common
4255
+ // refusal there is: every install that never opted in. gentle-ai answers
4256
+ // `global` for it now, not because default is a global opinion but because
4257
+ // global is the only scope that can turn reviews on at all, and Pi answers the
4258
+ // same. Leaving this undefined would hand the single most common state a dead
4259
+ // end.
3855
4260
  function reviewModeContinuation(source: NativeReviewModeSource): string | undefined {
3856
4261
  if (source === NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL) return "Run /gentle:review-mode enable to turn reviews back on for this clone.";
3857
4262
  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.";
3858
- return undefined;
4263
+ return "Run `gentle-ai review mode enable --scope=global` to turn reviews on; receipt-driven development is opt-in and nothing here has enabled it yet. /gentle:review-mode enable only sets clone scope, which can never turn reviews on.";
3859
4264
  }
3860
4265
 
3861
4266
  // Names the situation before the mechanism, then the mechanism, mirroring
@@ -3927,9 +4332,22 @@ function asNativeReviewConsentBindingError(error: unknown): { reason: string; me
3927
4332
  return typeof reason !== "string" || reason.length === 0 ? undefined : { reason, message: error.message };
3928
4333
  }
3929
4334
 
4335
+ function nativeStatusPackageBinaryMissing(operation: ReviewControllerOperation, diagnostics: NativeReviewProcessDiagnostics): Record<string, unknown> {
4336
+ return {
4337
+ operation,
4338
+ status: "blocked",
4339
+ outcome: "native-status-package-binary-missing",
4340
+ ...(operation === REVIEW_CONTROLLER_OPERATION.START ? nativeStartPreAuthorityRejection() : { lineage_created: false, mutation_performed: false, mutation_outcome: "none" }),
4341
+ inventory_complete: false,
4342
+ diagnostics,
4343
+ next_action: "reinstall-package-local-gentle-ai",
4344
+ };
4345
+ }
4346
+
3930
4347
  function nativeStatusFailed(operation: ReviewControllerOperation, error: unknown): Record<string, unknown> {
3931
4348
  const cliError = asNativeReviewCliError(error);
3932
4349
  if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE) return nativeStatusUnsupported(operation);
4350
+ if (cliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, cliError.diagnostics);
3933
4351
  if (cliError !== undefined) {
3934
4352
  return {
3935
4353
  ...nativeOperationFailure(operation, error),
@@ -3970,7 +4388,14 @@ function nativeMaintenanceOperation(operation: ReviewControllerOperation): Nativ
3970
4388
  }
3971
4389
 
3972
4390
  function missingNativeMaintenanceInputs(operation: NativeMaintenanceOperation, input: Record<string, unknown>): readonly string[] {
3973
- return NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key]));
4391
+ const missing = NATIVE_MAINTENANCE_INPUT[operation].filter((key) => !isCanonicalProcessString(input[key]));
4392
+ if (operation !== "abandon") return missing;
4393
+ return [
4394
+ ...missing,
4395
+ ...(Array.isArray(input.capturedLensResults) && input.capturedLensResults.every((entry) => isCanonicalProcessString(entry)) ? [] : ["capturedLensResults"]),
4396
+ ...(typeof input.findingsPresent === "boolean" ? [] : ["findingsPresent"]),
4397
+ ...(typeof input.evidenceRecordsPresent === "boolean" ? [] : ["evidenceRecordsPresent"]),
4398
+ ];
3974
4399
  }
3975
4400
 
3976
4401
  function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, input: Record<string, unknown>): boolean {
@@ -3979,7 +4404,7 @@ function invalidNativeMaintenanceInput(operation: NativeMaintenanceOperation, in
3979
4404
  }
3980
4405
 
3981
4406
  function nativeMaintenanceAuthorization(operation: NativeMaintenanceOperation, input: Record<string, unknown>): string {
3982
- if (operation === "abandon") return nativeReviewAbandonAuthorization({ lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), actor: String(input.actor), reason: String(input.reason) });
4407
+ 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, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason) });
3983
4408
  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) });
3984
4409
  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 }) });
3985
4410
  }
@@ -4008,7 +4433,7 @@ async function executeNativeAuthorityMaintenance(
4008
4433
  pendingAuthorizations.clear();
4009
4434
  try {
4010
4435
  const result = nativeOperation === "abandon"
4011
- ? await nativeReviewCli.abandon!({ cwd, lineage: String(input.lineage), expectedRevision: String(input.expectedRevision), snapshotIdentity: String(input.snapshotIdentity), actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
4436
+ ? 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, evidenceRecordsPresent: input.evidenceRecordsPresent === true, actor: String(input.actor), reason: String(input.reason), maintainerAuthorization: nativeMaintenanceAuthorization(nativeOperation, input), ...(signal === undefined ? {} : { signal }) })
4012
4437
  : nativeOperation === "quarantineLegacy"
4013
4438
  ? 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 }) })
4014
4439
  : 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 }) });
@@ -4530,6 +4955,18 @@ function validateNativeStartPolicyPath(cwd: string, value: unknown): NativeStart
4530
4955
  }
4531
4956
  }
4532
4957
 
4958
+ const NATIVE_START_FOCUS = {
4959
+ RISK: "risk",
4960
+ RESILIENCE: "resilience",
4961
+ READABILITY: "readability",
4962
+ RELIABILITY: "reliability",
4963
+ } as const;
4964
+ type NativeStartFocus = (typeof NATIVE_START_FOCUS)[keyof typeof NATIVE_START_FOCUS];
4965
+
4966
+ function isNativeStartFocus(value: unknown): value is NativeStartFocus {
4967
+ return typeof value === "string" && (Object.values(NATIVE_START_FOCUS) as readonly string[]).includes(value);
4968
+ }
4969
+
4533
4970
  function nativeStartRejection(reason: string, field?: string): Record<string, unknown> {
4534
4971
  return {
4535
4972
  operation: REVIEW_CONTROLLER_OPERATION.START,
@@ -4546,7 +4983,7 @@ function nativeStartRejection(reason: string, field?: string): Record<string, un
4546
4983
  ? "native-start-committed-only-required"
4547
4984
  : reason === "committed-only-invalid"
4548
4985
  ? "native-start-committed-only-invalid"
4549
- : reason === "unknown-field"
4986
+ : reason === "unknown-field" || reason === "focus-invalid"
4550
4987
  ? "native-start-input-invalid"
4551
4988
  : "native-start-policy-path-invalid",
4552
4989
  reason,
@@ -4562,7 +4999,7 @@ interface PendingReviewConsent {
4562
4999
  repositoryCwd: string;
4563
5000
  authorityCwd: string;
4564
5001
  candidateView: CandidateView;
4565
- consent: ReviewConsentV2;
5002
+ consent: ReviewConsentEnvelope;
4566
5003
  consentDigest: string;
4567
5004
  expiresAt: number;
4568
5005
  expiry?: ReturnType<typeof setTimeout>;
@@ -4583,7 +5020,21 @@ function cleanupAllPendingReviewConsents(pendingReviewConsents: Map<string, Pend
4583
5020
  for (const pending of [...pendingReviewConsents.values()]) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
4584
5021
  }
4585
5022
 
4586
- function reviewConsentDigest(consent: ReviewConsentV2): string {
5023
+ // An unused consent binding and the candidate view retained exclusively for
5024
+ // that binding expire as one lifecycle unit. TTL expiry is observable the
5025
+ // moment synchronous time says `expiresAt <= now`, so cleanup must be
5026
+ // synchronous with respect to that observation — the queued cleanup
5027
+ // macrotask is a safety net, not the authority. Pruning here (before any
5028
+ // later START may reuse the retained view) keeps timer order from deciding
5029
+ // correctness: a fresh candidate retry never reuses a view whose binding
5030
+ // already expired, so it cannot trip `candidate-target-projection-drift`.
5031
+ function pruneExpiredReviewConsents(pendingReviewConsents: Map<string, PendingReviewConsent>, candidateViews: CandidateViewRegistry | null, now: () => number): void {
5032
+ for (const pending of [...pendingReviewConsents.values()]) {
5033
+ if (pending.expiresAt <= now()) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
5034
+ }
5035
+ }
5036
+
5037
+ function reviewConsentDigest(consent: ReviewConsentEnvelope): string {
4587
5038
  return createHash("sha256").update(JSON.stringify(consent)).digest("hex");
4588
5039
  }
4589
5040
 
@@ -4600,6 +5051,17 @@ function assertNativeStartCandidateBinding(candidateView: CandidateView, target:
4600
5051
  }
4601
5052
  }
4602
5053
 
5054
+ function assertNativeFinalizeCandidateBinding(candidateView: CandidateView, target: ReviewStatusV3): void {
5055
+ candidateView.verify();
5056
+ if (
5057
+ target.projection.baseTree !== candidateView.baseTree ||
5058
+ target.projection.currentCandidateTree !== candidateView.candidateTree ||
5059
+ JSON.stringify([...target.projection.paths].sort()) !== JSON.stringify([...candidateView.paths].sort())
5060
+ ) {
5061
+ throw new CandidateViewError("native FINALIZE target does not match the immutable reviewer candidate view", "candidate-target-projection-drift");
5062
+ }
5063
+ }
5064
+
4603
5065
  function completeNativeStart(
4604
5066
  operation: ReviewControllerOperation,
4605
5067
  result: NativeStartResult,
@@ -4665,12 +5127,18 @@ function nativeOperationFailure(operation: ReviewControllerOperation, error: unk
4665
5127
  };
4666
5128
  }
4667
5129
  const mutationOutcome = value.mutationOutcome === "unknown" ? "unknown" : "none";
4668
- const nativeDiagnostics = asNativeReviewCliError(error)?.diagnostics;
5130
+ const nativeCliError = asNativeReviewCliError(error);
5131
+ if (nativeCliError?.code === NATIVE_REVIEW_ERROR_CODE.PACKAGE_BINARY_MISSING) return nativeStatusPackageBinaryMissing(operation, nativeCliError.diagnostics);
5132
+ const nativeDiagnostics = nativeCliError?.diagnostics;
5133
+ // A target-status probe verifies `version` before it invokes `review/status`.
5134
+ // Preserve either already-sanitized diagnostic on every controller route rather
5135
+ // than relabeling an actionable failure as an opaque controller failure.
5136
+ const preservesNativeTargetStatusDiagnostic = nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.VERSION || nativeDiagnostics?.operation === NATIVE_REVIEW_OPERATION.STATUS;
4669
5137
  const diagnostics = operation === REVIEW_CONTROLLER_OPERATION.START && error instanceof CandidateViewError && value.candidateViewPreNative === true
4670
- ? { code: error.reason, message: "candidate view rejected before native START" }
5138
+ ? error.diagnostics ?? { code: error.reason, message: "candidate view rejected before native START" }
4671
5139
  : error instanceof CandidateViewError
4672
5140
  ? { code: error.reason, message: error.message }
4673
- : nativeDiagnostics?.operation === `review/${operation}`
5141
+ : nativeDiagnostics?.operation === `review/${operation}` || preservesNativeTargetStatusDiagnostic
4674
5142
  ? nativeDiagnostics
4675
5143
  : undefined;
4676
5144
  return {
@@ -4708,6 +5176,7 @@ async function reconcileNativeMutationFailure(
4708
5176
  error: unknown,
4709
5177
  nativeReviewCli: NativeReviewCli,
4710
5178
  target: { cwd: string; lineageId?: string; baseRef?: string; projection?: "workspace" | "staged" },
5179
+ preOperationRevision?: string,
4711
5180
  ): Promise<Record<string, unknown>> {
4712
5181
  const failure = nativeOperationFailure(operation, error);
4713
5182
  if (!nativeMutationRequiresStatus(error)) return failure;
@@ -4733,6 +5202,28 @@ async function reconcileNativeMutationFailure(
4733
5202
  ...reconcileFinalizeRouting(status, target.lineageId, operation === REVIEW_CONTROLLER_OPERATION.FINALIZE),
4734
5203
  };
4735
5204
  }
5205
+ // Field defect (fambig, 2026-08-16): an envelope-less mutating failure
5206
+ // is stamped mutationOutcome "unknown", but a reconciled authority
5207
+ // revision identical to the pre-operation revision PROVES the failed
5208
+ // call never mutated. Report that proof as mutation_outcome none and
5209
+ // claim no replay prohibition for it. Every genuinely ambiguous result
5210
+ // — revision moved, no pre-operation revision held, or STATUS
5211
+ // unavailable — stays fail-closed exactly as before.
5212
+ if (preOperationRevision !== undefined && status.authority?.revision === preOperationRevision) {
5213
+ const { replayability: staleReplayability, ...provenBase } = reconciledBase;
5214
+ void staleReplayability;
5215
+ return {
5216
+ ...provenBase,
5217
+ outcome: "native-mutation-status-reconciled",
5218
+ reconciliation: status.raw,
5219
+ authority_applicability: status.applicability,
5220
+ provider_action: status.action,
5221
+ mutation_performed: false,
5222
+ mutation_outcome: "none",
5223
+ mutation_outcome_reason: `authority revision unchanged across reconciliation (${preOperationRevision}); the failed operation provably did not mutate`,
5224
+ next_action: status.action,
5225
+ };
5226
+ }
4736
5227
  return {
4737
5228
  ...reconciledBase,
4738
5229
  outcome: "native-mutation-status-reconciled",
@@ -4824,97 +5315,173 @@ function resolveReviewControllerWorkspaceRoot(requested: string | undefined, ses
4824
5315
  return resolved;
4825
5316
  }
4826
5317
 
4827
- function providerArgumentValue(name: string, input: NonNullable<NonNullable<ReviewStatusV3["nextTransition"]>["collect"]>["inputs"][number]): string {
4828
- const matches = input.arguments.filter((argument) => argument.name === name);
4829
- if (matches.length !== 1 || !isCanonicalProcessString(matches[0]?.value)) {
4830
- throw new CandidateViewError(`provider reviewer collect input requires exactly one canonical ${name} argument`, "manifest-input-divergence");
5318
+ function correctionOutcome(input: ReturnType<typeof parseNativeCompactFinalizeInput>): CorrectionOutcome | undefined {
5319
+ if (input.final_verification_outcome !== undefined) return input.final_verification_outcome;
5320
+ if (input.final_verification_passed === undefined) return undefined;
5321
+ return input.final_verification_passed ? "passed" : "verification_failed";
5322
+ }
5323
+
5324
+ // Both targeted-validation collection forms count as "validation offered":
5325
+ // the host-run `external.run_targeted_validation` input and the Go-owned
5326
+ // `review.capture-validation` vector (gentle-pi#311 P4-roles).
5327
+ function isTargetedValidationCollectInput(input: { captureOperation: string }): boolean {
5328
+ return input.captureOperation === "external.run_targeted_validation" || input.captureOperation === "review.capture-validation";
5329
+ }
5330
+
5331
+ function requireEvidenceCollection(status: ReviewStatusV3): ReviewCollectInputV3 {
5332
+ const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
5333
+ // An offered validation STEP is a targeted-validation collect input. The
5334
+ // bare `validation_request` field is descriptive context both live
5335
+ // emitters (pinned 2.2.3 and 2.4.0-main, probed 2026-08-16) publish
5336
+ // alongside the evidence collect at `correction_required`; treating it as
5337
+ // an offered step made the controller demand its validation phase before
5338
+ // capturing the evidence the state itself demanded (field defect).
5339
+ if (inputs.some(isTargetedValidationCollectInput)) {
5340
+ throw new CandidateViewError("targeted validation was offered before correction evidence was captured", "evidence-first-ordering");
5341
+ }
5342
+ const evidence = inputs.filter((input) => input.captureOperation === "review.capture-evidence");
5343
+ if (evidence.length !== 1) {
5344
+ throw new CandidateViewError("provider status must collect exactly one correction evidence record before targeted validation", "evidence-first-ordering");
4831
5345
  }
4832
- return matches[0]!.value;
5346
+ return evidence[0]!;
4833
5347
  }
4834
5348
 
4835
- function providerReviewerProjection(status: ReviewStatusV3): NativeCandidateProjectionDescriptor {
4836
- const authority = status.authority;
4837
- const inputs = status.nextTransition?.kind === "collect"
4838
- ? status.nextTransition.collect?.inputs.filter((input) => input.captureOperation === "review.capture-result") ?? []
4839
- : [];
4840
- if (authority === undefined || inputs.length === 0) {
4841
- throw new CandidateViewError("provider status did not supply reviewer collect inputs for the frozen candidate", "manifest-input-divergence");
4842
- }
4843
- const first = inputs[0]!;
4844
- if (first.artifactSubject === undefined || first.baseTree === undefined || first.candidateTree === undefined || first.changedPathManifest === undefined) {
4845
- throw new CandidateViewError("provider reviewer collect input omitted its frozen artifact subject or manifest", "manifest-input-divergence");
4846
- }
4847
- const manifestBytes = JSON.stringify(first.changedPathManifest);
4848
- const manifestHash = first.artifactSubject.changedPathManifestSha256;
4849
- const seenLenses = new Set<string>();
4850
- const seenOrders = new Set<number>();
4851
- const seenSubjects = new Set<string>();
4852
- for (const input of inputs) {
4853
- const subject = input.artifactSubject;
4854
- if (
4855
- subject === undefined || input.baseTree === undefined || input.candidateTree === undefined || input.changedPathManifest === undefined ||
4856
- input.baseTree !== status.projection.baseTree || input.candidateTree !== status.projection.currentCandidateTree ||
4857
- subject.baseTree !== input.baseTree || subject.candidateTree !== input.candidateTree ||
4858
- subject.lineageId !== authority.lineageId || subject.authorityRevision !== authority.revision || subject.targetIdentity !== status.targetIdentity ||
4859
- subject.changedPathManifestSha256 !== manifestHash || JSON.stringify(input.changedPathManifest) !== manifestBytes ||
4860
- providerArgumentValue("lineage", input) !== subject.lineageId ||
4861
- providerArgumentValue("expected-revision", input) !== subject.authorityRevision ||
4862
- providerArgumentValue("target", input) !== subject.targetIdentity ||
4863
- providerArgumentValue("lens", input) !== subject.lens ||
4864
- providerArgumentValue("order", input) !== String(subject.selectedOrder) ||
4865
- providerArgumentValue("subject-hash", input) !== subject.subjectHash ||
4866
- seenLenses.has(subject.lens) || seenOrders.has(subject.selectedOrder) || seenSubjects.has(subject.subjectHash)
4867
- ) {
4868
- throw new CandidateViewError("provider reviewer collect inputs disagree on their frozen manifest binding", "manifest-input-divergence");
4869
- }
4870
- seenLenses.add(subject.lens);
4871
- seenOrders.add(subject.selectedOrder);
4872
- seenSubjects.add(subject.subjectHash);
5349
+ interface EvidenceCaptureBinding {
5350
+ /** The identity the collect slot demands; the top-level status identity only as a pre-slot compatibility fallback. */
5351
+ readonly targetIdentity: string;
5352
+ readonly expectedRevision: string;
5353
+ /** True when the slot rendered its own target identity; the record binds the slot's (fix-diff) scope, not the frozen projection scope. */
5354
+ readonly slotBound: boolean;
5355
+ readonly submission?: {
5356
+ readonly argumentTokens: readonly string[];
5357
+ readonly outcomeSubstitutionLocation: number;
5358
+ readonly inputSubstitutionLocation: number;
5359
+ readonly carriesRepositoryContext: boolean;
5360
+ };
5361
+ }
5362
+
5363
+ // Field defect (fambig, 2026-08-16): at every evidence-pending sub-state the
5364
+ // collect slot renders the identity native demands — for a correction that is
5365
+ // the FIX-DIFF identity, never the top-level live workspace snapshot identity.
5366
+ // Collect satisfaction binds to the slot's rendered arguments and submission
5367
+ // tokens; top-level status fields remain only a compatibility fallback for
5368
+ // pre-v5 emitters whose slots render no identities.
5369
+ function resolveEvidenceCaptureBinding(slot: ReviewCollectInputV3, status: ReviewStatusV3, lineageId: string, outcome: CorrectionOutcome): EvidenceCaptureBinding {
5370
+ const named = new Map(slot.arguments.map((argument) => [argument.name, argument.value]));
5371
+ const slotLineage = named.get("lineage");
5372
+ if (slotLineage !== undefined && slotLineage !== lineageId) {
5373
+ throw new CandidateViewError("provider evidence collect slot is bound to a different lineage", "correction-evidence-binding-drift");
5374
+ }
5375
+ const slotRevision = named.get("expected-revision");
5376
+ if (slotRevision !== undefined && slotRevision !== status.authority?.revision) {
5377
+ throw new CandidateViewError("provider evidence collect slot is bound to a different authority revision", "correction-evidence-binding-drift");
5378
+ }
5379
+ const slotTarget = named.get("target");
5380
+ const binding = {
5381
+ targetIdentity: slotTarget ?? status.targetIdentity,
5382
+ expectedRevision: slotRevision ?? status.authority!.revision,
5383
+ slotBound: slotTarget !== undefined,
5384
+ };
5385
+ const descriptor = slot.submissionDescriptor;
5386
+ if (descriptor === undefined || descriptor.operationToken !== "capture-evidence") return binding;
5387
+ const outcomeSlot = descriptor.values?.find((value) => value.slot === "outcome");
5388
+ const inputSlot = descriptor.values?.find((value) => value.slot === "input");
5389
+ if (outcomeSlot === undefined || inputSlot === undefined) {
5390
+ throw new CandidateViewError("provider capture-evidence submission descriptor must render the outcome and input slots", "correction-evidence-binding-drift");
4873
5391
  }
4874
- const intendedUntracked = first.changedPathManifest.filter((entry) => entry.intendedUntracked).map((entry) => entry.path).sort();
4875
- if (JSON.stringify(intendedUntracked) !== JSON.stringify([...status.projection.intendedUntracked].sort())) {
4876
- throw new CandidateViewError("provider manifest intended-untracked fields disagree with its projection", "manifest-intended-untracked-not-subset");
5392
+ if (outcomeSlot.allowedValues !== undefined && !outcomeSlot.allowedValues.includes(outcome)) {
5393
+ throw new CandidateViewError(`provider capture-evidence submission does not admit outcome ${outcome}`, "correction-evidence-binding-drift");
4877
5394
  }
4878
5395
  return {
4879
- ...status.projection,
4880
- manifest: first.changedPathManifest.map((entry) => ({
4881
- path: entry.path,
4882
- status: entry.status,
4883
- oldMode: entry.oldMode,
4884
- newMode: entry.newMode,
4885
- deleted: entry.deleted,
4886
- typeChanged: entry.typeChanged,
4887
- modeOnly: entry.modeOnly,
4888
- })),
4889
- manifestSha256: manifestHash,
4890
- providerManifestHashVerified: true,
5396
+ ...binding,
5397
+ submission: {
5398
+ argumentTokens: descriptor.argumentTokens,
5399
+ outcomeSubstitutionLocation: outcomeSlot.substitutionLocation,
5400
+ inputSubstitutionLocation: inputSlot.substitutionLocation,
5401
+ carriesRepositoryContext: descriptor.argumentTokens.some((token) => token === "--repository-context" || token.startsWith("--repository-context=")),
5402
+ },
4891
5403
  };
4892
5404
  }
4893
5405
 
4894
- function correctionOutcome(input: ReturnType<typeof parseNativeCompactFinalizeInput>): CorrectionOutcome | undefined {
4895
- if (input.final_verification_outcome !== undefined) return input.final_verification_outcome;
4896
- if (input.final_verification_passed === undefined) return undefined;
4897
- return input.final_verification_passed ? "passed" : "verification_failed";
5406
+ async function captureEvidenceForCollection(
5407
+ nativeReviewCli: NativeReviewCli,
5408
+ binding: EvidenceCaptureBinding,
5409
+ cwd: string,
5410
+ lineageId: string,
5411
+ outcome: CorrectionOutcome,
5412
+ evidenceDocument: string,
5413
+ signal: AbortSignal | undefined,
5414
+ ): Promise<NativeReviewVerificationEvidenceV2> {
5415
+ if (binding.submission !== undefined) {
5416
+ if (nativeReviewCli.captureEvidenceSubmission === undefined) throw new CandidateViewError("native capture-evidence submission execution is unavailable", "evidence-first-ordering");
5417
+ return nativeReviewCli.captureEvidenceSubmission({
5418
+ // The slot's --repository-context is cwd-independent and
5419
+ // authoritative; a path is passed only when the slot renders none.
5420
+ ...(binding.submission.carriesRepositoryContext ? {} : { cwd }),
5421
+ argumentTokens: binding.submission.argumentTokens,
5422
+ outcomeSubstitutionLocation: binding.submission.outcomeSubstitutionLocation,
5423
+ inputSubstitutionLocation: binding.submission.inputSubstitutionLocation,
5424
+ outcome,
5425
+ evidenceDocument,
5426
+ ...(signal === undefined ? {} : { signal }),
5427
+ });
5428
+ }
5429
+ if (nativeReviewCli.captureEvidence === undefined) throw new CandidateViewError("native capture-evidence execution is unavailable", "evidence-first-ordering");
5430
+ return nativeReviewCli.captureEvidence({
5431
+ cwd,
5432
+ lineageId,
5433
+ targetIdentity: binding.targetIdentity,
5434
+ expectedRevision: binding.expectedRevision,
5435
+ outcome,
5436
+ evidenceDocument,
5437
+ ...(signal === undefined ? {} : { signal }),
5438
+ });
4898
5439
  }
4899
5440
 
4900
- function requireEvidenceCollection(status: ReviewStatusV3): void {
5441
+ // Same misbinding class as capture-evidence (live smoke, 2026-08-16): the
5442
+ // correction PLAN and TARGETED VALIDATION collect slots render `finalize`
5443
+ // submission descriptors. When one is rendered, the reconstructed legacy
5444
+ // `--correction-lines`/`--validation` argv fails the live emitter's
5445
+ // committed-intent reconciliation, so the rendered tokens execute verbatim.
5446
+ function finalizeSubmissionSlot(
5447
+ status: ReviewStatusV3,
5448
+ slot: "correction_lines" | "validation",
5449
+ ): { argumentTokens: readonly string[]; value: NonNullable<NonNullable<ReviewCollectInputV3["submissionDescriptor"]>["value"]> } | undefined {
4901
5450
  const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
4902
- if (status.validationRequest !== undefined || inputs.some((input) => input.captureOperation === "external.run_targeted_validation")) {
4903
- throw new CandidateViewError("targeted validation was offered before correction evidence was captured", "evidence-first-ordering");
5451
+ for (const input of inputs) {
5452
+ const descriptor = input.submissionDescriptor;
5453
+ if (descriptor?.operationToken === "finalize" && descriptor.value?.slot === slot) {
5454
+ return { argumentTokens: descriptor.argumentTokens, value: descriptor.value };
5455
+ }
4904
5456
  }
4905
- if (inputs.filter((input) => input.captureOperation === "review.capture-evidence").length !== 1) {
4906
- throw new CandidateViewError("provider status must collect exactly one correction evidence record before targeted validation", "evidence-first-ordering");
5457
+ return undefined;
5458
+ }
5459
+
5460
+ // A slot-bound record covers the slot-demanded (fix-diff) scope: its paths are
5461
+ // a subset of the frozen projection paths and its digest binds the record's
5462
+ // own paths (captured 2026-08-16, lineage review-2b6206ed68fb9128). Only the
5463
+ // pre-slot compatibility fallback still demands projection-exact paths.
5464
+ function evidencePathsDrift(captured: NativeReviewVerificationEvidenceV2, binding: EvidenceCaptureBinding, status: ReviewStatusV3): boolean {
5465
+ if (binding.slotBound) {
5466
+ return captured.paths.length === 0 || captured.paths.some((path) => !status.projection.paths.includes(path));
4907
5467
  }
5468
+ return captured.pathsDigest !== status.projection.pathsDigest || JSON.stringify([...captured.paths].sort()) !== JSON.stringify([...status.projection.paths].sort());
4908
5469
  }
4909
5470
 
4910
5471
  function requireTargetedValidationAfterEvidence(status: ReviewStatusV3): NonNullable<ReviewStatusV3["validationRequest"]> {
4911
5472
  const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
4912
- const targeted = inputs.filter((input) => input.captureOperation === "external.run_targeted_validation");
5473
+ const targeted = inputs.filter(isTargetedValidationCollectInput);
4913
5474
  const request = status.validationRequest;
4914
5475
  if (
4915
- status.authority?.state !== "validating" || targeted.length !== 1 || targeted[0]?.validationRequest === undefined || request === undefined ||
5476
+ // Both live emitters (pinned 2.2.3 and 2.4.0-main, probed 2026-08-16)
5477
+ // keep the post-evidence authority state at `correction_required`;
5478
+ // `validating` is retained for pre-existing fixture compatibility.
5479
+ (status.authority?.state !== "validating" && status.authority?.state !== "correction_required") || targeted.length !== 1 || targeted[0]?.validationRequest === undefined || request === undefined ||
4916
5480
  JSON.stringify(targeted[0].validationRequest) !== JSON.stringify(request) || request.lineageId !== status.authority.lineageId ||
4917
- request.expectedRevision !== status.authority.revision || request.targetIdentity !== status.targetIdentity ||
5481
+ // The live emitter binds the request to the FROZEN authority target
5482
+ // identity; the top-level target identity is the live workspace
5483
+ // snapshot, which already contains the fix (probed 2026-08-16).
5484
+ request.expectedRevision !== status.authority.revision || request.targetIdentity !== (status.authorityTargetIdentity ?? status.targetIdentity) ||
4918
5485
  request.correctionCandidateTree !== status.projection.currentCandidateTree ||
4919
5486
  JSON.stringify([...request.correctionPaths].sort()) !== JSON.stringify([...status.projection.paths].sort()) ||
4920
5487
  request.correctionPathsDigest !== status.projection.pathsDigest
@@ -4926,11 +5493,278 @@ function requireTargetedValidationAfterEvidence(status: ReviewStatusV3): NonNull
4926
5493
 
4927
5494
  function assertNoTargetedValidation(status: ReviewStatusV3): void {
4928
5495
  const inputs = status.nextTransition?.kind === "collect" ? status.nextTransition.collect?.inputs ?? [] : [];
4929
- if (status.validationRequest !== undefined || inputs.some((input) => input.captureOperation === "external.run_targeted_validation")) {
5496
+ // Same rule as requireEvidenceCollection: only an offered targeted-
5497
+ // validation collect input counts; the descriptive `validation_request`
5498
+ // context rides along on non-passing outcomes too.
5499
+ if (inputs.some(isTargetedValidationCollectInput)) {
4930
5500
  throw new CandidateViewError("non-passing evidence unexpectedly unlocked targeted validation", "evidence-first-ordering");
4931
5501
  }
4932
5502
  }
4933
5503
 
5504
+ // gentle-pi#311 P4 — the thin Pi host relay. The provider decides which
5505
+ // capture slots the host satisfies by issuing the --materialize token on a
5506
+ // pi-bound `review.capture-result` collect input; nothing is ever inferred.
5507
+ // The runner is injectable for tests only; production always uses the real
5508
+ // relay in lib/review-host-relay.ts.
5509
+ let activeReviewHostRelayRunner: ReviewHostRelayRunner = runReviewHostRelaySlot;
5510
+ function setReviewHostRelayRunnerForTesting(runner?: ReviewHostRelayRunner): void {
5511
+ activeReviewHostRelayRunner = runner ?? runReviewHostRelaySlot;
5512
+ }
5513
+
5514
+ const REVIEW_HOST_RELAY_RETRY_ACTION =
5515
+ "Re-query negotiated STATUS and relaunch only if the exact same bound slot is reoffered; never rerun from transcript inference.";
5516
+
5517
+ async function executeReviewHostRelayCollection(
5518
+ operation: ReviewControllerOperation,
5519
+ lineageId: string,
5520
+ slots: readonly ReviewHostRelaySlot[],
5521
+ nativeReviewCli: NativeReviewCli,
5522
+ cwd: string,
5523
+ signal?: AbortSignal,
5524
+ ): Promise<Record<string, unknown>> {
5525
+ const captured: Array<Record<string, unknown>> = [];
5526
+ for (const slot of slots) {
5527
+ let result: Awaited<ReturnType<ReviewHostRelayRunner>>;
5528
+ try {
5529
+ // A materialize slot without the provider-owned submission form is
5530
+ // a provider contract mismatch: fail closed before any launch and
5531
+ // never synthesize the completing form.
5532
+ if (slot.submission === undefined) {
5533
+ throw new ReviewHostRelayError(
5534
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH,
5535
+ "binding",
5536
+ REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
5537
+ );
5538
+ }
5539
+ result = await activeReviewHostRelayRunner({
5540
+ captureArgumentTokens: slot.captureArgumentTokens,
5541
+ submission: slot.submission,
5542
+ ...(signal === undefined ? {} : { signal }),
5543
+ });
5544
+ } catch (error) {
5545
+ if (!(error instanceof ReviewHostRelayError)) throw error;
5546
+ const base = {
5547
+ operation,
5548
+ status: "blocked",
5549
+ captured_slots: captured,
5550
+ mutation_performed: captured.length > 0,
5551
+ mutation_outcome: error.mutationOutcome === "unknown" ? "unknown" : captured.length > 0 ? "committed" : "none",
5552
+ };
5553
+ if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE) {
5554
+ return {
5555
+ ...base,
5556
+ outcome: "pi-host-relay-unavailable",
5557
+ reason: REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
5558
+ next_action: "Install a gentle-ai release with the pi host relay surface; existing behavior stays untouched and there is no Pi-authored review document fallback.",
5559
+ };
5560
+ }
5561
+ if (error.kind === REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED) {
5562
+ return {
5563
+ ...base,
5564
+ outcome: "pi-host-relay-handshake-refused",
5565
+ reason: error.message,
5566
+ refusal: error.stderr,
5567
+ next_action: REVIEW_HOST_RELAY_RETRY_ACTION,
5568
+ };
5569
+ }
5570
+ return {
5571
+ ...base,
5572
+ outcome: "pi-host-relay-transport-failure",
5573
+ failure: { kind: error.kind, stage: error.stage, exit_code: error.exitCode, timed_out: error.timedOut },
5574
+ reason: error.message,
5575
+ next_action: REVIEW_HOST_RELAY_RETRY_ACTION,
5576
+ };
5577
+ }
5578
+ captured.push({
5579
+ ...(slot.lens === undefined ? {} : { lens: slot.lens }),
5580
+ ...(slot.order === undefined ? {} : { order: slot.order }),
5581
+ ...(slot.subjectHash === undefined ? {} : { subject_hash: slot.subjectHash }),
5582
+ prompt_bytes: result.promptByteLength,
5583
+ result_bytes: result.resultByteLength,
5584
+ submission: result.submission,
5585
+ });
5586
+ }
5587
+ const after = await nativeReviewCli.targetStatus!({ cwd, lineageId, ...(signal === undefined ? {} : { signal }) });
5588
+ return {
5589
+ ...mapNativeTargetStatus(operation, after, lineageId),
5590
+ host_relay: { transport: "pi_host_relay", captured_slots: captured },
5591
+ };
5592
+ }
5593
+
5594
+ // gentle-pi#311 P4-roles — the Go-owned adversarial role route. The provider
5595
+ // renders each non-lens role slot (`review.capture-refuter` /
5596
+ // `review.capture-validation`) as a SELF-CONTAINED authority-advancing
5597
+ // vector: binding tokens plus `--agent=pi --execute=true` and no submission
5598
+ // descriptor. Pi executes each exact vector once, verbatim and in the
5599
+ // foreground; Go materializes the role prompt, spawns its own locked-down pi
5600
+ // subprocess, and admits the raw verdict. On any failure the typed error is
5601
+ // surfaced and nothing is relaunched — the caller re-queries negotiated
5602
+ // STATUS and executes only a vector it reoffers.
5603
+ const REVIEW_PROVIDER_ROLE_RETRY_ACTION =
5604
+ "Re-query negotiated STATUS and execute only the exact role vector it reoffers; never relaunch from transcript inference.";
5605
+
5606
+ async function executeProviderRoleVectorCollection(
5607
+ operation: ReviewControllerOperation,
5608
+ lineageId: string,
5609
+ slots: readonly ReviewProviderRoleVectorSlot[],
5610
+ nativeReviewCli: NativeReviewCli,
5611
+ cwd: string,
5612
+ signal?: AbortSignal,
5613
+ ): Promise<Record<string, unknown>> {
5614
+ if (nativeReviewCli.captureProviderRole === undefined) {
5615
+ return {
5616
+ operation,
5617
+ status: "blocked",
5618
+ outcome: "provider-role-capture-unsupported",
5619
+ reason: "The provider issued self-contained role capture vectors, but this runtime has no native provider-role capture surface.",
5620
+ mutation_performed: false,
5621
+ mutation_outcome: "none",
5622
+ next_action: "Install a gentle-pi release with the provider-role capture surface; the vectors stay reoffered by negotiated STATUS.",
5623
+ };
5624
+ }
5625
+ const executed: Array<Record<string, unknown>> = [];
5626
+ for (const slot of slots) {
5627
+ let artifact: Awaited<ReturnType<NonNullable<NativeReviewCli["captureProviderRole"]>>>;
5628
+ try {
5629
+ artifact = await nativeReviewCli.captureProviderRole({
5630
+ captureOperation: slot.captureOperation,
5631
+ argumentTokens: slot.argumentTokens,
5632
+ cwd,
5633
+ ...(signal === undefined ? {} : { signal }),
5634
+ });
5635
+ } catch (error) {
5636
+ return {
5637
+ ...nativeOperationFailure(operation, error),
5638
+ outcome: "provider-role-vector-failed",
5639
+ provider_roles: { transport: "go_owned_pi_process", executed_slots: executed },
5640
+ retry_discipline: REVIEW_PROVIDER_ROLE_RETRY_ACTION,
5641
+ };
5642
+ }
5643
+ executed.push({
5644
+ capture_operation: slot.captureOperation,
5645
+ role: artifact.role,
5646
+ lineage_id: artifact.lineageId,
5647
+ target_identity: artifact.targetIdentity,
5648
+ captured: artifact.captured,
5649
+ });
5650
+ }
5651
+ const after = await nativeReviewCli.targetStatus!({ cwd, lineageId, ...(signal === undefined ? {} : { signal }) });
5652
+ return {
5653
+ ...mapNativeTargetStatus(operation, after, lineageId),
5654
+ provider_roles: { transport: "go_owned_pi_process", executed_slots: executed },
5655
+ };
5656
+ }
5657
+
5658
+ // The provider-named lenses still awaiting a reviewer result: one lens per
5659
+ // pending `review.capture-result` collect input, in provider order.
5660
+ function pendingReviewerLenses(status: ReviewStatusV3): readonly string[] {
5661
+ if (status.nextTransition?.kind !== "collect") return [];
5662
+ return [...new Set((status.nextTransition.collect?.inputs ?? [])
5663
+ .filter((input) => input.captureOperation === "review.capture-result")
5664
+ .map((input) => input.artifactSubject?.lens)
5665
+ .filter((lens): lens is NonNullable<typeof lens> => lens !== undefined))];
5666
+ }
5667
+
5668
+ // Live defect (2026-08-16, Engram #12461): a successor lineage created by
5669
+ // native `review recover` exists only in native authority — this controller
5670
+ // never saw its START, so direct reviewer dispatch refused with
5671
+ // current-binding-missing even though the controller itself had just decoded
5672
+ // the successor's authoritative STATUS. Mirror the START-time registration
5673
+ // from STATUS discovery: when an unknown-but-live lineage still collecting
5674
+ // reviewer results appears in a status this controller decoded, restore its
5675
+ // frozen projection from the native descriptor and bind the dispatch-facing
5676
+ // current candidate view with the provider-named pending lenses.
5677
+ //
5678
+ // Field report (2026-08-16, gentle-pi 402f9f77): hydration must run from
5679
+ // EVERY lane that decodes an authoritative status, not from the STATUS
5680
+ // operation alone — the reported flow was `finalize` (blocked on
5681
+ // review.capture-result) followed by a reviewer dispatch, which never passed
5682
+ // through STATUS. It also never fails its caller: STATUS and the blocked
5683
+ // FINALIZE envelope stay read-only, and the outcome is returned so the caller
5684
+ // can report it instead of swallowing it.
5685
+ // Field defect (2026-08-16, third report): the Pi host relay never ran for a
5686
+ // real lineage. Measured against the live 2.4.0-main provider on a faithful
5687
+ // reproduction — an agent-less `review status` returns a bare capture-result
5688
+ // collect input (lineage, expected-revision, target, repository-context, lens,
5689
+ // order, subject-hash), while the SAME status with `--agent pi` additionally
5690
+ // carries agent=pi, materialize=true and the provider submission. The adapter
5691
+ // never named its agent, so reviewHostRelaySlots() saw zero materialize slots,
5692
+ // the relay was unreachable, and no lens was ever launched.
5693
+ //
5694
+ // The agent is PROBED, never assumed. The pinned provider defines `--agent` as
5695
+ // of v2.4.0 — v2.2.3 did not define it on `review status` at all and refused it
5696
+ // outright — but Pi still never version-sniffs: the installed binary remains
5697
+ // the only authority on whether the flag exists. A typed refusal is remembered
5698
+ // per provider instance and the exact provider cause is reported to the user
5699
+ // rather than degraded into a generic candidate-view message.
5700
+ const REVIEW_HOST_AGENT = "pi" as const;
5701
+ const REVIEW_TRANSPORT_REFUSAL_CODES = new Set([
5702
+ "immutable_review_transport_unsupported",
5703
+ "unsupported_agent",
5704
+ "unknown_flag",
5705
+ ]);
5706
+ interface ReviewTransportRefusal { supported: false; code: string; message: string; }
5707
+ const reviewTransportRefusalByProvider = new WeakMap<object, ReviewTransportRefusal>();
5708
+
5709
+ function clearReviewTransportProbeForTesting(nativeReviewCli: NativeReviewCli | null): void {
5710
+ if (nativeReviewCli !== null) reviewTransportRefusalByProvider.delete(nativeReviewCli as unknown as object);
5711
+ }
5712
+
5713
+ /**
5714
+ * Queries negotiated STATUS for the pi reviewer transport so the provider
5715
+ * offers its materialize-marked relay slot, probing the agent exactly once per
5716
+ * provider and falling back to the agent-less status on a typed refusal. The
5717
+ * refusal is returned, never swallowed.
5718
+ */
5719
+ async function negotiatedStatusForHostTransport(
5720
+ nativeReviewCli: NativeReviewCli,
5721
+ request: NativeTargetStatusRequest,
5722
+ ): Promise<{ status: ReviewStatusV3; transport?: ReviewTransportRefusal }> {
5723
+ const provider = nativeReviewCli as unknown as object;
5724
+ const remembered = reviewTransportRefusalByProvider.get(provider);
5725
+ if (remembered !== undefined) {
5726
+ return { status: await nativeReviewCli.targetStatus!(request), transport: remembered };
5727
+ }
5728
+ try {
5729
+ return { status: await nativeReviewCli.targetStatus!({ ...request, agent: REVIEW_HOST_AGENT }) };
5730
+ } catch (error) {
5731
+ const code = error instanceof NativeReviewIntegrationError ? error.failureEnvelope.code : undefined;
5732
+ // Only a transport-shaped refusal falls back; every other failure is
5733
+ // the caller's to handle exactly as before.
5734
+ if (code === undefined || !REVIEW_TRANSPORT_REFUSAL_CODES.has(code)) throw error;
5735
+ const refusal: ReviewTransportRefusal = { supported: false, code, message: error.message };
5736
+ reviewTransportRefusalByProvider.set(provider, refusal);
5737
+ return { status: await nativeReviewCli.targetStatus!(request), transport: refusal };
5738
+ }
5739
+ }
5740
+
5741
+ type DispatchHydrationOutcome =
5742
+ | { hydrated: true; lineage_id: string; lenses: readonly string[] }
5743
+ | { hydrated: false; lineage_id: string; reason: string; message: string }
5744
+ | undefined;
5745
+
5746
+ function hydrateDispatchBindingFromStatus(candidateViews: CandidateViewRegistry | null, contributorRoot: string, status: ReviewStatusV3): DispatchHydrationOutcome {
5747
+ if (candidateViews === null || candidateViews.hasCurrentBinding()) return undefined;
5748
+ const lineageId = status.authority?.lineageId;
5749
+ if (lineageId === undefined || status.applicability !== "current_target" || candidateViews.hasProjection(lineageId)) return undefined;
5750
+ const lenses = pendingReviewerLenses(status);
5751
+ if (lenses.length === 0) return undefined;
5752
+ try {
5753
+ candidateViews.restoreCurrentForDispatchFromNative(lineageId, contributorRoot, status.projection, lenses);
5754
+ return { hydrated: true, lineage_id: lineageId, lenses };
5755
+ } catch (error) {
5756
+ // Never fail the caller on hydration; the registry records the typed
5757
+ // cause so the later dispatch refusal names the attempt instead of
5758
+ // claiming no binding was ever available.
5759
+ return {
5760
+ hydrated: false,
5761
+ lineage_id: lineageId,
5762
+ reason: error instanceof CandidateViewError ? error.reason : "candidate-view-invalid",
5763
+ message: error instanceof Error ? error.message : String(error),
5764
+ };
5765
+ }
5766
+ }
5767
+
4934
5768
  async function executeReviewControllerOperation(
4935
5769
  parametersValue: unknown,
4936
5770
  sessionCwd: string,
@@ -4943,6 +5777,9 @@ async function executeReviewControllerOperation(
4943
5777
  context?: ExtensionContext,
4944
5778
  correctionEvidenceByLineage: Map<string, CorrectionEvidence> = new Map(),
4945
5779
  pendingReviewConsents: Map<string, PendingReviewConsent> = new Map(),
5780
+ writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
5781
+ reviewConsentNow: () => number = Date.now,
5782
+ reviewConsentScheduleTimer: (callback: () => void, delayMs: number) => { unref: () => void } = setTimeout,
4946
5783
  ): Promise<Record<string, unknown>> {
4947
5784
  const parameters = parseReviewControllerParameters(parametersValue);
4948
5785
  const defaultCwd = resolveReviewControllerWorkspaceRoot(parameters.workspaceRoot, sessionCwd);
@@ -4995,6 +5832,20 @@ async function executeReviewControllerOperation(
4995
5832
  }
4996
5833
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RECOVER) {
4997
5834
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
5835
+ // The authorization binding is Pi-derived, never caller-carried. It is
5836
+ // recorded verbatim as a maintainer attestation, so accepting one the
5837
+ // caller composed would let an unapproved actor sign the recovery edge.
5838
+ if (input.maintainerAuthorization !== undefined) {
5839
+ return {
5840
+ operation: parameters.operation,
5841
+ status: "blocked",
5842
+ outcome: "native-recovery-caller-authorization-rejected",
5843
+ native_operation: "review recover",
5844
+ mutation_performed: false,
5845
+ mutation_outcome: "none",
5846
+ next_action: "resubmit-without-maintainer-authorization",
5847
+ };
5848
+ }
4998
5849
  const missing = NATIVE_RECOVERY_INPUT.recover.filter((key) =>
4999
5850
  key === "disposition"
5000
5851
  ? !["scope_changed", "invalidated", "escalated"].includes(input[key] as string)
@@ -5002,27 +5853,76 @@ async function executeReviewControllerOperation(
5002
5853
  );
5003
5854
  if (missing.length > 0) return await executeNativeRecoveryRoute(parameters.operation, "recover", input, defaultCwd, nativeReviewCli, pendingAuthorizations, signal);
5004
5855
  if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5856
+ const frozenTarget = candidateViews?.hasProjection(String(input.predecessorLineage))
5857
+ ? candidateViews.resolveProjection(String(input.predecessorLineage), defaultCwd)
5858
+ : undefined;
5859
+ const statusRequest = {
5860
+ cwd: defaultCwd,
5861
+ lineageId: String(input.predecessorLineage),
5862
+ ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit } : {}),
5863
+ ...(signal === undefined ? {} : { signal }),
5864
+ };
5005
5865
  let status: ReviewStatusV3;
5006
5866
  try {
5007
- const frozenTarget = candidateViews?.hasProjection(String(input.predecessorLineage))
5008
- ? candidateViews.resolveProjection(String(input.predecessorLineage), defaultCwd)
5009
- : undefined;
5010
- status = await nativeReviewCli.targetStatus({
5011
- cwd: defaultCwd,
5012
- lineageId: String(input.predecessorLineage),
5013
- ...(frozenTarget?.committedOnly === true ? { baseRef: frozenTarget.baseCommit } : {}),
5014
- ...(signal === undefined ? {} : { signal }),
5015
- });
5867
+ status = await nativeReviewCli.targetStatus(statusRequest);
5016
5868
  } catch (error) {
5017
5869
  return nativeStatusFailed(parameters.operation, error);
5018
5870
  }
5019
- if (status.action !== "recover" || status.actionDisposition === undefined || status.authority?.lineageId !== input.predecessorLineage || status.authority.revision !== input.expectedPredecessorRevision) {
5871
+ const pinnedRecoveryStatus = (candidate: ReviewStatusV3): boolean =>
5872
+ candidate.action === "recover"
5873
+ && candidate.actionDisposition === status.actionDisposition
5874
+ && candidate.authority?.lineageId === input.predecessorLineage
5875
+ && candidate.authority?.revision === input.expectedPredecessorRevision
5876
+ && candidate.targetIdentity === status.targetIdentity;
5877
+ if (status.action !== "recover" || status.actionDisposition === undefined || status.authority?.lineageId !== input.predecessorLineage || status.authority.revision !== input.expectedPredecessorRevision || !isCanonicalProcessString(status.targetIdentity)) {
5020
5878
  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" };
5021
5879
  }
5022
5880
  if (input.disposition !== status.actionDisposition) {
5023
5881
  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" };
5024
5882
  }
5025
- return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition }, defaultCwd, nativeReviewCli, pendingAuthorizations, signal);
5883
+ const recoverAuthorization = nativeReviewRecoverAuthorization({
5884
+ predecessorLineage: String(input.predecessorLineage),
5885
+ expectedPredecessorRevision: String(input.expectedPredecessorRevision),
5886
+ targetIdentity: status.targetIdentity,
5887
+ actor: String(input.actor),
5888
+ reason: String(input.reason),
5889
+ });
5890
+ if (context?.hasUI !== true) throw new Error("Review controller RECOVER requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed");
5891
+ const approved = await context.ui.confirm(
5892
+ "Authorize destructive review authority RECOVER?",
5893
+ [
5894
+ "Operation: RECOVER",
5895
+ `Provider-selected disposition: ${status.actionDisposition}`,
5896
+ "Exact published authorization binding:",
5897
+ recoverAuthorization,
5898
+ `The native command creates one auditable successor authority (${String(input.successorLineage)}) for this exact predecessor and target identity; the predecessor stays untouched.`,
5899
+ ].join("\n"),
5900
+ );
5901
+ if (!approved) throw new Error("Review controller RECOVER was not explicitly authorized");
5902
+ // Time-of-check to time-of-use: the human deliberates for an unbounded
5903
+ // interval, and the authority can advance, be recovered by someone else, or
5904
+ // stop being recovery-eligible while they do. The approval and the derived
5905
+ // binding are pinned to the pre-approval read, so the authority is read once
5906
+ // more and must still match it exactly before anything mutates.
5907
+ let confirmedStatus: ReviewStatusV3;
5908
+ try {
5909
+ confirmedStatus = await nativeReviewCli.targetStatus(statusRequest);
5910
+ } catch (error) {
5911
+ return nativeStatusFailed(parameters.operation, error);
5912
+ }
5913
+ if (!pinnedRecoveryStatus(confirmedStatus)) {
5914
+ return {
5915
+ operation: parameters.operation,
5916
+ status: "blocked",
5917
+ outcome: "native-recovery-authority-changed",
5918
+ native_operation: "review recover",
5919
+ mutation_performed: false,
5920
+ mutation_outcome: "none",
5921
+ result: confirmedStatus.raw,
5922
+ next_action: "reinspect-and-reauthorize-recovery",
5923
+ };
5924
+ }
5925
+ return await executeNativeRecoveryRoute(parameters.operation, "recover", { ...input, disposition: status.actionDisposition, maintainerAuthorization: recoverAuthorization }, defaultCwd, nativeReviewCli, pendingAuthorizations, signal);
5026
5926
  }
5027
5927
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.RESET) {
5028
5928
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
@@ -5030,7 +5930,12 @@ async function executeReviewControllerOperation(
5030
5930
  }
5031
5931
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.REPAIR) {
5032
5932
  if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5033
- const status = await nativeReviewCli.targetStatus({ cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(signal === undefined ? {} : { signal }) });
5933
+ let status: ReviewStatusV3;
5934
+ try {
5935
+ status = await nativeReviewCli.targetStatus({ cwd: defaultCwd, ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }), ...(signal === undefined ? {} : { signal }) });
5936
+ } catch (error) {
5937
+ return nativeStatusFailed(parameters.operation, error);
5938
+ }
5034
5939
  if (status.authority?.version === "compact-v2") return { operation: parameters.operation, repaired: false, compact_authority: "immutable-untouched", status: mapNativeTargetStatus(parameters.operation, status, parameters.lineageId) };
5035
5940
  if (status.authority?.version !== "legacy-v1") return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId);
5036
5941
  const store = ReviewTransactionStore.forRepository(defaultCwd);
@@ -5097,7 +6002,7 @@ async function executeReviewControllerOperation(
5097
6002
  if (typeof input.consentBinding !== "string" || input.consentBinding.length === 0) throw new Error("Review controller answer-consent requires an opaque consentBinding");
5098
6003
  if (input.answer !== "granted" && input.answer !== "declined") throw new Error("Review controller answer-consent answer must be granted or declined");
5099
6004
  const pending = pendingReviewConsents.get(input.consentBinding);
5100
- if (pending === undefined || pending.expiresAt <= Date.now()) {
6005
+ if (pending === undefined || pending.expiresAt <= reviewConsentNow()) {
5101
6006
  if (pending !== undefined) cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews);
5102
6007
  throw new Error("Review controller consent binding is unknown, expired, or already consumed");
5103
6008
  }
@@ -5117,6 +6022,7 @@ async function executeReviewControllerOperation(
5117
6022
  // The one-shot binding is consumed before the provider mutation. Any
5118
6023
  // ambiguous result reconciles through STATUS and can never be replayed.
5119
6024
  consumePendingReviewConsent(pending, pendingReviewConsents);
6025
+ let completed: Record<string, unknown>;
5120
6026
  try {
5121
6027
  const answered = await nativeReviewCli.answerConsent({
5122
6028
  cwd: pending.authorityCwd,
@@ -5134,7 +6040,7 @@ async function executeReviewControllerOperation(
5134
6040
  ...nativeStartPreAuthorityRejection(),
5135
6041
  };
5136
6042
  }
5137
- return completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, candidateViews);
6043
+ completed = completeNativeStart(parameters.operation, answered.start, pending.repositoryCwd, pending.candidateView, candidateViews);
5138
6044
  } catch (error) {
5139
6045
  const value = error as { mutationOutcome?: unknown };
5140
6046
  if (value.mutationOutcome === "none") candidateViews?.cleanup(pending.candidateView.token);
@@ -5144,6 +6050,16 @@ async function executeReviewControllerOperation(
5144
6050
  projection: "workspace",
5145
6051
  });
5146
6052
  }
6053
+ if (input.answer === "granted") {
6054
+ try {
6055
+ writeReviewConsentLatch(pending.repositoryCwd);
6056
+ } catch (error) {
6057
+ try {
6058
+ context?.ui.notify(`Native review start completed, but Pi could not record the local consent latch: ${error instanceof Error ? error.message : String(error)}`, "warning");
6059
+ } catch { /* Reporting is best effort; native completion remains authoritative. */ }
6060
+ }
6061
+ }
6062
+ return completed;
5147
6063
  }
5148
6064
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.START) {
5149
6065
  const rawStart = parseControllerJson(
@@ -5151,16 +6067,11 @@ async function executeReviewControllerOperation(
5151
6067
  REVIEW_CONTROLLER_OPERATION.START,
5152
6068
  );
5153
6069
  if (rawStart.mode === REVIEW_MODE.ORDINARY) {
5154
- try {
5155
- const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
5156
- if (gated !== undefined) return gated;
5157
- } catch (error) {
5158
- return nativeOperationFailure(parameters.operation, error);
5159
- }
5160
- if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5161
6070
  if ("policyHash" in rawStart) return nativeStartRejection("legacy-policy-hash-unsupported");
5162
- const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath"].includes(field));
6071
+ const unknownField = Object.keys(rawStart).find((field) => !["mode", "baseRef", "committedOnly", "policyPath", "focus"].includes(field));
5163
6072
  if (unknownField !== undefined) return nativeStartRejection("unknown-field", unknownField);
6073
+ const focus = rawStart.focus;
6074
+ if (focus !== undefined && !isNativeStartFocus(focus)) return nativeStartRejection("focus-invalid");
5164
6075
  const policy: NativeStartPolicyValidation = rawStart.policyPath === undefined
5165
6076
  ? {}
5166
6077
  : validateNativeStartPolicyPath(defaultCwd, rawStart.policyPath);
@@ -5174,10 +6085,18 @@ async function executeReviewControllerOperation(
5174
6085
  try {
5175
6086
  canonicalBaseRef = resolveCanonicalCandidateBase(defaultCwd, baseRef).commit;
5176
6087
  } catch (error) {
6088
+ if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
5177
6089
  if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
5178
6090
  return nativeStartRejection("base-ref-unresolvable");
5179
6091
  }
5180
6092
  }
6093
+ try {
6094
+ const gated = await resolveReviewModeGate(nativeReviewCli, parameters.operation, defaultCwd, signal);
6095
+ if (gated !== undefined) return gated;
6096
+ } catch (error) {
6097
+ return nativeOperationFailure(parameters.operation, error);
6098
+ }
6099
+ if (nativeReviewCli?.targetStatus === undefined) return nativeStatusUnsupported(parameters.operation);
5181
6100
  let target: ReviewStatusV3;
5182
6101
  try {
5183
6102
  target = await nativeReviewCli.targetStatus({
@@ -5191,6 +6110,12 @@ async function executeReviewControllerOperation(
5191
6110
  return nativeOperationFailure(parameters.operation, error);
5192
6111
  }
5193
6112
  const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
6113
+ // Synchronously drop any binding whose TTL has already elapsed
6114
+ // before reusing its retained candidate view, so a fresh-candidate
6115
+ // retry cannot reuse a view tied to an expired binding and trip
6116
+ // candidate-target-projection-drift. Timer order must not decide
6117
+ // correctness: the queued cleanup macrotask may not have fired yet.
6118
+ pruneExpiredReviewConsents(pendingReviewConsents, candidateViews, reviewConsentNow);
5194
6119
  let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
5195
6120
  let nativeStartAttempted = false;
5196
6121
  try {
@@ -5208,6 +6133,7 @@ async function executeReviewControllerOperation(
5208
6133
  projection: target.projection.projection,
5209
6134
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5210
6135
  ...(policy.policyPath === undefined ? {} : { policyPath: policy.policyPath }),
6136
+ ...(focus === undefined ? {} : { focus }),
5211
6137
  ...(signal === undefined ? {} : { signal }),
5212
6138
  });
5213
6139
  } catch (error) {
@@ -5216,13 +6142,13 @@ async function executeReviewControllerOperation(
5216
6142
  const consentCandidateView = candidateView;
5217
6143
  const repositoryCwd = realpathSync(defaultCwd);
5218
6144
  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());
6145
+ const existing = [...pendingReviewConsents.values()].find((pending) => pending.repositoryCwd === repositoryCwd && pending.candidateView.token === consentCandidateView.token && pending.consentDigest === consentDigest && pending.expiresAt > reviewConsentNow());
5220
6146
  if (existing === undefined) for (const pending of [...pendingReviewConsents.values()]) if (pending.candidateView.token === consentCandidateView.token) consumePendingReviewConsent(pending, pendingReviewConsents);
5221
6147
  const id = existing?.id ?? randomUUID();
5222
6148
  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 };
6149
+ const pending: PendingReviewConsent = { id, repositoryCwd, authorityCwd: defaultCwd, candidateView: consentCandidateView, consent: error.consent, consentDigest, expiresAt: reviewConsentNow() + PENDING_REVIEW_CONSENT_TTL_MS };
5224
6150
  pendingReviewConsents.set(id, pending);
5225
- pending.expiry = setTimeout(() => cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews), PENDING_REVIEW_CONSENT_TTL_MS);
6151
+ pending.expiry = reviewConsentScheduleTimer(() => cleanupPendingReviewConsent(pending, pendingReviewConsents, candidateViews), PENDING_REVIEW_CONSENT_TTL_MS);
5226
6152
  pending.expiry.unref();
5227
6153
  }
5228
6154
  return {
@@ -5236,6 +6162,7 @@ async function executeReviewControllerOperation(
5236
6162
  }
5237
6163
  return completeNativeStart(parameters.operation, result, defaultCwd, candidateView, candidateViews);
5238
6164
  } catch (error) {
6165
+ if (error instanceof CandidateViewError && error.diagnostics !== undefined) return nativeOperationFailure(parameters.operation, Object.assign(error, { candidateViewPreNative: true }));
5239
6166
  if (error instanceof CandidateViewError && (error.reason === "base-ref-ambiguous" || error.reason === "base-ref-unresolvable" || error.reason === "base-ref-moved")) return nativeStartRejection(error.reason);
5240
6167
  const value = error as { mutationOutcome?: unknown; nextAction?: unknown };
5241
6168
  const provenNoMutation = value.mutationOutcome === "none";
@@ -5328,22 +6255,180 @@ async function executeReviewControllerOperation(
5328
6255
  let correctionCompletion = false;
5329
6256
  let negotiatedStatus: ReviewStatusV3 | undefined;
5330
6257
  let candidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
5331
- let nativeResult: NativeFinalizeResult;
6258
+ let provisionalCandidateView: ReturnType<CandidateViewRegistry["create"]> | undefined;
6259
+ let nativeResult: NativeFinalizeResult | undefined;
5332
6260
  let correctionStep: CorrectionStep | undefined;
6261
+ let transportRefusal: ReviewTransportRefusal | undefined;
5333
6262
  try {
5334
6263
  if (parameters.lineageId === undefined) throw new CandidateViewError("Native FINALIZE requires an explicit lineage");
5335
- negotiatedStatus = await nativeReviewCli.targetStatus({ cwd: defaultCwd, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
5336
- if (negotiatedStatus.applicability !== "current_target" || negotiatedStatus.authority?.lineageId !== parameters.lineageId || (negotiatedStatus.action !== "finalize" && negotiatedStatus.action !== "reconcile_finalize")) return mapNativeTargetStatus(parameters.operation, negotiatedStatus, parameters.lineageId);
5337
- correctionCompletion = input.review_result === undefined && (input.validation !== undefined || input.validation_proof !== undefined) && input.final_evidence !== undefined;
5338
- const validationAttempt = input.review_result === undefined && input.correction_line_forecast === undefined && input.final_evidence !== undefined;
5339
- // A correction-forecast-only FINALIZE (the pre-edit forecast step in
5340
- // correction_required) must also participate in fresh-process projection
5341
- // reconstruction; before #176 it skipped restoration and failed against
5342
- // an empty in-memory registry without ever invoking native FINALIZE.
5343
- const correctionForecast = input.review_result === undefined && input.correction_line_forecast !== undefined;
6264
+ correctionCompletion = input.validation !== undefined && input.final_evidence !== undefined;
6265
+ const validationAttempt = input.correction_line_forecast === undefined && input.final_evidence !== undefined;
5344
6266
  const replayKey = JSON.stringify({ cwd: defaultCwd, lineageId: parameters.lineageId ?? null, input: parameters.input ?? null, inputPath: parameters.inputPath ?? null });
5345
- if ((validationAttempt || input.review_result !== undefined || correctionForecast) && candidateViews && !candidateViews.hasProjection(parameters.lineageId)) {
5346
- const projection = input.review_result === undefined ? negotiatedStatus.projection : providerReviewerProjection(negotiatedStatus);
6267
+ if (candidateViews?.hasProjection(parameters.lineageId)) {
6268
+ candidateViews.resolveProjection(parameters.lineageId, defaultCwd);
6269
+ candidateView = correctionCompletion || validationAttempt
6270
+ ? candidateViews.createCorrected(parameters.lineageId, defaultCwd, replayKey)
6271
+ : candidateViews.resolveForFinalize(parameters.lineageId);
6272
+ } else if (candidateViews) {
6273
+ provisionalCandidateView = candidateViews.createOrReuse({ contributorRoot: defaultCwd, replayKey: `${replayKey}:status-candidate` });
6274
+ candidateView = provisionalCandidateView;
6275
+ }
6276
+ candidateView?.verify();
6277
+ const statusCandidateRoot = candidateView?.root ?? defaultCwd;
6278
+ // Name the host's reviewer transport so the provider offers its
6279
+ // materialize-marked relay slot; a provider without that
6280
+ // transport answers the agent-less status and its typed refusal
6281
+ // travels with the result.
6282
+ const negotiated = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: statusCandidateRoot, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
6283
+ negotiatedStatus = negotiated.status;
6284
+ transportRefusal = negotiated.transport;
6285
+ if (negotiatedStatus.applicability !== "current_target" || negotiatedStatus.authority?.lineageId !== parameters.lineageId || (negotiatedStatus.action !== "finalize" && negotiatedStatus.action !== "reconcile_finalize")) {
6286
+ if (provisionalCandidateView && candidateViews) {
6287
+ candidateViews.cleanup(provisionalCandidateView.token);
6288
+ provisionalCandidateView = undefined;
6289
+ candidateView = undefined;
6290
+ }
6291
+ return mapNativeTargetStatus(parameters.operation, negotiatedStatus, parameters.lineageId);
6292
+ }
6293
+ if (provisionalCandidateView && candidateViews) {
6294
+ candidateViews.cleanup(provisionalCandidateView.token);
6295
+ provisionalCandidateView = undefined;
6296
+ candidateView = undefined;
6297
+ }
6298
+ // Live smoke root cause (2026-08-16, dev binary 2.4.0-main):
6299
+ // status/v5 mints the opaque --repository-context handle bound to
6300
+ // the STATUS query root, and every rendered payload embeds it. A
6301
+ // context minted from a frozen candidate-view root fails the live
6302
+ // emitter's committed-intent reconciliation on the next lifecycle
6303
+ // operation, so on v5 the lane rebinds its negotiated STATUS to
6304
+ // the workspace root before executing any rendered payload.
6305
+ // Pinned pre-v5 emitters keep the frozen-view status untouched.
6306
+ if (statusCandidateRoot !== defaultCwd && (negotiatedStatus.raw as { schema?: unknown }).schema === "gentle-ai.review-integration.status/v5") {
6307
+ const rebound = await negotiatedStatusForHostTransport(nativeReviewCli, { cwd: defaultCwd, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
6308
+ const workspaceStatus = rebound.status;
6309
+ transportRefusal = rebound.transport;
6310
+ if (workspaceStatus.authority?.lineageId !== parameters.lineageId || workspaceStatus.authority.revision !== negotiatedStatus.authority.revision) {
6311
+ throw new CandidateViewError("workspace-root status no longer matches the negotiated lifecycle authority", "workspace-status-rebind-drift");
6312
+ }
6313
+ negotiatedStatus = workspaceStatus;
6314
+ }
6315
+ // gentle-pi#311 P4: pi-slot capture inputs route through the host
6316
+ // relay ONLY when the provider issued the --materialize token on
6317
+ // the collect input. Every other slot and lane stays untouched.
6318
+ const hostRelaySlots = negotiatedStatus.nextTransition?.kind === "collect"
6319
+ ? reviewHostRelaySlots(negotiatedStatus.nextTransition.collect?.inputs ?? [])
6320
+ : [];
6321
+ if (hostRelaySlots.length > 0 && input.final_evidence === undefined && input.correction_line_forecast === undefined) {
6322
+ // One cost/side-effect forecast BEFORE launch, once per
6323
+ // FINALIZE and never per lens: each host-relay slot runs a
6324
+ // real locked-down `pi` reviewer subprocess against the
6325
+ // user's own model, so an unacknowledged finalize would spend
6326
+ // tokens as a silent side effect of what reads like
6327
+ // bookkeeping. Same acknowledgement shape the adapter already
6328
+ // uses for consequential inputs (committedOnly): the caller
6329
+ // states the cost it accepts, which keeps headless callers
6330
+ // working without an interactive prompt.
6331
+ if (input.reviewer_run_acknowledged !== true) {
6332
+ const forecastLenses = hostRelaySlots.map((slot, index) => slot.lens ?? `slot-${index}`);
6333
+ return {
6334
+ operation: parameters.operation,
6335
+ status: "blocked",
6336
+ outcome: "reviewer-model-run-forecast",
6337
+ reason: `Finalize is about to run ${hostRelaySlots.length} real reviewer model run${hostRelaySlots.length === 1 ? "" : "s"} through the pi host relay (${forecastLenses.join(", ")}), one locked-down pi subprocess per outstanding lens, in the foreground. This spends model tokens on your configured model and provider.`,
6338
+ cost_forecast: {
6339
+ transport: "pi_host_relay",
6340
+ model_runs: hostRelaySlots.length,
6341
+ lenses: forecastLenses,
6342
+ side_effects: [
6343
+ "one locked-down pi subprocess per lens, in an empty scratch directory with every discovery surface disabled",
6344
+ "each captured reviewer result is admitted natively into this lineage's authority",
6345
+ "no candidate file, index, or commit is modified",
6346
+ ],
6347
+ model_selection: "user-owned: the relay never sets --model, --provider, or --profile",
6348
+ },
6349
+ mutation_performed: false,
6350
+ mutation_outcome: "none",
6351
+ next_action: "Re-run finalize with {\"reviewer_run_acknowledged\": true} to authorize exactly this reviewer work.",
6352
+ };
6353
+ }
6354
+ // The relay itself needs no candidate view (it consumes only
6355
+ // provider-issued tokens), but a session that dispatches a
6356
+ // reviewer by hand on this same lineage does. Hydrate here too
6357
+ // so both routes work; it is best-effort and never fails the
6358
+ // relay.
6359
+ const relayDispatchBinding = hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, negotiatedStatus);
6360
+ const relayResult = await executeReviewHostRelayCollection(parameters.operation, parameters.lineageId, hostRelaySlots, nativeReviewCli, defaultCwd, signal);
6361
+ return relayDispatchBinding === undefined ? relayResult : { ...relayResult, dispatch_binding: relayDispatchBinding };
6362
+ }
6363
+ // gentle-pi#311 P4-roles: the provider renders the non-lens
6364
+ // adversarial roles as self-contained --execute vectors; each is
6365
+ // run exactly as rendered and STATUS is re-queried. Nothing here
6366
+ // authors, parses, or transports role output.
6367
+ const roleVectorSlots = negotiatedStatus.nextTransition?.kind === "collect"
6368
+ ? reviewProviderRoleVectorSlots(negotiatedStatus.nextTransition.collect?.inputs ?? [])
6369
+ : [];
6370
+ if (roleVectorSlots.length > 0 && input.final_evidence === undefined && input.correction_line_forecast === undefined && input.validation === undefined) {
6371
+ return await executeProviderRoleVectorCollection(parameters.operation, parameters.lineageId, roleVectorSlots, nativeReviewCli, defaultCwd, signal);
6372
+ }
6373
+ // Live defect (2026-08-16, Engram #12466): FINALIZE on a lineage
6374
+ // still at reviewer_results_required misrouted into the correction
6375
+ // evidence-first-ordering lane and failed. Route strictly from the
6376
+ // provider transition: a collect naming review.capture-result means
6377
+ // reviewer results are still outstanding — no correction-evidence
6378
+ // or targeted-validation lane is ever admissible here, so any
6379
+ // document-carrying FINALIZE stops with the provider-offered step.
6380
+ // A document-free FINALIZE stops too on the live status/v5 lane
6381
+ // (the same v5 keying as the workspace rebind above); the pinned
6382
+ // pre-v5 raw-finalize fallback keeps its native captured-results
6383
+ // discovery unchanged. Materialize-marked pi slots were already
6384
+ // routed through the host relay above.
6385
+ const reviewerResultsOutstanding = negotiatedStatus.nextTransition?.kind === "collect"
6386
+ && (negotiatedStatus.nextTransition.collect?.inputs ?? []).some((collectInput) => collectInput.captureOperation === "review.capture-result");
6387
+ const finalizeDocumentsPresent = input.final_evidence !== undefined || input.validation !== undefined || input.correction_line_forecast !== undefined;
6388
+ if (reviewerResultsOutstanding && (finalizeDocumentsPresent || (negotiatedStatus.raw as { schema?: unknown }).schema === "gentle-ai.review-integration.status/v5")) {
6389
+ const outstandingReviewerLenses = pendingReviewerLenses(negotiatedStatus);
6390
+ if (candidateView !== undefined && candidateViews && (correctionCompletion || validationAttempt)) candidateViews.cleanup(candidateView.token);
6391
+ // Field report (2026-08-16): this lane is where the reported
6392
+ // flow actually learns reviewer results are outstanding, and
6393
+ // the reviewer dispatch follows it directly. Hydrate the
6394
+ // dispatch binding from the authoritative status just decoded
6395
+ // — against the workspace root, never a frozen view root —
6396
+ // and report the outcome either way.
6397
+ const dispatchBinding = hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, negotiatedStatus);
6398
+ return {
6399
+ operation: parameters.operation,
6400
+ status: "blocked",
6401
+ outcome: "reviewer-results-required",
6402
+ reason: transportRefusal === undefined
6403
+ ? "Capture the reviewer result first; the provider offers review.capture-result. Correction evidence and targeted validation are never admissible while reviewer results are outstanding."
6404
+ : `Capture the reviewer result first; the provider offers review.capture-result. This provider does not admit the pi reviewer transport (${transportRefusal.code}), so it offers no host-relay slot: ${transportRefusal.message}`,
6405
+ ...(outstandingReviewerLenses.length === 0 ? {} : { pending_lenses: outstandingReviewerLenses }),
6406
+ ...(dispatchBinding === undefined ? {} : { dispatch_binding: dispatchBinding }),
6407
+ ...(transportRefusal === undefined ? {} : { relay_transport: transportRefusal }),
6408
+ result: negotiatedStatus.raw,
6409
+ mutation_performed: false,
6410
+ mutation_outcome: "none",
6411
+ next_action: "review.capture-result",
6412
+ };
6413
+ }
6414
+ // Ordinary final verification (field defect, 2026-08-16): at
6415
+ // native state `validating` the provider collects one final
6416
+ // `review.capture-evidence` record and then offers exactly one
6417
+ // execute `review.finalize --captured-evidence` transition. This
6418
+ // lane is provider-owned end to end: it is not a correction
6419
+ // transaction, no targeted validation exists in it, and no
6420
+ // candidate view is materialized — the workspace root IS the
6421
+ // unchanged frozen candidate, native FINALIZE validates the live
6422
+ // snapshot itself, and the provider binds its repository-context
6423
+ // effects to the root the lifecycle runs from. The correction
6424
+ // lane keeps its pre-capture state `correction_required` and
6425
+ // stays byte-identical below.
6426
+ const ordinaryFinalVerification = validationAttempt &&
6427
+ negotiatedStatus.authority?.state === "validating" &&
6428
+ negotiatedStatus.validationRequest === undefined &&
6429
+ !(negotiatedStatus.nextTransition?.kind === "collect" && (negotiatedStatus.nextTransition.collect?.inputs ?? []).some(isTargetedValidationCollectInput));
6430
+ if (candidateViews && !ordinaryFinalVerification && !candidateViews.hasProjection(parameters.lineageId)) {
6431
+ const projection = negotiatedStatus.projection;
5347
6432
  candidateView = validationAttempt
5348
6433
  ? (candidateViews.restoreProjectionFromNative(parameters.lineageId, defaultCwd, projection), undefined)
5349
6434
  : candidateViews.restoreForFinalizeFromNative(parameters.lineageId, defaultCwd, projection);
@@ -5351,25 +6436,102 @@ async function executeReviewControllerOperation(
5351
6436
  // Fail closed before any native mutation when the frozen projection
5352
6437
  // belongs to a different worktree than the requested workspace (#169).
5353
6438
  if (candidateViews && parameters.lineageId && candidateViews.hasProjection(parameters.lineageId)) candidateViews.resolveProjection(parameters.lineageId, defaultCwd);
5354
- candidateView ??= candidateViews && parameters.lineageId ? (correctionCompletion || validationAttempt) ? candidateViews.createCorrected(parameters.lineageId, defaultCwd, replayKey) : candidateViews.resolveForFinalize(parameters.lineageId) : undefined;
5355
- if (validationAttempt && candidateView && parameters.lineageId) {
5356
- if (nativeReviewCli.captureEvidence === undefined) throw new CandidateViewError("native correction evidence capture is unavailable", "evidence-first-ordering");
6439
+ candidateView ??= candidateViews && parameters.lineageId && !ordinaryFinalVerification ? (correctionCompletion || validationAttempt) ? candidateViews.createCorrected(parameters.lineageId, defaultCwd, replayKey) : candidateViews.resolveForFinalize(parameters.lineageId) : undefined;
6440
+ // Field defect (Engram #12547): a FINALIZE that merely follows the
6441
+ // provider's own execute transition carries no documents, so
6442
+ // neither correctionCompletion nor validationAttempt holds and the
6443
+ // START-time reviewer view is resolved. After an admitted bounded
6444
+ // correction the candidate identity has legitimately moved, so
6445
+ // that view is compared against the corrected target the provider
6446
+ // itself authorized and every finalize fails as drift — no receipt
6447
+ // is ever minted, while a fresh process finalizes the same lineage
6448
+ // fine because it restores from the native descriptor. Re-derive
6449
+ // the binding from that same descriptor here instead of reading a
6450
+ // retired reviewer view as drift. It is not a relaxation: the
6451
+ // replacement is materialized from Git, must match the provider
6452
+ // descriptor exactly, and is asserted immediately below.
6453
+ if (
6454
+ candidateView !== undefined && candidateViews && parameters.lineageId && !ordinaryFinalVerification &&
6455
+ candidateView.candidateTree !== negotiatedStatus.projection.currentCandidateTree
6456
+ ) {
6457
+ candidateView = candidateViews.rebindForFinalizeFromNative(parameters.lineageId, defaultCwd, negotiatedStatus.projection);
6458
+ }
6459
+ if (candidateView !== undefined) assertNativeFinalizeCandidateBinding(candidateView, negotiatedStatus);
6460
+ if (ordinaryFinalVerification && parameters.lineageId) {
6461
+ // A projection held by this process routes the top-of-try path
6462
+ // through createCorrected before the lane is known; that view
6463
+ // is unused here — the lifecycle runs from the workspace root.
6464
+ if (candidateView !== undefined && candidateViews) {
6465
+ candidateViews.cleanup(candidateView.token);
6466
+ candidateView = undefined;
6467
+ }
6468
+ if (nativeReviewCli.captureEvidence === undefined && nativeReviewCli.captureEvidenceSubmission === undefined) throw new CandidateViewError("native final verification evidence capture is unavailable", "final-verification-provider-owned");
5357
6469
  const outcome = correctionOutcome(input);
5358
- if (outcome === undefined || negotiatedStatus.authority === undefined) throw new CandidateViewError("native correction evidence requires one authoritative outcome-bound status", "evidence-first-ordering");
5359
- requireEvidenceCollection(negotiatedStatus);
5360
- const captured = await nativeReviewCli.captureEvidence({
6470
+ if (outcome === undefined || negotiatedStatus.authority === undefined) throw new CandidateViewError("native final verification evidence requires one authoritative outcome-bound status", "final-verification-provider-owned");
6471
+ if (input.validation !== undefined) {
6472
+ throw new CandidateViewError("native final verification is provider-owned; a targeted validation document is not admissible at state validating", "final-verification-provider-owned");
6473
+ }
6474
+ const evidenceSlot = requireEvidenceCollection(negotiatedStatus);
6475
+ const evidenceBinding = resolveEvidenceCaptureBinding(evidenceSlot, negotiatedStatus, parameters.lineageId, outcome);
6476
+ const captured = await captureEvidenceForCollection(nativeReviewCli, evidenceBinding, defaultCwd, parameters.lineageId, outcome, input.final_evidence!, signal);
6477
+ if (
6478
+ captured.lineageId !== parameters.lineageId || captured.authorityRevision !== evidenceBinding.expectedRevision ||
6479
+ captured.targetIdentity !== evidenceBinding.targetIdentity || captured.candidateTree !== negotiatedStatus.projection.currentCandidateTree ||
6480
+ evidencePathsDrift(captured, evidenceBinding, negotiatedStatus) ||
6481
+ captured.outcome !== outcome
6482
+ ) {
6483
+ throw new CandidateViewError("captured final verification evidence does not match the requested lineage, target, and outcome", "correction-evidence-binding-drift");
6484
+ }
6485
+ // Follow the provider transition faithfully: re-query
6486
+ // negotiated STATUS and execute only the exact rendered
6487
+ // `review.finalize` transition it offers for the captured
6488
+ // evidence. Never demand targeted validation here and never
6489
+ // substitute the validate gate.
6490
+ const afterEvidence = await nativeReviewCli.targetStatus({ cwd: defaultCwd, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
6491
+ if (afterEvidence.authority?.lineageId !== parameters.lineageId) throw new CandidateViewError("post-evidence status lost the final-verification lineage", "correction-evidence-binding-drift");
6492
+ if (afterEvidence.validationRequest !== undefined || (afterEvidence.nextTransition?.kind === "collect" && (afterEvidence.nextTransition.collect?.inputs ?? []).some(isTargetedValidationCollectInput))) {
6493
+ throw new CandidateViewError("final verification evidence unexpectedly unlocked targeted validation", "final-verification-provider-owned");
6494
+ }
6495
+ const evidenceTransition = afterEvidence.nextTransition?.kind === "execute" && afterEvidence.nextTransition.execute?.operation === "review.finalize"
6496
+ ? afterEvidence.nextTransition.execute
6497
+ : undefined;
6498
+ if (evidenceTransition === undefined || nativeReviewCli.finalizeTransition === undefined) {
6499
+ // Fail closed on an unrecognized transition: report the
6500
+ // committed capture and the provider's own status verbatim.
6501
+ return {
6502
+ ...mapNativeTargetStatus(parameters.operation, afterEvidence, parameters.lineageId),
6503
+ outcome: "final-verification-transition-unavailable",
6504
+ verification_evidence: { outcome: captured.outcome, record_digest: captured.recordDigest },
6505
+ mutation_performed: true,
6506
+ mutation_outcome: "committed",
6507
+ };
6508
+ }
6509
+ if (evidenceTransition.binding.lineageId !== undefined && evidenceTransition.binding.lineageId !== parameters.lineageId) {
6510
+ throw new CandidateViewError("provider finalize transition is bound to a different lineage", "finalize-transition-binding-drift");
6511
+ }
6512
+ nativeResult = await nativeReviewCli.finalizeTransition({
5361
6513
  cwd: defaultCwd,
5362
- lineageId: parameters.lineageId,
5363
- targetIdentity: negotiatedStatus.targetIdentity,
5364
- expectedRevision: negotiatedStatus.authority.revision,
5365
- outcome,
5366
- evidenceDocument: input.final_evidence!,
6514
+ // The exact rendered tokens, verbatim and in provider
6515
+ // order; the hyphenated fallback mirrors the provider's
6516
+ // published rendering rule for older payloads.
6517
+ argumentTokens: evidenceTransition.arguments.map((argument) => argument.token ?? `--${argument.name.replaceAll("_", "-")}=${argument.value}`),
5367
6518
  ...(signal === undefined ? {} : { signal }),
5368
6519
  });
6520
+ if (nativeResult.lineageId !== parameters.lineageId) {
6521
+ throw new CandidateViewError("provider finalize transition answered for a different lineage", "finalize-transition-binding-drift");
6522
+ }
6523
+ }
6524
+ if (validationAttempt && !ordinaryFinalVerification && candidateView && parameters.lineageId) {
6525
+ if (nativeReviewCli.captureEvidence === undefined && nativeReviewCli.captureEvidenceSubmission === undefined) throw new CandidateViewError("native correction evidence capture is unavailable", "evidence-first-ordering");
6526
+ const outcome = correctionOutcome(input);
6527
+ if (outcome === undefined || negotiatedStatus.authority === undefined) throw new CandidateViewError("native correction evidence requires one authoritative outcome-bound status", "evidence-first-ordering");
6528
+ const evidenceSlot = requireEvidenceCollection(negotiatedStatus);
6529
+ const evidenceBinding = resolveEvidenceCaptureBinding(evidenceSlot, negotiatedStatus, parameters.lineageId, outcome);
6530
+ const captured = await captureEvidenceForCollection(nativeReviewCli, evidenceBinding, candidateView.root, parameters.lineageId, outcome, input.final_evidence!, signal);
5369
6531
  if (
5370
- captured.lineageId !== parameters.lineageId || captured.authorityRevision !== negotiatedStatus.authority.revision ||
5371
- captured.targetIdentity !== negotiatedStatus.targetIdentity || captured.candidateTree !== negotiatedStatus.projection.currentCandidateTree || captured.candidateTree !== candidateView.candidateTree ||
5372
- captured.pathsDigest !== negotiatedStatus.projection.pathsDigest || JSON.stringify([...captured.paths].sort()) !== JSON.stringify([...negotiatedStatus.projection.paths].sort()) ||
6532
+ captured.lineageId !== parameters.lineageId || captured.authorityRevision !== evidenceBinding.expectedRevision ||
6533
+ captured.targetIdentity !== evidenceBinding.targetIdentity || captured.candidateTree !== negotiatedStatus.projection.currentCandidateTree || captured.candidateTree !== candidateView.candidateTree ||
6534
+ evidencePathsDrift(captured, evidenceBinding, negotiatedStatus) ||
5373
6535
  captured.outcome !== outcome
5374
6536
  ) {
5375
6537
  throw new CandidateViewError("captured correction evidence does not match the requested lineage, target, and outcome", "correction-evidence-binding-drift");
@@ -5391,11 +6553,13 @@ async function executeReviewControllerOperation(
5391
6553
  }
5392
6554
  correctionStep = resolveCorrectionStep({
5393
6555
  lineageId: parameters.lineageId,
5394
- targetIdentity: negotiatedStatus.targetIdentity,
5395
- authorityRevision: negotiatedStatus.authority.revision,
6556
+ targetIdentity: evidenceBinding.targetIdentity,
6557
+ authorityRevision: evidenceBinding.expectedRevision,
5396
6558
  correctionBudget: negotiatedStatus.frozen?.correctionBudget ?? 0,
5397
6559
  changedLinesCharged: 0,
5398
6560
  }, evidence);
6561
+ // Workspace-bound like the pre-capture rebind above: rendered
6562
+ // validation payloads embed the context this status mints.
5399
6563
  const afterEvidence = await nativeReviewCli.targetStatus({ cwd: defaultCwd, lineageId: parameters.lineageId, ...(signal === undefined ? {} : { signal }) });
5400
6564
  if (afterEvidence.authority?.lineageId !== parameters.lineageId) throw new CandidateViewError("post-evidence status lost the correction lineage", "correction-evidence-binding-drift");
5401
6565
  if (correctionStep.kind === "recapture-required") {
@@ -5416,55 +6580,143 @@ async function executeReviewControllerOperation(
5416
6580
  const validationRequest = requireTargetedValidationAfterEvidence(afterEvidence);
5417
6581
  correctionEvidenceByLineage.delete(parameters.lineageId);
5418
6582
  negotiatedStatus = afterEvidence;
6583
+ // gentle-pi#311 P4-roles: when the provider offers targeted
6584
+ // validation as a self-contained capture-validation vector, the
6585
+ // verdict is Go-owned — a Pi-authored validation document is not
6586
+ // admissible for it, and the next FINALIZE call executes the
6587
+ // vector exactly as rendered.
6588
+ const validationVectors = afterEvidence.nextTransition?.kind === "collect"
6589
+ ? reviewProviderRoleVectorSlots(afterEvidence.nextTransition.collect?.inputs ?? [])
6590
+ : [];
6591
+ if (validationVectors.length > 0) {
6592
+ candidateViews.cleanup(candidateView.token);
6593
+ if (input.validation !== undefined) {
6594
+ return {
6595
+ operation: parameters.operation,
6596
+ status: "blocked",
6597
+ outcome: "targeted-validation-is-provider-owned",
6598
+ reason: "The provider rendered targeted validation as a self-contained review.capture-validation vector: Go materializes the validator prompt and runs its own locked-down pi process, so a Pi-authored validation document is not admissible.",
6599
+ correction_step: correctionStep,
6600
+ mutation_performed: true,
6601
+ mutation_outcome: "committed",
6602
+ next_action: "Re-run finalize without validation; the provider-rendered vector executes verbatim and STATUS is re-queried.",
6603
+ };
6604
+ }
6605
+ return { ...mapNativeTargetStatus(parameters.operation, afterEvidence, parameters.lineageId), correction_step: correctionStep };
6606
+ }
5419
6607
  if (input.validation === undefined) {
5420
6608
  candidateViews.cleanup(candidateView.token);
5421
6609
  return { ...mapNativeTargetStatus(parameters.operation, afterEvidence, parameters.lineageId), correction_step: correctionStep };
5422
6610
  }
5423
- if (input.validation_proof !== undefined) throw new Error("Negotiated FINALIZE requires the native targeted validation document");
5424
6611
  if (input.validation.request_hash !== validationRequest.requestHash.replace(/^sha256:/, "") || JSON.stringify([...input.validation.correction_ids].sort()) !== JSON.stringify([...validationRequest.fixFindingIds].sort())) {
5425
6612
  throw new CandidateViewError("targeted validation document does not match the provider request", "targeted-validation-binding-drift");
5426
6613
  }
5427
- }
5428
- if (input.review_result !== undefined) {
5429
- if (parameters.lineageId === undefined) throw new CandidateViewError("Native FINALIZE requires an explicit lineage for refuter derivation");
5430
- let request;
5431
- try {
5432
- request = deriveNativeRefuterRequest({ lineageId: parameters.lineageId, ...(candidateView === undefined ? {} : { candidateTree: candidateView.candidateTree }), reviewResult: input.review_result });
5433
- } catch (error) {
5434
- if (!(error instanceof CompactReviewContractError) || error.code !== "candidate-tree") throw error;
5435
- const candidateTree = captureLiveReviewCandidateBinding({
6614
+ const validationSubmission = finalizeSubmissionSlot(negotiatedStatus, "validation");
6615
+ if (validationSubmission !== undefined) {
6616
+ if (nativeReviewCli.finalizeSubmission === undefined) throw new CandidateViewError("native finalize submission execution is unavailable", "finalize-transition-binding-drift");
6617
+ nativeResult = await nativeReviewCli.finalizeSubmission({
6618
+ // The rendered tokens are self-contained (the opaque
6619
+ // --repository-context binds the repository); the process
6620
+ // runs from the authority workspace root — a frozen
6621
+ // candidate-view root is a different toplevel of the same
6622
+ // store and fails the live emitter's effect binding.
5436
6623
  cwd: defaultCwd,
5437
- repositoryId: resolveRepositoryAuthorityV1(defaultCwd).repository_id,
5438
- }).initial_review_tree;
5439
- request = deriveNativeRefuterRequest({ lineageId: parameters.lineageId, candidateTree, reviewResult: input.review_result });
6624
+ argumentTokens: validationSubmission.argumentTokens,
6625
+ valueSubstitutionLocation: validationSubmission.value.substitutionLocation,
6626
+ // The rendered submission consumes the raw validator
6627
+ // artifact, so the request binding rides inside it.
6628
+ valueDocument: JSON.stringify({
6629
+ targeted_validation_request_hash: validationRequest.requestHash,
6630
+ correction_target_identity: validationRequest.correctionTargetIdentity,
6631
+ ...toNativeValidatorDocument(input.validation),
6632
+ }),
6633
+ ...(signal === undefined ? {} : { signal }),
6634
+ });
6635
+ if (nativeResult.lineageId !== parameters.lineageId) {
6636
+ throw new CandidateViewError("provider finalize submission answered for a different lineage", "finalize-transition-binding-drift");
6637
+ }
5440
6638
  }
5441
- if (request !== undefined && input.refuter_batch === undefined) {
5442
- return { operation: parameters.operation, status: "blocked", outcome: "refuter-required", lineage_created: false, mutation_performed: false, mutation_outcome: "none", refuter_request: request };
6639
+ }
6640
+ // gentle-pi#311 P5: when the provider's negotiated transition IS a
6641
+ // review.finalize execution (captured_results_ready), run it exactly
6642
+ // as rendered — the provider discovers its own admitted lens and
6643
+ // role slots. Pi never assembles reviewer, refuter, or validator
6644
+ // documents for this lane; the remaining document lanes below
6645
+ // (correction forecast, targeted validation, final evidence) are the
6646
+ // negotiated collection answers the pinned provider still consumes
6647
+ // through --correction-lines/--validation/--evidence.
6648
+ const finalizeTransition = negotiatedStatus.nextTransition?.kind === "execute" && negotiatedStatus.nextTransition.execute?.operation === "review.finalize"
6649
+ ? negotiatedStatus.nextTransition.execute
6650
+ : undefined;
6651
+ if (
6652
+ nativeResult === undefined &&
6653
+ finalizeTransition !== undefined && nativeReviewCli.finalizeTransition !== undefined &&
6654
+ input.validation === undefined && input.final_evidence === undefined && input.correction_line_forecast === undefined
6655
+ ) {
6656
+ if (finalizeTransition.binding.lineageId !== undefined && finalizeTransition.binding.lineageId !== parameters.lineageId) {
6657
+ throw new CandidateViewError("provider finalize transition is bound to a different lineage", "finalize-transition-binding-drift");
5443
6658
  }
5444
- if (request !== undefined && input.review_result.refuter_request_hash !== request.request_hash) {
5445
- throw new Error("Native FINALIZE refuter_request_hash must exactly match the controller-derived request");
6659
+ nativeResult = await nativeReviewCli.finalizeTransition({
6660
+ // The provider binds this transition's effects to the root
6661
+ // the lifecycle runs from (live smoke, 2026-08-16): running
6662
+ // the rendered vector from a frozen candidate-view root
6663
+ // records a mismatched repository binding that fails every
6664
+ // later committed-intent reconciliation. The workspace root
6665
+ // IS the frozen candidate here; run from it.
6666
+ cwd: defaultCwd,
6667
+ // The exact rendered tokens, verbatim and in provider order.
6668
+ // The provider tokenizes each argument itself; the hyphenated
6669
+ // fallback mirrors its published rendering rule for older
6670
+ // payloads that omit the token field.
6671
+ argumentTokens: finalizeTransition.arguments.map((argument) => argument.token ?? `--${argument.name.replaceAll("_", "-")}=${argument.value}`),
6672
+ ...(signal === undefined ? {} : { signal }),
6673
+ });
6674
+ if (parameters.lineageId !== undefined && nativeResult.lineageId !== parameters.lineageId) {
6675
+ throw new CandidateViewError("provider finalize transition answered for a different lineage", "finalize-transition-binding-drift");
5446
6676
  }
5447
- if (request === undefined && (input.review_result.refuter_request_hash !== undefined || input.refuter_batch !== undefined)) {
5448
- throw new Error("Native FINALIZE refuter material is invalid without inferential candidate-caused severe findings");
6677
+ } else if (nativeResult === undefined) {
6678
+ const planSubmission = input.correction_line_forecast === undefined ? undefined : finalizeSubmissionSlot(negotiatedStatus, "correction_lines");
6679
+ if (planSubmission !== undefined) {
6680
+ if (nativeReviewCli.finalizeSubmission === undefined) throw new CandidateViewError("native finalize submission execution is unavailable", "finalize-transition-binding-drift");
6681
+ const forecast = input.correction_line_forecast!;
6682
+ if ((planSubmission.value.minimum !== undefined && forecast < planSubmission.value.minimum) || (planSubmission.value.maximum !== undefined && forecast > planSubmission.value.maximum)) {
6683
+ throw new CandidateViewError(`correction line forecast ${forecast} is outside the provider-rendered bounds`, "correction-forecast-out-of-bounds");
6684
+ }
6685
+ nativeResult = await nativeReviewCli.finalizeSubmission({
6686
+ // Self-contained rendered tokens; run from the authority
6687
+ // workspace root, never a frozen candidate-view root.
6688
+ cwd: defaultCwd,
6689
+ argumentTokens: planSubmission.argumentTokens,
6690
+ valueSubstitutionLocation: planSubmission.value.substitutionLocation,
6691
+ valueLiteral: String(forecast),
6692
+ ...(signal === undefined ? {} : { signal }),
6693
+ });
6694
+ if (parameters.lineageId !== undefined && nativeResult.lineageId !== parameters.lineageId) {
6695
+ throw new CandidateViewError("provider finalize submission answered for a different lineage", "finalize-transition-binding-drift");
6696
+ }
6697
+ } else {
6698
+ nativeResult = await nativeReviewCli.finalize({
6699
+ cwd: candidateView?.root ?? defaultCwd,
6700
+ ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
6701
+ ...(input.correction_line_forecast === undefined ? {} : { correctionLines: input.correction_line_forecast }),
6702
+ ...(input.validation === undefined ? {} : { validationDocument: toNativeValidatorDocument(input.validation) }),
6703
+ ...(input.final_evidence === undefined ? {} : { evidenceDocument: input.final_evidence, failed: input.final_verification_passed === false }),
6704
+ ...(signal === undefined ? {} : { signal }),
6705
+ });
5449
6706
  }
5450
6707
  }
5451
- nativeResult = await nativeReviewCli.finalize({
5452
- cwd: candidateView?.root ?? defaultCwd,
5453
- ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5454
- ...(input.review_result === undefined ? {} : { lensResults: input.review_result.lens_results.map((document, index) => ({ lens: document.lens ?? `lens-${index}`, document: toNativeReviewerDocument(document) })) }),
5455
- ...(input.refuter_batch === undefined ? {} : { refuterDocument: toNativeRefuterDocument(input.refuter_batch) }),
5456
- ...(input.correction_line_forecast === undefined ? {} : { correctionLines: input.correction_line_forecast }),
5457
- ...(input.validation === undefined && input.validation_proof === undefined ? {} : { validationDocument: toNativeValidatorDocument(input.validation ?? input.validation_proof!) }),
5458
- ...(input.final_evidence === undefined ? {} : { evidenceDocument: input.final_evidence, failed: input.final_verification_passed === false }),
5459
- ...(signal === undefined ? {} : { signal }),
5460
- });
5461
6708
  } catch (error) {
6709
+ if (provisionalCandidateView && candidateViews) {
6710
+ candidateViews.cleanup(provisionalCandidateView.token);
6711
+ provisionalCandidateView = undefined;
6712
+ candidateView = undefined;
6713
+ }
5462
6714
  if (correctionCompletion && candidateView && candidateViews && !nativeMutationRequiresStatus(error)) candidateViews.cleanup(candidateView.token);
5463
6715
  return reconcileNativeMutationFailure(parameters.operation, error, nativeReviewCli, {
5464
6716
  cwd: candidateView?.root ?? defaultCwd,
5465
6717
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5466
6718
  projection: "workspace",
5467
- });
6719
+ }, negotiatedStatus?.authority?.revision);
5468
6720
  }
5469
6721
  try {
5470
6722
  if (correctionCompletion && candidateViews && parameters.lineageId) candidateViews.promoteCorrected(parameters.lineageId, candidateView!.token);
@@ -5534,6 +6786,7 @@ async function executeReviewControllerOperation(
5534
6786
  ...(parameters.lineageId === undefined ? {} : { lineageId: parameters.lineageId }),
5535
6787
  ...(signal === undefined ? {} : { signal }),
5536
6788
  });
6789
+ hydrateDispatchBindingFromStatus(candidateViews, defaultCwd, status);
5537
6790
  return mapNativeTargetStatus(parameters.operation, status, parameters.lineageId);
5538
6791
  } catch (error) {
5539
6792
  return nativeOperationFailure(parameters.operation, error);
@@ -5940,14 +7193,25 @@ export const __testing = {
5940
7193
  gateLifecycleCommand,
5941
7194
  nativeStatusUnsupported,
5942
7195
  executeReviewControllerOperation,
7196
+ setReviewHostRelayRunnerForTesting,
7197
+ clearReviewTransportProbeForTesting,
5943
7198
  enforceReviewGateAndCommandSafety,
5944
7199
  renderSddModelPanel: renderSddModelPanelForTesting,
5945
7200
  getOrchestratorPrompt,
5946
7201
  renderOrchestratorPrompt,
7202
+ loadBackgroundSubagentsPolicy,
7203
+ resolveBackgroundSubagentsPolicy,
7204
+ renderBackgroundSubagentsReport,
7205
+ writeGlobalBackgroundSubagentsPolicy,
7206
+ parseBackgroundSubagentsPolicyFile,
7207
+ resolveBackgroundSubagentsCapability,
7208
+ readActiveToolNames,
7209
+ renderBackgroundSubagentsStatusLine,
5947
7210
  resolveControllerSddStatus,
5948
7211
  resolveStartupControllerSddStatus,
5949
7212
  repositoryLocationIdentity,
5950
7213
  runPublicationProbeGit,
7214
+ createGentleAiExtension: createGentleAiExtensionForTesting,
5951
7215
  publicationProbeErrorCode: PUBLICATION_PROBE_ERROR_CODE,
5952
7216
  };
5953
7217
 
@@ -6001,13 +7265,28 @@ export interface GentleAiRuntimeDependencies {
6001
7265
  publicationProbe?: PublicationProbe;
6002
7266
  publicationProbeTimeoutMs?: number;
6003
7267
  bashTimeRevalidationTimeoutMs?: number;
7268
+ // Deterministic test seam for the consent-binding TTL clock. Production
7269
+ // leaves both undefined so the consent path observes real wall-clock time;
7270
+ // tests inject a fake clock so expiry is observable without a 10-minute
7271
+ // sleep and without relying on the queued cleanup macrotask firing.
7272
+ now?: () => number;
7273
+ scheduleTimer?: (callback: () => void, delayMs: number) => { unref: () => void };
6004
7274
  }
6005
7275
 
6006
7276
  export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
7277
+ return createGentleAiExtensionForTesting(dependencies);
7278
+ }
7279
+
7280
+ function createGentleAiExtensionForTesting(
7281
+ dependencies: GentleAiRuntimeDependencies = {},
7282
+ writeReviewConsentLatch: typeof recordReviewConsentLatch = recordReviewConsentLatch,
7283
+ ): (pi: ExtensionAPI) => void {
6007
7284
  const nativeReviewCli = dependencies.nativeReviewCli === undefined ? createNativeReviewCli() : dependencies.nativeReviewCli;
6008
7285
  const publicationProbe = dependencies.publicationProbe ?? nodePublicationProbe;
6009
7286
  const publicationProbeTimeoutMs = dependencies.publicationProbeTimeoutMs ?? PUBLICATION_PROBE_TIMEOUT_MS;
6010
7287
  const bashTimeRevalidationTimeoutMs = dependencies.bashTimeRevalidationTimeoutMs ?? BASH_TIME_REVALIDATION_TIMEOUT_MS;
7288
+ const reviewConsentNow = dependencies.now ?? (() => Date.now());
7289
+ const reviewConsentScheduleTimer = dependencies.scheduleTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
6011
7290
  if (!Number.isSafeInteger(publicationProbeTimeoutMs) || publicationProbeTimeoutMs <= 0) throw new TypeError("Publication probe timeout must be a positive safe integer");
6012
7291
  if (!Number.isSafeInteger(bashTimeRevalidationTimeoutMs) || bashTimeRevalidationTimeoutMs <= 0) throw new TypeError("Bash-time revalidation timeout must be a positive safe integer");
6013
7292
  return function gentleAi(pi: ExtensionAPI): void {
@@ -6022,18 +7301,31 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6022
7301
  cleanupAllPendingReviewConsents(pendingReviewConsents, candidateViews);
6023
7302
  });
6024
7303
 
7304
+ pi.registerTool({
7305
+ name: "gentle_review_scope",
7306
+ label: "Gentle Review Scope",
7307
+ 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.",
7308
+ parameters: REVIEW_SCOPE_PARAMETERS,
7309
+ executionMode: "parallel",
7310
+ async execute(_toolCallId, parameters) {
7311
+ const input = parameters as ReviewScopeParameters;
7312
+ const details = readCandidateContextManifestPage(input.manifest, input.sha256, input.cursor ?? 0);
7313
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
7314
+ },
7315
+ });
7316
+
6025
7317
  pi.registerTool({
6026
7318
  name: "gentle_review",
6027
7319
  label: "Gentle Review Controller",
6028
7320
  description:
6029
- "Inspect and recover review authority, run new native ordinary review through start/finalize/validate, preserve legacy compact compatibility reads and graph-v1 Judgment Day, and authorize one exact lifecycle command. FINALIZE input is a JSON string: review_result.lens_results[] entries contain lens, findings, and non-empty evidence exactly once for every lens selected by START; final_evidence is paired with either the legacy final_verification_passed boolean or one closed final_verification_outcome. This is the Pi wrapper contract, distinct from native CLI --result, --refuter, --validation, and --evidence files. RESET/RECOVER remain destructive and are executed by the audited native CLI: RESET and RECOVER_LOCK map to `gentle-ai review reclaim` and RECOVER maps to `gentle-ai review recover` with the provider-selected disposition. Published v2.1.11 repair-legacy-alias derives its fixed repository binding from fresh native inventory before fresh UI approval; dispose-result remains unsupported pending design. Legacy bundle transport is retired: export/import return a legacy-operation-retired envelope pointing at the native gentle-ai review CLI and the Git common-directory store.",
7321
+ "Inspect and recover review authority, run new native ordinary review through start/finalize/validate, preserve legacy compact compatibility reads and graph-v1 Judgment Day, and authorize one exact lifecycle command. Reviewer, refuter, and validator verdicts are never Pi-authored: lens results are admitted natively (the pi host relay satisfies provider --materialize slots), adversarial roles execute through Go-owned pi processes via provider-rendered self-contained vectors, and FINALIZE follows the provider's negotiated next_transition (captured-results discovery). FINALIZE input is a JSON string carrying only the negotiated collection answers: correction_line_forecast, validation (the targeted validation document when the exact collection input requests it), and final_evidence paired with either the legacy final_verification_passed boolean or one closed final_verification_outcome. RESET/RECOVER remain destructive and are executed by the audited native CLI: RESET and RECOVER_LOCK map to `gentle-ai review reclaim` and RECOVER maps to `gentle-ai review recover` with the provider-selected disposition. Published v2.1.11 repair-legacy-alias derives its fixed repository binding from fresh native inventory before fresh UI approval; dispose-result remains unsupported pending design. Legacy bundle transport is retired: export/import return a legacy-operation-retired envelope pointing at the native gentle-ai review CLI and the Git common-directory store.",
6030
7322
  promptSnippet: "Inspect authority, then use native start/finalize/validate for a new ordinary review; use graph-v1 only for explicit Judgment Day",
6031
7323
  promptGuidelines: [
6032
7324
  '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.',
6033
7325
  "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.",
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.",
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.",
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.",
7326
+ "Use ABANDON or QUARANTINE_LEGACY only after an explicit user decision and with exact native inputs. ABANDON needs lineage, expectedRevision, snapshotIdentity, capturedLensResults, findingsPresent, evidenceRecordsPresent, 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.",
7327
+ "Lens, refuter, and validator verdicts are admitted natively, never Pi-authored: FINALIZE routes provider --materialize lens slots through the host relay, executes provider-rendered self-contained role vectors verbatim, and runs the provider's own review.finalize transition (captured-results discovery). Call FINALIZE with a JSON string carrying only the negotiated collection answers: correction_line_forecast for the pre-edit forecast, validation for the targeted validation document the exact collection input requests, and final_evidence paired 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. When the provider offers host-relay lens slots, FINALIZE first returns a `reviewer-model-run-forecast` naming the lenses and the real model runs it would spend; re-run it with `reviewer_run_acknowledged: true` to authorize exactly that reviewer work. Use ADVANCE only for explicit graph-v1 Judgment Day.",
7328
+ "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.",
6037
7329
  "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.",
6038
7330
  "Use gentle_review for bounded review transaction operations and exact lifecycle validation; never fabricate bash tool metadata or a separate gate target.",
6039
7331
  ],
@@ -6054,6 +7346,9 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6054
7346
  ctx,
6055
7347
  correctionEvidenceByLineage,
6056
7348
  pendingReviewConsents,
7349
+ writeReviewConsentLatch,
7350
+ reviewConsentNow,
7351
+ reviewConsentScheduleTimer,
6057
7352
  );
6058
7353
  return {
6059
7354
  content: [{ type: "text", text: JSON.stringify(details) }],
@@ -6071,6 +7366,15 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6071
7366
  }
6072
7367
 
6073
7368
  pi.on("session_start", async (_event, ctx) => {
7369
+ // Loud, every session: an active dev-binary override means this session
7370
+ // runs an unpinned gentle-ai. Announce which one before anything else.
7371
+ try {
7372
+ const devBinary = await describeDevBinaryOverride();
7373
+ if (ctx.hasUI && devBinary.state === "active") ctx.ui.notify(devBinary.line, "warning");
7374
+ if (ctx.hasUI && devBinary.state === "invalid") ctx.ui.notify(devBinary.line, "error");
7375
+ } catch (error) {
7376
+ if (ctx.hasUI) ctx.ui.notify(`Gentle AI dev binary override check failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
7377
+ }
6074
7378
  try {
6075
7379
  const transactionRecovery = reconcileCommitTransaction(ctx.cwd);
6076
7380
  if (ctx.hasUI && transactionRecovery.status !== "clean") {
@@ -6149,7 +7453,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6149
7453
  : "";
6150
7454
  const gentlePrompt = isNamedAgent || isSddAgent
6151
7455
  ? ""
6152
- : `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`;
7456
+ : `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd), ctx.cwd, readActiveToolNames(pi))}`;
6153
7457
  return {
6154
7458
  systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}${nativeStatusPrompt}`,
6155
7459
  };
@@ -6383,6 +7687,66 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6383
7687
  },
6384
7688
  });
6385
7689
 
7690
+ // Dev-binary override surfacing (unpinned field-test mode). While the
7691
+ // override is active every diagnostic surface names the exact binary, its
7692
+ // live version, and its fresh content digest, so the maintainer always
7693
+ // knows which gentle-ai actually answered. An invalid override surfaces as
7694
+ // a failure — it is never silently ignored, because the native resolver
7695
+ // refuses to fall back to the pin while an override is declared.
7696
+ const describeDevBinaryOverride = async (): Promise<
7697
+ | { state: "inactive" }
7698
+ | { state: "active"; line: string; override: GentleAiDevBinaryOverride }
7699
+ | { state: "invalid"; line: string }
7700
+ > => {
7701
+ let override: GentleAiDevBinaryOverride | undefined;
7702
+ try {
7703
+ override = resolveGentleAiDevBinaryOverride();
7704
+ } catch (error) {
7705
+ if (error instanceof GentleAiDevBinaryOverrideError) return { state: "invalid", line: `Gentle AI dev binary override invalid — ${error.message}` };
7706
+ throw error;
7707
+ }
7708
+ if (override === undefined) return { state: "inactive" };
7709
+ let version = "version unavailable";
7710
+ try {
7711
+ const adapter = createNodeExecFileAdapter();
7712
+ const result = await adapter({ file: override.path, arguments: ["version"], cwd: dirname(override.path), timeoutMs: 10_000, maxBufferBytes: 1024 * 1024 });
7713
+ const banner = result.stdout.trim();
7714
+ if (result.exitCode === 0 && banner.startsWith("gentle-ai ")) version = banner.slice("gentle-ai ".length);
7715
+ } catch {
7716
+ // The doctor line still names the binary; the version stays unavailable.
7717
+ }
7718
+ return {
7719
+ state: "active",
7720
+ override,
7721
+ line: `Gentle AI dev binary override active (unpinned, field-test only): ${override.path} ${version} sha256:${override.sha256.slice(0, 16)}`,
7722
+ };
7723
+ };
7724
+
7725
+ pi.registerCommand("gentle:dev-binary", {
7726
+ description: "Register, inspect, or clear the persistent Gentle AI dev-binary override (status | <absolute path> | off). Unpinned, field-test only.",
7727
+ handler: async (args, ctx) => {
7728
+ const argument = args.trim();
7729
+ try {
7730
+ if (argument === "off") {
7731
+ const removed = unregisterGentleAiDevBinary();
7732
+ ctx.ui.notify(removed ? "Gentle AI dev binary registration removed; the pinned binary is active again." : "No dev binary registration to remove.", "info");
7733
+ return;
7734
+ }
7735
+ if (argument === "" || argument === "status") {
7736
+ const described = await describeDevBinaryOverride();
7737
+ if (described.state === "inactive") ctx.ui.notify("No dev binary override; the pinned Gentle AI binary is active.", "info");
7738
+ else ctx.ui.notify(described.line, described.state === "active" ? "warning" : "error");
7739
+ return;
7740
+ }
7741
+ registerGentleAiDevBinary(argument);
7742
+ const described = await describeDevBinaryOverride();
7743
+ ctx.ui.notify(described.state === "inactive" ? "Dev binary registration written." : described.line, "warning");
7744
+ } catch (error) {
7745
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
7746
+ }
7747
+ },
7748
+ });
7749
+
6386
7750
  pi.registerCommand("gentle:doctor", {
6387
7751
  description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
6388
7752
  handler: async (_args, ctx) => {
@@ -6402,6 +7766,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6402
7766
  const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
6403
7767
  const modelConfig = await readSavedModelConfigAsync(ctx.cwd);
6404
7768
  const engramActive = hasWritableEngramTool(pi);
7769
+ const devBinary = await describeDevBinaryOverride();
6405
7770
  const lines = [
6406
7771
  "el Gentleman doctor",
6407
7772
  `${agentsInstalled ? "pass" : "fail"}: Global SDD agents ${agentsInstalled ? "installed" : "missing"}`,
@@ -6413,6 +7778,8 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6413
7778
  `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
6414
7779
  "pass: Sensitive-path guard active for read/write/edit tools",
6415
7780
  `${engramActive ? "pass" : "warn"}: Engram memory tools ${engramActive ? "active" : "not active in this session"}`,
7781
+ ...(devBinary.state === "active" ? [`warn: ${devBinary.line}`] : []),
7782
+ ...(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)"] : []),
6416
7783
  ];
6417
7784
  if (!agentsInstalled || !chainsInstalled) {
6418
7785
  lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally");
@@ -6474,6 +7841,31 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6474
7841
  },
6475
7842
  });
6476
7843
 
7844
+ // Mirrors gentle:review-mode: a user-owned switch, never an automated one.
7845
+ // It matters more here than there, because this policy governs whether
7846
+ // background subagents may be launched at all, so nothing in Pi may write
7847
+ // it. The only writer is this handler, reached only by explicit invocation.
7848
+ pi.registerCommand("gentle:background-subagents", {
7849
+ description: "Show or set the managed background-subagents policy (status|enable|disable). Every sub-action is user-initiated only; Pi automation never toggles it.",
7850
+ handler: async (args, ctx) => {
7851
+ const subAction = args.trim().length === 0 ? "status" : args.trim();
7852
+ if (subAction !== "status" && subAction !== "enable" && subAction !== "disable") {
7853
+ ctx.ui.notify(`Unknown /gentle:background-subagents sub-action "${subAction}". Use status, enable, or disable.`, "warning");
7854
+ return;
7855
+ }
7856
+ try {
7857
+ const wrote: BackgroundSubagentsPolicy | undefined = subAction === "enable" ? "on" : subAction === "disable" ? "off" : undefined;
7858
+ if (wrote !== undefined) writeGlobalBackgroundSubagentsPolicy(wrote);
7859
+ const resolution = resolveBackgroundSubagentsPolicy(ctx.cwd);
7860
+ const capability = resolveBackgroundSubagentsCapability(ctx.cwd, readActiveToolNames(pi));
7861
+ const report = renderBackgroundSubagentsReport(resolution, capability, wrote);
7862
+ ctx.ui.notify(report.message, report.type);
7863
+ } catch (error) {
7864
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
7865
+ }
7866
+ },
7867
+ });
7868
+
6477
7869
  pi.registerCommand("gentle:status", {
6478
7870
  description: "Show Gentle AI package status for this project.",
6479
7871
  handler: async (_args, ctx) => {
@@ -6489,9 +7881,11 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6489
7881
  const staleSddAssets = sddGlobalAssetDriftCount();
6490
7882
  const localSddAgentOverrides = sddLocalAgentOverrideCount(ctx.cwd);
6491
7883
  const modelConfig = await readModelConfigAsync(ctx.cwd);
7884
+ const devBinary = await describeDevBinaryOverride();
6492
7885
  ctx.ui.notify(
6493
7886
  [
6494
7887
  "el Gentleman package is active.",
7888
+ ...(devBinary.state === "inactive" ? [] : [devBinary.line]),
6495
7889
  `Persona: ${readPersonaMode(ctx.cwd)}`,
6496
7890
  `Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
6497
7891
  `Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
@@ -6509,7 +7903,7 @@ export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencie
6509
7903
  `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
6510
7904
  ...describeModelConfig(ctx.cwd, modelConfig),
6511
7905
  ].join("\n"),
6512
- staleSddAssets > 0 || localSddAgentOverrides > 0 ? "warning" : "info",
7906
+ staleSddAssets > 0 || localSddAgentOverrides > 0 || devBinary.state !== "inactive" ? "warning" : "info",
6513
7907
  );
6514
7908
  },
6515
7909
  });