gentle-pi 1.2.0 → 2.0.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 (107) hide show
  1. package/README.md +40 -9
  2. package/assets/agents/review-readability.md +0 -1
  3. package/assets/agents/review-reliability.md +0 -1
  4. package/assets/agents/review-resilience.md +0 -1
  5. package/assets/agents/review-risk.md +0 -1
  6. package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
  7. package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
  8. package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
  9. package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
  10. package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
  11. package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
  12. package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
  13. package/contracts/review-integration/v1/fixtures/operation.fixture.json +4 -0
  14. package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
  15. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
  16. package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
  17. package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +4 -0
  18. package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +4 -0
  19. package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +4 -0
  20. package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +4 -0
  21. package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
  22. package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
  23. package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
  24. package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
  25. package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
  26. package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
  27. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
  28. package/contracts/review-integration/v1/fixtures/status.fixture.json +19 -5
  29. package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
  30. package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
  31. package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
  32. package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
  33. package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
  34. package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
  35. package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
  36. package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
  37. package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
  38. package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
  39. package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
  40. package/contracts/review-integration/v1/schemas/operation.schema.json +204 -31
  41. package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
  42. package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
  43. package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
  44. package/contracts/review-integration/v1/schemas/start.schema.json +79 -3
  45. package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
  46. package/contracts/review-integration/v1/schemas/status.schema.json +37 -10
  47. package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
  48. package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
  49. package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
  50. package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
  51. package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
  52. package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
  53. package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
  54. package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
  55. package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
  56. package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
  57. package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
  58. package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
  59. package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
  60. package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
  61. package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
  62. package/docs/native-authority-architecture.md +18 -3
  63. package/docs/review-integration.md +175 -27
  64. package/extensions/gentle-ai.ts +414 -19
  65. package/lib/gentle-ai-binary.ts +1 -1
  66. package/lib/native-review-cli.ts +679 -57
  67. package/lib/review-candidate-view.ts +131 -1
  68. package/lib/review-compact-contract.ts +12 -3
  69. package/lib/review-consent-latch.ts +51 -0
  70. package/lib/review-correction-lifecycle.ts +170 -0
  71. package/lib/review-integration-v2.ts +1563 -0
  72. package/package.json +15 -11
  73. package/runtime/gentle-ai-binary.mjs +1 -1
  74. package/runtime/native-review-cli.mjs +663 -41
  75. package/runtime/review-integration-v2.mjs +1564 -0
  76. package/scripts/build-git-commit-transaction-runner.mjs +1 -1
  77. package/scripts/gentle-ai-installer.mjs +16 -11
  78. package/scripts/install-gentle-ai.mjs +7 -3
  79. package/scripts/test-packed-runner.mjs +2 -2
  80. package/scripts/verify-package-files.mjs +216 -64
  81. package/skills/release/SKILL.md +38 -11
  82. package/tests/devbinary/native-review-parity.devtest.ts +348 -0
  83. package/tests/gentle-ai-binary.test.ts +8 -1
  84. package/tests/gentle-ai-installer.test.ts +24 -21
  85. package/tests/native-binary-gate.test.ts +145 -0
  86. package/tests/native-review-capability-contract.test.ts +87 -0
  87. package/tests/native-review-cli.test.ts +44 -1
  88. package/tests/native-review-parity-runtime.test.ts +70 -23
  89. package/tests/native-review-parity.test.ts +673 -0
  90. package/tests/native-risk-evidence-parity.test.ts +91 -0
  91. package/tests/package-manifest.test.ts +72 -7
  92. package/tests/review-actor-tool-deny.test.ts +20 -5
  93. package/tests/review-candidate-view.test.ts +331 -1
  94. package/tests/review-compact-contract.test.ts +5 -0
  95. package/tests/review-consent-latch.test.ts +67 -0
  96. package/tests/review-controller-native-recovery.test.ts +180 -5
  97. package/tests/review-controller-native-routing.test.ts +385 -12
  98. package/tests/review-controller-workspace-root.test.ts +26 -6
  99. package/tests/review-correction-lifecycle.test.ts +174 -0
  100. package/tests/review-integration-v2.test.ts +539 -0
  101. package/tests/runtime-harness.mjs +187 -0
  102. package/tests/support/native-binary-gate.ts +86 -0
  103. package/tests/verify-package-files.test.ts +119 -0
  104. package/lib/review-integration-v1.ts +0 -795
  105. package/runtime/review-integration-v1.mjs +0 -796
  106. package/tests/native-review-integration-v1.test.ts +0 -102
  107. package/tests/review-integration-v1.test.ts +0 -530
