gentle-pi 2.1.1 → 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 +532 -66
  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 +528 -62
  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 +83 -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
@@ -5,18 +5,21 @@ import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, isAbsolute, join, posix, win32 } from "node:path";
7
7
  import { promisify } from "node:util";
8
- import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
8
+ import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, gentleAiDevBinaryOverrideConfigured, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
9
+ import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
9
10
  import {
10
11
  REVIEW_INTEGRATION_CONTRACT,
11
12
  decodeReviewCapabilitiesV2,
12
13
  decodeReviewConsentV2,
14
+ decodeReviewConsentV3,
13
15
  decodeReviewFailureV2,
14
16
  decodeReviewOperationV2,
15
17
  decodeReviewRepairV2,
18
+ decodeReviewResultArtifactV2,
16
19
  decodeReviewStartV3,
17
20
  decodeReviewStatusV3,
18
21
  type ReviewCapabilitiesV2,
19
- type ReviewConsentV2,
22
+ type ReviewConsentEnvelope,
20
23
  type ReviewFailureV2,
21
24
  type ReviewRepairV2,
22
25
  type ReviewStartState,
@@ -25,6 +28,23 @@ import {
25
28
 
26
29
  const execFileAsync = promisify(execFile);
27
30
 
31
+ // Negotiated review/status responses can carry a complete authority inventory.
32
+ // Keep the production default large enough for that payload while retaining a
33
+ // hard 64 MiB ceiling even when GENTLE_PI_REVIEW_MAX_BUFFER_BYTES is set.
34
+ export const NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
35
+ const NATIVE_REVIEW_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
36
+ const NATIVE_REVIEW_MAX_BUFFER_BYTES_ENV = "GENTLE_PI_REVIEW_MAX_BUFFER_BYTES";
37
+ const NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT = "Inspect native review state before any new START; GENTLE_PI_REVIEW_MAX_BUFFER_BYTES accepts a positive decimal up to 67108864.";
38
+
39
+ function resolveNativeReviewMaxBufferBytes(environment: NodeJS.ProcessEnv = process.env): number {
40
+ const value = environment[NATIVE_REVIEW_MAX_BUFFER_BYTES_ENV];
41
+ if (value === undefined || !/^[1-9]\d*$/.test(value)) return NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES;
42
+ const parsed = Number(value);
43
+ return Number.isSafeInteger(parsed) && parsed <= NATIVE_REVIEW_MAX_BUFFER_BYTES
44
+ ? parsed
45
+ : NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES;
46
+ }
47
+
28
48
  export const NATIVE_REVIEW_OPERATION = {
29
49
  VERSION: "version",
30
50
  START: "review/start",
@@ -43,6 +63,7 @@ export const NATIVE_REVIEW_OPERATION = {
43
63
  REPAIR: "review/repair",
44
64
  CAPTURE_EVIDENCE: "review/capture-evidence",
45
65
  CAPTURE_RESULT: "review/capture-result",
66
+ CAPTURE_PROVIDER_ROLE: "review/capture-provider-role",
46
67
  } as const;
47
68
  export type NativeReviewOperation = (typeof NATIVE_REVIEW_OPERATION)[keyof typeof NATIVE_REVIEW_OPERATION];
48
69
 
@@ -118,6 +139,22 @@ export interface NativeReviewCli {
118
139
  // evidence-first correction lifecycle's collection step.
119
140
  repair?(request: NativeReviewRepairRequest): Promise<ReviewRepairV2>;
120
141
  captureEvidence?(request: NativeReviewCaptureEvidenceRequest): Promise<NativeReviewVerificationEvidenceV2>;
142
+ // Executes one provider-rendered capture-evidence submission exactly as
143
+ // rendered (verbatim tokens with only the {{outcome}}/{{input}} slot
144
+ // substitutions); the preferred form whenever the collect slot renders one.
145
+ captureEvidenceSubmission?(request: NativeReviewCaptureEvidenceSubmissionRequest): Promise<NativeReviewVerificationEvidenceV2>;
146
+ // gentle-pi#311 P4-roles: executes one provider-rendered self-contained
147
+ // role capture vector exactly as rendered (verbatim tokens, foreground).
148
+ captureProviderRole?(request: NativeReviewProviderRoleCaptureRequest): Promise<NativeReviewProviderRoleCaptureArtifact>;
149
+ // gentle-pi#311 P5: executes one provider-rendered `review.finalize`
150
+ // transition exactly as rendered (e.g. `--lineage=<id>
151
+ // --captured-results=true`); the host never assembles reviewer, refuter,
152
+ // or validator documents for it.
153
+ finalizeTransition?(request: NativeReviewFinalizeTransitionRequest): Promise<NativeFinalizeResult>;
154
+ // Executes one provider-rendered `finalize` submission descriptor exactly
155
+ // as rendered, substituting only its {{value}} slot (plan line-count
156
+ // literal, or a staged validation artifact path).
157
+ finalizeSubmission?(request: NativeReviewFinalizeSubmissionRequest): Promise<NativeFinalizeResult>;
121
158
  // Dark until a negotiated version reports the `mode` capability true
122
159
  // (Design Decision #7, organic-rdd-parity). Plain versioned CLI operation,
123
160
  // outside the negotiated review-integration protocol — same shape as
@@ -175,18 +212,24 @@ export interface NativeReviewModeResult {
175
212
 
176
213
  // Exact-match tolerated-stderr allowlist for START only, gated on the `mode`
177
214
  // capability being true (Design Decision #6, organic-rdd-parity). Byte-exact
178
- // against gentle-ai's headless notice (internal/cli/review_mode.go
179
- // reviewConsentSkippedNotice) written when the switch is on but no interactive
180
- // terminal answered the one-time consent question — which is always true when
181
- // Pi spawns gentle-ai without a TTY. Any other text still fails closed as
182
- // UNEXPECTED_STDERR.
183
- // Each entry is one whole line gentle-ai may write to the console stream while
184
- // still succeeding. The provenance line rides with the skip notice whenever the
185
- // resolved mode source is `default`, so a headless START legitimately emits two
186
- // lines; they are separate Fprintln calls, never one joined string.
215
+ // against gentle-ai's headless notices (internal/cli/review_mode.go
216
+ // reviewConsentSkippedNotice and its siblings) written when the switch is on
217
+ // but no interactive terminal answered the one-time consent question — which
218
+ // is always true when Pi spawns gentle-ai without a TTY. Any other text still
219
+ // fails closed as UNEXPECTED_STDERR.
220
+ //
221
+ // Each entry is one whole line the PINNED gentle-ai may write to the console
222
+ // stream while still succeeding. That last qualifier is what keeps this list
223
+ // honest: v2.4.0 deleted reviewConsentSkippedDefaultProvenance ("Reviews are
224
+ // on by default; this was never explicitly chosen. ..."), which used to ride
225
+ // with the skip notice whenever the resolved mode source was `default`. Under
226
+ // opt-in receipt-driven development a default-source clone is refused long
227
+ // before the consent ceremony runs, so the pinned binary can no longer emit
228
+ // that line and it is removed here rather than left as dead tolerance. Multi-
229
+ // line stderr is still expected in principle — these are separate Fprintln
230
+ // calls, never one joined string — which is why membership is per line.
187
231
  export const REVIEW_CONSENT_NOTICES = Object.freeze([
188
232
  "Gentle AI reviewed this change without asking, because this session has no terminal to answer on. Run 'gentle-ai review mode disable' to turn reviews off, or 'gentle-ai review mode status' to see the current setting.",
189
- "Reviews are on by default; this was never explicitly chosen. Run 'gentle-ai review mode enable' to make reviews an explicit choice, or 'gentle-ai review mode disable' to turn them off.",
190
233
  "Gentle AI could not read an answer, so it reviewed this change and will ask again next time.",
191
234
  "Gentle AI did not recognize that answer, so it reviewed this change and will ask again next time.",
192
235
  "Review skipped for this candidate at your request. It will be offered again on the next change.",
@@ -201,6 +244,22 @@ function stderrIsTolerated(stderr: string, tolerated: readonly string[]): boolea
201
244
  return lines.length > 0 && lines.every((line) => tolerated.includes(line));
202
245
  }
203
246
 
247
+ // gentle-ai main narrates the negotiated STATUS forecast head to a human on
248
+ // stderr while keeping the machine envelope on stdout (reviewNarrateForecast
249
+ // in internal/cli/review_narration.go, ground-truthed live against a main-line
250
+ // dev build). The pinned release does not emit it, so this narration is
251
+ // tolerated only for negotiated STATUS while the dev-binary override is
252
+ // configured; any other stderr keeps failing closed.
253
+ const FORECAST_NARRATION_LINES = Object.freeze([
254
+ /^Forecast horizon: (?:partial|terminal)$/,
255
+ /^step [0-9]+: (?:execute|collect|stop); reason_code=[a-z0-9_]+; description=.+$/,
256
+ /^Re-query STATUS after completing this partial head\.$/,
257
+ ]);
258
+ function stderrIsForecastNarration(stderr: string): boolean {
259
+ const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
260
+ return lines.length > 0 && lines.every((line) => FORECAST_NARRATION_LINES.some((pattern) => pattern.test(line)));
261
+ }
262
+
204
263
  export const NATIVE_REVIEW_RECOVER_DISPOSITION = ["scope_changed", "invalidated", "escalated"] as const;
205
264
  export type NativeReviewRecoverDisposition = (typeof NATIVE_REVIEW_RECOVER_DISPOSITION)[number];
206
265
 
@@ -244,6 +303,9 @@ export interface NativeReviewAbandonRequest {
244
303
  lineage: string;
245
304
  expectedRevision: string;
246
305
  snapshotIdentity: string;
306
+ capturedLensResults: readonly string[];
307
+ findingsPresent: boolean;
308
+ evidenceRecordsPresent: boolean;
247
309
  actor: string;
248
310
  reason: string;
249
311
  maintainerAuthorization: string;
@@ -345,6 +407,33 @@ export interface NativeReviewAdmittedResultManifest {
345
407
  readonly reference?: string;
346
408
  }
347
409
 
410
+ // gentle-pi#311 P4-roles: one Go-owned non-lens provider role capture. The
411
+ // provider renders a SELF-CONTAINED authority-advancing vector
412
+ // (`review.capture-refuter` / `review.capture-validation` with binding tokens
413
+ // plus `--agent=pi --execute=true`, no submission descriptor); Pi executes the
414
+ // exact rendered invocation verbatim, in the foreground, and Go materializes
415
+ // the role prompt, spawns its own locked-down pi subprocess, and admits the
416
+ // raw verdict. Nothing here authors, parses, or transports role output.
417
+ export const NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA = "gentle-ai.review-provider-role-capture/v1";
418
+
419
+ export interface NativeReviewProviderRoleCaptureRequest {
420
+ /** The provider-named capture operation, e.g. `review.capture-refuter`. */
421
+ readonly captureOperation: string;
422
+ /** Every provider-issued argument token, verbatim, in provider order. */
423
+ readonly argumentTokens: readonly string[];
424
+ /** Process working directory only; never rendered into the invocation. */
425
+ readonly cwd: string;
426
+ readonly signal?: AbortSignal;
427
+ }
428
+
429
+ export interface NativeReviewProviderRoleCaptureArtifact {
430
+ readonly schema: typeof NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA;
431
+ readonly lineageId: string;
432
+ readonly targetIdentity: string;
433
+ readonly role: string;
434
+ readonly captured: true;
435
+ }
436
+
348
437
  export interface NativeReviewCaptureEvidenceRequest {
349
438
  cwd: string;
350
439
  lineageId: string;
@@ -355,6 +444,27 @@ export interface NativeReviewCaptureEvidenceRequest {
355
444
  signal?: AbortSignal;
356
445
  }
357
446
 
447
+ // Field defect (fambig, 2026-08-16): the evidence collect slot renders the
448
+ // exact submission tokens native admits — fix-diff `--target`,
449
+ // `--expected-revision`, and an opaque cwd-independent `--repository-context` —
450
+ // with `{{outcome}}`/`{{input}}` substitution slots. Satisfying the slot means
451
+ // executing those tokens verbatim with only the two slot substitutions, the
452
+ // same discipline captureResult uses; identities are never reconstructed from
453
+ // top-level status fields.
454
+ export interface NativeReviewCaptureEvidenceSubmissionRequest {
455
+ /** Process working directory; forbidden when the tokens carry --repository-context (the context is cwd-independent). */
456
+ cwd?: string;
457
+ /** Provider-rendered submission argument tokens, verbatim, in provider order. */
458
+ argumentTokens: readonly string[];
459
+ /** Index of the token carrying the {{outcome}} substitution slot. */
460
+ outcomeSubstitutionLocation: number;
461
+ /** Index of the token carrying the {{input}} substitution slot. */
462
+ inputSubstitutionLocation: number;
463
+ outcome: NativeReviewCaptureOutcome;
464
+ evidenceDocument: string;
465
+ signal?: AbortSignal;
466
+ }
467
+
358
468
  export interface NativeReviewVerificationEvidenceV2 {
359
469
  schema: "gentle-ai.review-verification-evidence/v2";
360
470
  version: 2;
@@ -374,7 +484,7 @@ export interface NativeReviewVerificationEvidenceV2 {
374
484
  export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; targetIdentity?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
375
485
  export const NATIVE_REVIEW_CONSENT_ANSWER = { GRANTED: "granted", DECLINED: "declined" } as const;
376
486
  export type NativeReviewConsentAnswer = (typeof NATIVE_REVIEW_CONSENT_ANSWER)[keyof typeof NATIVE_REVIEW_CONSENT_ANSWER];
377
- export interface NativeReviewConsentAnswerRequest { cwd: string; consent: ReviewConsentV2; answer: NativeReviewConsentAnswer; signal?: AbortSignal; }
487
+ export interface NativeReviewConsentAnswerRequest { cwd: string; consent: ReviewConsentEnvelope; answer: NativeReviewConsentAnswer; signal?: AbortSignal; }
378
488
  export interface NativeReviewConsentDeclinedResult {
379
489
  kind: "declined";
380
490
  targetIdentity: string;
@@ -403,12 +513,56 @@ export interface NativeFinalizeRequest extends NativeReviewFinalizeCapturedResul
403
513
  failed?: boolean;
404
514
  signal?: AbortSignal;
405
515
  }
516
+ // gentle-pi#311 P5: the provider-driven FINALIZE. `argumentTokens` are the
517
+ // exact rendered tokens of one provider-returned `review.finalize` execute
518
+ // transition (e.g. `--lineage=<id> --captured-results=true`), passed through
519
+ // verbatim and never synthesized or reordered by the host.
520
+ export interface NativeReviewFinalizeTransitionRequest {
521
+ readonly cwd: string;
522
+ readonly argumentTokens: readonly string[];
523
+ readonly signal?: AbortSignal;
524
+ }
525
+
526
+ // The provider-rendered `finalize` submission descriptor forms (status/v5):
527
+ // the correction PLAN slot substitutes a positive line-count literal into its
528
+ // {{value}} token; the TARGETED VALIDATION slot substitutes a staged validator
529
+ // artifact path. The tokens are self-contained and execute verbatim.
530
+ export interface NativeReviewFinalizeSubmissionRequest {
531
+ /** Process working directory only; the rendered tokens are self-contained. */
532
+ readonly cwd: string;
533
+ /** Provider-rendered submission argument tokens, verbatim, in provider order. */
534
+ readonly argumentTokens: readonly string[];
535
+ /** Index of the token carrying the {{value}} substitution slot. */
536
+ readonly valueSubstitutionLocation: number;
537
+ /** The literal substitution (correction_lines). Exactly one of valueLiteral/valueDocument. */
538
+ readonly valueLiteral?: string;
539
+ /** The document to stage as a 0o600 artifact whose path substitutes {{value}} (validation). */
540
+ readonly valueDocument?: string;
541
+ readonly signal?: AbortSignal;
542
+ }
543
+
406
544
  export interface NativeValidateRequest { cwd: string; gate: string; lineageId?: string; flags?: readonly string[]; signal?: AbortSignal; }
407
545
  export interface NativeBindSddRequest { cwd: string; change: string; lineage: string; expectedBindingRevision: string; signal?: AbortSignal; }
408
546
  export interface NativeSddStatusRequest { cwd: string; change: string; signal?: AbortSignal; }
409
547
  export interface NativeReviewStatusRequest { cwd: string; signal?: AbortSignal; }
410
548
  export interface NativeCapabilitiesRequest { cwd?: string; signal?: AbortSignal; }
411
- export interface NativeTargetStatusRequest { cwd: string; lineageId?: string; baseRef?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
549
+ export interface NativeTargetStatusRequest {
550
+ cwd: string;
551
+ lineageId?: string;
552
+ baseRef?: string;
553
+ projection?: "workspace" | "staged";
554
+ /**
555
+ * The immutable reviewer runtime this host provides. Measured against the
556
+ * live 2.4.0-main provider: the materialize-marked relay slot (agent=pi,
557
+ * materialize=true, plus the provider submission) is offered ONLY when the
558
+ * caller names its agent; an agent-less status returns a bare
559
+ * capture-result input the host relay cannot consume. Providers older than
560
+ * v2.4.0 do not define the flag and refuse it, so callers probe and fall
561
+ * back rather than version-sniff.
562
+ */
563
+ agent?: "pi";
564
+ signal?: AbortSignal;
565
+ }
412
566
  export interface NativeGateContext { lineageId: string; storeRevision: string; raw: Record<string, unknown>; }
413
567
 
414
568
  export const NATIVE_REVIEW_AUTHORITY_STATUS = {
@@ -480,6 +634,7 @@ export interface NativeReviewAuthorityEntry {
480
634
  status: NativeReviewAuthorityEntryStatus;
481
635
  state?: string;
482
636
  revision?: string;
637
+ snapshotIdentity?: string;
483
638
  chainIdentity?: string;
484
639
  recovery?: NativeReviewRecovery;
485
640
  problems: readonly string[];
@@ -665,6 +820,20 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
665
820
  // advertises capabilities/v1.5 and the negotiated start envelope is still
666
821
  // the closed `start/v2`, so riskEvidence and hint still cannot arrive.
667
822
  "2.2.2": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
823
+ // Ground-truthed against the released v2.2.3 binary: the v2 lane remains
824
+ // protocol 2.0 with the same operation set and closed START fields consumed
825
+ // by Pi, so the existing capability columns are unchanged.
826
+ "2.2.3": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
827
+ // Ground-truthed against the released v2.4.0 binary: the v2 lane advertises
828
+ // capabilities/v2.2 and answers status/v5 and consent/v3, all of which the
829
+ // existing decoders already read, and the START envelope Pi consumes is
830
+ // still `start/v3` carrying `risk_reasons` with no `risk_evidence` and no
831
+ // `hint`, so the existing capability columns are unchanged. v2.4.0 also
832
+ // made receipt-driven development opt-in, which changes what the mode
833
+ // envelope reports, not whether it reports it. v2.2.4 and v2.3.0 shipped
834
+ // while Pi stayed on 2.2.3; they were never pinned or probed, so they get
835
+ // no row.
836
+ "2.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
668
837
  });
669
838
  type NativeCliCapability = keyof (typeof NATIVE_CLI_CONTRACTS)[keyof typeof NATIVE_CLI_CONTRACTS];
670
839
 
@@ -681,6 +850,21 @@ function resolvedNativeCliContract(version: string): Record<NativeCliCapability,
681
850
  return nativeCliContractsTestingOverlay.get(version) ?? (NATIVE_CLI_CONTRACTS as Record<string, Record<NativeCliCapability, boolean> | undefined>)[version];
682
851
  }
683
852
 
853
+ // The latest known contract row, used as the capability floor for the
854
+ // explicit dev-binary override (unpinned field-test mode): a maintainer
855
+ // dev build is at least as capable as the newest release Pi knows. The table
856
+ // is declared in ascending release order, so its last key is the latest.
857
+ const LATEST_NATIVE_CLI_CONTRACT_VERSION = Object.keys(NATIVE_CLI_CONTRACTS).at(-1) as keyof typeof NATIVE_CLI_CONTRACTS;
858
+
859
+ // Dev-binary override version discipline: with the override configured, a
860
+ // version reported outside the pinned table resolves to the latest known row;
861
+ // with the override absent, behavior is byte-identical to the pinned gate.
862
+ function effectiveNativeCliContract(version: string): Record<NativeCliCapability, boolean> | undefined {
863
+ const pinned = resolvedNativeCliContract(version);
864
+ if (pinned !== undefined) return pinned;
865
+ return gentleAiDevBinaryOverrideConfigured() ? NATIVE_CLI_CONTRACTS[LATEST_NATIVE_CLI_CONTRACT_VERSION] : undefined;
866
+ }
867
+
684
868
  export interface NativeReviewStructuredDenial {
685
869
  schema: "gentle-ai.review-gate-result/v1";
686
870
  result: "scope-changed" | "invalidated" | "escalated";
@@ -696,6 +880,8 @@ export interface NativeReviewProcessDiagnostics {
696
880
  signal?: NodeJS.Signals;
697
881
  timed_out: boolean;
698
882
  output_limit_exceeded: boolean;
883
+ max_buffer_bytes?: number;
884
+ configuration_hint?: string;
699
885
  stderr?: string;
700
886
  denial?: NativeReviewStructuredDenial;
701
887
  }
@@ -723,15 +909,25 @@ export class NativeReviewCliError extends Error {
723
909
  }
724
910
  }
725
911
 
912
+ // The one central runner for every gentle-ai CLI invocation the extension
913
+ // makes. It declares the Pi host relay handshake on each spawn: gentle-ai
914
+ // refuses pi admission pre-authority without it (gentle-pi#311 P4), and a
915
+ // single injection point keeps the declaration impossible to forget on any
916
+ // individual operation.
917
+ export function gentleAiProcessEnvironment(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
918
+ return { ...base, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
919
+ }
920
+
726
921
  export function createNodeExecFileAdapter(): ExecFileAdapter {
727
922
  return async (request) => {
728
923
  try {
729
- const output = await execFileAsync(request.file, [...request.arguments], { cwd: request.cwd, encoding: "utf8", shell: false, windowsHide: true, timeout: request.timeoutMs, maxBuffer: request.maxBufferBytes, signal: request.signal });
924
+ const output = await execFileAsync(request.file, [...request.arguments], { cwd: request.cwd, encoding: "utf8", shell: false, windowsHide: true, timeout: request.timeoutMs, maxBuffer: request.maxBufferBytes, signal: request.signal, env: gentleAiProcessEnvironment() });
730
925
  return { stdout: output.stdout, stderr: output.stderr, exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
731
926
  } catch (error) {
732
927
  const detail = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: string | number; signal?: NodeJS.Signals; killed?: boolean };
733
928
  if (detail.code === "ENOENT" || detail.code === "EACCES" || detail.name === "AbortError") throw error;
734
- return { stdout: detail.stdout ?? "", stderr: detail.stderr ?? "", exitCode: typeof detail.code === "number" ? detail.code : 1, signal: detail.signal ?? null, timedOut: detail.killed === true, outputLimitExceeded: detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" };
929
+ const outputLimitExceeded = detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
930
+ return { stdout: detail.stdout ?? "", stderr: detail.stderr ?? "", exitCode: typeof detail.code === "number" ? detail.code : 1, signal: detail.signal ?? null, timedOut: !outputLimitExceeded && detail.killed === true, outputLimitExceeded };
735
931
  }
736
932
  };
737
933
  }
@@ -748,6 +944,7 @@ function exactObject(value: unknown, required: readonly string[], optional: read
748
944
  }
749
945
  function requiredString(value: unknown): string { if (typeof value !== "string" || value.length === 0) throw new Error("expected string"); return value; }
750
946
  function stringValue(value: unknown): string { if (typeof value !== "string") throw new Error("expected string"); return value; }
947
+ function sha256Identity(value: unknown): string { const parsed = requiredString(value); if (!/^sha256:[0-9a-f]{64}$/.test(parsed)) throw new Error("expected canonical SHA-256 identity"); return parsed; }
751
948
  function booleanValue(value: unknown): boolean { if (typeof value !== "boolean") throw new Error("expected boolean"); return value; }
752
949
  function nonNegativeInteger(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("expected safe non-negative integer"); return value; }
753
950
  function positiveInteger(value: unknown): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new Error("expected safe positive integer"); return value; }
@@ -806,12 +1003,28 @@ function parseStructuredNativeDenial(stdout: string): NativeReviewStructuredDeni
806
1003
  // Rebuild diagnostics from a duplicated module instance before facade output.
807
1004
  export function sanitizeForeignNativeReviewDiagnostics(value: unknown): NativeReviewProcessDiagnostics | undefined {
808
1005
  try {
809
- const raw = exactObject(value, ["operation", "error_code", "timed_out", "output_limit_exceeded"], ["exit_code", "signal", "stderr", "denial"]);
1006
+ const raw = exactObject(value, ["operation", "error_code", "timed_out", "output_limit_exceeded"], ["exit_code", "signal", "max_buffer_bytes", "configuration_hint", "stderr", "denial"]);
810
1007
  const operation = enumString(raw.operation, Object.values(NATIVE_REVIEW_OPERATION)) as NativeReviewOperation;
811
1008
  const errorCode = enumString(raw.error_code, Object.values(NATIVE_REVIEW_ERROR_CODE)) as NativeReviewErrorCode;
812
1009
  const signal = raw.signal === undefined ? undefined : requiredString(raw.signal);
813
- if (signal !== undefined && !/^SIG[A-Z0-9]{1,12}$/.test(signal)) return undefined;
814
- return { operation, error_code: errorCode, ...(raw.exit_code === undefined ? {} : { exit_code: nonNegativeInteger(raw.exit_code) }), ...(signal === undefined ? {} : { signal: signal as NodeJS.Signals }), timed_out: booleanValue(raw.timed_out), output_limit_exceeded: booleanValue(raw.output_limit_exceeded), ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }), ...(raw.denial === undefined ? {} : { denial: sanitizeForeignStructuredDenial(raw.denial) }) };
1010
+ const maxBufferBytes = raw.max_buffer_bytes === undefined ? undefined : positiveInteger(raw.max_buffer_bytes);
1011
+ const configurationHint = raw.configuration_hint === undefined ? undefined : stringValue(raw.configuration_hint);
1012
+ if (
1013
+ (signal !== undefined && !/^SIG[A-Z0-9]{1,12}$/.test(signal)) ||
1014
+ (maxBufferBytes === undefined) !== (configurationHint === undefined) ||
1015
+ (configurationHint !== undefined && (errorCode !== NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT || configurationHint !== NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT))
1016
+ ) return undefined;
1017
+ return {
1018
+ operation,
1019
+ error_code: errorCode,
1020
+ ...(raw.exit_code === undefined ? {} : { exit_code: nonNegativeInteger(raw.exit_code) }),
1021
+ ...(signal === undefined ? {} : { signal: signal as NodeJS.Signals }),
1022
+ timed_out: booleanValue(raw.timed_out),
1023
+ output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
1024
+ ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint! }),
1025
+ ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
1026
+ ...(raw.denial === undefined ? {} : { denial: sanitizeForeignStructuredDenial(raw.denial) }),
1027
+ };
815
1028
  } catch { return undefined; }
816
1029
  }
817
1030
 
@@ -827,14 +1040,18 @@ function sanitizeForeignStructuredDenial(value: unknown): NativeReviewStructured
827
1040
  return { schema: "gentle-ai.review-gate-result/v1", result, action, reason, ...(denial === undefined ? {} : { denial }) };
828
1041
  }
829
1042
 
830
- function nativeProcessDiagnostics(operation: NativeReviewOperation, code: NativeReviewErrorCode, result?: ExecFileResult): NativeReviewProcessDiagnostics {
1043
+ function nativeProcessDiagnostics(operation: NativeReviewOperation, code: NativeReviewErrorCode, result?: ExecFileResult, maxBufferBytes?: number): NativeReviewProcessDiagnostics {
1044
+ const outputLimitExceeded = result?.outputLimitExceeded === true;
831
1045
  return {
832
1046
  operation,
833
1047
  error_code: code,
834
1048
  ...(result === undefined ? {} : { exit_code: result.exitCode }),
835
1049
  ...(result?.signal === null || result?.signal === undefined ? {} : { signal: result.signal }),
836
- timed_out: result?.timedOut === true,
837
- output_limit_exceeded: result?.outputLimitExceeded === true,
1050
+ timed_out: !outputLimitExceeded && result?.timedOut === true,
1051
+ output_limit_exceeded: outputLimitExceeded,
1052
+ ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
1053
+ ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
1054
+ : {}),
838
1055
  ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
839
1056
  ...(result === undefined ? {} : { denial: parseStructuredNativeDenial(result.stdout) }),
840
1057
  };
@@ -957,7 +1174,7 @@ function decodeNativeReviewRecovery(value: unknown): NativeReviewRecovery {
957
1174
  };
958
1175
  }
959
1176
  function decodeNativeReviewStatusEntry(value: unknown): NativeReviewAuthorityEntry {
960
- const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "chain_identity", "recovery"]);
1177
+ const entry = exactObject(value, ["version", "path", "status", "problems"], ["lineage_id", "state", "revision", "snapshot_identity", "chain_identity", "recovery"]);
961
1178
  return {
962
1179
  version: enumString(entry.version, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_VERSION)) as NativeReviewAuthorityEntryVersion,
963
1180
  ...(entry.lineage_id === undefined ? {} : { lineageId: requiredString(entry.lineage_id) }),
@@ -965,6 +1182,7 @@ function decodeNativeReviewStatusEntry(value: unknown): NativeReviewAuthorityEnt
965
1182
  status: enumString(entry.status, Object.values(NATIVE_REVIEW_AUTHORITY_ENTRY_STATUS)) as NativeReviewAuthorityEntryStatus,
966
1183
  ...(entry.state === undefined ? {} : { state: requiredString(entry.state) }),
967
1184
  ...(entry.revision === undefined ? {} : { revision: requiredString(entry.revision) }),
1185
+ ...(entry.snapshot_identity === undefined ? {} : { snapshotIdentity: sha256Identity(entry.snapshot_identity) }),
968
1186
  ...(entry.chain_identity === undefined ? {} : { chainIdentity: requiredString(entry.chain_identity) }),
969
1187
  ...(entry.recovery === undefined ? {} : { recovery: decodeNativeReviewRecovery(entry.recovery) }),
970
1188
  problems: stringArray(entry.problems),
@@ -1151,8 +1369,8 @@ function hasValidLensesRequired(action: NativeStartAction, state: string, riskLe
1151
1369
  return !lensesRequired;
1152
1370
  }
1153
1371
 
1154
- function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperation, mutating: boolean, message: string, result?: ExecFileResult, launchAttempted = true, auditRecord?: Record<string, unknown>): NativeReviewCliError {
1155
- return new NativeReviewCliError(code, operation, launchAttempted, mutating, message, nativeProcessDiagnostics(operation, code, result), auditRecord);
1372
+ function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperation, mutating: boolean, message: string, result?: ExecFileResult, launchAttempted = true, auditRecord?: Record<string, unknown>, maxBufferBytes?: number): NativeReviewCliError {
1373
+ return new NativeReviewCliError(code, operation, launchAttempted, mutating, message, nativeProcessDiagnostics(operation, code, result, maxBufferBytes), auditRecord);
1156
1374
  }
1157
1375
 
1158
1376
  interface NativeJsonExecution {
@@ -1167,7 +1385,7 @@ export class NativeReviewCliV214 {
1167
1385
  private readonly timeoutMs: number;
1168
1386
  private readonly maxBufferBytes: number;
1169
1387
  private readonly cleanupDirectory: (directory: string) => Promise<void>;
1170
- constructor(adapter: ExecFileAdapter, executable: string | (() => string) = resolveGentleAiBinary, timeoutMs = 30_000, maxBufferBytes = 1024 * 1024, cleanupDirectory = (directory: string) => rm(directory, { recursive: true, force: true })) {
1388
+ constructor(adapter: ExecFileAdapter, executable: string | (() => string) = resolveGentleAiBinary, timeoutMs = 30_000, maxBufferBytes = resolveNativeReviewMaxBufferBytes(), cleanupDirectory = (directory: string) => rm(directory, { recursive: true, force: true })) {
1171
1389
  if (typeof executable === "string" && (!isAbsolute(executable) || executable === "gentle-ai")) throw new TypeError("Native review requires an absolute package-local executable");
1172
1390
  this.adapter = adapter;
1173
1391
  this.executable = executable;
@@ -1204,8 +1422,8 @@ export class NativeReviewCliV214 {
1204
1422
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "native process could not start");
1205
1423
  }
1206
1424
  const diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.NON_ZERO, result);
1425
+ if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes);
1207
1426
  if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result);
1208
- if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result);
1209
1427
  if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result);
1210
1428
  const structuredValidateDenial = operation === NATIVE_REVIEW_OPERATION.VALIDATE && result.exitCode === 1;
1211
1429
  const maintenancePartialFailure = [NATIVE_REVIEW_OPERATION.ABANDON, NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS].includes(operation) && result.exitCode !== 0;
@@ -1223,12 +1441,18 @@ export class NativeReviewCliV214 {
1223
1441
  if (error instanceof Error && error.name === "AbortError") throw nativeError(NATIVE_REVIEW_ERROR_CODE.CANCELLED, NATIVE_REVIEW_OPERATION.VERSION, false, "version process was cancelled");
1224
1442
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, NATIVE_REVIEW_OPERATION.VERSION, false, "gentle-ai is unavailable");
1225
1443
  }
1444
+ if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, NATIVE_REVIEW_OPERATION.VERSION, false, "version process output exceeded limit", result, true, undefined, this.maxBufferBytes);
1226
1445
  if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, NATIVE_REVIEW_OPERATION.VERSION, false, "version process timed out", result);
