gentle-pi 2.1.2 → 2.3.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 (228) hide show
  1. package/README.md +108 -79
  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 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  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 +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -1,37 +1,56 @@
1
1
  import { execFile } from "node:child_process";
2
- import { createHash } from "node:crypto";
3
- import { readFileSync, statSync } from "node:fs";
4
2
  import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
5
3
  import { tmpdir } from "node:os";
6
4
  import { dirname, isAbsolute, join, posix, win32 } from "node:path";
7
5
  import { promisify } from "node:util";
8
- import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
6
+ import { PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
7
+ import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
9
8
  import {
10
9
  REVIEW_INTEGRATION_CONTRACT,
11
- decodeReviewCapabilitiesV2,
10
+ decodeReviewAcknowledgedV1,
12
11
  decodeReviewConsentV2,
12
+ decodeReviewConsentV3,
13
13
  decodeReviewFailureV2,
14
- decodeReviewOperationV2,
14
+ decodeReviewLastEventClosureV1,
15
15
  decodeReviewRepairV2,
16
+ decodeReviewResultArtifactV2,
16
17
  decodeReviewStartV3,
18
+ decodeReviewStartV4,
17
19
  decodeReviewStatusV3,
18
- type ReviewCapabilitiesV2,
19
- type ReviewConsentV2,
20
+ type ReviewAcknowledgedExpectationV1,
21
+ type ReviewAcknowledgedV1,
22
+ type ReviewConsentEnvelope,
20
23
  type ReviewFailureV2,
21
24
  type ReviewRepairV2,
25
+ type ReviewNextTransitionV3,
22
26
  type ReviewStartState,
27
+ type ReviewStartV3,
28
+ type ReviewStartV4,
23
29
  type ReviewStatusV3,
30
+ type ReviewLastEventClosureV1,
24
31
  } from "./review-integration-v2.ts";
25
32
 
26
33
  const execFileAsync = promisify(execFile);
27
34
 
35
+ // Negotiated review/status responses can carry a complete authority inventory.
36
+ // Keep the production default large enough for that payload while retaining a
37
+ // hard 64 MiB ceiling even when GENTLE_PI_REVIEW_MAX_BUFFER_BYTES is set.
38
+ export const NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
39
+ const NATIVE_REVIEW_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
40
+ const NATIVE_REVIEW_MAX_BUFFER_BYTES_ENV = "GENTLE_PI_REVIEW_MAX_BUFFER_BYTES";
41
+ 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.";
42
+
43
+ function resolveNativeReviewMaxBufferBytes(environment: NodeJS.ProcessEnv = process.env): number {
44
+ const value = environment[NATIVE_REVIEW_MAX_BUFFER_BYTES_ENV];
45
+ if (value === undefined || !/^[1-9]\d*$/.test(value)) return NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES;
46
+ const parsed = Number(value);
47
+ return Number.isSafeInteger(parsed) && parsed <= NATIVE_REVIEW_MAX_BUFFER_BYTES
48
+ ? parsed
49
+ : NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES;
50
+ }
51
+
28
52
  export const NATIVE_REVIEW_OPERATION = {
29
- VERSION: "version",
30
53
  START: "review/start",
31
- FINALIZE: "review/finalize",
32
- VALIDATE: "review/validate",
33
- BIND_SDD: "review/bind-sdd",
34
- SDD_STATUS: "sdd-status",
35
54
  STATUS: "review/status",
36
55
  RECLAIM: "review/reclaim",
37
56
  RECOVER: "review/recover",
@@ -41,8 +60,10 @@ export const NATIVE_REVIEW_OPERATION = {
41
60
  REPAIR_LEGACY_ALIAS: "review/repair-legacy-alias",
42
61
  MODE: "review/mode",
43
62
  REPAIR: "review/repair",
44
- CAPTURE_EVIDENCE: "review/capture-evidence",
45
63
  CAPTURE_RESULT: "review/capture-result",
64
+ CAPTURE_CORRECTION_PLAN: "review/capture-correction-plan",
65
+ CAPTURE_PROVIDER_ROLE: "review/capture-provider-role",
66
+ ACKNOWLEDGE_APPROVED: "review/acknowledge-approved",
46
67
  } as const;
47
68
  export type NativeReviewOperation = (typeof NATIVE_REVIEW_OPERATION)[keyof typeof NATIVE_REVIEW_OPERATION];
48
69
 
@@ -68,43 +89,9 @@ export interface ExecFileRequest { file: string; arguments: readonly string[]; c
68
89
  export interface ExecFileResult { stdout: string; stderr: string; exitCode: number; signal: NodeJS.Signals | null; timedOut: boolean; outputLimitExceeded: boolean; }
69
90
  export type ExecFileAdapter = (request: ExecFileRequest) => Promise<ExecFileResult>;
70
91
 
71
- export const NATIVE_SDD_ARTIFACT_STORE = {
72
- OPENSPEC: "openspec",
73
- ENGRAM: "engram",
74
- NONE: "none",
75
- } as const;
76
- export type NativeSddArtifactStore = (typeof NATIVE_SDD_ARTIFACT_STORE)[keyof typeof NATIVE_SDD_ARTIFACT_STORE];
77
-
78
- export const NATIVE_SDD_ARTIFACT_STATE = {
79
- MISSING: "missing",
80
- DONE: "done",
81
- PARTIAL: "partial",
82
- } as const;
83
- export type NativeSddArtifactState = (typeof NATIVE_SDD_ARTIFACT_STATE)[keyof typeof NATIVE_SDD_ARTIFACT_STATE];
84
-
85
- export interface NativeSddArtifactStates {
86
- proposal: NativeSddArtifactState;
87
- specs: NativeSddArtifactState;
88
- design: NativeSddArtifactState;
89
- tasks: NativeSddArtifactState;
90
- applyProgress: NativeSddArtifactState;
91
- verifyReport: NativeSddArtifactState;
92
- reviewPolicy?: NativeSddArtifactState;
93
- reviewLedger: NativeSddArtifactState;
94
- reviewReceipt: NativeSddArtifactState;
95
- reviewBundle: NativeSddArtifactState;
96
- reviewContext: NativeSddArtifactState;
97
- reviewState: NativeSddArtifactState;
98
- }
99
-
100
92
  export interface NativeReviewCli {
101
93
  start(request: NativeStartRequest): Promise<NativeStartResult>;
102
- finalize(request: NativeFinalizeRequest): Promise<NativeFinalizeResult>;
103
- validate(request: NativeValidateRequest): Promise<NativeValidateResult>;
104
- bindSdd(request: NativeBindSddRequest): Promise<NativeBindSddResult>;
105
- sddStatus(request: NativeSddStatusRequest): Promise<NativeSddStatusResult>;
106
94
  reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult>;
107
- capabilities?(request?: NativeCapabilitiesRequest): Promise<ReviewCapabilitiesV2>;
108
95
  targetStatus?(request: NativeTargetStatusRequest): Promise<ReviewStatusV3>;
109
96
  answerConsent?(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult>;
110
97
  reclaim?(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult>;
@@ -113,15 +100,14 @@ export interface NativeReviewCli {
113
100
  quarantineLegacy?(request: NativeReviewLegacyQuarantineRequest): Promise<NativeReviewRecoveryResult>;
114
101
  reconcileAuthority?(request: NativeReviewReconcileAuthorityRequest): Promise<NativeReviewRecoveryResult>;
115
102
  repairLegacyAlias?(request: NativeReviewLegacyAliasRepairRequest): Promise<NativeReviewRecoveryResult>;
116
- // Net-new negotiated operations (contract v2 only): `review.repair` is
117
- // advertised in the v2 operation list; `capture-evidence` is the
118
- // evidence-first correction lifecycle's collection step.
119
103
  repair?(request: NativeReviewRepairRequest): Promise<ReviewRepairV2>;
120
- captureEvidence?(request: NativeReviewCaptureEvidenceRequest): Promise<NativeReviewVerificationEvidenceV2>;
104
+ captureResult?(request: NativeReviewCaptureResultRequest): Promise<NativeReviewCaptureResultOutcome>;
105
+ captureCorrectionPlan?(request: NativeReviewCorrectionPlanCaptureRequest): Promise<ReviewLastEventClosureV1>;
106
+ captureProviderRole?(request: NativeReviewProviderRoleCaptureRequest): Promise<NativeReviewProviderRoleCaptureOutcome>;
121
107
  // Dark until a negotiated version reports the `mode` capability true
122
108
  // (Design Decision #7, organic-rdd-parity). Plain versioned CLI operation,
123
109
  // outside the negotiated review-integration protocol — same shape as
124
- // reviewStatus/sddStatus/reclaim above.
110
+ // reviewStatus/reclaim above.
125
111
  reviewMode?(request: NativeReviewModeRequest): Promise<NativeReviewModeResult>;
126
112
  }
127
113
 
@@ -146,6 +132,12 @@ export const NATIVE_REVIEW_MODE_SOURCE = {
146
132
  } as const;
147
133
  export type NativeReviewModeSource = (typeof NATIVE_REVIEW_MODE_SOURCE)[keyof typeof NATIVE_REVIEW_MODE_SOURCE];
148
134
 
135
+ export const NATIVE_REVIEW_MODE_REACH = {
136
+ MACHINE: "machine",
137
+ THIS_BUILD: "this_build",
138
+ } as const;
139
+ export type NativeReviewModeReach = (typeof NATIVE_REVIEW_MODE_REACH)[keyof typeof NATIVE_REVIEW_MODE_REACH];
140
+
149
141
  export const NATIVE_REVIEW_MODE_SCOPE = {
150
142
  GLOBAL: "global",
151
143
  CLONE: "clone",
@@ -165,6 +157,7 @@ export interface NativeReviewModeStatus {
165
157
  effective: "on" | "off";
166
158
  source: NativeReviewModeSource;
167
159
  revision?: string;
160
+ reach?: NativeReviewModeReach;
168
161
  }
169
162
 
170
163
  export interface NativeReviewModeResult {
@@ -175,18 +168,24 @@ export interface NativeReviewModeResult {
175
168
 
176
169
  // Exact-match tolerated-stderr allowlist for START only, gated on the `mode`
177
170
  // 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.
171
+ // against gentle-ai's headless notices (internal/cli/review_mode.go
172
+ // reviewConsentSkippedNotice and its siblings) written when the switch is on
173
+ // but no interactive terminal answered the one-time consent question — which
174
+ // is always true when Pi spawns gentle-ai without a TTY. Any other text still
175
+ // fails closed as UNEXPECTED_STDERR.
176
+ //
177
+ // Each entry is one whole line the PINNED gentle-ai may write to the console
178
+ // stream while still succeeding. That last qualifier is what keeps this list
179
+ // honest: v2.4.0 deleted reviewConsentSkippedDefaultProvenance ("Reviews are
180
+ // on by default; this was never explicitly chosen. ..."), which used to ride
181
+ // with the skip notice whenever the resolved mode source was `default`. Under
182
+ // opt-in receipt-driven development a default-source clone is refused long
183
+ // before the consent ceremony runs, so the pinned binary can no longer emit
184
+ // that line and it is removed here rather than left as dead tolerance. Multi-
185
+ // line stderr is still expected in principle — these are separate Fprintln
186
+ // calls, never one joined string — which is why membership is per line.
187
187
  export const REVIEW_CONSENT_NOTICES = Object.freeze([
188
188
  "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
189
  "Gentle AI could not read an answer, so it reviewed this change and will ask again next time.",
191
190
  "Gentle AI did not recognize that answer, so it reviewed this change and will ask again next time.",
192
191
  "Review skipped for this candidate at your request. It will be offered again on the next change.",
@@ -201,6 +200,22 @@ function stderrIsTolerated(stderr: string, tolerated: readonly string[]): boolea
201
200
  return lines.length > 0 && lines.every((line) => tolerated.includes(line));
202
201
  }
203
202
 
203
+ // gentle-ai main narrates the negotiated STATUS forecast head to a human on
204
+ // stderr while keeping the machine envelope on stdout (reviewNarrateForecast
205
+ // in internal/cli/review_narration.go, ground-truthed live against a main-line
206
+ // dev build). The pinned release does not emit it, so this narration is
207
+ // tolerated only for negotiated STATUS while the dev-binary override is
208
+ // configured; any other stderr keeps failing closed.
209
+ const FORECAST_NARRATION_LINES = Object.freeze([
210
+ /^Forecast horizon: (?:partial|terminal)$/,
211
+ /^step [0-9]+: (?:execute|collect|stop); reason_code=[a-z0-9_]+; description=.+$/,
212
+ /^Re-query STATUS after completing this partial head\.$/,
213
+ ]);
214
+ function stderrIsForecastNarration(stderr: string): boolean {
215
+ const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
216
+ return lines.length > 0 && lines.every((line) => FORECAST_NARRATION_LINES.some((pattern) => pattern.test(line)));
217
+ }
218
+
204
219
  export const NATIVE_REVIEW_RECOVER_DISPOSITION = ["scope_changed", "invalidated", "escalated"] as const;
205
220
  export type NativeReviewRecoverDisposition = (typeof NATIVE_REVIEW_RECOVER_DISPOSITION)[number];
206
221
 
@@ -244,6 +259,9 @@ export interface NativeReviewAbandonRequest {
244
259
  lineage: string;
245
260
  expectedRevision: string;
246
261
  snapshotIdentity: string;
262
+ capturedLensResults: readonly string[];
263
+ findingsPresent: boolean;
264
+ evidenceRecordsPresent: boolean;
247
265
  actor: string;
248
266
  reason: string;
249
267
  maintainerAuthorization: string;
@@ -305,28 +323,12 @@ export interface NativeReviewRepairRequest {
305
323
  signal?: AbortSignal;
306
324
  }
307
325
 
308
- // `review capture-evidence` argv and response shape are pinned to a real
309
- // v2.2.2 review run (lineage review-b39d803b68a90767): exactly
310
- // --cwd/--lineage/--target/--expected-revision/--outcome/--input, no
311
- // --contract, and the `gentle-ai.review-verification-evidence/v2` record
312
- // returned directly (not wrapped in an operation/v2 envelope).
313
- export const NATIVE_REVIEW_CAPTURE_OUTCOME = ["passed", "verification_failed", "procedural_tooling_failed"] as const;
314
- export type NativeReviewCaptureOutcome = (typeof NATIVE_REVIEW_CAPTURE_OUTCOME)[number];
315
-
316
326
  // `capture-result` is an additive headless command, NOT a negotiated
317
327
  // repository operation: it accepts no --contract, and the provider's own
318
328
  // transition tokens already carry the repository context -- it takes that or
319
329
  // --cwd, never both. So Pi passes the tokens through verbatim and adds only
320
330
  // --input. Reconstructing them would mean re-deriving a lineage, revision,
321
331
  // target, lens slot, and subject hash the provider already issued.
322
- // Added for the v2 finalize transport. `capturedResults` asks the provider to
323
- // discover every manifest it already admitted; `resultArtifactFiles` hands them
324
- // over explicitly in lens order. Both replace the retired `--result`.
325
- export interface NativeReviewFinalizeCapturedResults {
326
- readonly capturedResults?: boolean;
327
- readonly resultArtifactFiles?: readonly string[];
328
- }
329
-
330
332
  export interface NativeReviewCaptureResultRequest {
331
333
  readonly argumentTokens: readonly string[];
332
334
  readonly resultDocument: string;
@@ -345,36 +347,107 @@ export interface NativeReviewAdmittedResultManifest {
345
347
  readonly reference?: string;
346
348
  }
347
349
 
348
- export interface NativeReviewCaptureEvidenceRequest {
349
- cwd: string;
350
- lineageId: string;
351
- targetIdentity: string;
352
- expectedRevision: string;
353
- outcome: NativeReviewCaptureOutcome;
354
- evidenceDocument: string;
355
- signal?: AbortSignal;
350
+ /** A non-final reviewer capture acknowledges an admitted artifact; final captures close natively. */
351
+ export type NativeReviewCaptureResultOutcome = NativeReviewAdmittedResultManifest | ReviewLastEventClosureV1;
352
+
353
+ // The one continuation that burns approved authority. Its tokens are rendered
354
+ // by the provider in a closed order and are relayed verbatim: Pi never builds,
355
+ // reorders, or substitutes one, because a synthesized acknowledgement would be
356
+ // Pi deciding that a review is over. `binding` is the lineage, target, and
357
+ // revision the caller already holds from STATUS: when the provider answers the
358
+ // burn with a review-acknowledged/v1 envelope (gentle-ai #3947), that envelope
359
+ // must name exactly this burn.
360
+ export interface NativeReviewAcknowledgeApprovedRequest {
361
+ readonly argumentTokens: readonly string[];
362
+ readonly cwd: string;
363
+ readonly binding?: ReviewAcknowledgedExpectationV1;
364
+ readonly signal?: AbortSignal;
356
365
  }
357
366
 
358
- export interface NativeReviewVerificationEvidenceV2 {
359
- schema: "gentle-ai.review-verification-evidence/v2";
360
- version: 2;
361
- lineageId: string;
362
- authorityRevision: string;
363
- targetIdentity: string;
364
- candidateTree: string;
365
- pathsDigest: string;
366
- paths: readonly string[];
367
- ledgerIds: readonly string[];
368
- rawPayloadSha256: string;
369
- rawPayloadBytes: number;
370
- outcome: NativeReviewCaptureOutcome;
371
- recordDigest: string;
367
+ /** `undefined` is the pinned silent burn (every release up to v2.5.0-rc.3); an envelope is the #3947 typed burn. */
368
+ export type NativeReviewAcknowledgeApprovedOutcome = ReviewAcknowledgedV1 | undefined;
369
+
370
+ export interface NativeReviewCorrectionPlanCaptureRequest {
371
+ readonly argumentTokens: readonly string[];
372
+ readonly correctionLines: number;
373
+ readonly cwd: string;
374
+ readonly signal?: AbortSignal;
372
375
  }
373
376
 
374
- export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; targetIdentity?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
377
+ // gentle-pi#311 P4-roles: one Go-owned non-lens provider role capture. The
378
+ // provider renders a SELF-CONTAINED authority-advancing vector
379
+ // (`review.capture-refuter` / `review.capture-validation` with binding tokens
380
+ // plus `--agent=pi --execute=true`, no submission descriptor); Pi executes the
381
+ // exact rendered invocation verbatim, in the foreground, and Go materializes
382
+ // the role prompt, spawns its own locked-down pi subprocess, and admits the
383
+ // raw verdict. Nothing here authors, parses, or transports role output.
384
+ export const NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA = "gentle-ai.review-provider-role-capture/v1";
385
+
386
+ // Capture operations and their terminal closure operations are independently
387
+ // named upstream. Keep this closed mapping explicit: capture-validation is the
388
+ // intentionally nonuniform `review/capture-validation` closure operation.
389
+ export const NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION = {
390
+ "review.capture-refuter": "review.capture-refuter",
391
+ "review.capture-validation": "review/capture-validation",
392
+ } as const;
393
+ type NativeReviewProviderRoleCaptureOperation = keyof typeof NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION;
394
+
395
+ function isNativeReviewProviderRoleCaptureOperation(operation: string): operation is NativeReviewProviderRoleCaptureOperation {
396
+ return Object.hasOwn(NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION, operation);
397
+ }
398
+
399
+ export interface NativeReviewProviderRoleCaptureRequest {
400
+ /** The provider-named capture operation, e.g. `review.capture-refuter`. */
401
+ readonly captureOperation: string;
402
+ /** Every provider-issued argument token, verbatim, in provider order. */
403
+ readonly argumentTokens: readonly string[];
404
+ /** Process working directory only; never rendered into the invocation. */
405
+ readonly cwd: string;
406
+ readonly signal?: AbortSignal;
407
+ }
408
+
409
+ export interface NativeReviewProviderRoleCaptureArtifact {
410
+ readonly schema: typeof NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA;
411
+ readonly lineageId: string;
412
+ readonly targetIdentity: string;
413
+ readonly role: string;
414
+ readonly captured: true;
415
+ }
416
+
417
+ /** Refuter and validator captures close only when they are the native last event. */
418
+ export type NativeReviewProviderRoleCaptureOutcome = NativeReviewProviderRoleCaptureArtifact | ReviewLastEventClosureV1;
419
+
420
+ export const NATIVE_UNTRACKED_SCOPE = {
421
+ EXCLUDE: "exclude",
422
+ SELECT: "select",
423
+ } as const;
424
+ export type NativeUntrackedScope = (typeof NATIVE_UNTRACKED_SCOPE)[keyof typeof NATIVE_UNTRACKED_SCOPE];
425
+
426
+ export interface NativeIntendedUntrackedSelectionSubmission {
427
+ readonly argumentTokens: readonly string[];
428
+ readonly value: string;
429
+ }
430
+ interface NativeUntrackedSelectionRequest {
431
+ untrackedScope?: NativeUntrackedScope;
432
+ expectedUntrackedInventory?: string;
433
+ intendedUntracked?: readonly string[];
434
+ intendedUntrackedSelection?: NativeIntendedUntrackedSelectionSubmission;
435
+ }
436
+
437
+ export interface NativeStartRequest extends NativeUntrackedSelectionRequest {
438
+ cwd: string;
439
+ baseRef?: string;
440
+ committedOnly?: boolean;
441
+ lineageId?: string;
442
+ policyPath?: string;
443
+ focus?: string;
444
+ targetIdentity?: string;
445
+ projection?: "workspace" | "staged";
446
+ signal?: AbortSignal;
447
+ }
375
448
  export const NATIVE_REVIEW_CONSENT_ANSWER = { GRANTED: "granted", DECLINED: "declined" } as const;
376
449
  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; }
450
+ export interface NativeReviewConsentAnswerRequest { cwd: string; consent: ReviewConsentEnvelope; answer: NativeReviewConsentAnswer; signal?: AbortSignal; }
378
451
  export interface NativeReviewConsentDeclinedResult {
379
452
  kind: "declined";
380
453
  targetIdentity: string;
@@ -387,30 +460,25 @@ export interface NativeReviewConsentDeclinedResult {
387
460
  }
388
461
  export interface NativeReviewConsentStartedResult { kind: "started"; start: NativeStartResult; }
389
462
  export type NativeReviewConsentAnswerResult = NativeReviewConsentStartedResult | NativeReviewConsentDeclinedResult;
390
- export interface NativeFinalizeLensResult { lens: string; document: unknown; }
391
- export interface NativeFinalizeRequest extends NativeReviewFinalizeCapturedResults {
463
+ export interface NativeReviewStatusRequest { cwd: string; signal?: AbortSignal; }
464
+ export interface NativeTargetStatusRequest extends NativeUntrackedSelectionRequest {
392
465
  cwd: string;
393
466
  lineageId?: string;
394
- resultFiles?: readonly string[];
395
- lensResults?: readonly NativeFinalizeLensResult[];
396
- refuterFile?: string;
397
- refuterDocument?: unknown;
398
- correctionLines?: number;
399
- validationFile?: string;
400
- validationDocument?: unknown;
401
- evidenceFile?: string;
402
- evidenceDocument?: string;
403
- failed?: boolean;
467
+ baseRef?: string;
468
+ committedOnly?: boolean;
469
+ projection?: "workspace" | "staged";
470
+ /**
471
+ * The immutable reviewer runtime this host provides. Measured against the
472
+ * live 2.4.0-main provider: the materialize-marked relay slot (agent=pi,
473
+ * materialize=true, plus the provider submission) is offered ONLY when the
474
+ * caller names its agent; an agent-less status returns a bare
475
+ * capture-result input the host relay cannot consume. Providers older than
476
+ * v2.4.0 do not define the flag and refuse it, so callers probe and fall
477
+ * back rather than version-sniff.
478
+ */
479
+ agent?: "pi";
404
480
  signal?: AbortSignal;
405
481
  }
406
- export interface NativeValidateRequest { cwd: string; gate: string; lineageId?: string; flags?: readonly string[]; signal?: AbortSignal; }
407
- export interface NativeBindSddRequest { cwd: string; change: string; lineage: string; expectedBindingRevision: string; signal?: AbortSignal; }
408
- export interface NativeSddStatusRequest { cwd: string; change: string; signal?: AbortSignal; }
409
- export interface NativeReviewStatusRequest { cwd: string; signal?: AbortSignal; }
410
- export interface NativeCapabilitiesRequest { cwd?: string; signal?: AbortSignal; }
411
- export interface NativeTargetStatusRequest { cwd: string; lineageId?: string; baseRef?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
412
- export interface NativeGateContext { lineageId: string; storeRevision: string; raw: Record<string, unknown>; }
413
-
414
482
  export const NATIVE_REVIEW_AUTHORITY_STATUS = {
415
483
  CLEAN: "clean",
416
484
  ACTIVE: "active",
@@ -507,29 +575,60 @@ export interface NativeReviewStatusResult {
507
575
  diagnostics: readonly NativeReviewAuthorityDiagnostic[];
508
576
  raw: Record<string, unknown>;
509
577
  }
510
- export const NATIVE_START_ACTION = { CREATED: "created", RESUMED: "resumed", REUSE_RECEIPT: "reuse-receipt", BLOCKED_SCOPE_ACTION: "blocked-scope-action" } as const;
578
+ export const NATIVE_START_ACTION = { CREATED: "created", RESUMED: "resumed", REPLAYED: "replayed", CLOSED: "closed", BLOCKED_SCOPE_ACTION: "blocked-scope-action" } as const;
511
579
  export type NativeStartAction = (typeof NATIVE_START_ACTION)[keyof typeof NATIVE_START_ACTION];
512
- export interface NativeStartResult { lineageId: string; state: ReviewStartState; riskLevel: string; selectedLenses: readonly string[]; changedFiles: number; changedLines: number; correctionBudget: number; action: NativeStartAction; lensesRequired: boolean; riskReasons?: readonly Record<string, unknown>[]; raw?: Readonly<Record<string, unknown>>; riskEvidence?: readonly string[]; hint?: string; }
513
- export interface NativeValidateResult { allowed: boolean; result: "allow" | "scope-changed" | "invalidated" | "escalated"; action: string; reason: string; gateContext: NativeGateContext; delivery?: "disabled/unmanaged"; }
514
- export interface NativeFinalizeResult { lineageId: string; state: string; action: string; storeRevision: string; receiptPath?: string; validationRequest?: Readonly<Record<string, unknown>>; escalation?: string; }
515
- export interface NativeBindSddResult {
516
- revision: string;
517
- change: string;
518
- lineage: string;
519
- authorityRevision: string;
520
- receiptHash: string;
521
- gateContext: NativeGateContext;
580
+ export interface NativeStartResult { lineageId: string; state: ReviewStartState; riskLevel: string; selectedLenses: readonly string[]; changedFiles: number; changedLines: number; correctionBudget: number; action: NativeStartAction; lensesRequired: boolean; riskReasons?: readonly Record<string, unknown>[]; nextTransition?: ReviewNextTransitionV3; raw?: Readonly<Record<string, unknown>>; riskEvidence?: readonly string[]; hint?: string; }
581
+ export function isCanonicalProcessString(value: unknown): value is string {
582
+ return typeof value === "string" && value.length > 0 && value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value);
522
583
  }
523
- export interface NativeSddStatusResult {
524
- ready: boolean;
525
- artifactStore: NativeSddArtifactStore;
526
- artifacts: NativeSddArtifactStates;
527
- nextRecommended: string;
528
- [key: string]: unknown;
584
+
585
+ interface NativeUntrackedSelection {
586
+ untrackedScope?: NativeUntrackedScope;
587
+ expectedUntrackedInventory?: string;
588
+ intendedUntracked?: readonly string[];
529
589
  }
530
590
 
531
- export function isCanonicalProcessString(value: unknown): value is string {
532
- return typeof value === "string" && value.length > 0 && value.trim() === value && !/[\u0000-\u001f\u007f]/.test(value);
591
+ function isNativeUntrackedPath(value: unknown): value is string {
592
+ return isCanonicalProcessString(value)
593
+ && !posix.isAbsolute(value)
594
+ && !win32.isAbsolute(value)
595
+ && !value.includes("\\")
596
+ && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
597
+ }
598
+
599
+ function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): NativeUntrackedSelection {
600
+ const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request;
601
+ const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined;
602
+ if (!declared) return {};
603
+ if (
604
+ (untrackedScope !== NATIVE_UNTRACKED_SCOPE.EXCLUDE && untrackedScope !== NATIVE_UNTRACKED_SCOPE.SELECT) ||
605
+ !isCanonicalProcessString(expectedUntrackedInventory) ||
606
+ (intendedUntracked !== undefined && (!Array.isArray(intendedUntracked) || intendedUntracked.some((path) => !isNativeUntrackedPath(path) || intendedUntracked.indexOf(path) !== intendedUntracked.lastIndexOf(path))))
607
+ ) {
608
+ throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths");
609
+ }
610
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) {
611
+ throw new TypeError("Native exclude untracked selection cannot include paths");
612
+ }
613
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) {
614
+ throw new TypeError("Native select untracked selection requires at least one path");
615
+ }
616
+ return {
617
+ untrackedScope,
618
+ expectedUntrackedInventory,
619
+ intendedUntracked: intendedUntracked === undefined ? undefined : [...intendedUntracked],
620
+ };
621
+ }
622
+
623
+ function nativeUntrackedSelectionArguments(selection: NativeUntrackedSelection): readonly string[] {
624
+ if (selection.untrackedScope === undefined) return [];
625
+ return [
626
+ `--untracked-scope=${selection.untrackedScope}`,
627
+ `--expected-untracked-inventory=${selection.expectedUntrackedInventory!}`,
628
+ ...(selection.untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT
629
+ ? selection.intendedUntracked!.map((path) => `--intended-untracked=${path}`)
630
+ : []),
631
+ ];
533
632
  }
534
633
 
535
634
  const NATIVE_RISK_LEVEL = ["low", "medium", "high"] as const;
@@ -610,28 +709,21 @@ export function nativeRiskEvidencePhrases(riskLevel: string, reasons: readonly N
610
709
  return riskLevel === "medium" ? [REVIEW_MEDIUM_RISK_REASON, ...phrases] : phrases;
611
710
  }
612
711
  const NATIVE_REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
613
- const NATIVE_FINALIZE_STATE = ["reviewing", "correction_required", "validating", "approved", "escalated"] as const;
614
712
  const NATIVE_START_ACTION_VALUES = Object.values(NATIVE_START_ACTION);
615
- const NATIVE_GATE_RESULT = ["allow", "scope-changed", "invalidated", "escalated"] as const;
616
- const NATIVE_GATE = ["post-apply", "pre-commit", "pre-push", "pre-pr", "release"] as const;
617
- const NATIVE_SDD_NEXT_ACTION = ["apply", "verify", "remediate", "archive", "review", "resolve-review", "resolve-blockers", "sdd-new", "select-change", "propose", "spec", "design", "tasks"] as const;
618
- const NATIVE_SDD_POST_REVIEW_ACTION = ["verify", "archive"] as const;
619
-
620
- // Organic-parity columns (mode, riskEvidence, hint, delivery) stay false on
621
- // every shipped row, including "2.1.11". PI-2 adds the first capability-true
622
- // row (and bumps the triple pin) in one dedicated commit — never one without
623
- // the other. See Design Decision #1 (organic-rdd-parity).
713
+ // The pin table is intentionally preserved unchanged while #3587 is
714
+ // unpublished. Last-event capture support is exercised through the explicit
715
+ // dev-binary override; no source-level pin or contract-row mutation is allowed.
624
716
  const ORGANIC_PARITY_DARK = { mode: false, riskEvidence: false, hint: false, delivery: false } as const;
625
717
 
626
718
  export const NATIVE_CLI_CONTRACTS = Object.freeze({
627
- "2.1.4": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: false, inventory: false, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
628
- "2.1.5": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
629
- "2.1.6": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
630
- "2.1.7": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
631
- "2.1.8": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, sddStatus: true, status: true, inventory: true, reclaim: true, recover: true, abandon: false, quarantineLegacy: false, reconcileAuthority: true, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
632
- "2.1.9": 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: false, ...ORGANIC_PARITY_DARK }),
633
- "2.1.10": 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, ...ORGANIC_PARITY_DARK }),
634
- "2.1.11": 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, ...ORGANIC_PARITY_DARK }),
719
+ "2.1.4": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: false, inventory: false, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
720
+ "2.1.5": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
721
+ "2.1.6": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
722
+ "2.1.7": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: false, recover: false, abandon: false, quarantineLegacy: false, reconcileAuthority: false, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
723
+ "2.1.8": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: false, quarantineLegacy: false, reconcileAuthority: true, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
724
+ "2.1.9": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: false, ...ORGANIC_PARITY_DARK }),
725
+ "2.1.10": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, ...ORGANIC_PARITY_DARK }),
726
+ "2.1.11": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, ...ORGANIC_PARITY_DARK }),
635
727
  // First capability-true row, paired with the triple pin bump in this same
636
728
  // commit as Design Decision #1 requires.
637
729
  //
@@ -652,7 +744,7 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
652
744
  // needs the negotiated start envelope extended upstream, which moves a
653
745
  // byte-pinned fixture and therefore belongs to a gentle-ai release, not to
654
746
  // a Pi capability flip.
655
- "2.2.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 }),
747
+ "2.2.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
656
748
  // 2.2.1 repeats 2.2.0 because the wire did not move for the lane Pi speaks.
657
749
  // v2.2.1 advertises capabilities/v1.5 (protocol minor 5) on
658
750
  // review-integration/v1, but the negotiated start envelope is still the
@@ -660,35 +752,44 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
660
752
  // they are dark on 2.2.0. The release does publish a second contract,
661
753
  // review-integration/v2, whose `start/v3` carries base/candidate trees --
662
754
  // but Pi does not negotiate it yet, and a row must describe the lane in use.
663
- "2.2.1": 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 }),
755
+ "2.2.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
664
756
  // 2.2.2 repeats 2.2.1 for the same reason, confirmed against the released
665
757
  // v2.2.2 binary rather than assumed: on review-integration/v1 it still
666
758
  // advertises capabilities/v1.5 and the negotiated start envelope is still
667
759
  // the closed `start/v2`, so riskEvidence and hint still cannot arrive.
668
- "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 }),
760
+ "2.2.2": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
761
+ // Ground-truthed against the released v2.2.3 binary: the v2 lane remains
762
+ // protocol 2.0 with the same operation set and closed START fields consumed
763
+ // by Pi, so the existing capability columns are unchanged.
764
+ "2.2.3": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
765
+ // Ground-truthed against the released v2.4.0 binary: the v2 lane advertises
766
+ // capabilities/v2.2 and answers status/v5 and consent/v3, all of which the
767
+ // existing decoders already read, and the START envelope Pi consumes is
768
+ // still `start/v3` carrying `risk_reasons` with no `risk_evidence` and no
769
+ // `hint`, so the existing capability columns are unchanged. v2.4.0 also
770
+ // made receipt-driven development opt-in, which changes what the mode
771
+ // envelope reports, not whether it reports it. v2.2.4 and v2.3.0 shipped
772
+ // while Pi stayed on 2.2.3; they were never pinned or probed, so they get
773
+ // no row.
774
+ "2.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
775
+ // Ground-truthed by driving the exact v2.5.0-rc.3 tagged build through the
776
+ // gentle-ai-bench driven journey corpus (exit 0), which exercises the
777
+ // start/status/capture/validate/mode/delivery lifecycles Pi consumes. The
778
+ // v2 lane advertises capabilities/v2.3 and its reviewing START is the
779
+ // `start/v4` continuation envelope that #499 already decodes; the closed
780
+ // fields Pi consumes are unchanged, so the columns match the 2.4.0 row.
781
+ // riskEvidence and hint stay dark: still not proven to reach the
782
+ // negotiated path Pi reads.
783
+ "2.5.0-rc.3": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
784
+ // Ground-truthed against the published v2.5.0 binary installed from its
785
+ // signed archive: `review capabilities` on the v2 lane advertises
786
+ // capabilities/v2.4 (protocol minor 4) and answers status/v6, consent/v3,
787
+ // and the `start/v4` continuation, all of which the decoders already read.
788
+ // The closed fields Pi consumes did not change between rc.3 and stable, so
789
+ // the columns match the 2.5.0-rc.3 row. riskEvidence and hint stay dark:
790
+ // still not proven to reach the negotiated START path Pi reads.
791
+ "2.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
669
792
  });
670
- type NativeCliCapability = keyof (typeof NATIVE_CLI_CONTRACTS)[keyof typeof NATIVE_CLI_CONTRACTS];
671
-
672
- // Testing-only capability overlay: lets tests exercise mode/riskEvidence/hint/
673
- // delivery decode paths under a synthetic version key without ever making a
674
- // shipped row (including "2.1.11") capability-true. Production code never
675
- // calls the setter, so this is inert outside test files.
676
- const nativeCliContractsTestingOverlay = new Map<string, Record<NativeCliCapability, boolean>>();
677
- export function setNativeCliContractForTesting(version: string, contract: Record<NativeCliCapability, boolean> | undefined): void {
678
- if (contract === undefined) nativeCliContractsTestingOverlay.delete(version);
679
- else nativeCliContractsTestingOverlay.set(version, contract);
680
- }
681
- function resolvedNativeCliContract(version: string): Record<NativeCliCapability, boolean> | undefined {
682
- return nativeCliContractsTestingOverlay.get(version) ?? (NATIVE_CLI_CONTRACTS as Record<string, Record<NativeCliCapability, boolean> | undefined>)[version];
683
- }
684
-
685
- export interface NativeReviewStructuredDenial {
686
- schema: "gentle-ai.review-gate-result/v1";
687
- result: "scope-changed" | "invalidated" | "escalated";
688
- action: string;
689
- reason: string;
690
- denial?: { stage: string; code: string };
691
- }
692
793
 
693
794
  export interface NativeReviewProcessDiagnostics {
694
795
  operation: NativeReviewOperation;
@@ -697,8 +798,9 @@ export interface NativeReviewProcessDiagnostics {
697
798
  signal?: NodeJS.Signals;
698
799
  timed_out: boolean;
699
800
  output_limit_exceeded: boolean;
801
+ max_buffer_bytes?: number;
802
+ configuration_hint?: string;
700
803
  stderr?: string;
701
- denial?: NativeReviewStructuredDenial;
702
804
  }
703
805
 
704
806
  export class NativeReviewCliError extends Error {
@@ -724,15 +826,25 @@ export class NativeReviewCliError extends Error {
724
826
  }
725
827
  }
726
828
 
829
+ // The one central runner for every gentle-ai CLI invocation the extension
830
+ // makes. It declares the Pi host relay handshake on each spawn: gentle-ai
831
+ // refuses pi admission pre-authority without it (gentle-pi#311 P4), and a
832
+ // single injection point keeps the declaration impossible to forget on any
833
+ // individual operation.
834
+ export function gentleAiProcessEnvironment(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
835
+ return { ...base, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
836
+ }
837
+
727
838
  export function createNodeExecFileAdapter(): ExecFileAdapter {
728
839
  return async (request) => {
729
840
  try {
730
- 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 });
841
+ 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() });
731
842
  return { stdout: output.stdout, stderr: output.stderr, exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
732
843
  } catch (error) {
733
844
  const detail = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: string | number; signal?: NodeJS.Signals; killed?: boolean };
734
845
  if (detail.code === "ENOENT" || detail.code === "EACCES" || detail.name === "AbortError") throw error;
735
- 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" };
846
+ const outputLimitExceeded = detail.code === "ENOBUFS" || detail.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
847
+ 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 };
736
848
  }
737
849
  };