@@ -5,19 +5,23 @@ import { chmod, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
5
5
  import { tmpdir } from "node:os";
6
6
  import { dirname, isAbsolute, join, posix, win32 } from "node:path";
7
7
  import { promisify } from "node:util";
8
- import { PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
8
+ import { GENTLE_AI_VERSION, PackageLocalGentleAiBinaryMissingError, resolveGentleAiBinary } from "./gentle-ai-binary.ts";
9
9
  import {
10
10
  REVIEW_INTEGRATION_CONTRACT,
11
- decodeReviewCapabilitiesV1,
12
- decodeReviewFailureV1,
13
- decodeReviewOperationV1,
14
- decodeReviewStartV1,
15
- decodeReviewStatusV1,
16
- type ReviewCapabilitiesV1,
17
- type ReviewFailureV1,
11
+ decodeReviewCapabilitiesV2,
12
+ decodeReviewConsentV2,
13
+ decodeReviewFailureV2,
14
+ decodeReviewOperationV2,
15
+ decodeReviewRepairV2,
16
+ decodeReviewStartV3,
17
+ decodeReviewStatusV3,
18
+ type ReviewCapabilitiesV2,
19
+ type ReviewConsentV2,
20
+ type ReviewFailureV2,
21
+ type ReviewRepairV2,
18
22
  type ReviewStartState,
19
- type ReviewStatusV1,
20
- } from "./review-integration-v1.ts";
23
+ type ReviewStatusV3,
24
+ } from "./review-integration-v2.ts";
21
25
 
22
26
  const execFileAsync = promisify(execFile);
23
27
 
@@ -35,6 +39,10 @@ export const NATIVE_REVIEW_OPERATION = {
35
39
  QUARANTINE_LEGACY: "review/quarantine-legacy",
36
40
  RECONCILE_AUTHORITY: "review/reconcile-authority",
37
41
  REPAIR_LEGACY_ALIAS: "review/repair-legacy-alias",
42
+ MODE: "review/mode",
43
+ REPAIR: "review/repair",
44
+ CAPTURE_EVIDENCE: "review/capture-evidence",
45
+ CAPTURE_RESULT: "review/capture-result",
38
46
  } as const;
39
47
  export type NativeReviewOperation = (typeof NATIVE_REVIEW_OPERATION)[keyof typeof NATIVE_REVIEW_OPERATION];
40
48
 
@@ -52,6 +60,7 @@ export const NATIVE_REVIEW_ERROR_CODE = {
52
60
  VERSION_INCOMPATIBLE: "version-incompatible",
53
61
  CANCELLED: "cancelled",
54
62
  PACKAGE_BINARY_MISSING: "package-local-binary-missing",
63
+ UNSUPPORTED_TRANSITION_OPERATION: "unsupported-transition-operation",
55
64
  } as const;
56
65
  export type NativeReviewErrorCode = (typeof NATIVE_REVIEW_ERROR_CODE)[keyof typeof NATIVE_REVIEW_ERROR_CODE];
57
66
 
@@ -95,14 +104,100 @@ export interface NativeReviewCli {
95
104
  bindSdd(request: NativeBindSddRequest): Promise<NativeBindSddResult>;
96
105
  sddStatus(request: NativeSddStatusRequest): Promise<NativeSddStatusResult>;
97
106
  reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult>;
98
- capabilities?(request?: NativeCapabilitiesRequest): Promise<ReviewCapabilitiesV1>;
99
- targetStatus?(request: NativeTargetStatusRequest): Promise<ReviewStatusV1>;
107
+ capabilities?(request?: NativeCapabilitiesRequest): Promise<ReviewCapabilitiesV2>;
108
+ targetStatus?(request: NativeTargetStatusRequest): Promise<ReviewStatusV3>;
100
109
  reclaim?(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult>;
101
110
  recover?(request: NativeReviewRecoverRequest): Promise<NativeReviewRecoveryResult>;
102
111
  abandon?(request: NativeReviewAbandonRequest): Promise<NativeReviewRecoveryResult>;
103
112
  quarantineLegacy?(request: NativeReviewLegacyQuarantineRequest): Promise<NativeReviewRecoveryResult>;
104
113
  reconcileAuthority?(request: NativeReviewReconcileAuthorityRequest): Promise<NativeReviewRecoveryResult>;
105
114
  repairLegacyAlias?(request: NativeReviewLegacyAliasRepairRequest): Promise<NativeReviewRecoveryResult>;
115
+ // Net-new negotiated operations (contract v2 only): `review.repair` is
116
+ // advertised in the v2 operation list; `capture-evidence` is the
117
+ // evidence-first correction lifecycle's collection step.
118
+ repair?(request: NativeReviewRepairRequest): Promise<ReviewRepairV2>;
119
+ captureEvidence?(request: NativeReviewCaptureEvidenceRequest): Promise<NativeReviewVerificationEvidenceV2>;
120
+ // Dark until a negotiated version reports the `mode` capability true
121
+ // (Design Decision #7, organic-rdd-parity). Plain versioned CLI operation,
122
+ // outside the negotiated review-integration protocol — same shape as
123
+ // reviewStatus/sddStatus/reclaim above.
124
+ reviewMode?(request: NativeReviewModeRequest): Promise<NativeReviewModeResult>;
125
+ }
126
+
127
+ export const NATIVE_REVIEW_MODE_OPERATION = {
128
+ STATUS: "status",
129
+ ENABLE: "enable",
130
+ DISABLE: "disable",
131
+ } as const;
132
+ export type NativeReviewModeOperation = (typeof NATIVE_REVIEW_MODE_OPERATION)[keyof typeof NATIVE_REVIEW_MODE_OPERATION];
133
+
134
+ export const NATIVE_REVIEW_MODE_VALUE = {
135
+ UNSET: "",
136
+ ON: "on",
137
+ OFF: "off",
138
+ } as const;
139
+ export type NativeReviewModeValue = (typeof NATIVE_REVIEW_MODE_VALUE)[keyof typeof NATIVE_REVIEW_MODE_VALUE];
140
+
141
+ export const NATIVE_REVIEW_MODE_SOURCE = {
142
+ DEFAULT: "default",
143
+ GLOBAL: "global",
144
+ CLONE_LOCAL: "clone_local",
145
+ } as const;
146
+ export type NativeReviewModeSource = (typeof NATIVE_REVIEW_MODE_SOURCE)[keyof typeof NATIVE_REVIEW_MODE_SOURCE];
147
+
148
+ export const NATIVE_REVIEW_MODE_SCOPE = {
149
+ GLOBAL: "global",
150
+ CLONE: "clone",
151
+ BOTH: "both",
152
+ } as const;
153
+ export type NativeReviewModeScope = (typeof NATIVE_REVIEW_MODE_SCOPE)[keyof typeof NATIVE_REVIEW_MODE_SCOPE];
154
+
155
+ export interface NativeReviewModeRequest {
156
+ cwd: string;
157
+ operation: NativeReviewModeOperation;
158
+ signal?: AbortSignal;
159
+ }
160
+
161
+ export interface NativeReviewModeStatus {
162
+ global: NativeReviewModeValue;
163
+ cloneLocal: NativeReviewModeValue;
164
+ effective: "on" | "off";
165
+ source: NativeReviewModeSource;
166
+ revision?: string;
167
+ }
168
+
169
+ export interface NativeReviewModeResult {
170
+ operation: NativeReviewModeOperation;
171
+ scope: NativeReviewModeScope;
172
+ status: NativeReviewModeStatus;
173
+ }
174
+
175
+ // Exact-match tolerated-stderr allowlist for START only, gated on the `mode`
176
+ // capability being true (Design Decision #6, organic-rdd-parity). Byte-exact
177
+ // against gentle-ai's headless notice (internal/cli/review_mode.go
178
+ // reviewConsentSkippedNotice) written when the switch is on but no interactive
179
+ // terminal answered the one-time consent question — which is always true when
180
+ // Pi spawns gentle-ai without a TTY. Any other text still fails closed as
181
+ // UNEXPECTED_STDERR.
182
+ // Each entry is one whole line gentle-ai may write to the console stream while
183
+ // still succeeding. The provenance line rides with the skip notice whenever the
184
+ // resolved mode source is `default`, so a headless START legitimately emits two
185
+ // lines; they are separate Fprintln calls, never one joined string.
186
+ export const REVIEW_CONSENT_NOTICES = Object.freeze([
187
+ "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.",
188
+ "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.",
189
+ "Gentle AI could not read an answer, so it reviewed this change and will ask again next time.",
190
+ "Gentle AI did not recognize that answer, so it reviewed this change and will ask again next time.",
191
+ "Review skipped for this candidate at your request. It will be offered again on the next change.",
192
+ ]);
193
+
194
+ // Every non-empty line must be allowlisted. Matching the whole stderr blob as
195
+ // one string only worked while exactly one notice could appear; the moment a
196
+ // second legitimate line joined it, an otherwise successful START was reported
197
+ // as unexpected-stderr.
198
+ function stderrIsTolerated(stderr: string, tolerated: readonly string[]): boolean {
199
+ const lines = stderr.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
200
+ return lines.length > 0 && lines.every((line) => tolerated.includes(line));
106
201
  }
107
202
 
108
203
  export const NATIVE_REVIEW_RECOVER_DISPOSITION = ["scope_changed", "invalidated", "escalated"] as const;
@@ -196,9 +291,88 @@ export interface NativeReviewLegacyAliasRepairRequest {
196
291
  /** Raw audited native record; Pi relays it verbatim and never reinterprets it. */
197
292
  export interface NativeReviewRecoveryResult { record: Record<string, unknown>; }
198
293
 
199
- export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; signal?: AbortSignal; }
294
+ // Net-new negotiated `review.repair` (contract v2). `repair(request)` always
295
+ // runs a `--mode preflight` first; only an eligible assessment is ever
296
+ // executed, using the exact provider_inputs that assessment published — Pi's
297
+ // own NATIVE_REVIEW_LEGACY_ALIAS_REPAIR constants are never a source, only a
298
+ // disagreement check (Design Decision #6, migrate-review-integration-v2).
299
+ export interface NativeReviewRepairRequest {
300
+ cwd: string;
301
+ actor: string;
302
+ reason: string;
303
+ maintainerAuthorization: string;
304
+ signal?: AbortSignal;
305
+ }
306
+
307
+ // `review capture-evidence` argv and response shape are pinned to a real
308
+ // v2.2.2 review run (lineage review-b39d803b68a90767): exactly
309
+ // --cwd/--lineage/--target/--expected-revision/--outcome/--input, no
310
+ // --contract, and the `gentle-ai.review-verification-evidence/v2` record
311
+ // returned directly (not wrapped in an operation/v2 envelope).
312
+ export const NATIVE_REVIEW_CAPTURE_OUTCOME = ["passed", "verification_failed", "procedural_tooling_failed"] as const;
313
+ export type NativeReviewCaptureOutcome = (typeof NATIVE_REVIEW_CAPTURE_OUTCOME)[number];
314
+
315
+ // `capture-result` is an additive headless command, NOT a negotiated
316
+ // repository operation: it accepts no --contract, and the provider's own
317
+ // transition tokens already carry the repository context -- it takes that or
318
+ // --cwd, never both. So Pi passes the tokens through verbatim and adds only
319
+ // --input. Reconstructing them would mean re-deriving a lineage, revision,
320
+ // target, lens slot, and subject hash the provider already issued.
321
+ // Added for the v2 finalize transport. `capturedResults` asks the provider to
322
+ // discover every manifest it already admitted; `resultArtifactFiles` hands them
323
+ // over explicitly in lens order. Both replace the retired `--result`.
324
+ export interface NativeReviewFinalizeCapturedResults {
325
+ readonly capturedResults?: boolean;
326
+ readonly resultArtifactFiles?: readonly string[];
327
+ }
328
+
329
+ export interface NativeReviewCaptureResultRequest {
330
+ readonly argumentTokens: readonly string[];
331
+ readonly resultDocument: string;
332
+ // Only for the compatibility path-manifest mode, when the tokens carry no
333
+ // repository context. Supplying both is refused by the provider.
334
+ readonly cwd?: string;
335
+ readonly signal?: AbortSignal;
336
+ }
337
+
338
+ export interface NativeReviewAdmittedResultManifest {
339
+ readonly schema: string;
340
+ readonly subjectHash: string;
341
+ readonly admissionDecision: string;
342
+ readonly lens?: string;
343
+ readonly path?: string;
344
+ readonly reference?: string;
345
+ }
346
+
347
+ export interface NativeReviewCaptureEvidenceRequest {
348
+ cwd: string;
349
+ lineageId: string;
350
+ targetIdentity: string;
351
+ expectedRevision: string;
352
+ outcome: NativeReviewCaptureOutcome;
353
+ evidenceDocument: string;
354
+ signal?: AbortSignal;
355
+ }
356
+
357
+ export interface NativeReviewVerificationEvidenceV2 {
358
+ schema: "gentle-ai.review-verification-evidence/v2";
359
+ version: 2;
360
+ lineageId: string;
361
+ authorityRevision: string;
362
+ targetIdentity: string;
363
+ candidateTree: string;
364
+ pathsDigest: string;
365
+ paths: readonly string[];
366
+ ledgerIds: readonly string[];
367
+ rawPayloadSha256: string;
368
+ rawPayloadBytes: number;
369
+ outcome: NativeReviewCaptureOutcome;
370
+ recordDigest: string;
371
+ }
372
+
373
+ export interface NativeStartRequest { cwd: string; baseRef?: string; committedOnly?: boolean; lineageId?: string; policyPath?: string; focus?: string; projection?: "workspace" | "staged"; signal?: AbortSignal; }
200
374
  export interface NativeFinalizeLensResult { lens: string; document: unknown; }
201
- export interface NativeFinalizeRequest {
375
+ export interface NativeFinalizeRequest extends NativeReviewFinalizeCapturedResults {
202
376
  cwd: string;
203
377
  lineageId?: string;
204
378
  resultFiles?: readonly string[];
@@ -318,9 +492,9 @@ export interface NativeReviewStatusResult {
318
492
  }
319
493
  export const NATIVE_START_ACTION = { CREATED: "created", RESUMED: "resumed", REUSE_RECEIPT: "reuse-receipt", BLOCKED_SCOPE_ACTION: "blocked-scope-action" } as const;
320
494
  export type NativeStartAction = (typeof NATIVE_START_ACTION)[keyof typeof NATIVE_START_ACTION];
321
- 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>>; }
322
- export interface NativeValidateResult { allowed: boolean; result: "allow" | "scope-changed" | "invalidated" | "escalated"; action: string; reason: string; gateContext: NativeGateContext; }
323
- export interface NativeFinalizeResult { lineageId: string; state: string; action: string; storeRevision: string; receiptPath?: string; }
495
+ 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; }
496
+ export interface NativeValidateResult { allowed: boolean; result: "allow" | "scope-changed" | "invalidated" | "escalated"; action: string; reason: string; gateContext: NativeGateContext; delivery?: "disabled/unmanaged"; }
497
+ export interface NativeFinalizeResult { lineageId: string; state: string; action: string; storeRevision: string; receiptPath?: string; validationRequest?: Readonly<Record<string, unknown>>; escalation?: string; }
324
498
  export interface NativeBindSddResult {
325
499
  revision: string;
326
500
  change: string;
@@ -342,6 +516,76 @@ export function isCanonicalProcessString(value: unknown): value is string {
342
516
  }
343
517
 
344
518
  const NATIVE_RISK_LEVEL = ["low", "medium", "high"] as const;
519
+
520
+ // gentle-ai's negotiated `start/v2` envelope is a closed schema
521
+ // (`additionalProperties: false`), so the two projections its plain sibling
522
+ // carries cannot be added to it without a new contract version. Both are
523
+ // projections of facts `start/v2` already reports as required fields, so a
524
+ // negotiated caller reconstructs them here instead of ending up with a worse
525
+ // recovery story than a plain one. These are Pi-side renderings of native
526
+ // facts, never a claim that the CLI sent them: the `riskEvidence`/`hint`
527
+ // capability rows stay dark for every version whose envelope omits them.
528
+ //
529
+ // Byte-for-byte mirrors of internal/cli/review_mode.go and review_facade.go.
530
+ // `reviewConsentEvidencePhrases` is documented there as the single phrasing
531
+ // source so its surfaces cannot drift, which makes this a second surface: an
532
+ // unrecognized reason code therefore renders nothing rather than guessing, and
533
+ // nativeRiskEvidencePhrases is pinned against a gentle-ai fixture in
534
+ // tests/native-review-parity.test.ts so a vocabulary change fails loudly.
535
+ const REVIEW_EMPTY_CANDIDATE_HINT =
536
+ "the candidate has no pending changes; already-committed work can be reviewed by rerunning review start with --base-ref <commit> naming the base to compare against";
537
+ const REVIEW_MEDIUM_RISK_REASON = "this change is not purely passive documentation, so it gets one consolidated review.";
538
+ const REVIEW_EMPTY_CONTENT_CODE = "empty_content";
539
+ const REVIEW_RISK_SUBJECT_BY_CODE: Readonly<Record<string, string>> = Object.freeze({
540
+ service_token: "service credentials",
541
+ shell_source: "shell scripting",
542
+ process_boundary: "code that starts other processes",
543
+ process_scan_limit: "code that starts other processes",
544
+ executable_mode: "an executable permission change",
545
+ executable_change: "an executable change",
546
+ configuration_change: "a configuration change",
547
+ });
548
+ const REVIEW_RISK_SUBJECT_BY_SIGNAL: Readonly<Record<string, string>> = Object.freeze({
549
+ auth: "authentication",
550
+ update: "the update path",
551
+ security: "security",
552
+ payments: "payments",
553
+ data_exposure: "data exposure",
554
+ data_loss: "data loss",
555
+ permissions: "permissions",
556
+ shell_process: "shell or process execution",
557
+ });
558
+ // The Go signal switch has no empty default: every hot_path reason speaks, and
559
+ // an unmapped signal degrades to this rather than dropping the path entirely.
560
+ const REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT = "a sensitive area";
561
+
562
+ function nativeRiskEvidenceSubject(reason: Record<string, unknown>): string {
563
+ const code = typeof reason.code === "string" ? reason.code : "";
564
+ if (code === "hot_path") {
565
+ const signal = typeof reason.signal === "string" ? reason.signal : "";
566
+ return REVIEW_RISK_SUBJECT_BY_SIGNAL[signal] ?? REVIEW_RISK_UNKNOWN_SIGNAL_SUBJECT;
567
+ }
568
+ return REVIEW_RISK_SUBJECT_BY_CODE[code] ?? "";
569
+ }
570
+
571
+ function nativeRiskEvidencePhrase(reason: Record<string, unknown>): string {
572
+ const path = typeof reason.path === "string" ? reason.path.trim() : "";
573
+ // An empty file is named first and described second. Every other subject
574
+ // reads "<what changed> in <path>", which for a file with no bytes would
575
+ // assert something about content that is not there.
576
+ if (reason.code === REVIEW_EMPTY_CONTENT_CODE) {
577
+ return path === "" ? "" : `${path}, an empty file whose type cannot be determined from its content`;
578
+ }
579
+ const subject = nativeRiskEvidenceSubject(reason);
580
+ if (subject === "" || path === "") return subject;
581
+ return `${subject} in ${path}`;
582
+ }
583
+
584
+ export function nativeRiskEvidencePhrases(riskLevel: string, reasons: readonly Record<string, unknown>[]): readonly string[] {
585
+ if (riskLevel !== "high" && riskLevel !== "medium") return [];
586
+ const phrases = reasons.map((reason) => nativeRiskEvidencePhrase(reason)).filter((phrase) => phrase !== "");
587
+ return riskLevel === "medium" ? [REVIEW_MEDIUM_RISK_REASON, ...phrases] : phrases;
588
+ }
345
589
  const NATIVE_REVIEW_LENS = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
346
590
  const NATIVE_FINALIZE_STATE = ["reviewing", "correction_required", "validating", "approved", "escalated"] as const;
347
591
  const NATIVE_START_ACTION_VALUES = Object.values(NATIVE_START_ACTION);
@@ -350,18 +594,71 @@ const NATIVE_GATE = ["post-apply", "pre-commit", "pre-push", "pre-pr", "release"
350
594
  const NATIVE_SDD_NEXT_ACTION = ["apply", "verify", "remediate", "archive", "review", "resolve-review", "resolve-blockers", "sdd-new", "select-change", "propose", "spec", "design", "tasks"] as const;
351
595
  const NATIVE_SDD_POST_REVIEW_ACTION = ["verify", "archive"] as const;
352
596
 
597
+ // Organic-parity columns (mode, riskEvidence, hint, delivery) stay false on
598
+ // every shipped row, including "2.1.11". PI-2 adds the first capability-true
599
+ // row (and bumps the triple pin) in one dedicated commit — never one without
600
+ // the other. See Design Decision #1 (organic-rdd-parity).
601
+ const ORGANIC_PARITY_DARK = { mode: false, riskEvidence: false, hint: false, delivery: false } as const;
602
+
353
603
  export const NATIVE_CLI_CONTRACTS = Object.freeze({
354
- "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 }),
355
- "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 }),
356
- "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 }),
357
- "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 }),
358
- "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 }),
359
- "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 }),
360
- "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 }),
361
- "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 }),
604
+ "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 }),
605
+ "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 }),
606
+ "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 }),
607
+ "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 }),
608
+ "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 }),
609
+ "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 }),
610
+ "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 }),
611
+ "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 }),
612
+ // First capability-true row, paired with the triple pin bump in this same
613
+ // commit as Design Decision #1 requires.
614
+ //
615
+ // Only two of the four organic-parity columns are lit, because a capability
616
+ // row is a promise and these are the two whose data was proven to reach the
617
+ // negotiated path Pi actually consumes:
618
+ //
619
+ // mode `gentle-ai review mode status` answers the review-mode/v1
620
+ // envelope directly.
621
+ // delivery the gate result carries `delivery` ("disabled/unmanaged" when
622
+ // the kill switch is off), verified against v2.2.0.
623
+ //
624
+ // riskEvidence and hint stay dark deliberately. Both exist in gentle-ai
625
+ // v2.2.0 but only on the PLAIN start envelope; the negotiated
626
+ // `review-integration.start/v2` that NativeReviewCliV216 decodes carries
627
+ // `risk_reasons` instead of `risk_evidence` and omits `hint` entirely.
628
+ // Lighting them would advertise data that cannot arrive. Closing that gap
629
+ // needs the negotiated start envelope extended upstream, which moves a
630
+ // byte-pinned fixture and therefore belongs to a gentle-ai release, not to
631
+ // a Pi capability flip.
632
+ "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 }),
633
+ // 2.2.1 repeats 2.2.0 because the wire did not move for the lane Pi speaks.
634
+ // v2.2.1 advertises capabilities/v1.5 (protocol minor 5) on
635
+ // review-integration/v1, but the negotiated start envelope is still the
636
+ // closed `start/v2`, so riskEvidence and hint stay dark for the same reason
637
+ // they are dark on 2.2.0. The release does publish a second contract,
638
+ // review-integration/v2, whose `start/v3` carries base/candidate trees --
639
+ // but Pi does not negotiate it yet, and a row must describe the lane in use.
640
+ "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 }),
641
+ // 2.2.2 repeats 2.2.1 for the same reason, confirmed against the released
642
+ // v2.2.2 binary rather than assumed: on review-integration/v1 it still
643
+ // advertises capabilities/v1.5 and the negotiated start envelope is still
644
+ // the closed `start/v2`, so riskEvidence and hint still cannot arrive.
645
+ "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 }),
362
646
  });
363
647
  type NativeCliCapability = keyof (typeof NATIVE_CLI_CONTRACTS)[keyof typeof NATIVE_CLI_CONTRACTS];
364
648
 
649
+ // Testing-only capability overlay: lets tests exercise mode/riskEvidence/hint/
650
+ // delivery decode paths under a synthetic version key without ever making a
651
+ // shipped row (including "2.1.11") capability-true. Production code never
652
+ // calls the setter, so this is inert outside test files.
653
+ const nativeCliContractsTestingOverlay = new Map<string, Record<NativeCliCapability, boolean>>();
654
+ export function setNativeCliContractForTesting(version: string, contract: Record<NativeCliCapability, boolean> | undefined): void {
655
+ if (contract === undefined) nativeCliContractsTestingOverlay.delete(version);
656
+ else nativeCliContractsTestingOverlay.set(version, contract);
657
+ }
658
+ function resolvedNativeCliContract(version: string): Record<NativeCliCapability, boolean> | undefined {
659
+ return nativeCliContractsTestingOverlay.get(version) ?? (NATIVE_CLI_CONTRACTS as Record<string, Record<NativeCliCapability, boolean> | undefined>)[version];
660
+ }
661
+
365
662
  export interface NativeReviewStructuredDenial {
366
663
  schema: "gentle-ai.review-gate-result/v1";
367
664
  result: "scope-changed" | "invalidated" | "escalated";
@@ -536,6 +833,49 @@ function decodeLegacyReconcileAudit(value: unknown): NativeReviewRecoveryResult
536
833
  for (const field of ["predecessor_lineage", "successor_lineage", "outcome"]) requiredString(record[field]);
537
834
  return { record };
538
835
  }
836
+ function decodeNativeReviewVerificationEvidence(value: unknown): NativeReviewVerificationEvidenceV2 {
837
+ 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"]);
838
+ if (body.schema !== "gentle-ai.review-verification-evidence/v2") throw new Error("wrong verification evidence schema");
839
+ return {
840
+ schema: "gentle-ai.review-verification-evidence/v2",
841
+ // verification-evidence.schema.json pins `version` to {"const": 2} -- the
842
+ // NUMBER two, not the string. It was written as a string from a handoff
843
+ // that listed the observed field NAMES without their types.
844
+ 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; })(),
845
+ lineageId: requiredString(body.lineage_id),
846
+ authorityRevision: requiredString(body.authority_revision),
847
+ targetIdentity: requiredString(body.target_identity),
848
+ candidateTree: requiredString(body.candidate_tree),
849
+ pathsDigest: requiredString(body.paths_digest),
850
+ paths: stringArray(body.paths),
851
+ // The schema requires ledger_ids to be an array, but v2.2.2 emits null when
852
+ // there are none. Tolerated deliberately: refusing here would reject a
853
+ // response the provider actually sends, and the provider violating its own
854
+ // published schema is its defect to fix, not a reason to break the client.
855
+ ledgerIds: body.ledger_ids === null || body.ledger_ids === undefined ? [] : stringArray(body.ledger_ids),
856
+ rawPayloadSha256: requiredString(body.raw_payload_sha256),
857
+ rawPayloadBytes: nonNegativeInteger(body.raw_payload_bytes),
858
+ outcome: enumString(body.outcome, NATIVE_REVIEW_CAPTURE_OUTCOME) as NativeReviewCaptureOutcome,
859
+ recordDigest: requiredString(body.record_digest),
860
+ };
861
+ }
862
+ // Unimplemented next_transition.execute.operation values must never reach
863
+ // argv synthesis. Checked against the raw pre-decode body so an operation
864
+ // gentle-pi does not implement (e.g. a future `dispose-result`) fails with a
865
+ // named, typed refusal instead of a generic schema-incompatible error, and
866
+ // before any client ever tries to build an invocation for it (Design
867
+ // Decision #6, migrate-review-integration-v2).
868
+ const NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS = new Set(["review.start", "review.finalize", "review.recover", "review.repair", "review.validate"]);
869
+ function assertSupportedNextTransitionOperation(body: Record<string, unknown>): void {
870
+ const nextTransition = body.next_transition;
871
+ if (typeof nextTransition !== "object" || nextTransition === null || Array.isArray(nextTransition)) return;
872
+ const execute = (nextTransition as Record<string, unknown>).execute;
873
+ if (typeof execute !== "object" || execute === null || Array.isArray(execute)) return;
874
+ const operation = (execute as Record<string, unknown>).operation;
875
+ if (typeof operation === "string" && !NATIVE_REVIEW_SUPPORTED_TRANSITION_OPERATIONS.has(operation)) {
876
+ throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNSUPPORTED_TRANSITION_OPERATION, NATIVE_REVIEW_OPERATION.STATUS, false, `unsupported-transition-operation: gentle-pi does not implement the next_transition operation "${operation}"; refusing rather than synthesizing an invocation for it`);
877
+ }
878
+ }
539
879
  function decode<T>(operation: NativeReviewOperation, mutating: boolean, callback: () => T, diagnostics = nativeProcessDiagnostics(operation, NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE)): T {
540
880
  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 }); }