1227
- if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, NATIVE_REVIEW_OPERATION.VERSION, false, "version process output exceeded limit", result);
1228
1446
  if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, NATIVE_REVIEW_OPERATION.VERSION, false, "version process was signalled", result);
1229
1447
  if (result.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.VERSION, false, "version process failed", result);
1230
- const version = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(result.stdout.replace(/\r\n$/, "\n"))?.[1];
1231
- const contract = version === undefined ? undefined : resolvedNativeCliContract(version);
1448
+ const normalized = result.stdout.replace(/\r\n$/, "\n");
1449
+ const pinnedVersion = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(normalized)?.[1];
1450
+ // Dev-binary override (unpinned field-test mode): accept the binary's
1451
+ // reported version even when it is not a pinned three-part release
1452
+ // version (a main-line dev build reports e.g. "2.4.0-rc.8+fix...").
1453
+ // The banner shape itself stays strict. Pinned mode is byte-identical.
1454
+ const version = pinnedVersion ?? (gentleAiDevBinaryOverrideConfigured() ? /^gentle-ai (\S+)\n$/.exec(normalized)?.[1] : undefined);
1455
+ const contract = version === undefined ? undefined : effectiveNativeCliContract(version);
1232
1456
  if (result.stderr.trim().length > 0 || contract === undefined || capabilities.some((capability) => !contract[capability])) throw nativeError(NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `native gentle-ai lacks required capabilities: expected v${GENTLE_AI_VERSION}, found v${version ?? "unparseable"}`);