738
850
  }
@@ -760,7 +872,6 @@ function decodeSelectedLenses(value: unknown, riskLevel: string, lensesRequired:
760
872
  }
761
873
  function enumString(value: unknown, allowed: readonly string[]): string { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; }
762
874
  const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096;
763
- const NATIVE_REVIEW_DENIAL_TEXT_LIMIT = 1_024;
764
875
 
765
876
  function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT): string {
766
877
  const normalized = value
@@ -775,70 +886,46 @@ function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_T
775
886
  return normalized.length <= limit ? normalized : `${normalized.slice(0, limit - 14)}…[truncated]`;
776
887
  }
777
888
 
778
- function parseStructuredNativeDenial(stdout: string): NativeReviewStructuredDenial | undefined {
779
- if (Buffer.byteLength(stdout, "utf8") > NATIVE_DIAGNOSTIC_TEXT_LIMIT * 4) return undefined;
780
- try {
781
- const value = exactObject(JSON.parse(stdout), ["schema", "result", "allowed", "action", "reason", "context"]);
782
- const result = enumString(value.result, ["scope-changed", "invalidated", "escalated"] as const) as NativeReviewStructuredDenial["result"];
783
- const action = sanitizeNativeDiagnosticText(requiredString(value.action), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
784
- const reason = sanitizeNativeDiagnosticText(requiredString(value.reason), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
785
- const expectedAction = { "scope-changed": "create-new-lineage", invalidated: "explicit-maintainer-action", escalated: "stop" }[result];
786
- if (
787
- value.schema !== "gentle-ai.review-gate-result/v1" ||
788
- value.allowed !== false ||
789
- action !== expectedAction ||
790
- !isCanonicalProcessString(action) ||
791
- !isCanonicalProcessString(reason)
792
- ) return undefined;
793
- const context = decodeGateContext(value.context).raw;
794
- const rawDenial = context.denial;
795
- const denial = rawDenial === undefined
796
- ? undefined
797
- : (() => {
798
- const parsed = exactObject(rawDenial, ["stage", "code"]);
799
- const stage = sanitizeNativeDiagnosticText(requiredString(parsed.stage), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
800
- const code = sanitizeNativeDiagnosticText(requiredString(parsed.code), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
801
- if (!isCanonicalProcessString(stage) || !isCanonicalProcessString(code)) throw new Error("non-canonical denial evidence");
802
- return { stage, code };
803
- })();
804
- return { schema: "gentle-ai.review-gate-result/v1", result, action, reason, ...(denial === undefined ? {} : { denial }) };
805
- } catch { return undefined; }
806
- }
807
-
808
889
  // Rebuild diagnostics from a duplicated module instance before facade output.
809
890
  export function sanitizeForeignNativeReviewDiagnostics(value: unknown): NativeReviewProcessDiagnostics | undefined {
810
891
  try {
811
- const raw = exactObject(value, ["operation", "error_code", "timed_out", "output_limit_exceeded"], ["exit_code", "signal", "stderr", "denial"]);
892
+ const raw = exactObject(value, ["operation", "error_code", "timed_out", "output_limit_exceeded"], ["exit_code", "signal", "max_buffer_bytes", "configuration_hint", "stderr"]);
812
893
  const operation = enumString(raw.operation, Object.values(NATIVE_REVIEW_OPERATION)) as NativeReviewOperation;
813
894
  const errorCode = enumString(raw.error_code, Object.values(NATIVE_REVIEW_ERROR_CODE)) as NativeReviewErrorCode;
814
895
  const signal = raw.signal === undefined ? undefined : requiredString(raw.signal);
815
- if (signal !== undefined && !/^SIG[A-Z0-9]{1,12}$/.test(signal)) return undefined;
816
- 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) }) };
896
+ const maxBufferBytes = raw.max_buffer_bytes === undefined ? undefined : positiveInteger(raw.max_buffer_bytes);
897
+ const configurationHint = raw.configuration_hint === undefined ? undefined : stringValue(raw.configuration_hint);
898
+ if (
899
+ (signal !== undefined && !/^SIG[A-Z0-9]{1,12}$/.test(signal)) ||
900
+ (maxBufferBytes === undefined) !== (configurationHint === undefined) ||
901
+ (configurationHint !== undefined && (errorCode !== NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT || configurationHint !== NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT))
902
+ ) return undefined;
903
+ return {
904
+ operation,
905
+ error_code: errorCode,
906
+ ...(raw.exit_code === undefined ? {} : { exit_code: nonNegativeInteger(raw.exit_code) }),
907
+ ...(signal === undefined ? {} : { signal: signal as NodeJS.Signals }),
908
+ timed_out: booleanValue(raw.timed_out),
909
+ output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
910
+ ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint! }),
911
+ ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
912
+ };
817
913
  } catch { return undefined; }