541
881
  }
@@ -634,6 +974,28 @@ function decodeNativeReviewStatusDiagnostic(value: unknown): NativeReviewAuthori
634
974
  const diagnostic = exactObject(value, ["path", "problem"]);
635
975
  return { path: requiredString(diagnostic.path), problem: requiredString(diagnostic.problem) };
636
976
  }
977
+ function decodeNativeReviewModeStatus(value: unknown): NativeReviewModeStatus {
978
+ const status = exactObject(value, ["schema", "global", "clone_local", "effective", "source"], ["revision"]);
979
+ if (status.schema !== "gentle-ai.rdd-mode-status/v1") throw new Error("wrong review mode status schema");
980
+ return {
981
+ global: enumString(status.global, Object.values(NATIVE_REVIEW_MODE_VALUE)) as NativeReviewModeValue,
982
+ cloneLocal: enumString(status.clone_local, Object.values(NATIVE_REVIEW_MODE_VALUE)) as NativeReviewModeValue,
983
+ effective: enumString(status.effective, ["on", "off"]) as "on" | "off",
984
+ source: enumString(status.source, Object.values(NATIVE_REVIEW_MODE_SOURCE)) as NativeReviewModeSource,
985
+ ...(status.revision === undefined ? {} : { revision: requiredString(status.revision) }),
986
+ };
987
+ }
988
+
989
+ function decodeNativeReviewMode(value: unknown, expectedOperation: NativeReviewModeOperation): NativeReviewModeResult {
990
+ const body = exactObject(value, ["schema", "operation", "scope", "status"]);
991
+ if (body.schema !== "gentle-ai.review-mode/v1" || body.operation !== expectedOperation) throw new Error("wrong review mode discriminator");
992
+ return {
993
+ operation: expectedOperation,
994
+ scope: enumString(body.scope, Object.values(NATIVE_REVIEW_MODE_SCOPE)) as NativeReviewModeScope,
995
+ status: decodeNativeReviewModeStatus(body.status),
996
+ };
997
+ }
998
+
637
999
  function decodeNativeReviewStatus(value: unknown): NativeReviewStatusResult {
638
1000
  const body = exactObject(value, ["schema", "operation", "repository", "complete", "authoritative", "status", "entries", "locks", "diagnostics"]);
639
1001
  if (body.schema !== "gentle-ai.review-authority-status/v1" || body.operation !== "review/status") throw new Error("wrong review status discriminator");
@@ -790,7 +1152,12 @@ export class NativeReviewCliV214 {
790
1152
  }
791
1153
  }