1233
1457
  return version as keyof typeof NATIVE_CLI_CONTRACTS;
1234
1458
  }
@@ -1239,7 +1463,7 @@ export class NativeReviewCliV214 {
1239
1463
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
1240
1464
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
1241
1465
  const version = await this.verifyVersion(request.cwd, request.signal, ["start"]);
1242
- const toleratedStderr = resolvedNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : [];
1466
+ const toleratedStderr = effectiveNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : [];
1243
1467
  const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.START, request.cwd, ["review", "start", "--cwd", request.cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...(request.lineageId ? ["--lineage", request.lineageId] : []), ...(request.policyPath ? ["--policy", request.policyPath] : []), ...(request.focus ? ["--focus", request.focus] : [])], true, request.signal, toleratedStderr);
1244
1468
  return decode(NATIVE_REVIEW_OPERATION.START, true, () => {
1245
1469
  // `target_identity` and `lens_bindings` are real, unconditionally-present
@@ -1497,7 +1721,11 @@ export class NativeReviewCliV214 {
1497
1721
  for (const [name, value] of [["lineage", request.lineage], ["expectedRevision", request.expectedRevision], ["snapshotIdentity", request.snapshotIdentity], ["actor", request.actor], ["reason", request.reason]] as const) {
1498
1722
  if (!isCanonicalProcessString(value)) throw new TypeError(`Native ABANDON ${name} must be a non-empty, trimmed, NUL-free string`);
1499
1723
  }
1500
- if (request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request)) throw new TypeError("Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, actor, and reason binding");
1724
+ if (!Array.isArray(request.capturedLensResults) || request.capturedLensResults.some((entry) => !isCanonicalProcessString(entry))) throw new TypeError("Native ABANDON capturedLensResults must be an array of non-empty, trimmed, NUL-free strings");
1725
+ for (const [name, value] of [["findingsPresent", request.findingsPresent], ["evidenceRecordsPresent", request.evidenceRecordsPresent]] as const) {
1726
+ if (typeof value !== "boolean") throw new TypeError(`Native ABANDON ${name} must be a boolean`);
1727
+ }
1728
+ if (request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request)) throw new TypeError("Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, reason, discarded-work, and actor binding");
1501
1729
  await this.verifyVersion(request.cwd, request.signal, ["abandon"]);
1502
1730
  const execution = await this.execute(NATIVE_REVIEW_OPERATION.ABANDON, request.cwd, [
1503
1731
  "review", "abandon", "--cwd", request.cwd,
@@ -1591,14 +1819,17 @@ export class NativeReviewCliV214 {
1591
1819
  }
1592
1820
  }
1593
1821
 
1594
- export function nativeReviewAbandonAuthorization(request: Pick<NativeReviewAbandonRequest, "lineage" | "expectedRevision" | "snapshotIdentity" | "actor" | "reason">): string {
1822
+ export function nativeReviewAbandonAuthorization(request: Pick<NativeReviewAbandonRequest, "lineage" | "expectedRevision" | "snapshotIdentity" | "capturedLensResults" | "findingsPresent" | "evidenceRecordsPresent" | "actor" | "reason">): string {
1595
1823
  return [
1596
- "gentle-ai.review-abandon-authorization/v1",
1824
+ "gentle-ai.review-abandon-authorization/v2",
1597
1825
  `lineage=${request.lineage}`,
1598
1826
  `revision=${request.expectedRevision}`,
1599
1827
  `snapshot_identity=${request.snapshotIdentity}`,
1600
- `actor=${request.actor}`,
1601
1828
  `reason=${request.reason}`,
1829
+ `captured_lens_results=${request.capturedLensResults.join(",")}`,
1830
+ `findings_present=${request.findingsPresent}`,
1831
+ `evidence_records_present=${request.evidenceRecordsPresent}`,
1832
+ `actor=${request.actor.trim()}`,
1602
1833
  ].join("\n");
1603
1834
  }
1604
1835
 
@@ -1615,6 +1846,32 @@ export function nativeReviewLegacyQuarantineAuthorization(request: Pick<NativeRe
1615
1846
  ].join("\n");
1616
1847
  }
1617
1848
 
1849
+ /**
1850
+ * The exact native `gentle-ai.review-recovery-authorization/v1` binding for one
1851
+ * recovery edge.
1852
+ *
1853
+ * Native `review recover` accepts a caller-supplied authorization only when it
1854
+ * reproduces this binding byte for byte, because it is copied verbatim into the
1855
+ * recovery provenance and read afterwards as a maintainer attestation. Pi
1856
+ * therefore derives it from freshly read native target status and never
1857
+ * forwards a caller-supplied one: a wrong binding is worse than an absent one,
1858
+ * since an absent field cannot lie about who approved what.
1859
+ *
1860
+ * `targetIdentity` is the live target identity the provider itself publishes in
1861
+ * the `review.recover` eligibility binding (`status.target_identity`), which is
1862
+ * the identity the successor's initial snapshot takes.
1863
+ */
1864
+ export function nativeReviewRecoverAuthorization(request: Pick<NativeReviewRecoverRequest, "predecessorLineage" | "expectedPredecessorRevision" | "actor" | "reason"> & { targetIdentity: string }): string {
1865
+ return [
1866
+ "gentle-ai.review-recovery-authorization/v1",
1867
+ `predecessor_lineage=${request.predecessorLineage}`,
1868
+ `predecessor_revision=${request.expectedPredecessorRevision}`,
1869
+ `target_identity=${request.targetIdentity}`,
1870
+ `actor=${request.actor.trim()}`,
1871
+ `reason=${request.reason.trim()}`,
1872
+ ].join("\n");
1873
+ }
1874
+
1618
1875
  export function nativeReviewReconcileAuthorization(request: Pick<NativeReviewReconcileAuthorityRequest, "predecessorLineage" | "expectedPredecessorRevision" | "successorLineage" | "expectedSuccessorRevision" | "actor" | "reason" | "anomalies">): string {
1619
1876
  return [
1620
1877
  "gentle-ai.review-reconcile-authorization/v1",
@@ -1655,15 +1912,16 @@ export class NativeReviewIntegrationError extends Error {
1655
1912
  }
1656
1913
  }
1657
1914
 
1658
- // Raised when negotiated START answers `consent/v2` (action:
1915
+ // Raised when negotiated START answers a consent question (`consent/v2` from
1916
+ // the pinned line, `consent/v3` from gentle-ai >= 2.3.0; action:
1659
1917
  // "consent_required") instead of `start/v3`. The provider has frozen no
1660
1918
  // authority yet: Pi must relay this complete candidate-scoped question and may
1661
1919
  // answer only through one of the exact invocations carried by the envelope.
1662
1920
  export class NativeReviewConsentRequiredError extends Error {
1663
- readonly consent: ReviewConsentV2;
1921
+ readonly consent: ReviewConsentEnvelope;
1664
1922
  readonly launchAttempted = true;
1665
1923
  readonly mutationOutcome = "none";
1666
- constructor(consent: ReviewConsentV2) {
1924
+ constructor(consent: ReviewConsentEnvelope) {
1667
1925
  super(consent.headline);
1668
1926
  this.name = "NativeReviewConsentRequiredError";
1669
1927
  this.consent = consent;
@@ -1747,7 +2005,30 @@ function exactConsentOption(arguments_: readonly string[], name: string): string
1747
2005
  return values[0]!;
1748
2006
  }
1749
2007
 
1750
- function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): readonly string[] {
2008
+ function optionalConsentLineageOption(arguments_: readonly string[]): string | undefined {
2009
+ const values: string[] = [];
2010
+ for (let index = 0; index < arguments_.length; index += 1) {
2011
+ const token = arguments_[index]!;
2012
+ if (token === "--lineage") {
2013
+ const value = arguments_[index + 1];
2014
+ if (value === undefined || value.startsWith("--")) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation --lineage is missing its value");
2015
+ values.push(value);
2016
+ index += 1;
2017
+ } else if (token.startsWith("--lineage=")) values.push(token.slice("--lineage=".length));
2018
+ }
2019
+ if (values.length > 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation permits at most one --lineage");
2020
+ if (values.length === 0) return undefined;
2021
+ const lineageId = values[0]!;
2022
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(lineageId)) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation --lineage is malformed");
2023
+ return lineageId;
2024
+ }
2025
+
2026
+ interface ConsentInvocation {
2027
+ arguments_: readonly string[];
2028
+ lineageId?: string;
2029
+ }
2030
+
2031
+ function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): ConsentInvocation {
1751
2032
  const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
1752
2033
  if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
1753
2034
  const words = splitNativeConsentInvocation(choice.invocation);
@@ -1757,8 +2038,9 @@ function consentInvocationArguments(request: NativeReviewConsentAnswerRequest):
1757
2038
  if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
1758
2039
  if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
1759
2040
  if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
2041
+ const lineageId = optionalConsentLineageOption(arguments_);
1760
2042
  if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new NativeReviewConsentBindingError("consent-invocation-answer-changed", "Native consent invocation answer binding changed");
1761
- return arguments_;
2043
+ return { arguments_, ...(lineageId === undefined ? {} : { lineageId }) };
1762
2044
  }
1763
2045
 
1764
2046
  function decodeDeclinedConsentStart(value: unknown, expected: NativeReviewConsentAnswerRequest): NativeReviewConsentDeclinedResult {
@@ -1807,27 +2089,46 @@ interface NegotiatedExecution {
1807
2089
  }
1808
2090
 
1809
2091
  function decodeNativeAdmittedResultManifest(value: unknown): NativeReviewAdmittedResultManifest {
1810
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native capture-result manifest must be an object");
2092
+ // The admission answer routes through the exact-identity forward decoder
2093
+ // (decoder-freshness discipline): the complete live envelope — identity
2094
+ // constants, binding fields, and the exactly-one-locator rule — is
2095
+ // validated before anything is handed to FINALIZE, and an unknown field
2096
+ // grown by gentle-ai main is rejected instead of silently dropped.
2097
+ const artifact = decodeReviewResultArtifactV2(value);
2098
+ return Object.freeze({
2099
+ schema: artifact.schema,
2100
+ subjectHash: artifact.subjectHash,
2101
+ admissionDecision: artifact.admissionDecision,
2102
+ lens: artifact.lens,
2103
+ ...(artifact.path === undefined ? {} : { path: artifact.path }),
2104
+ ...(artifact.reference === undefined ? {} : { reference: artifact.reference }),
2105
+ });
2106
+ }
2107
+
2108
+ // gentle-pi#311 P4-roles: the strict acknowledgement for one executed
2109
+ // provider role vector. The immutable verdict bytes live in the Go-owned
2110
+ // compact store slot; this envelope only names the binding the capture
2111
+ // proved, so anything beyond that exact shape is refused.
2112
+ function decodeNativeProviderRoleCaptureArtifact(value: unknown): NativeReviewProviderRoleCaptureArtifact {
2113
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native provider role capture artifact must be an object");
1811
2114
  const body = value as Record<string, unknown>;
2115
+ const allowed = new Set(["schema", "lineage_id", "target_identity", "role", "captured"]);
2116
+ for (const key of Object.keys(body)) if (!allowed.has(key)) throw new TypeError(`native provider role capture artifact carries unexpected key ${key}`);
1812
2117
  const text = (key: string): string => {
1813
2118
  const found = body[key];
1814
- if (typeof found !== "string" || found.trim() !== found || found.length === 0) throw new TypeError(`native capture-result manifest ${key} must be a non-empty trimmed string`);
2119
+ if (typeof found !== "string" || found.trim() !== found || found.length === 0) throw new TypeError(`native provider role capture artifact ${key} must be a non-empty trimmed string`);
1815
2120
  return found;
1816
2121
  };
1817
- const schema = text("schema");
1818
- if (schema !== "gentle-ai.review-result-artifact/v2") throw new TypeError(`native capture-result manifest schema must be gentle-ai.review-result-artifact/v2, received ${schema}`);
1819
- const admission = text("admission_decision");
1820
- if (admission !== "completed") throw new TypeError(`native capture-result manifest admission_decision must be completed, received ${admission}`);
1821
- // Exactly one locator: a provider-owned path OR an opaque reference. Both or
1822
- // neither means the manifest cannot be handed to FINALIZE.
1823
- const hasPath = body.path !== undefined, hasReference = body.reference !== undefined;
1824
- if (hasPath === hasReference) throw new TypeError("native capture-result manifest must carry exactly one of path or reference");
2122
+ if (text("schema") !== NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA) throw new TypeError(`native provider role capture artifact schema must be ${NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA}`);
2123
+ const role = text("role");
2124
+ if (role !== "refuter" && role !== "targeted-validator") throw new TypeError(`native provider role capture artifact role must be refuter or targeted-validator, received ${role}`);
2125
+ if (body.captured !== true) throw new TypeError("native provider role capture artifact must report captured: true");
1825
2126
  return Object.freeze({
1826
- schema,
1827
- subjectHash: text("subject_hash"),
1828
- admissionDecision: admission,
1829
- ...(body.lens === undefined ? {} : { lens: text("lens") }),
1830
- ...(hasPath ? { path: text("path") } : { reference: text("reference") }),
2127
+ schema: NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA,
2128
+ lineageId: text("lineage_id"),
2129
+ targetIdentity: text("target_identity"),
2130
+ role,
2131
+ captured: true,
1831
2132
  });
1832
2133
  }
1833
2134
 
@@ -1843,7 +2144,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1843
2144
  adapter: ExecFileAdapter,
1844
2145
  executable: string | (() => string) = resolveGentleAiBinary,
1845
2146
  timeoutMs = 30_000,
1846
- maxBufferBytes = 1024 * 1024,
2147
+ maxBufferBytes = resolveNativeReviewMaxBufferBytes(),
1847
2148
  cleanupDirectory: (directory: string) => Promise<void> = (directory) => rm(directory, { recursive: true, force: true }),
1848
2149
  executableDigest: NativeExecutableDigestResolver = defaultExecutableDigest,
1849
2150
  ) {
@@ -1893,8 +2194,8 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1893
2194
  if (error instanceof Error && error.name === "AbortError") throw nativeError(NATIVE_REVIEW_ERROR_CODE.CANCELLED, operation, mutating, "native process was cancelled");
1894
2195
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "native process could not start");
1895
2196
  }
2197
+ if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes);
1896
2198
  if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result);
1897
- if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result);
1898
2199
  if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result);