818
914
  }
819
915
 
820
- function sanitizeForeignStructuredDenial(value: unknown): NativeReviewStructuredDenial {
821
- const parsed = exactObject(value, ["schema", "result", "action", "reason"], ["denial"]);
822
- if (parsed.schema !== "gentle-ai.review-gate-result/v1") throw new Error("invalid denial schema");
823
- const result = enumString(parsed.result, ["scope-changed", "invalidated", "escalated"] as const) as NativeReviewStructuredDenial["result"];
824
- const action = sanitizeNativeDiagnosticText(requiredString(parsed.action), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
825
- const reason = sanitizeNativeDiagnosticText(requiredString(parsed.reason), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
826
- const expectedAction = { "scope-changed": "create-new-lineage", invalidated: "explicit-maintainer-action", escalated: "stop" }[result];
827
- if (action !== expectedAction || !isCanonicalProcessString(action) || !isCanonicalProcessString(reason)) throw new Error("non-canonical denial evidence");
828
- const denial = parsed.denial === undefined ? undefined : (() => { const nested = exactObject(parsed.denial, ["stage", "code"]); const stage = sanitizeNativeDiagnosticText(requiredString(nested.stage), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); const code = sanitizeNativeDiagnosticText(requiredString(nested.code), NATIVE_REVIEW_DENIAL_TEXT_LIMIT); if (!isCanonicalProcessString(stage) || !isCanonicalProcessString(code)) throw new Error("non-canonical denial evidence"); return { stage, code }; })();
829
- return { schema: "gentle-ai.review-gate-result/v1", result, action, reason, ...(denial === undefined ? {} : { denial }) };
830
- }
831
-
832
- function nativeProcessDiagnostics(operation: NativeReviewOperation, code: NativeReviewErrorCode, result?: ExecFileResult): NativeReviewProcessDiagnostics {
916
+ function nativeProcessDiagnostics(operation: NativeReviewOperation, code: NativeReviewErrorCode, result?: ExecFileResult, maxBufferBytes?: number): NativeReviewProcessDiagnostics {
917
+ const outputLimitExceeded = result?.outputLimitExceeded === true;
833
918
  return {
834
919
  operation,
835
920
  error_code: code,
836
921
  ...(result === undefined ? {} : { exit_code: result.exitCode }),
837
922
  ...(result?.signal === null || result?.signal === undefined ? {} : { signal: result.signal }),
838
- timed_out: result?.timedOut === true,
839
- output_limit_exceeded: result?.outputLimitExceeded === true,
923
+ timed_out: !outputLimitExceeded && result?.timedOut === true,
924
+ output_limit_exceeded: outputLimitExceeded,
925
+ ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
926
+ ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
927
+ : {}),
840
928
  ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
841
- ...(result === undefined ? {} : { denial: parseStructuredNativeDenial(result.stdout) }),
842
929
  };
843
930
  }
844
931
 
@@ -857,39 +944,13 @@ function decodeLegacyReconcileAudit(value: unknown): NativeReviewRecoveryResult
857
944
  for (const field of ["predecessor_lineage", "successor_lineage", "outcome"]) requiredString(record[field]);
858
945
  return { record };
859
946
  }
860
- function decodeNativeReviewVerificationEvidence(value: unknown): NativeReviewVerificationEvidenceV2 {
861
- const body = exactObject(value, ["schema", "version", "lineage_id", "authority_revision", "target_identity", "candidate_tree", "paths_digest", "paths", "ledger_ids", "raw_payload_sha256", "raw_payload_bytes", "outcome", "record_digest"]);
862
- if (body.schema !== "gentle-ai.review-verification-evidence/v2") throw new Error("wrong verification evidence schema");
863
- return {
864
- schema: "gentle-ai.review-verification-evidence/v2",
865
- // verification-evidence.schema.json pins `version` to {"const": 2} -- the
866
- // NUMBER two, not the string. It was written as a string from a handoff
867
- // that listed the observed field NAMES without their types.
868
- version: (() => { if (body.version !== 2) throw new Error(`native verification evidence version must be the number 2, received ${JSON.stringify(body.version)}`); return 2 as const; })(),
869
- lineageId: requiredString(body.lineage_id),
870
- authorityRevision: requiredString(body.authority_revision),
871
- targetIdentity: requiredString(body.target_identity),
872
- candidateTree: requiredString(body.candidate_tree),
873
- pathsDigest: requiredString(body.paths_digest),
874
- paths: stringArray(body.paths),
875
- // The schema requires ledger_ids to be an array, but v2.2.2 emits null when
876
- // there are none. Tolerated deliberately: refusing here would reject a
877
- // response the provider actually sends, and the provider violating its own
878
- // published schema is its defect to fix, not a reason to break the client.
879
- ledgerIds: body.ledger_ids === null || body.ledger_ids === undefined ? [] : stringArray(body.ledger_ids),
880
- rawPayloadSha256: requiredString(body.raw_payload_sha256),
881
- rawPayloadBytes: nonNegativeInteger(body.raw_payload_bytes),
882
- outcome: enumString(body.outcome, NATIVE_REVIEW_CAPTURE_OUTCOME) as NativeReviewCaptureOutcome,
883
- recordDigest: requiredString(body.record_digest),
884
- };
885
- }
886
947
  // Unimplemented next_transition.execute.operation values must never reach
887
948
  // argv synthesis. Checked against the raw pre-decode body so an operation
888
949
  // gentle-pi does not implement (e.g. a future `dispose-result`) fails with a
889
950
  // named, typed refusal instead of a generic schema-incompatible error, and
890
951
  // before any client ever tries to build an invocation for it (Design
891
952
  // Decision #6, migrate-review-integration-v2).
892
- const NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS = new Set(["review.start", "review.finalize", "review.recover", "review.repair", "review.validate"]);
953
+ const NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS = new Set(["review.start", "review.recover", "review.repair", "review.acknowledge-approved"]);
893
954
  function assertSupportedNextTransitionOperation(body: Record<string, unknown>): void {
894
955
  const nextTransition = body.next_transition;
895
956
  if (typeof nextTransition !== "object" || nextTransition === null || Array.isArray(nextTransition)) return;
@@ -903,11 +964,23 @@ function assertSupportedNextTransitionOperation(body: Record<string, unknown>):
903
964
  function decode<T>(operation: NativeReviewOperation, mutating: boolean, callback: () => T, diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE)): T {
904
965
  try { return callback(); } catch (error) { if (error instanceof NativeReviewCliError) throw error; throw new NativeReviewCliError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, operation, true, mutating, "native response is schema incompatible", { ...diagnostics, error_code: NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE }); }
905
966
  }
967
+ function decodeReviewStartResponse(value: unknown): ReviewStartV3 | ReviewStartV4 {
968
+ const body = object(value);
969
+ return body.schema === "gentle-ai.review-integration.start/v4"
970
+ ? decodeReviewStartV4(body)
971
+ : decodeReviewStartV3(body);
972
+ }
906
973
  function decodeReleaseEvidence(value: unknown): void {
907
974
  const release = exactObject(value, ["release_tree", "configuration_hash", "generated_artifact_hash", "provenance_hash", "publication_boundary_hash", "publication_state", "evidence_freshness_hash", "evidence_freshness_state"]);
908
975
  for (const field of ["release_tree", "configuration_hash", "generated_artifact_hash", "provenance_hash", "publication_boundary_hash", "evidence_freshness_hash"]) requiredString(release[field]);
909
976
  if (release.publication_state !== "sealed" || release.evidence_freshness_state !== "current") throw new Error("invalid release evidence");
910
977
  }