792
1154
 
793
- private async execute(operation: NativeReviewOperation, cwd: string, arguments_: readonly string[], mutating: boolean, signal?: AbortSignal): Promise<NativeJsonExecution> {
1155
+ // toleratedStderr is an exact-match, operation-scoped allowlist (Design
1156
+ // Decision #6, organic-rdd-parity): only START passes a non-empty list, and
1157
+ // only when the negotiated version's `mode` capability is true. A near-miss,
1158
+ // prefixed, or multi-line stderr is never tolerated — only byte-exact
1159
+ // membership in the frozen set.
1160
+ private async execute(operation: NativeReviewOperation, cwd: string, arguments_: readonly string[], mutating: boolean, signal?: AbortSignal, toleratedStderr: readonly string[] = []): Promise<NativeJsonExecution> {
794
1161
  let result: ExecFileResult;
795
1162
  try { result = await this.adapter({ file: this.executablePath(operation, mutating), arguments: arguments_, cwd, timeoutMs: mutating ? undefined : this.timeoutMs, maxBufferBytes: this.maxBufferBytes, signal }); }
796
1163
  catch (error) {
@@ -804,8 +1171,9 @@ export class NativeReviewCliV214 {
804
1171
  if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, operation, mutating, "native process was signalled", result);
805
1172
  const structuredValidateDenial = operation === NATIVE_REVIEW_OPERATION.VALIDATE && result.exitCode === 1;
806
1173
  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;
1174
+ const toleratedNotice = stderrIsTolerated(result.stderr, toleratedStderr);
807
1175
  if (result.exitCode !== 0 && !structuredValidateDenial && !maintenancePartialFailure) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native process failed", result);
808
- if (result.stderr.trim().length > 0 && !structuredValidateDenial && !maintenancePartialFailure) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result);
1176
+ if (result.stderr.trim().length > 0 && !structuredValidateDenial && !maintenancePartialFailure && !toleratedNotice) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result);
809
1177
  return { body: parseJson(result.stdout, operation, mutating, diagnostics), exitCode: result.exitCode, process: result };
810
1178
  }