1899
2200
  const diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.NON_ZERO, result);
1900
2201
  const body = parseJson(result.stdout, operation, mutating, diagnostics);
@@ -1906,7 +2207,8 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1906
2207
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native negotiated operation failed without a valid failure envelope", result);
1907
2208
  }
1908
2209
  }
1909
- if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result);
2210
+ const forecastNarrationTolerated = operation === NATIVE_REVIEW_OPERATION.STATUS && gentleAiDevBinaryOverrideConfigured() && stderrIsForecastNarration(result.stderr);
2211
+ if (result.stderr.trim().length > 0 && !stderrIsTolerated(result.stderr, toleratedStderr) && !forecastNarrationTolerated) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result);
1910
2212
  return { body, exitCode: result.exitCode };
1911
2213
  }
1912
2214
 
@@ -1935,7 +2237,12 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1935
2237
  if (error instanceof NativeReviewCliError) throw error;
1936
2238
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `expected gentle-ai v${GENTLE_AI_VERSION}; the installed runtime is incompatible — reinstall gentle-pi`);
1937
2239
  }
1938
- if (capabilities.packageVersion !== GENTLE_AI_VERSION) {
2240
+ // Dev-binary override (unpinned field-test mode): the session accepts
2241
+ // the binary's self-reported package version instead of the exact pin.
2242
+ // The full capabilities decode above still applies unchanged, so an
2243
+ // incompatible envelope is refused either way. Pinned mode keeps the
2244
+ // exact equality byte-identical.
2245
+ if (capabilities.packageVersion !== GENTLE_AI_VERSION && !gentleAiDevBinaryOverrideConfigured()) {
1939
2246
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.VERSION_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.VERSION, false, `expected gentle-ai v${GENTLE_AI_VERSION}, provider reported v${capabilities.packageVersion}`);
1940
2247
  }