978
+ function decodeNonDecidingGateContext(value: unknown, expectedGate: string): NativeGateContext {
979
+ const context = exactObject(value, ["gate"]);
980
+ const gate = enumString(context.gate, NATIVE_GATE);
981
+ if (gate !== expectedGate) throw new Error("native non-deciding gate context does not match the requested gate");
982
+ return { lineageId: "", storeRevision: "", raw: context };
983
+ }
911
984
  function decodeGateContext(value: unknown): NativeGateContext {
912
985
  const context = exactObject(
913
986
  value,
@@ -1000,7 +1073,7 @@ function decodeNativeReviewStatusDiagnostic(value: unknown): NativeReviewAuthori
1000
1073
  return { path: requiredString(diagnostic.path), problem: requiredString(diagnostic.problem) };
1001
1074
  }
1002
1075
  function decodeNativeReviewModeStatus(value: unknown): NativeReviewModeStatus {
1003
- const status = exactObject(value, ["schema", "global", "clone_local", "effective", "source"], ["revision"]);
1076
+ const status = exactObject(value, ["schema", "global", "clone_local", "effective", "source"], ["revision", "reach"]);
1004
1077
  if (status.schema !== "gentle-ai.rdd-mode-status/v1") throw new Error("wrong review mode status schema");
1005
1078
  return {
1006
1079
  global: enumString(status.global, Object.values(NATIVE_REVIEW_MODE_VALUE)) as NativeReviewModeValue,
@@ -1008,6 +1081,7 @@ function decodeNativeReviewModeStatus(value: unknown): NativeReviewModeStatus {
1008
1081
  effective: enumString(status.effective, ["on", "off"]) as "on" | "off",
1009
1082
  source: enumString(status.source, Object.values(NATIVE_REVIEW_MODE_SOURCE)) as NativeReviewModeSource,
1010
1083
  ...(status.revision === undefined ? {} : { revision: requiredString(status.revision) }),
1084
+ ...(status.reach === undefined ? {} : { reach: enumString(status.reach, Object.values(NATIVE_REVIEW_MODE_REACH)) as NativeReviewModeReach }),
1011
1085
  };
1012
1086
  }
1013
1087
 
@@ -1150,12 +1224,11 @@ function hasValidLensesRequired(action: NativeStartAction, state: string, riskLe
1150
1224
  if (riskLevel === "low") return !lensesRequired;
1151
1225
  if (action === NATIVE_START_ACTION.CREATED) return state === "reviewing" && lensesRequired;
1152
1226
  if (action === NATIVE_START_ACTION.RESUMED) return !lensesRequired || state === "reviewing";
1153
- if (action === NATIVE_START_ACTION.REUSE_RECEIPT) return state === "approved" && !lensesRequired;
1154
1227
  return !lensesRequired;
1155
1228
  }
1156
1229
 
1157
- function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperation, mutating: boolean, message: string, result?: ExecFileResult, launchAttempted = true, auditRecord?: Record<string, unknown>): NativeReviewCliError {
1158
- return new NativeReviewCliError(code, operation, launchAttempted, mutating, message, nativeProcessDiagnostics(operation, code, result), auditRecord);
1230
+ function nativeError(code: NativeReviewErrorCode, operation: NativeReviewOperation, mutating: boolean, message: string, result?: ExecFileResult, launchAttempted = true, auditRecord?: Record<string, unknown>, maxBufferBytes?: number): NativeReviewCliError {
1231
+ return new NativeReviewCliError(code, operation, launchAttempted, mutating, message, nativeProcessDiagnostics(operation, code, result, maxBufferBytes), auditRecord);
1159
1232
  }
1160
1233
 
1161
1234
  interface NativeJsonExecution {
@@ -1164,13 +1237,13 @@ interface NativeJsonExecution {
1164
1237
  process: ExecFileResult;
1165
1238
  }
1166
1239
 
1167
- export class NativeReviewCliV214 {
1240
+ class NativeReviewPlainCli {
1168
1241
  private readonly adapter: ExecFileAdapter;
1169
1242
  private readonly executable: string | (() => string);
1170
1243
  private readonly timeoutMs: number;
1171
1244
  private readonly maxBufferBytes: number;
1172
1245
  private readonly cleanupDirectory: (directory: string) => Promise<void>;
1173
- constructor(adapter: ExecFileAdapter, executable: string | (() => string) = resolveGentleAiBinary, timeoutMs = 30_000, maxBufferBytes = 1024 * 1024, cleanupDirectory = (directory: string) => rm(directory, { recursive: true, force: true })) {
1246
+ constructor(adapter: ExecFileAdapter, executable: string | (() => string) = resolveGentleAiBinary, timeoutMs = 30_000, maxBufferBytes = resolveNativeReviewMaxBufferBytes(), cleanupDirectory = (directory: string) => rm(directory, { recursive: true, force: true })) {
1174
1247
  if (typeof executable === "string" && (!isAbsolute(executable) || executable === "gentle-ai")) throw new TypeError("Native review requires an absolute package-local executable");
1175
1248
  this.adapter = adapter;
1176
1249
  this.executable = executable;
@@ -1207,165 +1280,17 @@ export class NativeReviewCliV214 {
1207
1280
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "native process could not start");
1208
1281
  }
1209
1282
  const diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.NON_ZERO, result);
1283
+ if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes);
1210
1284
  if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result);
1211
- if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result);
1212
1285
  if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result);
1213
- const structuredValidateDenial = operation === NATIVE_REVIEW_OPERATION.VALIDATE && result.exitCode === 1;
1214
1286
  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;
1215
1287
  const toleratedNotice = stderrIsTolerated(result.stderr, toleratedStderr);
1216
- if (result.exitCode !== 0 && !structuredValidateDenial && !maintenancePartialFailure) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native process failed", result);
1217
- if (result.stderr.trim().length > 0 && !structuredValidateDenial && !maintenancePartialFailure && !toleratedNotice) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result);
1288
+ if (result.exitCode !== 0 && !maintenancePartialFailure) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native process failed", result);
1289
+ if (result.stderr.trim().length > 0 && !maintenancePartialFailure && !toleratedNotice) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result);
1218
1290
  return { body: parseJson(result.stdout, operation, mutating, diagnostics), exitCode: result.exitCode, process: result };
1219
1291
  }
1220
1292
 
1221
- private async verifyVersion(cwd: string, signal: AbortSignal | undefined, capabilities: readonly NativeCliCapability[]): Promise<keyof typeof NATIVE_CLI_CONTRACTS> {
1222
- let result: ExecFileResult;
1223
- try { result = await this.adapter({ file: this.executablePath(NATIVE_REVIEW_OPERATION.VERSION, false), arguments: ["version"], cwd, timeoutMs: this.timeoutMs, maxBufferBytes: this.maxBufferBytes, signal }); }
1224
- catch (error) {
1225
- if (error instanceof NativeReviewCliError) throw error;
1226
- if (error instanceof Error && error.name === "AbortError") throw nativeError(NATIVE_REVIEW_ERROR_CODE.CANCELLED, NATIVE_REVIEW_OPERATION.VERSION, false, "version process was cancelled");
1227
- throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, NATIVE_REVIEW_OPERATION.VERSION, false, "gentle-ai is unavailable");
1228
- }
1229
- if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, NATIVE_REVIEW_OPERATION.VERSION, false, "version process timed out", result);
1230
- if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, NATIVE_REVIEW_OPERATION.VERSION, false, "version process output exceeded limit", result);
1231
- if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, NATIVE_REVIEW_OPERATION.VERSION, false, "version process was signalled", result);
1232
- if (result.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.VERSION, false, "version process failed", result);
1233
- const version = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(result.stdout.replace(/\r\n$/, "\n"))?.[1];
1234
- const contract = version === undefined ? undefined : resolvedNativeCliContract(version);
1235
- 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"}`);
1236
- return version as keyof typeof NATIVE_CLI_CONTRACTS;
1237
- }
1238
-
1239
- async start(request: NativeStartRequest): Promise<NativeStartResult> {
1240
- if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
1241
- if (request.committedOnly !== undefined && typeof request.committedOnly !== "boolean") throw new TypeError("Native START committedOnly must be a boolean when supplied");
1242
- if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
1243
- if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
1244
- const version = await this.verifyVersion(request.cwd, request.signal, ["start"]);
1245
- const toleratedStderr = resolvedNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : [];
1246
- 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);
1247
- return decode(NATIVE_REVIEW_OPERATION.START, true, () => {
1248
- // `target_identity` and `lens_bindings` are real, unconditionally-present
1249
- // fields on gentle-ai's plain (non-negotiated) `review start` JSON output
1250
- // (confirmed against the pinned v2.1.11 Go source and live against a dev
1251
- // build during Phase 6 dev-binary ground-truthing, organic-rdd-parity) —
1252
- // tolerated here but not consumed: Pi's negotiated-contract client
1253
- // (NativeReviewCliV216, the production default) carries lineage/target
1254
- // identity through its own `review-integration/v1` decoder instead.
1255
- const body = exactObject(result, ["operation", "lineage_id", "state", "risk_level", "selected_lenses", "changed_files", "changed_lines", "correction_budget", "action", "lenses_required", "projection"], ["risk_evidence", "hint", "target_identity", "lens_bindings"]);
1256
- if (body.operation !== "review/start" || body.projection !== "workspace" || !(NATIVE_FINALIZE_STATE as readonly string[]).includes(stringValue(body.state))) throw new Error("wrong start discriminator");
1257
- const lineageId = requiredString(body.lineage_id);
1258
- if (request.lineageId && lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start lineage mismatch");
1259
- const riskLevel = requiredString(body.risk_level);
1260
- const action = enumString(body.action, NATIVE_START_ACTION_VALUES) as NativeStartAction;
1261
- const lensesRequired = booleanValue(body.lenses_required);
1262
- const selectedLenses = decodeSelectedLenses(body.selected_lenses, riskLevel, lensesRequired);
1263
- if (
1264
- !(NATIVE_RISK_LEVEL as readonly string[]).includes(riskLevel) ||
1265
- selectedLenses.some((lens) => !(NATIVE_REVIEW_LENS as readonly string[]).includes(lens)) ||
1266
- !hasCanonicalSelectedLenses(riskLevel, selectedLenses) ||
1267
- !hasValidLensesRequired(action, body.state as string, riskLevel, lensesRequired)
1268
- ) throw new Error("unknown or contradictory start enum");
1269
- return {
1270
- lineageId, state: body.state as NativeStartResult["state"], riskLevel, selectedLenses, changedFiles: nonNegativeInteger(body.changed_files), changedLines: nonNegativeInteger(body.changed_lines), correctionBudget: nonNegativeInteger(body.correction_budget), action, lensesRequired,
1271
- ...(body.risk_evidence === undefined ? {} : { riskEvidence: stringArray(body.risk_evidence) }),
1272
- ...(body.hint === undefined ? {} : { hint: requiredString(body.hint) }),
1273
- };
1274
- });
1275
- }
1276
-
1277
- private async stageDocument(directory: string, name: string, document: unknown): Promise<string> {
1278
- const path = join(directory, `${name}.json`);
1279
- await writeFile(path, JSON.stringify(document), { encoding: "utf8", mode: 0o600 });
1280
- await chmod(path, 0o600);
1281
- return path;
1282
- }
1283
- private async stageEvidence(directory: string, evidence: string): Promise<string> {
1284
- const path = join(directory, "evidence.txt");
1285
- await writeFile(path, evidence, { encoding: "utf8", mode: 0o600 });
1286
- await chmod(path, 0o600);
1287
- return path;
1288
- }
1289
-
1290
- async finalize(request: NativeFinalizeRequest): Promise<NativeFinalizeResult> {
1291
- if (request.evidenceDocument !== undefined && (typeof request.evidenceDocument !== "string" || request.evidenceDocument.length === 0)) throw new TypeError("Native FINALIZE evidence must contain at least one byte");
1292
- await this.verifyVersion(request.cwd, request.signal, ["finalize"]);
1293
- const needsStaging = request.lensResults !== undefined || request.refuterDocument !== undefined || request.validationDocument !== undefined || request.evidenceDocument !== undefined;
1294
- const directory = needsStaging ? await mkdtemp(join(tmpdir(), "gentle-ai-finalize-")) : undefined;
1295
- try {
1296
- if (directory) await chmod(directory, 0o700);
1297
- const resultFiles = directory && request.lensResults ? await Promise.all(request.lensResults.map((entry, index) => this.stageDocument(directory, `result-${index}`, entry.document))) : request.resultFiles ?? [];
1298
- const refuterFile = directory && request.refuterDocument !== undefined ? await this.stageDocument(directory, "refuter", request.refuterDocument) : request.refuterFile;
1299
- const validationFile = directory && request.validationDocument !== undefined ? await this.stageDocument(directory, "validation", request.validationDocument) : request.validationFile;
1300
- const evidenceFile = directory && request.evidenceDocument !== undefined ? await this.stageEvidence(directory, request.evidenceDocument) : request.evidenceFile;
1301
- const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.FINALIZE, request.cwd, ["review", "finalize", "--cwd", request.cwd, ...(request.lineageId ? ["--lineage", request.lineageId] : []), ...resultFiles.flatMap((path) => ["--result", path]), ...(refuterFile ? ["--refuter", refuterFile] : []), ...(request.correctionLines === undefined ? [] : ["--correction-lines", String(request.correctionLines)]), ...(validationFile ? ["--validation", validationFile] : []), ...(evidenceFile ? ["--evidence", evidenceFile] : []), ...(request.failed ? ["--failed"] : [])], true, request.signal);
1302
- return decode(NATIVE_REVIEW_OPERATION.FINALIZE, true, () => {
1303
- const body = exactObject(result, ["operation", "lineage_id", "state", "action", "store_revision"], ["receipt_path"]);
1304
- if (body.operation !== "review/finalize") throw new Error("wrong finalize discriminator");
1305
- const lineageId = requiredString(body.lineage_id);
1306
- if (request.lineageId && lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.FINALIZE, true, "native finalize lineage mismatch");
1307
- const state = requiredString(body.state);
1308
- if (!(NATIVE_FINALIZE_STATE as readonly string[]).includes(state)) throw new Error("unknown finalize state");
1309
- return { lineageId, state, action: requiredString(body.action), storeRevision: requiredString(body.store_revision), ...(body.receipt_path === undefined ? {} : { receiptPath: requiredString(body.receipt_path) }) };
1310
- });
1311
- } finally { if (directory) await this.cleanupDirectory(directory).catch(() => undefined); }
1312
- }
1313
-
1314
- async validate(request: NativeValidateRequest): Promise<NativeValidateResult> {
1315
- await this.verifyVersion(request.cwd, request.signal, ["validate"]);
1316
- const execution = await this.execute(NATIVE_REVIEW_OPERATION.VALIDATE, request.cwd, ["review", "validate", "--gate", request.gate, "--cwd", request.cwd, ...(request.lineageId ? ["--lineage", request.lineageId] : []), ...(request.flags ?? [])], false, request.signal);
1317
- return decode(NATIVE_REVIEW_OPERATION.VALIDATE, false, () => {
1318
- const body = exactObject(execution.body, ["schema", "result", "allowed", "action", "reason", "context"], ["delivery"]);
1319
- const gateResult = enumString(body.result, NATIVE_GATE_RESULT) as NativeValidateResult["result"];
1320
- const action = sanitizeNativeDiagnosticText(requiredString(body.action), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
1321
- const reason = sanitizeNativeDiagnosticText(requiredString(body.reason), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
1322
- // `delivery` (Design Decision #9, organic-rdd-parity) is an alternate
1323
- // discriminator: when present it must be the single literal
1324
- // "disabled/unmanaged" (gentle-ai's RDDDeliveryDisabledUnmanaged — the
1325
- // kill switch is off and no receipt governs the candidate), paired
1326
- // exactly with result:invalidated, allowed:false, action:repository-policy,
1327
- // and exit 0 — never the strict continue/create-new-lineage/
1328
- // explicit-maintainer-action/stop pairing used when delivery is absent.
1329
- const delivery = body.delivery === undefined ? undefined : (enumString(body.delivery, ["disabled/unmanaged"]) as NativeValidateResult["delivery"]);
1330
- if (body.schema !== "gentle-ai.review-gate-result/v1" || !isCanonicalProcessString(action) || !isCanonicalProcessString(reason)) throw new Error("wrong validate discriminator");
1331
- if (delivery !== undefined) {
1332
- if (gateResult !== "invalidated" || body.allowed !== false || action !== "repository-policy" || execution.exitCode !== 0) throw new Error("wrong validate delivery discriminator");
1333
- } else {
1334
- const expectedAction = { allow: "continue", "scope-changed": "create-new-lineage", invalidated: "explicit-maintainer-action", escalated: "stop" }[gateResult];
1335
- const expectedExitCode = gateResult === "allow" ? 0 : 1;
1336
- if (typeof body.allowed !== "boolean" || body.allowed !== (gateResult === "allow") || action !== expectedAction || execution.exitCode !== expectedExitCode) throw new Error("wrong validate discriminator");
1337
- }
1338
- const gateContext = decodeGateContext(body.context);
1339
- const returnedGate = gateContext.raw.gate;
1340
- if (returnedGate !== request.gate && (gateResult === "allow" || returnedGate !== "")) throw new Error("native gate context does not match the requested gate");
1341
- if (request.lineageId && returnedGate !== "" && gateContext.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.VALIDATE, false, "native gate lineage mismatch");
1342
- return { allowed: body.allowed as boolean, result: gateResult, action, reason, gateContext, ...(delivery === undefined ? {} : { delivery }) };
1343
- });
1344
- }
1345
-
1346
- async bindSdd(request: NativeBindSddRequest): Promise<NativeBindSddResult> {
1347
- await this.verifyVersion(request.cwd, request.signal, ["bindSdd"]);
1348
- const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.BIND_SDD, request.cwd, ["review", "bind-sdd", "--cwd", request.cwd, "--change", request.change, "--lineage", request.lineage, `--expected-binding-revision=${request.expectedBindingRevision}`], true, request.signal);
1349
- return decode(NATIVE_REVIEW_OPERATION.BIND_SDD, true, () => {
1350
- const body = exactObject(result, ["schema", "revision", "change", "lineage", "authority_revision", "receipt_hash", "gate_context"]);
1351
- if (body.schema !== "gentle-ai.sdd-review-binding/v1" || body.change !== request.change || body.lineage !== request.lineage) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.BIND_SDD, true, "native binding identity mismatch");
1352
- const receiptHash = requiredString(body.receipt_hash);
1353
- const gateContext = decodeGateContext(body.gate_context);
1354
- const authorityRevision = requiredString(body.authority_revision);
1355
- if (gateContext.lineageId !== request.lineage || gateContext.storeRevision !== authorityRevision || gateContext.raw.gate !== "post-apply") throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.BIND_SDD, true, "native binding gate mismatch");
1356
- return {
1357
- revision: requiredString(body.revision),
1358
- change: requiredString(body.change),
1359
- lineage: requiredString(body.lineage),
1360
- authorityRevision,
1361
- receiptHash,
1362
- gateContext,
1363
- };
1364
- });
1365
- }
1366
-
1367
1293
  async reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult> {
1368
- await this.verifyVersion(request.cwd, request.signal, ["status", "inventory"]);
1369
1294
  const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.STATUS, request.cwd, ["review", "status", "--cwd", request.cwd], false, request.signal);