811
1179
 
@@ -822,8 +1190,8 @@ export class NativeReviewCliV214 {
822
1190
  if (result.signal) throw nativeError(NATIVE_REVIEW_ERROR_CODE.SIGNAL, NATIVE_REVIEW_OPERATION.VERSION, false, "version process was signalled", result);
823
1191
  if (result.exitCode !== 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, NATIVE_REVIEW_OPERATION.VERSION, false, "version process failed", result);
824
1192
  const version = /^gentle-ai ([0-9]+\.[0-9]+\.[0-9]+)\n$/.exec(result.stdout.replace(/\r\n$/, "\n"))?.[1];
825
- const contract = version === undefined ? undefined : NATIVE_CLI_CONTRACTS[version as keyof typeof NATIVE_CLI_CONTRACTS];
826
- 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");
1193
+ const contract = version === undefined ? undefined : resolvedNativeCliContract(version);
1194
+ 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"}`);
827
1195
  return version as keyof typeof NATIVE_CLI_CONTRACTS;
828
1196
  }
829
1197
 
@@ -832,10 +1200,18 @@ export class NativeReviewCliV214 {
832
1200
  if (request.committedOnly !== undefined && typeof request.committedOnly !== "boolean") throw new TypeError("Native START committedOnly must be a boolean when supplied");
833
1201
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
834
1202
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
835
- await this.verifyVersion(request.cwd, request.signal, ["start"]);
836
- 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);
1203
+ const version = await this.verifyVersion(request.cwd, request.signal, ["start"]);
1204
+ const toleratedStderr = resolvedNativeCliContract(version)?.mode === true ? REVIEW_CONSENT_NOTICES : [];
1205
+ 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);
837
1206
  return decode(NATIVE_REVIEW_OPERATION.START, true, () => {
838
- const body = exactObject(result, ["operation", "lineage_id", "state", "risk_level", "selected_lenses", "changed_files", "changed_lines", "correction_budget", "action", "lenses_required", "projection"]);
1207
+ // `target_identity` and `lens_bindings` are real, unconditionally-present
1208
+ // fields on gentle-ai's plain (non-negotiated) `review start` JSON output
1209
+ // (confirmed against the pinned v2.1.11 Go source and live against a dev
1210
+ // build during Phase 6 dev-binary ground-truthing, organic-rdd-parity) —
1211
+ // tolerated here but not consumed: Pi's negotiated-contract client
1212
+ // (NativeReviewCliV216, the production default) carries lineage/target
1213
+ // identity through its own `review-integration/v1` decoder instead.
1214
+ 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"]);
839
1215
  if (body.operation !== "review/start" || body.projection !== "workspace" || !(NATIVE_FINALIZE_STATE as readonly string[]).includes(stringValue(body.state))) throw new Error("wrong start discriminator");
840
1216
  const lineageId = requiredString(body.lineage_id);
841
1217
  if (request.lineageId && lineageId !== request.lineageId) throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, NATIVE_REVIEW_OPERATION.START, true, "native start lineage mismatch");
@@ -849,7 +1225,11 @@ export class NativeReviewCliV214 {
849
1225
  !hasCanonicalSelectedLenses(riskLevel, selectedLenses) ||
850
1226
  !hasValidLensesRequired(action, body.state as string, riskLevel, lensesRequired)
851
1227
  ) throw new Error("unknown or contradictory start enum");
852
- return { 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 };
1228
+ return {
1229
+ 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,
1230
+ ...(body.risk_evidence === undefined ? {} : { riskEvidence: stringArray(body.risk_evidence) }),
1231
+ ...(body.hint === undefined ? {} : { hint: requiredString(body.hint) }),
1232
+ };
853
1233
  });
854
1234
  }
855
1235
 
@@ -894,18 +1274,31 @@ export class NativeReviewCliV214 {
894
1274
  await this.verifyVersion(request.cwd, request.signal, ["validate"]);
895
1275
  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);
896
1276
  return decode(NATIVE_REVIEW_OPERATION.VALIDATE, false, () => {
897
- const body = exactObject(execution.body, ["schema", "result", "allowed", "action", "reason", "context"]);
1277
+ const body = exactObject(execution.body, ["schema", "result", "allowed", "action", "reason", "context"], ["delivery"]);
898
1278
  const gateResult = enumString(body.result, NATIVE_GATE_RESULT) as NativeValidateResult["result"];
899
1279
  const action = sanitizeNativeDiagnosticText(requiredString(body.action), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
900
1280
  const reason = sanitizeNativeDiagnosticText(requiredString(body.reason), NATIVE_REVIEW_DENIAL_TEXT_LIMIT);
901
- const expectedAction = { allow: "continue", "scope-changed": "create-new-lineage", invalidated: "explicit-maintainer-action", escalated: "stop" }[gateResult];
902
- const expectedExitCode = gateResult === "allow" ? 0 : 1;
903
- if (body.schema !== "gentle-ai.review-gate-result/v1" || typeof body.allowed !== "boolean" || body.allowed !== (gateResult === "allow") || action !== expectedAction || !isCanonicalProcessString(action) || !isCanonicalProcessString(reason) || execution.exitCode !== expectedExitCode) throw new Error("wrong validate discriminator");
1281
+ // `delivery` (Design Decision #9, organic-rdd-parity) is an alternate
1282
+ // discriminator: when present it must be the single literal
1283
+ // "disabled/unmanaged" (gentle-ai's RDDDeliveryDisabledUnmanaged — the
1284
+ // kill switch is off and no receipt governs the candidate), paired
1285
+ // exactly with result:invalidated, allowed:false, action:repository-policy,
1286
+ // and exit 0 — never the strict continue/create-new-lineage/
1287
+ // explicit-maintainer-action/stop pairing used when delivery is absent.
1288
+ const delivery = body.delivery === undefined ? undefined : (enumString(body.delivery, ["disabled/unmanaged"]) as NativeValidateResult["delivery"]);
1289
+ if (body.schema !== "gentle-ai.review-gate-result/v1" || !isCanonicalProcessString(action) || !isCanonicalProcessString(reason)) throw new Error("wrong validate discriminator");
1290
+ if (delivery !== undefined) {
1291
+ if (gateResult !== "invalidated" || body.allowed !== false || action !== "repository-policy" || execution.exitCode !== 0) throw new Error("wrong validate delivery discriminator");
1292
+ } else {
1293
+ const expectedAction = { allow: "continue", "scope-changed": "create-new-lineage", invalidated: "explicit-maintainer-action", escalated: "stop" }[gateResult];
1294
+ const expectedExitCode = gateResult === "allow" ? 0 : 1;
1295
+ if (typeof body.allowed !== "boolean" || body.allowed !== (gateResult === "allow") || action !== expectedAction || execution.exitCode !== expectedExitCode) throw new Error("wrong validate discriminator");
1296
+ }
904
1297
  const gateContext = decodeGateContext(body.context);
905
1298
  const returnedGate = gateContext.raw.gate;
906
1299
  if (returnedGate !== request.gate && (gateResult === "allow" || returnedGate !== "")) throw new Error("native gate context does not match the requested gate");
907
1300
  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");
908
- return { allowed: body.allowed, result: gateResult, action, reason, gateContext };
1301
+ return { allowed: body.allowed as boolean, result: gateResult, action, reason, gateContext, ...(delivery === undefined ? {} : { delivery }) };
909
1302
  });
910
1303
  }
911
1304
 
@@ -938,6 +1331,24 @@ export class NativeReviewCliV214 {
938
1331
  return status;
939
1332
  }
940
1333
 