1941
2248
  return capabilities;
@@ -1991,12 +2298,20 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1991
2298
  ...(request.focus === undefined ? [] : ["--focus", request.focus]),
1992
2299
  "--consent", "relay",
1993
2300
  ], true, request.signal);
1994
- // A negotiated v2 START may answer `consent/v2` (action:
2301
+ // A negotiated v2 START may answer a consent question (action:
1995
2302
  // "consent_required") instead of `start/v3` when the provider needs an
1996
2303
  // explicit answer it cannot infer. Discriminate before decode and surface
1997
- // the complete envelope; only the caller can map a human answer.
2304
+ // the complete envelope; only the caller can map a human answer. The
2305
+ // body's own schema string selects the identity-exact decoder: the
2306
+ // pinned 2.2.x line emits consent/v2, gentle-ai >= 2.3.0 (capabilities
2307
+ // v2.1+) emits consent/v3, and any other identity fails closed inside
2308
+ // the v3 decoder's exact identity gate.
1998
2309
  if (execution.body.action === "consent_required") {
1999
- const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
2310
+ const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => (
2311
+ execution.body.schema === "gentle-ai.review-integration.consent/v2"
2312
+ ? decodeReviewConsentV2(execution.body)
2313
+ : decodeReviewConsentV3(execution.body)
2314
+ ));
2000
2315
  if (consent.targetIdentity !== targetIdentity || consent.projection !== projection) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent target binding mismatch");