1370
1295
  const status = decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeNativeReviewStatus(result));
1371
1296
  if (!await repositoriesMatch(request.cwd, status.repository)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.STATUS, false, "native review status repository mismatch");
@@ -1379,7 +1304,6 @@ export class NativeReviewCliV214 {
1379
1304
  // never mutates the operator's global gentle-ai state across other clones.
1380
1305
  async reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
1381
1306
  const cwd = await canonicalNativeReviewCwd(request.cwd);
1382
- await this.verifyVersion(cwd, request.signal, ["mode"]);
1383
1307
  const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
1384
1308
  const { body } = await this.execute(
1385
1309
  NATIVE_REVIEW_OPERATION.MODE,
@@ -1391,77 +1315,10 @@ export class NativeReviewCliV214 {
1391
1315
  return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => decodeNativeReviewMode(body, request.operation));
1392
1316
  }
1393
1317
 
1394
- async sddStatus(request: NativeSddStatusRequest): Promise<NativeSddStatusResult> {
1395
- await this.verifyVersion(request.cwd, request.signal, ["sddStatus"]);
1396
- const { body: result } = await this.execute(NATIVE_REVIEW_OPERATION.SDD_STATUS, request.cwd, ["sdd-status", request.change, "--cwd", request.cwd, "--json", "--instructions"], false, request.signal);
1397
- return decode(NATIVE_REVIEW_OPERATION.SDD_STATUS, false, () => {
1398
- const body = exactObject(result, ["schemaName", "schemaVersion", "changeName", "artifactStore", "planningHome", "changeRoot", "artifactPaths", "contextFiles", "artifacts", "taskProgress", "dependencies", "applyState", "actionContext", "relationships", "remediationState", "nextRecommended", "blockedReasons"], ["reviewGate", "reviewTransaction", "phaseInstructions"]);
1399
- if (body.schemaName !== "gentle-ai.sdd-status" || body.schemaVersion !== 1 || body.changeName !== request.change || !["openspec", "engram", "none"].includes(body.artifactStore as string) || !["blocked", "all_done", "ready"].includes(body.applyState as string)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.SDD_STATUS, false, "native status identity mismatch");
1400
- const paths = ["proposal", "specs", "design", "tasks", "applyProgress", "verifyReport", "reviewPolicy", "reviewLedger", "reviewReceipt", "reviewBundle", "reviewContext", "reviewState"];
1401
- const pathMap = (value: unknown) => { const parsed = exactObject(value, paths); for (const path of paths) stringArray(parsed[path]); };
1402
- const planningHome = exactObject(body.planningHome, ["mode", "path"]);
1403
- if (planningHome.mode !== "repo-local") throw new Error("invalid planning home");
1404
- requiredString(planningHome.path); requiredString(body.changeRoot); pathMap(body.artifactPaths); pathMap(body.contextFiles);
1405
- const artifactStates = paths.filter((path) => path !== "reviewPolicy" || body.artifactStore === NATIVE_SDD_ARTIFACT_STORE.ENGRAM);
1406
- const artifacts = exactObject(body.artifacts, artifactStates);
1407
- for (const path of artifactStates) if (!Object.values(NATIVE_SDD_ARTIFACT_STATE).includes(artifacts[path] as NativeSddArtifactState)) throw new Error("invalid artifact state");
1408
- const taskProgress = exactObject(body.taskProgress, ["total", "completed", "pending", "allComplete"]);
1409
- const total = nonNegativeInteger(taskProgress.total), completed = nonNegativeInteger(taskProgress.completed), pending = nonNegativeInteger(taskProgress.pending);
1410
- if (typeof taskProgress.allComplete !== "boolean" || completed + pending !== total || taskProgress.allComplete !== (pending === 0)) throw new Error("invalid task progress");
1411
- const dependencies = exactObject(body.dependencies, ["proposal", "specs", "design", "tasks", "apply", "verify", "archive"]);
1412
- for (const phase of ["proposal", "specs", "design", "tasks", "apply", "verify", "archive"]) if (!["blocked", "ready", "all_done"].includes(dependencies[phase] as string)) throw new Error("invalid dependency state");
1413
- const actionContext = exactObject(body.actionContext, ["mode", "workspaceRoot", "allowedEditRoots"]);
1414
- if (actionContext.mode !== "repo-local" || requiredString(actionContext.workspaceRoot).length === 0 || stringArray(actionContext.allowedEditRoots).length === 0) throw new Error("invalid action context");
1415
- const relationships = exactObject(body.relationships, ["dependsOn", "supersedes", "amends", "conflictsWith", "sameDomainActiveChanges"]);
1416
- for (const field of ["dependsOn", "supersedes", "amends", "conflictsWith", "sameDomainActiveChanges"]) stringArray(relationships[field]);
1417
- const remediation = exactObject(body.remediationState, ["required", "complete", "failedEvidenceRevision", "lineageId", "generation", "fixBatch", "reason"], ["correctionBudget"]);
1418
- if (typeof remediation.required !== "boolean" || typeof remediation.complete !== "boolean" || ["failedEvidenceRevision", "lineageId", "reason"].some((field) => typeof remediation[field] !== "string")) throw new Error("invalid remediation state");
1419
- nonNegativeInteger(remediation.generation); nonNegativeInteger(remediation.fixBatch);
1420
- if (remediation.correctionBudget !== undefined) nonNegativeInteger(remediation.correctionBudget);
1421
- let reviewGateResult: string | undefined;
1422
- let reviewGateDelivery: string | undefined;
1423
- if (body.reviewGate !== undefined) {
1424
- // `delivery` (present since 09e4b14c, gentle-ai v2.2.0+) is emitted
1425
- // when the kill switch is off: gentle-ai's status struct carries
1426
- // `Delivery` with `json:"delivery,omitempty"`, so an exact-key decode
1427
- // without it rejects that payload outright. Pre-existing bug fix
1428
- // (Phase 13.13, migrate-review-integration-v2).
1429
- const gate = exactObject(body.reviewGate, ["result", "reason"], ["delivery"]);
1430
- reviewGateResult = enumString(gate.result, NATIVE_GATE_RESULT); requiredString(gate.reason);
1431
- if (gate.delivery !== undefined) reviewGateDelivery = enumString(gate.delivery, ["disabled/unmanaged"]);
1432
- }
1433
- if (body.reviewTransaction !== undefined) decodeReviewTransaction(body.reviewTransaction);
1434
- if (body.phaseInstructions !== undefined) {
1435
- const instructions = exactObject(body.phaseInstructions, ["apply", "verify", "remediate", "archive"]);
1436
- for (const phase of ["apply", "verify", "remediate", "archive"]) stringArray(instructions[phase]);
1437
- }
1438
- const nextRecommended = requiredString(body.nextRecommended);
1439
- if (!(NATIVE_SDD_NEXT_ACTION as readonly string[]).includes(nextRecommended)) throw new Error("unknown SDD next action");
1440
- const blockedReasons = stringArray(body.blockedReasons);
1441
- return {
1442
- ...body,
1443
- artifactStore: body.artifactStore as NativeSddArtifactStore,
1444
- artifacts: artifacts as unknown as NativeSddArtifactStates,
1445
- nextRecommended,
1446
- ready:
1447
- (NATIVE_SDD_POST_REVIEW_ACTION as readonly string[]).includes(nextRecommended) &&
1448
- blockedReasons.length === 0 &&
1449
- // With the kill switch off, the gate result can never become
1450
- // "allow" (delivery follows ordinary repository policy instead) —
1451
- // gated only on `result === "allow"` this deadlocked `ready`
1452
- // forever, the same deadlock gentle-ai fixed upstream in 2c18fa10.
1453
- // `disabled/unmanaged` delivery is the legitimate non-blocking
1454
- // terminal state and must also unblock readiness.
1455
- (reviewGateResult === "allow" || reviewGateDelivery === "disabled/unmanaged"),
1456
- };
1457
- });
1458
- }
1459
-
1460
1318
  async reclaim(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult> {
1461
1319
  for (const [name, value] of [["lineage", request.lineage], ["actor", request.actor], ["reason", request.reason]] as const) {
1462
1320
  if (!isCanonicalProcessString(value)) throw new TypeError(`Native RECLAIM ${name} must be a non-empty, trimmed, NUL-free string`);
1463
1321
  }
1464
- await this.verifyVersion(request.cwd, request.signal, ["reclaim"]);
1465
1322
  const { body } = await this.execute(NATIVE_REVIEW_OPERATION.RECLAIM, request.cwd, ["review", "reclaim", "--cwd", request.cwd, "--lineage", request.lineage, "--actor", request.actor, "--reason", request.reason], true, request.signal);
1466
1323
  return { record: body };
1467
1324
  }
@@ -1482,7 +1339,6 @@ export class NativeReviewCliV214 {
1482
1339
  throw new TypeError("Native RECOVER maintainerAuthorization must be a non-empty LF-only binding");
1483
1340
  }
1484
1341
  if (!(NATIVE_REVIEW_RECOVER_DISPOSITION as readonly string[]).includes(request.disposition)) throw new TypeError("Native RECOVER disposition must be scope_changed, invalidated, or escalated");
1485
- await this.verifyVersion(request.cwd, request.signal, ["recover"]);
1486
1342
  const { body } = await this.execute(NATIVE_REVIEW_OPERATION.RECOVER, request.cwd, [
1487
1343
  "review", "recover", "--cwd", request.cwd,
1488
1344
  "--predecessor-lineage", request.predecessorLineage,
@@ -1500,8 +1356,11 @@ export class NativeReviewCliV214 {
1500
1356
  for (const [name, value] of [["lineage", request.lineage], ["expectedRevision", request.expectedRevision], ["snapshotIdentity", request.snapshotIdentity], ["actor", request.actor], ["reason", request.reason]] as const) {
1501
1357
  if (!isCanonicalProcessString(value)) throw new TypeError(`Native ABANDON ${name} must be a non-empty, trimmed, NUL-free string`);
1502
1358
  }
1503
- if (request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request)) throw new TypeError("Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, actor, and reason binding");
1504
- await this.verifyVersion(request.cwd, request.signal, ["abandon"]);
1359
+ 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");
1360
+ for (const [name, value] of [["findingsPresent", request.findingsPresent], ["evidenceRecordsPresent", request.evidenceRecordsPresent]] as const) {
1361
+ if (typeof value !== "boolean") throw new TypeError(`Native ABANDON ${name} must be a boolean`);
1362
+ }
1363
+ if (request.maintainerAuthorization !== nativeReviewAbandonAuthorization(request)) throw new TypeError("Native ABANDON maintainerAuthorization must match the exact lineage, revision, snapshot, reason, discarded-work, and actor binding");
1505
1364
  const execution = await this.execute(NATIVE_REVIEW_OPERATION.ABANDON, request.cwd, [
1506
1365
  "review", "abandon", "--cwd", request.cwd,
1507
1366
  "--lineage", request.lineage,
@@ -1521,7 +1380,6 @@ export class NativeReviewCliV214 {
1521
1380
  }
1522
1381
  if (request.diagnostic !== NATIVE_REVIEW_LEGACY_QUARANTINE.DIAGNOSTIC || request.disposition !== NATIVE_REVIEW_LEGACY_QUARANTINE.DISPOSITION) throw new TypeError("Native QUARANTINE_LEGACY supports only the published malformed freeze-findings diagnostic and disposition");
1523
1382
  if (request.maintainerAuthorization !== nativeReviewLegacyQuarantineAuthorization(request)) throw new TypeError("Native QUARANTINE_LEGACY maintainerAuthorization must match the exact repository, lineage, revision, diagnostic, disposition, actor, and reason binding");
1524
- await this.verifyVersion(request.cwd, request.signal, ["quarantineLegacy"]);
1525
1383
  const execution = await this.execute(NATIVE_REVIEW_OPERATION.QUARANTINE_LEGACY, request.cwd, [
1526
1384
  "review", "quarantine-legacy", "--cwd", request.cwd,
1527
1385
  "--lineage", request.lineage,
@@ -1553,7 +1411,6 @@ export class NativeReviewCliV214 {
1553
1411
  if (request.maintainerAuthorization !== expectedAuthorization) {
1554
1412
  throw new TypeError("Native RECONCILE_AUTHORITY maintainerAuthorization must match the exact target and revision binding");
1555
1413
  }
1556
- const version = await this.verifyVersion(request.cwd, request.signal, ["reconcileAuthority"]);
1557
1414
  const execution = await this.execute(NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, request.cwd, [
1558
1415
  "review", "reconcile-authority", "--cwd", request.cwd,
1559
1416
  "--predecessor-lineage", request.predecessorLineage,
@@ -1564,9 +1421,7 @@ export class NativeReviewCliV214 {
1564
1421
  "--reason", request.reason,
1565
1422
  "--maintainer-authorization", request.maintainerAuthorization,
1566
1423
  ], true, request.signal);
1567
- const result = decode(NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, true, () => version === "2.1.8"
1568
- ? decodeLegacyReconcileAudit(execution.body)
1569
- : decodeNativeMaintenanceResult(execution.body, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY));
1424
+ const result = decode(NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, true, () => decodeNativeMaintenanceResult(execution.body, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY));
1570
1425
  if (execution.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.RECONCILE_AUTHORITY, true, "native authority reconciliation partially failed", execution.process, true, result.record);
1571
1426
  return result;
1572
1427
  }
@@ -1577,7 +1432,6 @@ export class NativeReviewCliV214 {
1577
1432
  }
1578
1433
  if (request.diagnostic !== NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DIAGNOSTIC || request.disposition !== NATIVE_REVIEW_LEGACY_ALIAS_REPAIR.DISPOSITION) throw new TypeError("Native REPAIR_LEGACY_ALIAS supports only the published historical alias diagnostic and disposition");
1579
1434
  if (request.maintainerAuthorization !== nativeReviewLegacyAliasRepairAuthorization(request)) throw new TypeError("Native REPAIR_LEGACY_ALIAS maintainerAuthorization must match the exact repository, lineage, revision, diagnostic, disposition, actor, and reason binding");
1580
- await this.verifyVersion(request.cwd, request.signal, ["repairLegacyAlias"]);
1581
1435
  const execution = await this.execute(NATIVE_REVIEW_OPERATION.REPAIR_LEGACY_ALIAS, request.cwd, [
1582
1436
  "review", "repair-legacy-alias", "--cwd", request.cwd,
1583
1437
  "--lineage", request.lineage,
@@ -1594,14 +1448,17 @@ export class NativeReviewCliV214 {
1594
1448
  }
1595
1449
  }
1596
1450
 
1597
- export function nativeReviewAbandonAuthorization(request: Pick<NativeReviewAbandonRequest, "lineage" | "expectedRevision" | "snapshotIdentity" | "actor" | "reason">): string {
1451
+ export function nativeReviewAbandonAuthorization(request: Pick<NativeReviewAbandonRequest, "lineage" | "expectedRevision" | "snapshotIdentity" | "capturedLensResults" | "findingsPresent" | "evidenceRecordsPresent" | "actor" | "reason">): string {
1598
1452
  return [
1599
- "gentle-ai.review-abandon-authorization/v1",
1453
+ "gentle-ai.review-abandon-authorization/v2",
1600
1454
  `lineage=${request.lineage}`,
1601
1455
  `revision=${request.expectedRevision}`,
1602
1456
  `snapshot_identity=${request.snapshotIdentity}`,
1603
- `actor=${request.actor}`,
1604
1457
  `reason=${request.reason}`,
1458
+ `captured_lens_results=${request.capturedLensResults.join(",")}`,
1459
+ `findings_present=${request.findingsPresent}`,
1460
+ `evidence_records_present=${request.evidenceRecordsPresent}`,
1461
+ `actor=${request.actor.trim()}`,
1605
1462
  ].join("\n");
1606
1463
  }
1607
1464
 
@@ -1618,6 +1475,32 @@ export function nativeReviewLegacyQuarantineAuthorization(request: Pick<NativeRe
1618
1475
  ].join("\n");
1619
1476
  }
1620
1477
 
1478
+ /**
1479
+ * The exact native `gentle-ai.review-recovery-authorization/v1` binding for one
1480
+ * recovery edge.
1481
+ *
1482
+ * Native `review recover` accepts a caller-supplied authorization only when it
1483
+ * reproduces this binding byte for byte, because it is copied verbatim into the
1484
+ * recovery provenance and read afterwards as a maintainer attestation. Pi
1485
+ * therefore derives it from freshly read native target status and never
1486
+ * forwards a caller-supplied one: a wrong binding is worse than an absent one,
1487
+ * since an absent field cannot lie about who approved what.
1488
+ *
1489
+ * `targetIdentity` is the live target identity the provider itself publishes in
1490
+ * the `review.recover` eligibility binding (`status.target_identity`), which is
1491
+ * the identity the successor's initial snapshot takes.
1492
+ */
1493
+ export function nativeReviewRecoverAuthorization(request: Pick<NativeReviewRecoverRequest, "predecessorLineage" | "expectedPredecessorRevision" | "actor" | "reason"> & { targetIdentity: string }): string {
1494
+ return [
1495
+ "gentle-ai.review-recovery-authorization/v1",
1496
+ `predecessor_lineage=${request.predecessorLineage}`,
1497
+ `predecessor_revision=${request.expectedPredecessorRevision}`,
1498
+ `target_identity=${request.targetIdentity}`,
1499
+ `actor=${request.actor.trim()}`,
1500
+ `reason=${request.reason.trim()}`,
1501
+ ].join("\n");
1502
+ }
1503
+
1621
1504
  export function nativeReviewReconcileAuthorization(request: Pick<NativeReviewReconcileAuthorityRequest, "predecessorLineage" | "expectedPredecessorRevision" | "successorLineage" | "expectedSuccessorRevision" | "actor" | "reason" | "anomalies">): string {
1622
1505
  return [
1623
1506
  "gentle-ai.review-reconcile-authorization/v1",
@@ -1658,15 +1541,16 @@ export class NativeReviewIntegrationError extends Error {
1658
1541
  }
1659
1542
  }
1660
1543
 
1661
- // Raised when negotiated START answers `consent/v2` (action:
1544
+ // Raised when negotiated START answers a consent question (`consent/v2` from
1545
+ // the pinned line, `consent/v3` from gentle-ai >= 2.3.0; action:
1662
1546
  // "consent_required") instead of `start/v3`. The provider has frozen no
1663
1547
  // authority yet: Pi must relay this complete candidate-scoped question and may
1664
1548
  // answer only through one of the exact invocations carried by the envelope.
1665
1549
  export class NativeReviewConsentRequiredError extends Error {
1666
- readonly consent: ReviewConsentV2;
1550
+ readonly consent: ReviewConsentEnvelope;
1667
1551
  readonly launchAttempted = true;
1668
1552
  readonly mutationOutcome = "none";
1669
- constructor(consent: ReviewConsentV2) {
1553
+ constructor(consent: ReviewConsentEnvelope) {
1670
1554
  super(consent.headline);
1671
1555
  this.name = "NativeReviewConsentRequiredError";
1672
1556
  this.consent = consent;
@@ -1750,7 +1634,44 @@ function exactConsentOption(arguments_: readonly string[], name: string): string
1750
1634
  return values[0]!;
1751
1635
  }
1752
1636
 
1753
- function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): readonly string[] {
1637
+ // A Pi consent envelope is bound to Pi only when every exact provider replay
1638
+ // path carries exactly one `--agent pi` option. The outer envelope agent alone
1639
+ // cannot bind an omitted, embedded, duplicated, or conflicting invocation.
1640
+ function validatePiConsentChoiceAgentBindings(consent: ReviewConsentEnvelope): void {
1641
+ if (!("agent" in consent) || consent.agent !== "pi") return;
1642
+ for (const choice of consent.choices) {
1643
+ const arguments_ = splitNativeConsentInvocation(choice.invocation).slice(1);
1644
+ if (exactConsentOption(arguments_, "--agent") !== "pi") {
1645
+ throw new NativeReviewConsentBindingError("consent-invocation-agent-changed", "Native Pi consent invocation agent binding changed");
1646
+ }
1647
+ }
1648
+ }
1649
+
1650
+ function optionalConsentLineageOption(arguments_: readonly string[]): string | undefined {
1651
+ const values: string[] = [];
1652
+ for (let index = 0; index < arguments_.length; index += 1) {
1653
+ const token = arguments_[index]!;
1654
+ if (token === "--lineage") {
1655
+ const value = arguments_[index + 1];
1656
+ if (value === undefined || value.startsWith("--")) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation --lineage is missing its value");
1657
+ values.push(value);
1658
+ index += 1;
1659
+ } else if (token.startsWith("--lineage=")) values.push(token.slice("--lineage=".length));
1660
+ }
1661
+ if (values.length > 1) throw new NativeReviewConsentBindingError("consent-invocation-option-invalid", "Native consent invocation permits at most one --lineage");
1662
+ if (values.length === 0) return undefined;
1663
+ const lineageId = values[0]!;
1664
+ 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");
1665
+ return lineageId;
1666
+ }
1667
+
1668
+ interface ConsentInvocation {
1669
+ arguments_: readonly string[];
1670
+ lineageId?: string;
1671
+ }
1672
+
1673
+ function consentInvocationArguments(request: NativeReviewConsentAnswerRequest): ConsentInvocation {
1674
+ validatePiConsentChoiceAgentBindings(request.consent);
1754
1675
  const choice = request.consent.choices.find((candidate) => candidate.answer === request.answer);
1755
1676
  if (choice === undefined) throw new NativeReviewConsentBindingError("consent-answer-unknown", "Native consent answer must be granted or declined");
1756
1677
  const words = splitNativeConsentInvocation(choice.invocation);
@@ -1760,8 +1681,9 @@ function consentInvocationArguments(request: NativeReviewConsentAnswerRequest):
1760
1681
  if (exactConsentOption(arguments_, "--cwd") !== request.cwd) throw new NativeReviewConsentBindingError("consent-invocation-cwd-changed", "Native consent invocation repository binding changed");
1761
1682
  if (exactConsentOption(arguments_, "--target") !== request.consent.targetIdentity) throw new NativeReviewConsentBindingError("consent-invocation-target-changed", "Native consent invocation target binding changed");
1762
1683
  if (exactConsentOption(arguments_, "--projection") !== request.consent.projection) throw new NativeReviewConsentBindingError("consent-invocation-projection-changed", "Native consent invocation projection binding changed");
1684
+ const lineageId = optionalConsentLineageOption(arguments_);
1763
1685
  if (exactConsentOption(arguments_, "--consent") !== request.answer || arguments_.at(-1) !== request.answer) throw new NativeReviewConsentBindingError("consent-invocation-answer-changed", "Native consent invocation answer binding changed");
1764
- return arguments_;
1686
+ return { arguments_, ...(lineageId === undefined ? {} : { lineageId }) };
1765
1687
  }
1766
1688
 
1767
1689
  function decodeDeclinedConsentStart(value: unknown, expected: NativeReviewConsentAnswerRequest): NativeReviewConsentDeclinedResult {
@@ -1784,71 +1706,71 @@ function decodeDeclinedConsentStart(value: unknown, expected: NativeReviewConsen
1784
1706
  };
1785
1707
  }
1786
1708
 
1787
- type NativeExecutableDigestResolver = (path: string) => string;
1788
- const nativeCapabilitiesByDigest = new Map<string, Promise<ReviewCapabilitiesV2>>();
1789
-
1790
- export function clearNativeReviewCapabilitiesCacheForTesting(): void {
1791
- nativeCapabilitiesByDigest.clear();
1792
- }
1793
-
1794
- function defaultExecutableDigest(path: string): string {
1795
- const before = statSync(path);
1796
- const digest = createHash("sha256").update(readFileSync(path)).digest("hex");
1797
- const after = statSync(path);
1798
- if (
1799
- before.dev !== after.dev ||
1800
- before.ino !== after.ino ||
1801
- before.size !== after.size ||
1802
- before.mtimeMs !== after.mtimeMs
1803
- ) throw new Error("native review executable changed during capability verification");
1804
- return digest;
1805
- }
1806
-
1807
1709
  interface NegotiatedExecution {
1808
1710
  body: Record<string, unknown>;
1809
1711
  exitCode: number;
1712
+ // True only for a zero-exit bodyless operation that printed nothing: the
1713
+ // pinned silent acknowledgement burn. A body is never synthesized for it.
1714
+ silent?: true;
1810
1715
  }
1811
1716
 
1812
1717
  function decodeNativeAdmittedResultManifest(value: unknown): NativeReviewAdmittedResultManifest {
1813
- if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native capture-result manifest must be an object");
1718
+ // The admission answer routes through the exact-identity forward decoder
1719
+ // (decoder-freshness discipline): the complete live envelope — identity
1720
+ // constants, binding fields, and the exactly-one-locator rule — is
1721
+ // validated before anything is handed to FINALIZE, and an unknown field
1722
+ // grown by gentle-ai main is rejected instead of silently dropped.
1723
+ const artifact = decodeReviewResultArtifactV2(value);
1724
+ return Object.freeze({
1725
+ schema: artifact.schema,
1726
+ subjectHash: artifact.subjectHash,
1727
+ admissionDecision: artifact.admissionDecision,
1728
+ lens: artifact.lens,
1729
+ ...(artifact.path === undefined ? {} : { path: artifact.path }),
1730
+ ...(artifact.reference === undefined ? {} : { reference: artifact.reference }),
1731
+ });
1732
+ }
1733
+
1734
+ // gentle-pi#311 P4-roles: the strict acknowledgement for one executed
1735
+ // provider role vector. The immutable verdict bytes live in the Go-owned
1736
+ // compact store slot; this envelope only names the binding the capture
1737
+ // proved, so anything beyond that exact shape is refused.
1738
+ function decodeNativeProviderRoleCaptureArtifact(value: unknown): NativeReviewProviderRoleCaptureArtifact {
1739
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native provider role capture artifact must be an object");
1814
1740
  const body = value as Record<string, unknown>;
1741
+ const allowed = new Set(["schema", "lineage_id", "target_identity", "role", "captured"]);
1742
+ for (const key of Object.keys(body)) if (!allowed.has(key)) throw new TypeError(`native provider role capture artifact carries unexpected key ${key}`);
1815
1743
  const text = (key: string): string => {
1816
1744
  const found = body[key];
1817
- 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`);
1745
+ 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`);
1818
1746
  return found;
1819
1747
  };
1820
- const schema = text("schema");
1821
- 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}`);
1822
- const admission = text("admission_decision");
1823
- if (admission !== "completed") throw new TypeError(`native capture-result manifest admission_decision must be completed, received ${admission}`);
1824
- // Exactly one locator: a provider-owned path OR an opaque reference. Both or
1825
- // neither means the manifest cannot be handed to FINALIZE.
1826
- const hasPath = body.path !== undefined, hasReference = body.reference !== undefined;
1827
- if (hasPath === hasReference) throw new TypeError("native capture-result manifest must carry exactly one of path or reference");
1748
+ 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}`);
1749
+ const role = text("role");
1750
+ if (role !== "refuter" && role !== "targeted-validator") throw new TypeError(`native provider role capture artifact role must be refuter or targeted-validator, received ${role}`);
1751
+ if (body.captured !== true) throw new TypeError("native provider role capture artifact must report captured: true");
1828
1752
  return Object.freeze({
1829
- schema,
1830
- subjectHash: text("subject_hash"),
1831
- admissionDecision: admission,
1832
- ...(body.lens === undefined ? {} : { lens: text("lens") }),
1833
- ...(hasPath ? { path: text("path") } : { reference: text("reference") }),
1753
+ schema: NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_SCHEMA,
1754
+ lineageId: text("lineage_id"),
1755
+ targetIdentity: text("target_identity"),
1756
+ role,
1757
+ captured: true,
1834
1758
  });
1835
1759
  }
1836
1760
 
1837
1761
  export class NativeReviewCliV216 implements NativeReviewCli {
1838
- private readonly legacy: NativeReviewCliV214;
1762
+ private readonly plain: NativeReviewPlainCli;
1839
1763
  private readonly adapter: ExecFileAdapter;
1840
1764
  private readonly executable: string | (() => string);
1841
1765
  private readonly timeoutMs: number;
1842
1766
  private readonly maxBufferBytes: number;
1843
1767
  private readonly cleanupDirectory: (directory: string) => Promise<void>;
1844
- private readonly executableDigest: NativeExecutableDigestResolver;
1845
1768
  constructor(
1846
1769
  adapter: ExecFileAdapter,
1847
1770
  executable: string | (() => string) = resolveGentleAiBinary,
1848
1771
  timeoutMs = 30_000,
1849
- maxBufferBytes = 1024 * 1024,
1772
+ maxBufferBytes = resolveNativeReviewMaxBufferBytes(),
1850
1773
  cleanupDirectory: (directory: string) => Promise<void> = (directory) => rm(directory, { recursive: true, force: true }),
1851
- executableDigest: NativeExecutableDigestResolver = defaultExecutableDigest,
1852
1774
  ) {
1853
1775
  if (typeof executable === "string" && (!isAbsolute(executable) || executable === "gentle-ai")) throw new TypeError("Native review requires an absolute package-local executable");
1854
1776
  this.adapter = adapter;
@@ -1856,8 +1778,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1856
1778
  this.timeoutMs = timeoutMs;
1857
1779
  this.maxBufferBytes = maxBufferBytes;
1858
1780
  this.cleanupDirectory = cleanupDirectory;
1859
- this.executableDigest = executableDigest;
1860
- this.legacy = new NativeReviewCliV214(adapter, executable, timeoutMs, maxBufferBytes, cleanupDirectory);
1781
+ this.plain = new NativeReviewPlainCli(adapter, executable, timeoutMs, maxBufferBytes, cleanupDirectory);
1861
1782
  }
1862
1783
 
1863
1784
  private executablePath(operation: NativeReviewOperation, mutating: boolean): string {
@@ -1871,15 +1792,6 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1871
1792
  }
1872
1793
  }
1873
1794
 
1874
- private verifiedExecutable(operation: NativeReviewOperation, mutating: boolean): { path: string; digest: string } {
1875
- const path = this.executablePath(operation, mutating);
1876
- try {
1877
- return { path, digest: this.executableDigest(path) };
1878
- } catch {
1879
- throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, operation, mutating, "package-local native executable identity could not be verified", undefined, false);
1880
- }
1881
- }
1882
-
1883
1795
  private async invoke(
1884
1796
  operation: NativeReviewOperation,
1885
1797
  cwd: string,
@@ -1888,6 +1800,14 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1888
1800
  signal: AbortSignal | undefined,
1889
1801
  path: string,
1890
1802
  toleratedStderr: readonly string[] = [],
1803
+ // A successful acknowledgement burns its authority and, on every
1804
+ // published release up to v2.5.0-rc.3, prints nothing: there is no
1805
+ // receipt left to describe. Only that shape opts out of the body, and
1806
+ // only for a zero exit. When such an operation does print, the bytes
1807
+ // are handed back as a body for the caller to decode against the one
1808
+ // identity it accepts (gentle-ai #3947); a non-zero exit still has to
1809
+ // produce its typed failure envelope like every other operation.
1810
+ expectsBody = true,
1891
1811
  ): Promise<NegotiatedExecution> {
1892
1812
  let result: ExecFileResult;
1893
1813
  try {
@@ -1896,10 +1816,14 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1896
1816
  if (error instanceof Error && error.name === "AbortError") throw nativeError(NATIVE_REVIEW_ERROR_CODE.CANCELLED, operation, mutating, "native process was cancelled");
1897
1817
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNAVAILABLE, operation, mutating, "native process could not start");
1898
1818
  }
1819
+ if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result, true, undefined, this.maxBufferBytes);
1899
1820
  if (result.timedOut) throw nativeError(NATIVE_REVIEW_ERROR_CODE.TIMEOUT, operation, mutating, "native process timed out", result);
1900
- if (result.outputLimitExceeded) throw nativeError(NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT, operation, mutating, "native process output exceeded limit", result);
1901
1821
  if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result);
1902
1822
  const diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.NON_ZERO, result);
1823
+ if (!expectsBody && result.exitCode === 0 && result.stdout.trim().length === 0) {
1824
+ 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);
1825
+ return { body: {}, exitCode: result.exitCode, silent: true };
1826
+ }
1903
1827
  const body = parseJson(result.stdout, operation, mutating, diagnostics);
1904
1828
  if (result.exitCode !== 0) {
1905
1829
  try {
@@ -1909,49 +1833,11 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1909
1833
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native negotiated operation failed without a valid failure envelope", result);
1910
1834
  }
1911
1835
  }
1912
- 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);
1836
+ const forecastNarrationTolerated = operation === NATIVE_REVIEW_OPERATION.STATUS && stderrIsForecastNarration(result.stderr);
1837
+ 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);
1913
1838
  return { body, exitCode: result.exitCode };
1914
1839
  }
1915
1840
 
1916
- async capabilities(request: NativeCapabilitiesRequest = {}): Promise<ReviewCapabilitiesV2> {
1917
- const executable = this.verifiedExecutable(NATIVE_REVIEW_OPERATION.VERSION, false);
1918
- const cached = nativeCapabilitiesByDigest.get(executable.digest);
1919
- if (cached !== undefined) return cached;
1920
- const negotiation = (async () => {
1921
- const execution = await this.invoke(
1922
- NATIVE_REVIEW_OPERATION.VERSION,
1923
- request.cwd ?? dirname(executable.path),
1924
- ["review", "capabilities", "--contract", REVIEW_INTEGRATION_CONTRACT],
1925
- false,
1926
- request.signal,
1927
- executable.path,
1928
- );
1929
- // Two distinct half-upgraded-install failures (Design Decision #7,
1930
- // migrate-review-integration-v2): a decode failure means the installed
1931
- // runtime cannot even answer this shape (an older `.gentle-ai/` payload
1932
- // that still speaks `unsupported_contract` for v2); a successful decode
1933
- // whose package.version disagrees with the pin names both versions.
1934
- let capabilities: ReviewCapabilitiesV2;
1935
- try {
1936
- capabilities = decodeReviewCapabilitiesV2(execution.body, executable.digest);
1937
- } catch (error) {
1938
- if (error instanceof NativeReviewCliError) throw error;
1939
- 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`);
1940
- }
1941
- if (capabilities.packageVersion !== GENTLE_AI_VERSION) {
1942
- 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}`);
1943
- }
1944
- return capabilities;
1945
- })();
1946
- nativeCapabilitiesByDigest.set(executable.digest, negotiation);
1947
- try {
1948
- return await negotiation;
1949
- } catch (error) {
1950
- nativeCapabilitiesByDigest.delete(executable.digest);
1951
- throw error;
1952
- }
1953
- }
1954
-
1955
1841
  private async negotiated(
1956
1842
  operation: NativeReviewOperation,
1957
1843
  cwd: string,
@@ -1960,13 +1846,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1960
1846
  signal?: AbortSignal,
1961
1847
  toleratedStderr: readonly string[] = [],
1962
1848
  ): Promise<NegotiatedExecution> {
1963
- const executable = this.verifiedExecutable(operation, mutating);
1964
- await this.capabilities({ cwd, ...(signal === undefined ? {} : { signal }) });
1965
- const afterNegotiation = this.verifiedExecutable(operation, mutating);
1966
- if (afterNegotiation.path !== executable.path || afterNegotiation.digest !== executable.digest) {
1967
- throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, operation, mutating, "native executable was replaced after capability negotiation", undefined, false);
1968
- }
1969
- return this.invoke(operation, cwd, arguments_, mutating, signal, executable.path, toleratedStderr);
1849
+ return this.invoke(operation, cwd, arguments_, mutating, signal, this.executablePath(operation, mutating), toleratedStderr);
1970
1850
  }