1334
+ // Dark until the negotiated version reports `mode: true` (Design Decision
1335
+ // #7, organic-rdd-parity). `status` is read-only (no timeout suppression,
1336
+ // exact fixed argv per the design's data flow); `enable`/`disable` mutate
1337
+ // and always pass `--scope clone` so Pi's own kill-switch command surface
1338
+ // never mutates the operator's global gentle-ai state across other clones.
1339
+ async reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
1340
+ await this.verifyVersion(request.cwd, request.signal, ["mode"]);
1341
+ const mutating = request.operation !== NATIVE_REVIEW_MODE_OPERATION.STATUS;
1342
+ const { body } = await this.execute(
1343
+ NATIVE_REVIEW_OPERATION.MODE,
1344
+ request.cwd,
1345
+ ["review", "mode", request.operation, "--cwd", request.cwd, ...(mutating ? ["--scope", "clone"] : []), "--json"],
1346
+ mutating,
1347
+ request.signal,
1348
+ );
1349
+ return decode(NATIVE_REVIEW_OPERATION.MODE, mutating, () => decodeNativeReviewMode(body, request.operation));
1350
+ }
1351
+
941
1352
  async sddStatus(request: NativeSddStatusRequest): Promise<NativeSddStatusResult> {
942
1353
  await this.verifyVersion(request.cwd, request.signal, ["sddStatus"]);
943
1354
  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);
@@ -966,9 +1377,16 @@ export class NativeReviewCliV214 {
966
1377
  nonNegativeInteger(remediation.generation); nonNegativeInteger(remediation.fixBatch);
967
1378
  if (remediation.correctionBudget !== undefined) nonNegativeInteger(remediation.correctionBudget);
968
1379
  let reviewGateResult: string | undefined;
1380
+ let reviewGateDelivery: string | undefined;
969
1381
  if (body.reviewGate !== undefined) {
970
- const gate = exactObject(body.reviewGate, ["result", "reason"]);
1382
+ // `delivery` (present since 09e4b14c, gentle-ai v2.2.0+) is emitted
1383
+ // when the kill switch is off: gentle-ai's status struct carries
1384
+ // `Delivery` with `json:"delivery,omitempty"`, so an exact-key decode
1385
+ // without it rejects that payload outright. Pre-existing bug fix
1386
+ // (Phase 13.13, migrate-review-integration-v2).
1387
+ const gate = exactObject(body.reviewGate, ["result", "reason"], ["delivery"]);
971
1388
  reviewGateResult = enumString(gate.result, NATIVE_GATE_RESULT); requiredString(gate.reason);
1389
+ if (gate.delivery !== undefined) reviewGateDelivery = enumString(gate.delivery, ["disabled/unmanaged"]);
972
1390
  }
973
1391
  if (body.reviewTransaction !== undefined) decodeReviewTransaction(body.reviewTransaction);
974
1392
  if (body.phaseInstructions !== undefined) {
@@ -986,7 +1404,13 @@ export class NativeReviewCliV214 {
986
1404
  ready:
987
1405
  (NATIVE_SDD_POST_REVIEW_ACTION as readonly string[]).includes(nextRecommended) &&
988
1406
  blockedReasons.length === 0 &&
989
- reviewGateResult === "allow",
1407
+ // With the kill switch off, the gate result can never become
1408
+ // "allow" (delivery follows ordinary repository policy instead) —
1409
+ // gated only on `result === "allow"` this deadlocked `ready`
1410
+ // forever, the same deadlock gentle-ai fixed upstream in 2c18fa10.
1411
+ // `disabled/unmanaged` delivery is the legitimate non-blocking
1412
+ // terminal state and must also unblock readiness.
1413
+ (reviewGateResult === "allow" || reviewGateDelivery === "disabled/unmanaged"),
990
1414
  };
991
1415
  });
992
1416
  }
@@ -1179,11 +1603,11 @@ export function nativeReviewLegacyAliasRepairAuthorization(request: Pick<NativeR
1179
1603
  }
1180
1604
 
1181
1605
  export class NativeReviewIntegrationError extends Error {
1182
- readonly failureEnvelope: ReviewFailureV1;
1183
- readonly mutationOutcome: ReviewFailureV1["mutationOutcome"];
1606
+ readonly failureEnvelope: ReviewFailureV2;
1607
+ readonly mutationOutcome: ReviewFailureV2["mutationOutcome"];
1184
1608
  readonly nextAction: string;
1185
1609
  readonly launchAttempted = true;
1186
- constructor(failure: ReviewFailureV1) {
1610
+ constructor(failure: ReviewFailureV2) {
1187
1611
  super(failure.message);
1188
1612
  this.name = "NativeReviewIntegrationError";
1189
1613
  this.failureEnvelope = failure;
@@ -1192,8 +1616,22 @@ export class NativeReviewIntegrationError extends Error {
1192
1616
  }
1193
1617
  }
1194
1618
 
1619
+ // Raised when negotiated START answers `consent/v2` (action:
1620
+ // "consent_required") instead of `start/v3`. Pi stays headless and never
1621
+ // sends `--consent relay`, so this is a blocking question surfaced to the
1622
+ // caller rather than answered on its behalf.
1623
+ export class NativeReviewConsentRequiredError extends Error {
1624
+ readonly consent: ReviewConsentV2;
1625
+ readonly launchAttempted = true;
1626
+ constructor(consent: ReviewConsentV2) {
1627
+ super(consent.headline);
1628
+ this.name = "NativeReviewConsentRequiredError";
1629
+ this.consent = consent;
1630
+ }
1631
+ }
1632
+
1195
1633
  type NativeExecutableDigestResolver = (path: string) => string;
1196
- const nativeCapabilitiesByDigest = new Map<string, Promise<ReviewCapabilitiesV1>>();
1634
+ const nativeCapabilitiesByDigest = new Map<string, Promise<ReviewCapabilitiesV2>>();
1197
1635
 
1198
1636
  export function clearNativeReviewCapabilitiesCacheForTesting(): void {
1199
1637
  nativeCapabilitiesByDigest.clear();
@@ -1217,6 +1655,31 @@ interface NegotiatedExecution {
1217
1655
  exitCode: number;
1218
1656
  }
1219
1657
 
1658
+ function decodeNativeAdmittedResultManifest(value: unknown): NativeReviewAdmittedResultManifest {
1659
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new TypeError("native capture-result manifest must be an object");
1660
+ const body = value as Record<string, unknown>;
1661
+ const text = (key: string): string => {
1662
+ const found = body[key];
1663
+ 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`);
1664
+ return found;
1665
+ };
1666
+ const schema = text("schema");
1667
+ 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}`);
1668
+ const admission = text("admission_decision");
1669
+ if (admission !== "completed") throw new TypeError(`native capture-result manifest admission_decision must be completed, received ${admission}`);
1670
+ // Exactly one locator: a provider-owned path OR an opaque reference. Both or
1671
+ // neither means the manifest cannot be handed to FINALIZE.
1672
+ const hasPath = body.path !== undefined, hasReference = body.reference !== undefined;
1673
+ if (hasPath === hasReference) throw new TypeError("native capture-result manifest must carry exactly one of path or reference");
1674
+ return Object.freeze({
1675
+ schema,
1676
+ subjectHash: text("subject_hash"),
1677
+ admissionDecision: admission,
1678
+ ...(body.lens === undefined ? {} : { lens: text("lens") }),
1679
+ ...(hasPath ? { path: text("path") } : { reference: text("reference") }),
1680
+ });
1681
+ }
1682
+
1220
1683
  export class NativeReviewCliV216 implements NativeReviewCli {
1221
1684
  private readonly legacy: NativeReviewCliV214;
1222
1685
  private readonly adapter: ExecFileAdapter;
@@ -1270,6 +1733,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1270
1733
  mutating: boolean,
1271
1734
  signal: AbortSignal | undefined,
1272
1735
  path: string,
1736
+ toleratedStderr: readonly string[] = [],
1273
1737
  ): Promise<NegotiatedExecution> {
1274
1738
  let result: ExecFileResult;
1275
1739
  try {
@@ -1285,17 +1749,17 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1285
1749
  const body = parseJson(result.stdout, operation, mutating, diagnostics);
1286
1750
  if (result.exitCode !== 0) {
1287
1751
  try {
1288
- throw new NativeReviewIntegrationError(decodeReviewFailureV1(body));
1752
+ throw new NativeReviewIntegrationError(decodeReviewFailureV2(body));
1289
1753
  } catch (error) {
1290
1754
  if (error instanceof NativeReviewIntegrationError) throw error;
1291
1755
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.NON_ZERO, operation, mutating, "native negotiated operation failed without a valid failure envelope", result);
1292
1756
  }
1293
1757
  }
1294
- if (result.stderr.trim().length > 0) throw nativeError(NATIVE_REVIEW_ERROR_CODE.UNEXPECTED_STDERR, operation, mutating, "native process wrote stderr", result);
1758
+ 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);
1295
1759
  return { body, exitCode: result.exitCode };
1296
1760
  }
1297
1761
 
1298
- async capabilities(request: NativeCapabilitiesRequest = {}): Promise<ReviewCapabilitiesV1> {
1762
+ async capabilities(request: NativeCapabilitiesRequest = {}): Promise<ReviewCapabilitiesV2> {
1299
1763
  const executable = this.verifiedExecutable(NATIVE_REVIEW_OPERATION.VERSION, false);
1300
1764
  const cached = nativeCapabilitiesByDigest.get(executable.digest);
1301
1765
  if (cached !== undefined) return cached;
@@ -1308,7 +1772,22 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1308
1772
  request.signal,
1309
1773
  executable.path,
1310
1774
  );
1311
- return decode(NATIVE_REVIEW_OPERATION.VERSION, false, () => decodeReviewCapabilitiesV1(execution.body, executable.digest));
1775
+ // Two distinct half-upgraded-install failures (Design Decision #7,
1776
+ // migrate-review-integration-v2): a decode failure means the installed
1777
+ // runtime cannot even answer this shape (an older `.gentle-ai/` payload
1778
+ // that still speaks `unsupported_contract` for v2); a successful decode
1779
+ // whose package.version disagrees with the pin names both versions.
1780
+ let capabilities: ReviewCapabilitiesV2;
1781
+ try {
1782
+ capabilities = decodeReviewCapabilitiesV2(execution.body, executable.digest);
1783
+ } catch (error) {
1784
+ if (error instanceof NativeReviewCliError) throw error;
1785
+ 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`);
1786
+ }
1787
+ if (capabilities.packageVersion !== GENTLE_AI_VERSION) {
1788
+ 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}`);
1789
+ }
1790
+ return capabilities;
1312
1791
  })();