2001
2316
  throw new NativeReviewConsentRequiredError(consent);
2002
2317
  }
@@ -2033,16 +2348,15 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2033
2348
  }
2034
2349
 
2035
2350
  async answerConsent(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult> {
2036
- const arguments_ = consentInvocationArguments(request);
2037
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, arguments_, true, request.signal);
2351
+ const invocation = consentInvocationArguments(request);
2352
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, invocation.arguments_, true, request.signal);
2038
2353
  if (request.answer === NATIVE_REVIEW_CONSENT_ANSWER.DECLINED) {
2039
2354
  return decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeDeclinedConsentStart(execution.body, request));
2040
2355
  }
2041
2356
  const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
2042
2357
  const answeredTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2043
2358
  if (answeredTarget !== request.consent.targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer target mismatch");
2044
- const lineageId = exactConsentOption(arguments_, "--lineage");
2045
- if (result.lineageId !== lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch");
2359
+ if (invocation.lineageId !== undefined && result.lineageId !== invocation.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch");
2046
2360
  return { kind: "started", start: {
2047
2361
  lineageId: result.lineageId,
2048
2362
  state: result.state as NativeStartResult["state"],
@@ -2173,6 +2487,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2173
2487
  "--projection", request.projection ?? "workspace",
2174
2488
  ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef]),
2175
2489
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
2490
+ ...(request.agent === undefined ? [] : ["--agent", request.agent]),
2176
2491
  "--next-transition",
2177
2492
  ], false, request.signal);
2178
2493
  assertSupportedNextTransitionOperation(execution.body);
@@ -2239,6 +2554,118 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2239
2554
  }