1971
1851
 
1972
1852
  async start(request: NativeStartRequest): Promise<NativeStartResult> {
@@ -1974,36 +1854,54 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1974
1854
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
1975
1855
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
1976
1856
  if (request.targetIdentity !== undefined && !/^sha256:[0-9a-f]{64}$/.test(request.targetIdentity)) throw new TypeError("Native START targetIdentity must be a canonical sha256 identity");
1977
- // The controller supplies the target it already projected from the
1978
- // authority workspace after proving its immutable actor view is identical.
1979
- // Direct adapter callers may omit it and retain the same-root projection.
1857
+ // STATUS owns the candidate binding and renders the only executable START
1858
+ // vector. Callers may supply a previously observed identity only to detect
1859
+ // drift; Pi never rebuilds that vector from request fields.
1980
1860
  const projection = request.projection ?? "workspace";
1981
- const targetIdentity = request.targetIdentity ?? (await this.targetStatus({
1861
+ const selection = nativeUntrackedSelection(request);
1862
+ const status = await this.targetStatus({
1982
1863
  cwd: request.cwd,
1983
1864
  projection,
1984
- ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
1865
+ ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef, committedOnly: true }),
1985
1866
  ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
1867
+ ...selection,
1868
+ ...(request.intendedUntrackedSelection === undefined ? {} : { intendedUntrackedSelection: request.intendedUntrackedSelection }),
1869
+ agent: "pi",
1986
1870
  ...(request.signal === undefined ? {} : { signal: request.signal }),
1987
- })).targetIdentity;
1988
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
1989
- "review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
1990
- "--target", targetIdentity, "--projection", projection,
1991
- ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
1992
- ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1993
- ...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
1994
- ...(request.focus === undefined ? [] : ["--focus", request.focus]),
1995
- "--consent", "relay",
1996
- ], true, request.signal);
1997
- // A negotiated v2 START may answer `consent/v2` (action:
1871
+ });
1872
+ const transition = status.nextTransition?.kind === "execute" && status.nextTransition.execute?.operation === "review.start"
1873
+ ? status.nextTransition.execute
1874
+ : undefined;
1875
+ if (transition === undefined) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.START, false, "native STATUS did not offer an executable review.start transition", undefined, false);
1876
+ if (status.projection.projection !== projection || transition.binding.targetIdentity !== status.targetIdentity || (request.targetIdentity !== undefined && request.targetIdentity !== status.targetIdentity)) {
1877
+ throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, false, "native START transition target binding mismatch", undefined, false);
1878
+ }
1879
+ if (request.lineageId !== undefined && transition.binding.lineageId !== undefined && transition.binding.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, false, "native START transition lineage binding mismatch", undefined, false);
1880
+ const transitionTokens = transition.arguments.map((argument) => {
1881
+ if (argument.token === undefined) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE, NATIVE_REVIEW_OPERATION.START, false, "native START transition omitted an ordered argument token", undefined, false);
1882
+ return argument.token;
1883
+ });
1884
+ const targetIdentity = status.targetIdentity;
1885
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, ["review", "start", ...transitionTokens], true, request.signal);
1886
+ // A negotiated v2 START may answer a consent question (action:
1998
1887
  // "consent_required") instead of `start/v3` when the provider needs an