1313
1792
  nativeCapabilitiesByDigest.set(executable.digest, negotiation);
1314
1793
  try {
@@ -1325,6 +1804,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1325
1804
  arguments_: readonly string[],
1326
1805
  mutating: boolean,
1327
1806
  signal?: AbortSignal,
1807
+ toleratedStderr: readonly string[] = [],
1328
1808
  ): Promise<NegotiatedExecution> {
1329
1809
  const executable = this.verifiedExecutable(operation, mutating);
1330
1810
  await this.capabilities({ cwd, ...(signal === undefined ? {} : { signal }) });
@@ -1332,21 +1812,47 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1332
1812
  if (afterNegotiation.path !== executable.path || afterNegotiation.digest !== executable.digest) {
1333
1813
  throw nativeError(NATIVE_REVIEW_ERROR_CODE.IDENTITY_MISMATCH, operation, mutating, "native executable was replaced after capability negotiation", undefined, false);
1334
1814
  }
1335
- return this.invoke(operation, cwd, arguments_, mutating, signal, executable.path);
1815
+ return this.invoke(operation, cwd, arguments_, mutating, signal, executable.path, toleratedStderr);
1336
1816
  }
1337
1817
 
1338
1818
  async start(request: NativeStartRequest): Promise<NativeStartResult> {
1339
1819
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native START baseRef must be a non-empty, trimmed, NUL-free string");
1340
1820
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native START baseRef requires explicit committedOnly acknowledgement");
1341
1821
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native START committedOnly requires an explicit baseRef");
1822
+ // A negotiated START requires exactly one --contract, --target, and
1823
+ // --projection. The target is resolved here, from the same root START is
1824
+ // about to run in, rather than threaded in by the caller: the caller's
1825
+ // root and START's root differ whenever a candidate view is in play, and
1826
+ // a target frozen from the wrong root would name a snapshot this START
1827
+ // never inspects. targetStatus is read-only, so this costs one extra
1828
+ // read and cannot create authority.
1829
+ const projection = request.projection ?? "workspace";
1830
+ const target = await this.targetStatus({
1831
+ cwd: request.cwd,
1832
+ projection,
1833
+ ...(request.baseRef === undefined ? {} : { baseRef: request.baseRef }),
1834
+ ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }),
1835
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
1836
+ });
1342
1837
  const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.START, request.cwd, [
1343
1838
  "review", "start", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
1839
+ "--target", target.targetIdentity, "--projection", projection,
1344
1840
  ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]),
1345
1841
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1346
1842
  ...(request.policyPath === undefined ? [] : ["--policy", request.policyPath]),
1347
1843
  ...(request.focus === undefined ? [] : ["--focus", request.focus]),
1348
- ], true, request.signal);
1349
- const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV1(execution.body));
1844
+ // A headless START that reviews without asking still succeeds; its
1845
+ // consent notices are console output, never a failure signal.
1846
+ ], true, request.signal, REVIEW_CONSENT_NOTICES);
1847
+ // A negotiated v2 START may answer `consent/v2` (action:
1848
+ // "consent_required") instead of `start/v3` when the provider needs an
1849
+ // explicit answer it cannot infer. Discriminated BEFORE decode: the two
1850
+ // envelopes share no required shape, and Pi never sends `--consent
1851
+ // relay`, so this is always surfaced to the caller, never answered here.
1852
+ if (execution.body.action === "consent_required") {
1853
+ throw new NativeReviewConsentRequiredError(decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewConsentV2(execution.body)));
1854
+ }
1855
+ const result = decode(NATIVE_REVIEW_OPERATION.START, true, () => decodeReviewStartV3(execution.body));
1350
1856
  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");
1351
1857
  return {
1352
1858
  lineageId: result.lineageId,
@@ -1359,6 +1865,19 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1359
1865
  action: result.action as NativeStartAction,
1360
1866
  lensesRequired: result.lensesRequired,
1361
1867
  riskReasons: result.riskReasons.map((reason) => ({ ...reason })),
1868
+ // Derived, not received. `risk_reasons` is a required start/v2 field
1869
+ // already recomputed against the authoritative frozen snapshot, so
1870
+ // these phrases describe the same candidate the lenses will review.
1871
+ ...(() => {
1872
+ const evidence = nativeRiskEvidencePhrases(result.riskLevel, result.riskReasons);
1873
+ return evidence.length === 0 ? {} : { riskEvidence: evidence };
1874
+ })(),
1875
+ // Only the empty-candidate recovery is reconstructed. Its sibling
1876
+ // tells a plain caller to rerun under the negotiated contract, which
1877
+ // this client already did, so relaying it would name a step that
1878
+ // changes nothing. A committed-only start (baseRef) is already the
1879
+ // recovery, and reporting zero changes there is a real answer.
1880
+ ...(result.changedFiles === 0 && request.baseRef === undefined ? { hint: REVIEW_EMPTY_CANDIDATE_HINT } : {}),
1362
1881
  raw: result.raw,
1363
1882
  };
1364
1883
  }
@@ -1390,14 +1909,20 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1390
1909
  const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.FINALIZE, request.cwd, [
1391
1910
  "review", "finalize", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
1392
1911
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1393
- ...resultFiles.flatMap((path) => ["--result", path]),
1912
+ // `--result` is RETIRED. A reviewer result supplied that way carries
1913
+ // no provider-owned admission, so it cannot prove the lens inspected
1914
+ // the frozen candidate. Results reach authority through
1915
+ // `review capture-result`, and FINALIZE either discovers them
1916
+ // (`--captured-results`) or is handed each manifest in lens order.
1917
+ ...(request.capturedResults === true ? ["--captured-results=true"] : []),
1918
+ ...(request.resultArtifactFiles ?? []).flatMap((path) => ["--result-artifact-file", path]),
1394
1919
  ...(refuterFile === undefined ? [] : ["--refuter", refuterFile]),
1395
1920
  ...(request.correctionLines === undefined ? [] : ["--correction-lines", String(request.correctionLines)]),
1396
1921
  ...(validationFile === undefined ? [] : ["--validation", validationFile]),
1397
1922
  ...(evidenceFile === undefined ? [] : ["--evidence", evidenceFile]),
1398
1923
  ...(request.failed === true ? ["--failed"] : []),
1399
1924
  ], true, request.signal);
1400
- const envelope = decode(NATIVE_REVIEW_OPERATION.FINALIZE, true, () => decodeReviewOperationV1(execution.body));
1925
+ const envelope = decode(NATIVE_REVIEW_OPERATION.FINALIZE, true, () => decodeReviewOperationV2(execution.body));
1401
1926
  if (envelope.operation !== "review.finalize") throw new Error("wrong finalize operation envelope");
1402
1927
  const body = envelope.result;
1403
1928
  const lineageId = requiredString(body.lineage_id);
@@ -1407,6 +1932,12 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1407
1932
  state: requiredString(body.state),
1408
1933
  action: requiredString(body.action),
1409
1934
  storeRevision: requiredString(body.store_revision),
1935
+ // Present only when state is correction_required (validated by
1936
+ // decodeReviewOperationV2); carried through as the raw validated
1937
+ // shape rather than re-decoded, since the typed decoder for this
1938
+ // nested object is private to lib/review-integration-v2.ts.
1939
+ ...(body.validation_request === undefined ? {} : { validationRequest: body.validation_request as Readonly<Record<string, unknown>> }),
1940
+ ...(body.escalation === undefined ? {} : { escalation: requiredString(body.escalation) }),
1410
1941
  };
1411
1942
  } finally {
1412
1943
  if (directory !== undefined) await this.cleanupDirectory(directory).catch(() => undefined);
@@ -1419,7 +1950,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1419
1950
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1420
1951
  ...(request.flags ?? []),
1421
1952
  ], false, request.signal);
1422
- const envelope = decode(NATIVE_REVIEW_OPERATION.VALIDATE, false, () => decodeReviewOperationV1(execution.body));
1953
+ const envelope = decode(NATIVE_REVIEW_OPERATION.VALIDATE, false, () => decodeReviewOperationV2(execution.body));
1423
1954
  if (envelope.operation !== "review.validate") throw new Error("wrong validate operation envelope");