2240
2555
  }
2241
2556
 
2557
+ // gentle-pi#311 P4-roles: executes one provider-rendered self-contained
2558
+ // role capture vector exactly as rendered — one CLI invocation, verbatim
2559
+ // tokens in provider order, in the foreground. Go materializes the role
2560
+ // prompt, spawns its own locked-down pi subprocess, and admits the raw
2561
+ // verdict; Pi never adds, removes, or reorders a single token (not even
2562
+ // --cwd: the vector's --repository-context is authoritative and mutually
2563
+ // exclusive with a path). The central adapter injects the relay handshake
2564
+ // environment on this spawn like on every other gentle-ai invocation.
2565
+ async captureProviderRole(request: NativeReviewProviderRoleCaptureRequest): Promise<NativeReviewProviderRoleCaptureArtifact> {
2566
+ const verb = request.captureOperation.startsWith("review.") ? request.captureOperation.slice("review.".length) : "";
2567
+ if (verb !== "capture-refuter" && verb !== "capture-validation") throw new TypeError(`Native CAPTURE_PROVIDER_ROLE supports only review.capture-refuter and review.capture-validation, received ${JSON.stringify(request.captureOperation)}`);
2568
+ if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_PROVIDER_ROLE requires the provider-rendered argument tokens");
2569
+ if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_PROVIDER_ROLE argument tokens must all be non-empty strings");
2570
+ const executable = this.verifiedExecutable(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, true);
2571
+ const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, request.cwd, [
2572
+ "review", verb,
2573
+ ...request.argumentTokens,
2574
+ ], true, request.signal, executable.path);
2575
+ return decode(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, true, () => decodeNativeProviderRoleCaptureArtifact(execution.body));
2576
+ }
2577
+
2578
+ // gentle-pi#311 P5: executes one provider-rendered `review.finalize`
2579
+ // execute transition exactly as rendered. The tokens come verbatim from
2580
+ // the negotiated next_transition (e.g. `--lineage=<id>
2581
+ // --captured-results=true`); Pi assembles no reviewer, refuter, or
2582
+ // validator documents for this lane — the provider discovers its own
2583
+ // admitted role and lens slots.
2584
+ async finalizeTransition(request: NativeReviewFinalizeTransitionRequest): Promise<NativeFinalizeResult> {
2585
+ if (request.argumentTokens.length === 0) throw new TypeError("Native FINALIZE transition requires the provider-rendered argument tokens");
2586
+ if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native FINALIZE transition argument tokens must all be non-empty strings");
2587
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.FINALIZE, request.cwd, [
2588
+ "review", "finalize",
2589
+ ...request.argumentTokens,
2590
+ ], true, request.signal);
2591
+ return this.decodeFinalizeTransitionExecution(execution);
2592
+ }
2593
+
2594
+ // Same misbinding class as capture-evidence (live smoke, 2026-08-16): the
2595
+ // correction PLAN and TARGETED VALIDATION collect slots render `finalize`
2596
+ // submission descriptors whose tokens are self-contained (--contract,
2597
+ // --lineage, --expected-revision, --target, --request-hash,
2598
+ // --repository-context) plus exactly one {{value}} slot. Executing anything
2599
+ // other than those rendered tokens fails the live emitter's committed-
2600
+ // intent reconciliation, so the tokens pass through verbatim with only the
2601
+ // {{value}} substitution: a literal for correction_lines, a staged 0o600
2602
+ // artifact path for a validation document.
2603
+ async finalizeSubmission(request: NativeReviewFinalizeSubmissionRequest): Promise<NativeFinalizeResult> {
2604
+ if (request.argumentTokens.length === 0) throw new TypeError("Native FINALIZE submission requires the provider-rendered argument tokens");
2605
+ if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native FINALIZE submission argument tokens must all be non-empty strings");
2606
+ if ((request.valueLiteral === undefined) === (request.valueDocument === undefined)) throw new TypeError("Native FINALIZE submission takes exactly one of valueLiteral or valueDocument");
2607
+ const valueToken = request.argumentTokens[request.valueSubstitutionLocation];
2608
+ if (valueToken === undefined || !valueToken.includes("{{value}}")) throw new TypeError("Native FINALIZE submission must render exactly one {{value}} slot token");
2609
+ if (request.valueDocument !== undefined && request.valueDocument.length === 0) throw new TypeError("Native FINALIZE submission value document must contain at least one byte");
2610
+ const directory = request.valueDocument === undefined ? undefined : await mkdtemp(join(tmpdir(), "gentle-ai-finalize-submission-"));
2611
+ try {
2612
+ let substituted: string;
2613
+ if (directory !== undefined) {
2614
+ await chmod(directory, 0o700);
2615
+ const valueFile = join(directory, "value.json");
2616
+ await writeFile(valueFile, request.valueDocument!, { encoding: "utf8", mode: 0o600 });
2617
+ await chmod(valueFile, 0o600);
2618
+ substituted = valueFile;
2619
+ } else {
2620
+ substituted = request.valueLiteral!;
2621
+ }
2622
+ const resolved = request.argumentTokens.map((token, index) => index === request.valueSubstitutionLocation ? token.replaceAll("{{value}}", substituted) : token);
2623
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.FINALIZE, request.cwd, [
2624
+ "review", "finalize",
2625
+ ...resolved,
2626
+ ], true, request.signal);
2627
+ return this.decodeFinalizeTransitionExecution(execution);
2628
+ } finally {
2629
+ if (directory !== undefined) await this.cleanupDirectory(directory).catch(() => undefined);
2630
+ }
2631
+ }
2632
+
2633
+ private decodeFinalizeTransitionExecution(execution: { body: unknown }): NativeFinalizeResult {
2634
+ return decode(NATIVE_REVIEW_OPERATION.FINALIZE, true, () => {
2635
+ const body = object(execution.body);
2636
+ // A transition rendered with `--contract` answers with the negotiated
2637
+ // operation envelope; one rendered without (the pi runtime form)
2638
+ // answers with the plain `review/finalize` shape. Both are decoded
2639
+ // strictly; nothing else is accepted.
2640
+ if (typeof body.schema === "string") {
2641
+ const envelope = decodeReviewOperationV2(body);
2642
+ if (envelope.operation !== "review.finalize") throw new Error("wrong finalize operation envelope");
2643
+ const result = envelope.result;
2644
+ return {
2645
+ lineageId: requiredString(result.lineage_id),
2646
+ state: requiredString(result.state),
2647
+ action: requiredString(result.action),
2648
+ storeRevision: requiredString(result.store_revision),
2649
+ ...(result.validation_request === undefined ? {} : { validationRequest: result.validation_request as Readonly<Record<string, unknown>> }),
2650
+ ...(result.escalation === undefined ? {} : { escalation: requiredString(result.escalation) }),
2651
+ };
2652
+ }
2653
+ const plain = exactObject(body, ["operation", "lineage_id", "state", "action", "store_revision"], ["receipt_path", "validation_request", "escalation"]);
2654
+ if (plain.operation !== "review/finalize") throw new Error("wrong finalize discriminator");
2655
+ const state = requiredString(plain.state);
2656
+ if (!(NATIVE_FINALIZE_STATE as readonly string[]).includes(state)) throw new Error("unknown finalize state");
2657
+ return {
2658
+ lineageId: requiredString(plain.lineage_id),
2659
+ state,
2660
+ action: requiredString(plain.action),
2661
+ storeRevision: requiredString(plain.store_revision),
2662
+ ...(plain.receipt_path === undefined ? {} : { receiptPath: requiredString(plain.receipt_path) }),
2663
+ ...(plain.validation_request === undefined ? {} : { validationRequest: plain.validation_request as Readonly<Record<string, unknown>> }),
2664
+ ...(plain.escalation === undefined ? {} : { escalation: requiredString(plain.escalation) }),
2665
+ };
2666
+ });
2667
+ }
2668
+
2242
2669
  async captureEvidence(request: NativeReviewCaptureEvidenceRequest): Promise<NativeReviewVerificationEvidenceV2> {
2243
2670
  if (!(NATIVE_REVIEW_CAPTURE_OUTCOME as readonly string[]).includes(request.outcome)) throw new TypeError("Native CAPTURE_EVIDENCE outcome must be passed, verification_failed, or procedural_tooling_failed");
2244
2671
  if (request.evidenceDocument.length === 0) throw new TypeError("Native CAPTURE_EVIDENCE evidence must contain at least one byte");
@@ -2260,6 +2687,45 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2260
2687
  }