1999
1888
  // explicit answer it cannot infer. Discriminate before decode and surface
2000
- // the complete envelope; only the caller can map a human answer.
1889
+ // the complete envelope; only the caller can map a human answer. The
1890
+ // body's own schema string selects the identity-exact decoder: the
1891
+ // pinned 2.2.x line emits consent/v2, gentle-ai >= 2.3.0 (capabilities
1892
+ // v2.1+) emits consent/v3, and any other identity fails closed inside
1893
+ // the v3 decoder's exact identity gate.
2001
1894
  if (execution.body.action === "consent_required") {
2002
- const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body));
1895
+ const consent = decode(NATIVE_REVIEW_OPERATION.START, true, () => {
1896
+ if (execution.body.schema === "gentle-ai.review-integration.consent/v2") return decodeReviewConsentV2(execution.body);
1897
+ const decoded = decodeReviewConsentV3(execution.body, "pi");
1898
+ validatePiConsentChoiceAgentBindings(decoded);
1899
+ return decoded;
1900
+ });
2003
1901
  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");
2004
1902
  throw new NativeReviewConsentRequiredError(consent);
2005
1903
  }
2006
- const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
1904
+ const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartResponse(execution.body));
2007
1905
  if (request.lineageId !== undefined && result.lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start lineage mismatch");
2008
1906
  const resultTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2009
1907
  if (resultTarget !== undefined && resultTarget !== targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start target mismatch");
@@ -2018,6 +1916,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2018
1916
  action: result.action as NativeStartAction,
2019
1917
  lensesRequired: result.lensesRequired,
2020
1918
  riskReasons: result.riskReasons.map((reason) => ({ ...reason })),
1919
+ ...("nextTransition" in result && result.nextTransition !== undefined ? { nextTransition: result.nextTransition } : {}),
2021
1920
  // Derived, not received. `risk_reasons` is a required start/v2 field
2022
1921
  // already recomputed against the authoritative frozen snapshot, so
2023
1922
  // these phrases describe the same candidate the lenses will review.
@@ -2036,16 +1935,15 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2036
1935
  }
2037
1936
 
2038
1937
  async answerConsent(request: NativeReviewConsentAnswerRequest): Promise<NativeReviewConsentAnswerResult> {
2039
- const arguments_ = consentInvocationArguments(request);
2040
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, arguments_, true, request.signal);
1938
+ const invocation = consentInvocationArguments(request);
1939
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, invocation.arguments_, true, request.signal);
2041
1940
  if (request.answer === NATIVE_REVIEW_CONSENT_ANSWER.DECLINED) {
2042
1941
  return decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeDeclinedConsentStart(execution.body, request));
2043
1942
  }
2044
- const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
1943
+ const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartResponse(execution.body));
2045
1944
  const answeredTarget = result.targetIdentity ?? result.repositoryContext?.targetIdentity;
2046
1945
  if (answeredTarget !== request.consent.targetIdentity) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer target mismatch");
2047
- const lineageId = exactConsentOption(arguments_, "--lineage");
2048
- if (result.lineageId !== lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native consent answer lineage mismatch");
1946
+ 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");
2049
1947
  return { kind: "started", start: {
2050
1948
  lineageId: result.lineageId,
2051
1949
  state: result.state as NativeStartResult["state"],
@@ -2057,6 +1955,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2057
1955
  action: result.action as NativeStartAction,
2058
1956
  lensesRequired: result.lensesRequired,
2059
1957
  riskReasons: result.riskReasons.map((reason) => ({ ...reason })),
1958
+ ...("nextTransition" in result && result.nextTransition !== undefined ? { nextTransition: result.nextTransition } : {}),
2060
1959
  ...(() => {
2061
1960
  const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons);
2062
1961
  return evidence.length === 0 ? {} : { riskEvidence: evidence };
@@ -2065,119 +1964,23 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2065
1964
  } };
2066
1965
  }
2067
1966
 
2068
- private async stageDocument(directory: string, name: string, document: unknown): Promise<string> {
2069
- const path = join(directory, `${name}.json`);
2070
- await writeFile(path, JSON.stringify(document), { encoding: "utf8", mode: 0o600 });
2071
- await chmod(path, 0o600);
2072
- return path;
2073
- }
2074
-
2075
- private async stageEvidence(directory: string, evidence: string): Promise<string> {
2076
- const path = join(directory, "evidence.txt");
2077
- await writeFile(path, evidence, { encoding: "utf8", mode: 0o600 });
2078
- await chmod(path, 0o600);
2079
- return path;
2080
- }
2081
-
2082
- async finalize(request: NativeFinalizeRequest): Promise<NativeFinalizeResult> {
2083
- if (request.evidenceDocument !== undefined && request.evidenceDocument.length === 0) throw new TypeError("Native FINALIZE evidence must contain at least one byte");
2084
- const needsStaging = request.lensResults !== undefined || request.refuterDocument !== undefined || request.validationDocument !== undefined || request.evidenceDocument !== undefined;
2085
- const directory = needsStaging ? await mkdtemp(join(tmpdir(), "gentle-ai-finalize-")) : undefined;
2086
- try {
2087
- if (directory !== undefined) await chmod(directory, 0o700);
2088
- const resultFiles = directory !== undefined && request.lensResults !== undefined ? await Promise.all(request.lensResults.map((entry, index) => this.stageDocument(directory, `result-${index}`, entry.document))) : request.resultFiles ?? [];
2089
- const refuterFile = directory !== undefined && request.refuterDocument !== undefined ? await this.stageDocument(directory, "refuter", request.refuterDocument) : request.refuterFile;
2090
- const validationFile = directory !== undefined && request.validationDocument !== undefined ? await this.stageDocument(directory, "validation", request.validationDocument) : request.validationFile;
2091
- const evidenceFile = directory !== undefined && request.evidenceDocument !== undefined ? await this.stageEvidence(directory, request.evidenceDocument) : request.evidenceFile;
2092
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.FINALIZE, request.cwd, [
2093
- "review", "finalize", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
2094
- ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
2095
- // `--result` is RETIRED. A reviewer result supplied that way carries
2096
- // no provider-owned admission, so it cannot prove the lens inspected
2097
- // the frozen candidate. Results reach authority through
2098
- // `review capture-result`, and FINALIZE either discovers them
2099
- // (`--captured-results`) or is handed each manifest in lens order.
2100
- ...(request.capturedResults === true ? ["--captured-results=true"] : []),
2101
- ...(request.resultArtifactFiles ?? []).flatMap((path) => ["--result-artifact-file", path]),
2102
- ...(refuterFile === undefined ? [] : ["--refuter", refuterFile]),
2103
- ...(request.correctionLines === undefined ? [] : ["--correction-lines", String(request.correctionLines)]),
2104
- ...(validationFile === undefined ? [] : ["--validation", validationFile]),
2105
- ...(evidenceFile === undefined ? [] : ["--evidence", evidenceFile]),
2106
- ...(request.failed === true ? ["--failed"] : []),
2107
- ], true, request.signal);
2108
- const envelope = decode(NATIVE_REVIEW_OPERATION.FINALIZE, true, () => decodeReviewOperationV2(execution.body));
2109
- if (envelope.operation !== "review.finalize") throw new Error("wrong finalize operation envelope");
2110
- const body = envelope.result;
2111
- const lineageId = requiredString(body.lineage_id);
2112
- if (request.lineageId !== undefined && lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.FINALIZE, true, "native finalize lineage mismatch");
2113
- return {
2114
- lineageId,
2115
- state: requiredString(body.state),
2116
- action: requiredString(body.action),
2117
- storeRevision: requiredString(body.store_revision),
2118
- // Present only when state is correction_required (validated by
2119
- // decodeReviewOperationV2); carried through as the raw validated
2120
- // shape rather than re-decoded, since the typed decoder for this
2121
- // nested object is private to lib/review-integration-v2.ts.
2122
- ...(body.validation_request === undefined ? {} : { validationRequest: body.validation_request as Readonly<Record<string, unknown>> }),
2123
- ...(body.escalation === undefined ? {} : { escalation: requiredString(body.escalation) }),
2124
- };
2125
- } finally {
2126
- if (directory !== undefined) await this.cleanupDirectory(directory).catch(() => undefined);
2127
- }
2128
- }
2129
-
2130
- async validate(request: NativeValidateRequest): Promise<NativeValidateResult> {
2131
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.VALIDATE, request.cwd, [
2132
- "review", "validate", "--contract", REVIEW_INTEGRATION_CONTRACT, "--gate", request.gate, "--cwd", request.cwd,
2133
- ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
2134
- ...(request.flags ?? []),
2135
- ], false, request.signal);
2136
- const envelope = decode(NATIVE_REVIEW_OPERATION.VALIDATE, false, () => decodeReviewOperationV2(execution.body));
2137
- if (envelope.operation !== "review.validate") throw new Error("wrong validate operation envelope");
2138
- const body = envelope.result;
2139
- const gateContext = decodeGateContext(body.context);
2140
- return {
2141
- allowed: booleanValue(body.allowed),
2142
- result: enumString(body.result, NATIVE_GATE_RESULT) as NativeValidateResult["result"],
2143
- action: requiredString(body.action),
2144
- reason: requiredString(body.reason),
2145
- gateContext,
2146
- ...(body.delivery === undefined ? {} : { delivery: enumString(body.delivery, ["disabled/unmanaged"]) as NativeValidateResult["delivery"] }),
2147
- };
2148
- }
2149
-
2150
- async bindSdd(request: NativeBindSddRequest): Promise<NativeBindSddResult> {
2151
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.BIND_SDD, request.cwd, [
2152
- "review", "bind-sdd", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
2153
- "--change", request.change, "--lineage", request.lineage,
2154
- `--expected-binding-revision=${request.expectedBindingRevision}`,
2155
- ], true, request.signal);
2156
- const envelope = decode(NATIVE_REVIEW_OPERATION.BIND_SDD, true, () => decodeReviewOperationV2(execution.body));
2157
- if (envelope.operation !== "review.bind_sdd") throw new Error("wrong bind-sdd operation envelope");
2158
- const body = envelope.result;
2159
- const gateContext = decodeGateContext(body.gate_context);
2160
- const lineage = requiredString(body.lineage);
2161
- const change = requiredString(body.change);
2162
- if (lineage !== request.lineage || change !== request.change || gateContext.raw.gate !== "post-apply") throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.BIND_SDD, true, "native binding identity mismatch");
2163
- return {
2164
- revision: requiredString(body.revision),
2165
- change,
2166
- lineage,
2167
- authorityRevision: requiredString(body.authority_revision),
2168
- receiptHash: requiredString(body.receipt_hash),
2169
- gateContext,
2170
- };
2171
- }
2172
-
2173
1967
  async targetStatus(request: NativeTargetStatusRequest): Promise<ReviewStatusV3> {
2174
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.STATUS, request.cwd, [
1968
+ if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native STATUS baseRef must be a non-empty, trimmed, NUL-free string");
1969
+ if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native STATUS baseRef requires explicit committedOnly acknowledgement");
1970
+ if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native STATUS committedOnly requires an explicit baseRef");
1971
+ const selection = nativeUntrackedSelection(request);
1972
+ const submitted = request.intendedUntrackedSelection;
1973
+ if (submitted !== undefined && submitted.argumentTokens.reduce((count, token) => count + token.split("{{value}}").length - 1, 0) !== 1) throw new TypeError("Native intended-untracked selection requires exactly one provider-issued {{value}} token");
1974
+ const statusArguments = submitted === undefined ? [
2175
1975
  "review", "status", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
2176
1976
  "--projection", request.projection ?? "workspace",
2177
- ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef]),
1977
+ ...nativeUntrackedSelectionArguments(selection),
1978
+ ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
2178
1979
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1980
+ ...(request.agent === undefined ? [] : ["--agent", request.agent]),
2179
1981
  "--next-transition",
2180
- ], false, request.signal);
1982
+ ] : ["review", "status", "--cwd", request.cwd, ...submitted.argumentTokens.map((token) => token.replaceAll("{{value}}", submitted.value))];
1983
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.STATUS, request.cwd, statusArguments, false, request.signal);
2181
1984
  assertSupportedNextTransitionOperation(execution.body);