1424
1955
  const body = envelope.result;
1425
1956
  const gateContext = decodeGateContext(body.context);
@@ -1429,6 +1960,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1429
1960
  action: requiredString(body.action),
1430
1961
  reason: requiredString(body.reason),
1431
1962
  gateContext,
1963
+ ...(body.delivery === undefined ? {} : { delivery: enumString(body.delivery, ["disabled/unmanaged"]) as NativeValidateResult["delivery"] }),
1432
1964
  };
1433
1965
  }
1434
1966
 
@@ -1438,7 +1970,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1438
1970
  "--change", request.change, "--lineage", request.lineage,
1439
1971
  `--expected-binding-revision=${request.expectedBindingRevision}`,
1440
1972
  ], true, request.signal);
1441
- const envelope = decode(NATIVE_REVIEW_OPERATION.BIND_SDD, true, () => decodeReviewOperationV1(execution.body));
1973
+ const envelope = decode(NATIVE_REVIEW_OPERATION.BIND_SDD, true, () => decodeReviewOperationV2(execution.body));
1442
1974
  if (envelope.operation !== "review.bind_sdd") throw new Error("wrong bind-sdd operation envelope");
1443
1975
  const body = envelope.result;
1444
1976
  const gateContext = decodeGateContext(body.gate_context);
@@ -1455,14 +1987,97 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1455
1987
  };
1456
1988
  }
1457
1989
 
1458
- async targetStatus(request: NativeTargetStatusRequest): Promise<ReviewStatusV1> {
1990
+ async targetStatus(request: NativeTargetStatusRequest): Promise<ReviewStatusV3> {
1459
1991
  const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.STATUS, request.cwd, [
1460
1992
  "review", "status", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd,
1461
1993
  "--projection", request.projection ?? "workspace",
1462
1994
  ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef]),
1463
1995
  ...(request.lineageId === undefined ? [] : ["--lineage", request.lineageId]),
1996
+ "--next-transition",
1997
+ ], false, request.signal);
1998
+ assertSupportedNextTransitionOperation(execution.body);
1999
+ return decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeReviewStatusV3(execution.body));
2000
+ }
2001
+
2002
+ // Net-new negotiated `review.repair`: preflight first, execute only when
2003
+ // the assessment is eligible, using exactly the provider_inputs that
2004
+ // preflight published (Design Decision #6, migrate-review-integration-v2).
2005
+ // Argv shape beyond --mode is inferred from provider_inputs' own field
2006
+ // names — no repair/v2 fixture is mirrored upstream to ground-truth it
2007
+ // against (design.md Open Questions); this is a documented risk.
2008
+ async repair(request: NativeReviewRepairRequest): Promise<ReviewRepairV2> {
2009
+ const preflightExecution = await this.negotiated(NATIVE_REVIEW_OPERATION.REPAIR, request.cwd, [
2010
+ "review", "repair", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd, "--mode", "preflight",
1464
2011
  ], false, request.signal);
1465
- return decode(NATIVE_REVIEW_OPERATION.STATUS, false, () => decodeReviewStatusV1(execution.body));
2012
+ const preflight = decode(NATIVE_REVIEW_OPERATION.REPAIR, false, () => decodeReviewRepairV2(preflightExecution.body));
2013
+ if (preflight.mode !== "preflight") throw new Error("wrong repair preflight discriminator");
2014
+ if (preflight.assessment.status !== "eligible" || preflight.providerInputs === undefined) return preflight;
2015
+ const providerInputs = preflight.providerInputs;
2016
+ const executeExecution = await this.negotiated(NATIVE_REVIEW_OPERATION.REPAIR, request.cwd, [
2017
+ "review", "repair", "--contract", REVIEW_INTEGRATION_CONTRACT, "--cwd", request.cwd, "--mode", "execute",
2018
+ "--lineage", providerInputs.lineageId,
2019
+ "--expected-revision", providerInputs.expectedRevision,
2020
+ "--cause", providerInputs.cause,
2021
+ "--disposition", providerInputs.disposition,
2022
+ "--repository-binding", providerInputs.repositoryBinding,
2023
+ "--actor", request.actor,
2024
+ "--reason", request.reason,
2025
+ "--maintainer-authorization", request.maintainerAuthorization,
2026
+ ], true, request.signal);
2027
+ return decode(NATIVE_REVIEW_OPERATION.REPAIR, true, () => decodeReviewRepairV2(executeExecution.body));
2028
+ }
2029
+
2030
+ // `review capture-evidence`: the evidence-first correction lifecycle's
2031
+ // collection step. Confirmed argv and response shape against a real v2.2.2
2032
+ // review run (lineage review-b39d803b68a90767): exactly
2033
+ // --cwd/--lineage/--target/--expected-revision/--outcome/--input, no
2034
+ // --contract, and the verification-evidence/v2 record returned DIRECTLY —
2035
+ // not wrapped in an operation/v2 envelope. Evidence is staged through the
2036
+ // same 0o600 tmpfile discipline FINALIZE uses.
2037
+ async captureResult(request: NativeReviewCaptureResultRequest): Promise<NativeReviewAdmittedResultManifest> {
2038
+ if (request.argumentTokens.length === 0) throw new TypeError("Native CAPTURE_RESULT requires the provider-issued argument tokens");
2039
+ 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");
2040
+ if (request.resultDocument.length === 0) throw new TypeError("Native CAPTURE_RESULT result document must contain at least one byte");
2041
+ const carriesContext = request.argumentTokens.some((token) => token === "--repository-context" || token.startsWith("--repository-context="));
2042
+ if (carriesContext && request.cwd !== undefined) throw new TypeError("Native CAPTURE_RESULT takes a repository context or --cwd, never both");
2043
+ const directory = await mkdtemp(join(tmpdir(), "gentle-ai-capture-result-"));
2044
+ try {
2045
+ await chmod(directory, 0o700);
2046
+ const resultFile = join(directory, "result.json");
2047
+ await writeFile(resultFile, request.resultDocument, { encoding: "utf8", mode: 0o600 });
2048
+ await chmod(resultFile, 0o600);
2049
+ const executable = this.verifiedExecutable(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true);
2050
+ const execution = await this.invoke(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, request.cwd ?? process.cwd(), [
2051
+ "review", "capture-result",
2052
+ ...request.argumentTokens,
2053
+ ...(carriesContext || request.cwd === undefined ? [] : ["--cwd", request.cwd]),
2054
+ "--input", resultFile,
2055
+ ], true, request.signal, executable.path);
2056
+ return decode(NATIVE_REVIEW_OPERATION.CAPTURE_RESULT, true, () => decodeNativeAdmittedResultManifest(execution.body));
2057
+ } finally {
2058
+ await this.cleanupDirectory(directory).catch(() => undefined);
2059
+ }
2060
+ }
2061
+
2062
+ async captureEvidence(request: NativeReviewCaptureEvidenceRequest): Promise<NativeReviewVerificationEvidenceV2> {
2063
+ 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");
2064
+ if (request.evidenceDocument.length === 0) throw new TypeError("Native CAPTURE_EVIDENCE evidence must contain at least one byte");
2065
+ const directory = await mkdtemp(join(tmpdir(), "gentle-ai-capture-evidence-"));
2066
+ try {
2067
+ await chmod(directory, 0o700);
2068
+ const evidenceFile = await this.stageEvidence(directory, request.evidenceDocument);
2069
+ const execution = await this.negotiated(NATIVE_REVIEW_OPERATION.CAPTURE_EVIDENCE, request.cwd, [
2070
+ "review", "capture-evidence", "--cwd", request.cwd,
2071
+ "--lineage", request.lineageId,
2072
+ "--target", request.targetIdentity,
2073
+ "--expected-revision", request.expectedRevision,
2074
+ "--outcome", request.outcome,
2075
+ "--input", evidenceFile,
2076
+ ], true, request.signal);
2077
+ return decode(NATIVE_REVIEW_OPERATION.CAPTURE_EVIDENCE, true, () => decodeNativeReviewVerificationEvidence(execution.body));
2078
+ } finally {
2079
+ await this.cleanupDirectory(directory).catch(() => undefined);
2080
+ }
1466
2081
  }
1467
2082
 
1468
2083
  reviewStatus(request: NativeReviewStatusRequest): Promise<NativeReviewStatusResult> {
@@ -1473,6 +2088,13 @@ export class NativeReviewCliV216 implements NativeReviewCli {
1473
2088
  return this.legacy.sddStatus(request);
1474
2089
  }
1475
2090
 
2091
+ // Same plain-CLI delegation as reviewStatus/sddStatus above: `review mode`
2092
+ // is dark until the negotiated version reports `mode: true` and stays
2093
+ // outside the negotiated integration-v1 contract.
2094
+ reviewMode(request: NativeReviewModeRequest): Promise<NativeReviewModeResult> {
2095
+ return this.legacy.reviewMode(request);
2096
+ }
2097
+
1476
2098
  // Recovery commands are version-gated plain CLI operations outside the
1477
2099
  // negotiated integration-v1 contract, exactly like reviewStatus/sddStatus.
1478
2100
  reclaim(request: NativeReviewReclaimRequest): Promise<NativeReviewRecoveryResult> {