2261
2688
  }
2262
2689
 
2690
+ // Field defect (fambig, 2026-08-16): at every evidence-pending sub-state
2691
+ // the collect slot renders the identity native demands — for a correction
2692
+ // that is the fix-diff `--target`, not the live workspace snapshot — so the
2693
+ // slot's rendered submission tokens execute verbatim, with only the
2694
+ // {{outcome}} and {{input}} slots substituted. Same verbatim-token
2695
+ // discipline as captureResult; --repository-context is authoritative and
2696
+ // mutually exclusive with a path.
2697
+ async captureEvidenceSubmission(request: NativeReviewCaptureEvidenceSubmissionRequest): Promise<NativeReviewVerificationEvidenceV2> {
2698
+ if (!(NATIVE_REVIEW_CAPTURE_OUTCOME as readonly string[]).includes(request.outcome)) throw new TypeError("Native CAPTURE_EVIDENCE outcome must be passed, verification_failed, or procedural_tooling_failed");
2699
+ if (request.evidenceDocument.length === 0) throw new TypeError("Native CAPTURE_EVIDENCE evidence must contain at least one byte");
2700
+ if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_EVIDENCE submission requires the provider-rendered argument tokens");
2701
+ if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_EVIDENCE submission argument tokens must all be non-empty strings");
2702
+ const outcomeToken = request.argumentTokens[request.outcomeSubstitutionLocation];
2703
+ const inputToken = request.argumentTokens[request.inputSubstitutionLocation];
2704
+ if (request.outcomeSubstitutionLocation === request.inputSubstitutionLocation || outcomeToken === undefined || !outcomeToken.includes("{{outcome}}")) throw new TypeError("Native CAPTURE_EVIDENCE submission must render exactly one {{outcome}} slot token");
2705
+ if (inputToken === undefined || !inputToken.includes("{{input}}")) throw new TypeError("Native CAPTURE_EVIDENCE submission must render exactly one {{input}} slot token");
2706
+ const carriesContext = request.argumentTokens.some((token) => token === "--repository-context" || token.startsWith("--repository-context="));
2707
+ if (carriesContext && request.cwd !== undefined) throw new TypeError("Native CAPTURE_EVIDENCE submission takes a repository context or --cwd, never both");
2708
+ const directory = await mkdtemp(join(tmpdir(), "gentle-ai-capture-evidence-"));
2709
+ try {
2710
+ await chmod(directory, 0o700);
2711
+ const evidenceFile = await this.stageEvidence(directory, request.evidenceDocument);
2712
+ const resolved = request.argumentTokens.map((token, index) =>
2713
+ index === request.outcomeSubstitutionLocation
2714
+ ? token.replaceAll("{{outcome}}", request.outcome)
2715
+ : index === request.inputSubstitutionLocation
2716
+ ? token.replaceAll("{{input}}", evidenceFile)
2717
+ : token);
2718
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.CAPTURE_EVIDENCE, request.cwd ?? process.cwd(), [
2719
+ "review", "capture-evidence",
2720
+ ...resolved,
2721
+ ...(carriesContext || request.cwd === undefined ? [] : ["--cwd", request.cwd]),
2722
+ ], true, request.signal);
2723
+ return decode(NATIVE_REVIEW_OPERATION.CAPTURE_EVIDENCE, true, () => decodeNativeReviewVerificationEvidence(execution.body));
2724
+ } finally {
2725
+ await this.cleanupDirectory(directory).catch(() => undefined);
2726
+ }
2727
+ }
2728
+
2263
2729
  reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult> {
2264
2730
  return this.legacy.reviewStatus(request);
2265
2731
  }