2182
1985
  return decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeReviewStatusV3(execution.body));
2183
1986
  }
@@ -2217,7 +2020,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2217
2020
  // --contract, and the verification-evidence/v2 record returned DIRECTLY —
2218
2021
  // not wrapped in an operation/v2 envelope. Evidence is staged through the
2219
2022
  // same 0o600 tmpfile discipline FINALIZE uses.
2220
- async captureResult(request: NativeReviewCaptureResultRequest): Promise<NativeReviewAdmittedResultManifest> {
2023
+ async captureResult(request: NativeReviewCaptureResultRequest): Promise<NativeReviewCaptureResultOutcome> {
2221
2024
  if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_RESULT requires the provider-issued argument tokens");
2222
2025
  if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_RESULT argument tokens must all be non-empty strings");
2223
2026
  if (request.resultDocument.length === 0) throw new TypeError("Native CAPTURE_RESULT result document must contain at least one byte");
@@ -2229,87 +2032,179 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2229
2032
  const resultFile = join(directory, "result.json");
2230
2033
  await writeFile(resultFile, request.resultDocument, { encoding: "utf8", mode: 0o600 });
2231
2034
  await chmod(resultFile, 0o600);
2232
- const executable = this.verifiedExecutable(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true);
2035
+ const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true);
2233
2036
  const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, request.cwd ?? process.cwd(), [
2234
2037
  "review", "capture-result",
2235
2038
  ...request.argumentTokens,
2236
2039
  ...(carriesContext || request.cwd === undefined ? [] : ["--cwd", request.cwd]),
2237
2040
  "--input", resultFile,
2238
- ], true, request.signal, executable.path);
2239
- return decode(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true, () => decodeNativeAdmittedResultManifest(execution.body));
2041
+ ], true, request.signal, executable);
2042
+ return decode(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true, () => {
2043
+ const body = object(execution.body);
2044
+ if (body.schema === "gentle-ai.review-last-event-closure/v1") {
2045
+ const closure = decodeReviewLastEventClosureV1(body);
2046
+ if (closure.operation !== "review/capture-result") throw new TypeError("capture-result returned a closure for another operation");
2047
+ return closure;
2048
+ }
2049
+ return decodeNativeAdmittedResultManifest(body);
2050
+ });
2240
2051
  } finally {
2241
2052
  await this.cleanupDirectory(directory).catch(() => undefined);
2242
2053
  }
2243
2054
  }
2244
2055
 
2245
- async captureEvidence(request: NativeReviewCaptureEvidenceRequest): Promise<NativeReviewVerificationEvidenceV2> {
2246
- 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");
2247
- if (request.evidenceDocument.length === 0) throw new TypeError("Native CAPTURE_EVIDENCE evidence must contain at least one byte");
2248
- const directory = await mkdtemp(join(tmpdir(), "gentle-ai-capture-evidence-"));
2249
- try {
2250
- await chmod(directory, 0o700);
2251
- const evidenceFile = await this.stageEvidence(directory, request.evidenceDocument);
2252
- const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.CAPTURE_EVIDENCE, request.cwd, [
2253
- "review", "capture-evidence", "--cwd", request.cwd,
2254
- "--lineage", request.lineageId,
2255
- "--target", request.targetIdentity,
2256
- "--expected-revision", request.expectedRevision,
2257
- "--outcome", request.outcome,
2258
- "--input", evidenceFile,
2259
- ], true, request.signal);
2260
- return decode(NATIVE_REVIEW_OPERATION.CAPTURE_EVIDENCE, true, () => decodeNativeReviewVerificationEvidence(execution.body));
2261
- } finally {
2262
- await this.cleanupDirectory(directory).catch(() => undefined);
2263
- }
2056
+ // Relays the provider-issued acknowledgement exactly as rendered. A success
2057
+ // burns the authority and its artifacts; a refusal still arrives as the
2058
+ // ordinary typed failure envelope. Two success shapes exist and both are
2059
+ // the same burn: every published release up to v2.5.0-rc.3 prints nothing
2060
+ // (resolved as `undefined`, byte-for-byte the pinned behaviour), and
2061
+ // gentle-ai #3947 prints one `gentle-ai.review-acknowledged/v1` envelope
2062
+ // naming the burned lineage, target, and consumed revision. Any other
2063
+ // output on a zero exit is schema-incompatible with the mutation outcome
2064
+ // unknown, exactly like a malformed terminal closure: the caller
2065
+ // reconciles through STATUS and never infers the burn from prose.
2066
+ async acknowledgeApproved(request: NativeReviewAcknowledgeApprovedRequest): Promise<NativeReviewAcknowledgeApprovedOutcome> {
2067
+ if (request.argumentTokens.length === 0) throw new TypeError("Native ACKNOWLEDGE_APPROVED requires the provider-issued argument tokens");
2068
+ if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native ACKNOWLEDGE_APPROVED argument tokens must all be non-empty strings");
2069
+ if (request.argumentTokens.some((token) => token.includes("{{value}}"))) throw new TypeError("Native ACKNOWLEDGE_APPROVED argument tokens carry no caller-substituted value");
2070
+ const executable = this.executablePath(NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, true);
2071
+ const execution = await this.invoke(NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, request.cwd, [
2072
+ "review", "acknowledge-approved", ...request.argumentTokens,
2073
+ ], true, request.signal, executable, [], false);
2074
+ if (execution.silent) return undefined;
2075
+ return decode(NATIVE_REVIEW_OPERATION.ACKNOWLEDGE_APPROVED, true, () => decodeReviewAcknowledgedV1(execution.body, request.binding ?? {}));
2264
2076
  }
2265
2077
 
2266
- reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult> {
2267
- return this.legacy.reviewStatus(request);
2078
+ async captureCorrectionPlan(request: NativeReviewCorrectionPlanCaptureRequest): Promise<ReviewLastEventClosureV1> {
2079
+ if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_CORRECTION_PLAN requires the provider-issued argument tokens");
2080
+ if (request.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) throw new TypeError("Native CAPTURE_CORRECTION_PLAN argument tokens must all be non-empty strings");
2081
+ if (!Number.isSafeInteger(request.correctionLines) || request.correctionLines < 1 || request.correctionLines > 200) throw new TypeError("Native CAPTURE_CORRECTION_PLAN correction lines must be a positive integer up to 200");
2082
+ const valueIndex = request.argumentTokens.findIndex((token) => token.includes("{{value}}"));
2083
+ if (valueIndex < 0 || request.argumentTokens.filter((token) => token.includes("{{value}}")).length !== 1) throw new TypeError("Native CAPTURE_CORRECTION_PLAN requires exactly one provider-issued {{value}} token");
2084
+ const argumentTokens = request.argumentTokens.map((token, index) => index === valueIndex ? token.replaceAll("{{value}}", String(request.correctionLines)) : token);
2085
+ const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, true);
2086
+ const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, request.cwd, [
2087
+ "review", "capture-correction-plan", ...argumentTokens,
2088
+ ], true, request.signal, executable);
2089
+ return decode(NATIVE_REVIEW_OPERATION.CAPTURE_CORRECTION_PLAN, true, () => {
2090
+ const closure = decodeReviewLastEventClosureV1(execution.body);
2091
+ if (closure.operation !== "review.capture-correction-plan") throw new TypeError("capture-correction-plan returned a closure for another operation");
2092
+ return closure;
2093
+ });
2268
2094
  }
2269
2095
 
2270
- sddStatus(request: NativeSddStatusRequest): Promise<NativeSddStatusResult> {
2271
- return this.legacy.sddStatus(request);
2096
+ // gentle-pi#311 P4-roles: executes one provider-rendered self-contained
2097
+ // role capture vector exactly as rendered — one CLI invocation, verbatim
2098
+ // tokens in provider order, in the foreground. Go materializes the role
2099
+ // prompt, spawns its own locked-down pi subprocess, and admits the raw
2100
+ // verdict; Pi never adds, removes, or reorders a single token (not even
2101
+ // --cwd: the vector's --repository-context is authoritative and mutually
2102
+ // exclusive with a path). The central adapter injects the relay handshake
2103
+ // environment on this spawn like on every other gentle-ai invocation.
2104
+ async captureProviderRole(request: NativeReviewProviderRoleCaptureRequest): Promise<NativeReviewProviderRoleCaptureOutcome> {
2105
+ if (!isNativeReviewProviderRoleCaptureOperation(request.captureOperation)) throw new TypeError(`Native CAPTURE_PROVIDER_ROLE supports only review.capture-refuter and review.capture-validation, received ${JSON.stringify(request.captureOperation)}`);
2106
+ const verb = request.captureOperation.slice("review.".length);
2107
+ const expectedClosureOperation = NATIVE_REVIEW_PROVIDER_ROLE_CAPTURE_CLOSURE_OPERATION[request.captureOperation];
2108
+ if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_PROVIDER_ROLE requires the provider-rendered argument tokens");
2109
+ 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");
2110
+ const executable = this.executablePath(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, true);
2111
+ const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, request.cwd, [
2112
+ "review", verb,
2113
+ ...request.argumentTokens,
2114
+ ], true, request.signal, executable);
2115
+ return decode(NATIVE_REVIEW_OPERATION.CAPTURE_PROVIDER_ROLE, true, () => {
2116
+ const body = object(execution.body);
2117
+ if (body.schema === "gentle-ai.review-last-event-closure/v1") {
2118
+ const closure = decodeReviewLastEventClosureV1(body);
2119
+ if (closure.operation !== expectedClosureOperation) throw new TypeError("provider role capture returned a closure for another operation");
2120
+ return closure;
2121
+ }
2122
+ return decodeNativeProviderRoleCaptureArtifact(body);
2123
+ });
2272
2124
  }
2273
2125
 
2274
- // Same plain-CLI delegation as reviewStatus/sddStatus above: `review mode`
2275
- // is dark until the negotiated version reports `mode: true` and stays
2276
- // outside the negotiated integration-v1 contract.
2277
- reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
2278
- return this.legacy.reviewMode(request);
2126
+ // gentle-pi#311 P5: executes one provider-rendered `review.finalize`
2127
+ // execute transition exactly as rendered. The tokens come verbatim from
2128
+ // the negotiated next_transition (e.g. `--lineage=<id>
2129
+ // --captured-results=true`); Pi assembles no reviewer, refuter, or
2130
+ // validator documents for this lane — the provider discovers its own
2131
+ // admitted role and lens slots.
2132
+
2133
+ // Same misbinding class as capture-evidence (live smoke, 2026-08-16): the
2134
+ // correction PLAN and TARGETED VALIDATION collect slots render `finalize`
2135
+ // submission descriptors whose tokens are self-contained (--contract,
2136
+ // --lineage, --expected-revision, --target, --request-hash,
2137
+ // --repository-context) plus exactly one {{value}} slot. Executing anything
2138
+ // other than those rendered tokens fails the live emitter's committed-
2139
+ // intent reconciliation, so the tokens pass through verbatim with only the
2140
+ // {{value}} substitution: a literal for correction_lines, a staged 0o600
2141
+ // artifact path for a validation document.
2142
+
2143
+
2144
+
2145
+ // Field defect (fambig, 2026-08-16): at every evidence-pending sub-state
2146
+ // the collect slot renders the identity native demands — for a correction
2147
+ // that is the fix-diff `--target`, not the live workspace snapshot — so the
2148
+ // slot's rendered submission tokens execute verbatim, with only the
2149
+ // {{outcome}} and {{input}} slots substituted. Same verbatim-token
2150
+ // discipline as captureResult; --repository-context is authoritative and
2151
+ // mutually exclusive with a path.
2152
+
2153
+ async reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult> {
2154
+ const execution = await this.invoke(
2155
+ NATIVE_REVIEW_OPERATION.STATUS,
2156
+ request.cwd,
2157
+ ["review", "status", "--cwd", request.cwd],
2158
+ false,
2159
+ request.signal,
2160
+ this.executablePath(NATIVE_REVIEW_OPERATION.STATUS, false),
2161
+ );
2162
+ const status = decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeNativeReviewStatus(execution.body));
2163
+ if (!await repositoriesMatch(request.cwd, status.repository)) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.STATUS, false, "native review status repository mismatch");
2164
+ return status;
2165
+ }
2166
+
2167
+ async reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
2168
+ const cwd = await canonicalNativeReviewCwd(request.cwd);
2169
+ const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
2170
+ const execution = await this.invoke(
2171
+ NATIVE_REVIEW_OPERATION.MODE,
2172
+ cwd,
2173
+ ["review", "mode", request.operation, "--cwd", cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
2174
+ mutating,
2175
+ request.signal,
2176
+ this.executablePath(NATIVE_REVIEW_OPERATION.MODE, mutating),
2177
+ );
2178
+ return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => decodeNativeReviewMode(execution.body, request.operation));
2279
2179
  }
2280
2180
 
2281
2181
  // Recovery commands are version-gated plain CLI operations outside the
2282
- // negotiated integration-v1 contract, exactly like reviewStatus/sddStatus.
2182
+ // negotiated integration-v1 contract, exactly like reviewStatus.
2283
2183
  reclaim(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult> {
2284
- return this.legacy.reclaim(request);
2184
+ return this.plain.reclaim(request);
2285
2185
  }
2286
2186
 
2287
2187
  recover(request: NativeReviewRecoverRequest): Promise<NativeReviewRecoveryResult> {
2288
- return this.legacy.recover(request);
2188
+ return this.plain.recover(request);
2289
2189
  }
2290
2190
 
2291
2191
  abandon(request: NativeReviewAbandonRequest): Promise<NativeReviewRecoveryResult> {
2292
- return this.legacy.abandon(request);
2192
+ return this.plain.abandon(request);
2293
2193
  }
2294
2194
 
2295
2195
  quarantineLegacy(request: NativeReviewLegacyQuarantineRequest): Promise<NativeReviewRecoveryResult> {
2296
- return this.legacy.quarantineLegacy(request);
2196
+ return this.plain.quarantineLegacy(request);
2297
2197
  }
2298
2198
 
2299
2199
  reconcileAuthority(request: NativeReviewReconcileAuthorityRequest): Promise<NativeReviewRecoveryResult> {
2300
- return this.legacy.reconcileAuthority(request);
2200
+ return this.plain.reconcileAuthority(request);
2301
2201
  }
2302
2202
 
2303
2203
  repairLegacyAlias(request: NativeReviewLegacyAliasRepairRequest): Promise<NativeReviewRecoveryResult> {
2304
- return this.legacy.repairLegacyAlias(request);
2204
+ return this.plain.repairLegacyAlias(request);
2305
2205
  }
2306
2206
  }
2307
2207
 
2308
2208
  export function createNativeReviewCli(adapter?: ExecFileAdapter, executable: string | (() => string) = resolveGentleAiBinary): NativeReviewCli {
2309
- if (adapter !== undefined) return new NativeReviewCliV214(adapter, executable);
2310
- return new NativeReviewCliV216(createNodeExecFileAdapter(), executable);
2209
+ return new NativeReviewCliV216(adapter ?? createNodeExecFileAdapter(), executable);
2311
2210
  }
2312
-
2313
- // Response-schema fixtures remain v2.1.3 historical contracts; the production
2314
- // client above accepts only the v2.1.4 runtime release.
2315
- export { NativeReviewCliV214 as NativeReviewCliV213 };