gentle-pi 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +70 -12
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -11,6 +11,7 @@ import {
11
11
  NativeReviewCliV214 as NativeReviewCliV214Production,
12
12
  NativeReviewCliV216,
13
13
  clearNativeReviewCapabilitiesCacheForTesting,
14
+ nativeReviewAbandonAuthorization,
14
15
  nativeReviewLegacyAliasRepairAuthorization,
15
16
  type ExecFileAdapter,
16
17
  type NativeReviewCli,
@@ -69,7 +70,23 @@ const LEGACY_ALIAS_AUTHORIZATION = [
69
70
  "actor=maintainer",
70
71
  "reason=quarantine approved historical alias",
71
72
  ].join("\n");
73
+ const ABANDON_DISCARDED_WORK = {
74
+ capturedLensResults: ["00-risk.json", "01-readability.json"],
75
+ findingsPresent: true,
76
+ evidenceRecordsPresent: false,
77
+ } as const;
72
78
  const ABANDON_AUTHORIZATION = [
79
+ "gentle-ai.review-abandon-authorization/v2",
80
+ "lineage=pristine",
81
+ "revision=revision",
82
+ "snapshot_identity=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
83
+ "reason=retire pristine lineage",
84
+ "captured_lens_results=00-risk.json,01-readability.json",
85
+ "findings_present=true",
86
+ "evidence_records_present=false",
87
+ "actor=maintainer",
88
+ ].join("\n");
89
+ const LEGACY_V1_ABANDON_AUTHORIZATION = [
73
90
  "gentle-ai.review-abandon-authorization/v1",
74
91
  "lineage=pristine",
75
92
  "revision=revision",
@@ -77,6 +94,16 @@ const ABANDON_AUTHORIZATION = [
77
94
  "actor=maintainer",
78
95
  "reason=retire pristine lineage",
79
96
  ].join("\n");
97
+ const ABANDON_REQUEST = {
98
+ cwd: "/repo",
99
+ lineage: "pristine",
100
+ expectedRevision: "revision",
101
+ snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
102
+ ...ABANDON_DISCARDED_WORK,
103
+ actor: "maintainer",
104
+ reason: "retire pristine lineage",
105
+ maintainerAuthorization: ABANDON_AUTHORIZATION,
106
+ } as const;
80
107
  const LEGACY_QUARANTINE_AUTHORIZATION = [
81
108
  "gentle-ai.review-legacy-quarantine-authorization/v1",
82
109
  "repository=/repo",
@@ -97,6 +124,26 @@ const RECONCILE_AUTHORIZATION = [
97
124
  "reason=invalid recovery edge",
98
125
  ].join("\n");
99
126
  const COMBINED_RECONCILE_AUTHORIZATION = `${RECONCILE_AUTHORIZATION}\nanomalies=${COMBINED_RECONCILE_ANOMALIES}`;
127
+ const RECOVER_TARGET_IDENTITY = `sha256:${"e".repeat(64)}`;
128
+ // The six canonical native recovery inputs, and nothing else. INSPECT never
129
+ // publishes the legacy RESET quartet for a compact-v2 recovery, so this is the
130
+ // complete request a maintainer can actually assemble (issue #212).
131
+ const RECOVER_INPUT = {
132
+ predecessorLineage: "broken",
133
+ expectedPredecessorRevision: "rev-1",
134
+ successorLineage: "successor",
135
+ disposition: "invalidated",
136
+ actor: "maintainer",
137
+ reason: "invalid authority",
138
+ } as const;
139
+ const RECOVER_AUTHORIZATION = [
140
+ "gentle-ai.review-recovery-authorization/v1",
141
+ "predecessor_lineage=broken",
142
+ "predecessor_revision=rev-1",
143
+ `target_identity=${RECOVER_TARGET_IDENTITY}`,
144
+ "actor=maintainer",
145
+ "reason=invalid authority",
146
+ ].join("\n");
100
147
 
101
148
  function scratchDir(prefix: string): string {
102
149
  const dir = mkdtempSync(join(tmpdir(), prefix));
@@ -106,8 +153,9 @@ function scratchDir(prefix: string): string {
106
153
 
107
154
  interface RecordedNativeCall { operation: "reclaim" | "recover" | "reconcileAuthority" | "abandon" | "quarantineLegacy"; request: Record<string, unknown>; }
108
155
 
109
- function fakeRecoveryNative(record: Record<string, unknown>): { native: NativeReviewCli; calls: RecordedNativeCall[] } {
156
+ function fakeRecoveryNative(record: Record<string, unknown>): { native: NativeReviewCli; calls: RecordedNativeCall[]; statusReads: Array<string | undefined> } {
110
157
  const calls: RecordedNativeCall[] = [];
158
+ const statusReads: Array<string | undefined> = [];
111
159
  const native = {
112
160
  async reclaim(request: Record<string, unknown>) {
113
161
  calls.push({ operation: "reclaim", request });
@@ -130,14 +178,23 @@ function fakeRecoveryNative(record: Record<string, unknown>): { native: NativeRe
130
178
  return { record };
131
179
  },
132
180
  async targetStatus(request: { lineageId?: string }) {
133
- return {
134
- action: "recover",
135
- actionDisposition: "invalidated",
136
- authority: { lineageId: request.lineageId ?? "broken", revision: "rev-1" },
137
- } as unknown;
181
+ statusReads.push(request.lineageId);
182
+ return recoveryTargetStatus(request.lineageId ?? "broken");
138
183
  },
139
184
  } as unknown as NativeReviewCli;
140
- return { native, calls };
185
+ return { native, calls, statusReads };
186
+ }
187
+
188
+ /** One recovery-eligible native target status, shaped as the provider publishes it. */
189
+ function recoveryTargetStatus(lineageId: string, overrides: Record<string, unknown> = {}): unknown {
190
+ return {
191
+ action: "recover",
192
+ actionDisposition: "invalidated",
193
+ authority: { lineageId, revision: "rev-1" },
194
+ targetIdentity: RECOVER_TARGET_IDENTITY,
195
+ raw: { action: "recover", target_identity: RECOVER_TARGET_IDENTITY },
196
+ ...overrides,
197
+ };
141
198
  }
142
199
 
143
200
  async function runControllerOperation(
@@ -156,6 +213,39 @@ async function runControllerOperation(
156
213
  );
157
214
  }
158
215
 
216
+ /**
217
+ * The registered `gentle_review` tool, driven exactly as Pi drives it. RECOVER
218
+ * regressions hide from `executeReviewControllerOperation` alone, because the
219
+ * defect in issue #212 lived in the authorization preflight the tool runs first.
220
+ */
221
+ function recoveryController(native: NativeReviewCli, prefix: string): (id: string, parameters: Record<string, unknown>, context: ExtensionContext) => Promise<Record<string, unknown>> {
222
+ const tools = new Map<string, { execute: (id: string, params: unknown, signal: undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<unknown> }>();
223
+ const pi = { on() {}, registerTool(definition: { name: string; execute: never }) { tools.set(definition.name, definition as never); }, registerCommand() {} } as unknown as ExtensionAPI;
224
+ createGentleAiExtension({ nativeReviewCli: native })(pi);
225
+ const controller = tools.get("gentle_review");
226
+ assert.ok(controller);
227
+ const cwd = scratchDir(prefix);
228
+ return async (id, parameters, context) => {
229
+ const result = await controller.execute(id, parameters, undefined, undefined, { ...context, cwd } as unknown as ExtensionContext) as { details: Record<string, unknown> };
230
+ return result.details;
231
+ };
232
+ }
233
+
234
+ /** An interactive Pi context that records every prompt it is shown. */
235
+ function interactiveContext(prompts: string[], approve = true): ExtensionContext {
236
+ return {
237
+ hasUI: true,
238
+ ui: {
239
+ async confirm(_title: string, message: string) {
240
+ prompts.push(message);
241
+ return approve;
242
+ },
243
+ },
244
+ } as unknown as ExtensionContext;
245
+ }
246
+
247
+ const HEADLESS_CONTEXT = { hasUI: false, ui: { confirm: async () => true } } as unknown as ExtensionContext;
248
+
159
249
  test("native reclaim wrapper issues the exact review reclaim command and returns the audit record", async () => {
160
250
  const { adapter, calls } = queuedAdapter([VERSION_219, { stdout: JSON.stringify(RECLAIM_RECORD) }]);
161
251
  const cli = new NativeReviewCliV214(adapter);
@@ -249,7 +339,7 @@ test("native v2.1.9 maintenance wrappers use exact argv and published authorizat
249
339
  VERSION_219, { stdout: JSON.stringify({ operation: "review/reconcile-authority", record: RECONCILE_RECORD }) },
250
340
  ]);
251
341
  const cli = new NativeReviewCliV214(adapter);
252
- assert.deepEqual((await cli.abandon!({ cwd: "/repo", lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", actor: "maintainer", reason: "retire pristine lineage", maintainerAuthorization: ABANDON_AUTHORIZATION })).record, ABANDON_RECORD);
342
+ assert.deepEqual((await cli.abandon!({ ...ABANDON_REQUEST })).record, ABANDON_RECORD);
253
343
  assert.deepEqual((await cli.quarantineLegacy!({ cwd: "/repo", repository: "/repo", lineage: "legacy", expectedRevision: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", diagnostic: LEGACY_FREEZE_DIAGNOSTIC, disposition: LEGACY_FREEZE_DISPOSITION, actor: "maintainer", reason: "quarantine malformed legacy freeze", maintainerAuthorization: LEGACY_QUARANTINE_AUTHORIZATION })).record, LEGACY_QUARANTINE_RECORD);
254
344
  assert.deepEqual((await cli.reconcileAuthority!({ cwd: "/repo", predecessorLineage: "predecessor", expectedPredecessorRevision: "predecessor-revision", successorLineage: "successor", expectedSuccessorRevision: "successor-revision", actor: "maintainer", reason: "invalid recovery edge", anomalies: COMBINED_RECONCILE_ANOMALIES, maintainerAuthorization: COMBINED_RECONCILE_AUTHORIZATION })).record, RECONCILE_RECORD);
255
345
  assert.deepEqual(calls.filter((call) => call.arguments[0] === "review").map((call) => call.arguments), [
@@ -350,7 +440,7 @@ test("native repair-legacy-alias fails closed for stale bindings, malformed outp
350
440
 
351
441
  test("native v2.1.9 maintenance wrappers fail closed before launch for unsupported versions and invalid bindings", async () => {
352
442
  const unsupported = queuedAdapter([{ stdout: "gentle-ai 2.1.8\n" }]);
353
- await assert.rejects(() => new NativeReviewCliV214(unsupported.adapter).abandon!({ cwd: "/repo", lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", actor: "maintainer", reason: "retire pristine lineage", maintainerAuthorization: ABANDON_AUTHORIZATION }), NativeReviewCliError);
443
+ await assert.rejects(() => new NativeReviewCliV214(unsupported.adapter).abandon!({ ...ABANDON_REQUEST }), NativeReviewCliError);
354
444
  const invalid = queuedAdapter([]);
355
445
  await assert.rejects(() => new NativeReviewCliV214(invalid.adapter).reconcileAuthority!({ cwd: "/repo", predecessorLineage: "predecessor", expectedPredecessorRevision: "predecessor-revision", successorLineage: "successor", expectedSuccessorRevision: "successor-revision", actor: "maintainer", reason: "invalid recovery edge", anomalies: "malformed_recovery_authorization,unchanged_target", maintainerAuthorization: COMBINED_RECONCILE_AUTHORIZATION }), TypeError);
356
446
  assert.equal(invalid.calls.length, 0);
@@ -358,14 +448,14 @@ test("native v2.1.9 maintenance wrappers fail closed before launch for unsupport
358
448
 
359
449
  test("native v2.1.9 maintenance wrappers preserve only valid prepared audit records on partial failures", async () => {
360
450
  for (const [operation, invoke, result] of [
361
- ["review/abandon", (cli: NativeReviewCliV214) => cli.abandon!({ cwd: "/repo", lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", actor: "maintainer", reason: "retire pristine lineage", maintainerAuthorization: ABANDON_AUTHORIZATION }), ABANDON_RECORD],
451
+ ["review/abandon", (cli: NativeReviewCliV214) => cli.abandon!({ ...ABANDON_REQUEST }), ABANDON_RECORD],
362
452
  ["review/quarantine-legacy", (cli: NativeReviewCliV214) => cli.quarantineLegacy!({ cwd: "/repo", repository: "/repo", lineage: "legacy", expectedRevision: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", diagnostic: LEGACY_FREEZE_DIAGNOSTIC, disposition: LEGACY_FREEZE_DISPOSITION, actor: "maintainer", reason: "quarantine malformed legacy freeze", maintainerAuthorization: LEGACY_QUARANTINE_AUTHORIZATION }), LEGACY_QUARANTINE_RECORD],
363
453
  ] as const) {
364
454
  const queue = queuedAdapter([VERSION_219, { stdout: JSON.stringify({ operation, record: result }), stderr: "quarantine interrupted", exitCode: 1 }]);
365
455
  await assert.rejects(() => invoke(new NativeReviewCliV214(queue.adapter)), (error: unknown) => error instanceof NativeReviewCliError && error.mutationOutcome === "unknown" && error.nextAction === "review.status" && error.auditRecord?.schema === result.schema);
366
456
  }
367
457
  const malformed = queuedAdapter([VERSION_219, { stdout: JSON.stringify({ operation: "review/abandon" }), exitCode: 1 }]);
368
- await assert.rejects(() => new NativeReviewCliV214(malformed.adapter).abandon!({ cwd: "/repo", lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", actor: "maintainer", reason: "retire pristine lineage", maintainerAuthorization: ABANDON_AUTHORIZATION }), (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE && error.auditRecord === undefined);
458
+ await assert.rejects(() => new NativeReviewCliV214(malformed.adapter).abandon!({ ...ABANDON_REQUEST }), (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.SCHEMA_INCOMPATIBLE && error.auditRecord === undefined);
369
459
  });
370
460
 
371
461
  test("native abandon forwards cancellation and preserves the unknown mutation outcome", async () => {
@@ -379,7 +469,75 @@ test("native abandon forwards cancellation and preserves the unknown mutation ou
379
469
  error.name = "AbortError";
380
470
  throw error;
381
471
  };
382
- await assert.rejects(() => new NativeReviewCliV214(adapter).abandon!({ cwd: "/repo", lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", actor: "maintainer", reason: "retire pristine lineage", maintainerAuthorization: ABANDON_AUTHORIZATION, signal: controller.signal }), (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.CANCELLED && error.mutationOutcome === "unknown" && error.nextAction === "review.status");
472
+ await assert.rejects(() => new NativeReviewCliV214(adapter).abandon!({ ...ABANDON_REQUEST, signal: controller.signal }), (error: unknown) => error instanceof NativeReviewCliError && error.code === NATIVE_REVIEW_ERROR_CODE.CANCELLED && error.mutationOutcome === "unknown" && error.nextAction === "review.status");
473
+ });
474
+
475
+ test("native abandon authorization emits the exact nine-line v2 discarded-work binding", () => {
476
+ assert.equal(
477
+ nativeReviewAbandonAuthorization({
478
+ lineage: "pristine",
479
+ expectedRevision: "revision",
480
+ snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
481
+ actor: " maintainer ",
482
+ reason: "operator_disposition",
483
+ capturedLensResults: ["00-risk.json", "01-readability.json"],
484
+ findingsPresent: true,
485
+ evidenceRecordsPresent: false,
486
+ }),
487
+ [
488
+ "gentle-ai.review-abandon-authorization/v2",
489
+ "lineage=pristine",
490
+ "revision=revision",
491
+ "snapshot_identity=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
492
+ "reason=operator_disposition",
493
+ "captured_lens_results=00-risk.json,01-readability.json",
494
+ "findings_present=true",
495
+ "evidence_records_present=false",
496
+ "actor=maintainer",
497
+ ].join("\n"),
498
+ );
499
+ assert.equal(
500
+ nativeReviewAbandonAuthorization({
501
+ lineage: "pristine",
502
+ expectedRevision: "revision",
503
+ snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
504
+ actor: "maintainer",
505
+ reason: "retired_schema",
506
+ capturedLensResults: [],
507
+ findingsPresent: false,
508
+ evidenceRecordsPresent: true,
509
+ }),
510
+ [
511
+ "gentle-ai.review-abandon-authorization/v2",
512
+ "lineage=pristine",
513
+ "revision=revision",
514
+ "snapshot_identity=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
515
+ "reason=retired_schema",
516
+ "captured_lens_results=",
517
+ "findings_present=false",
518
+ "evidence_records_present=true",
519
+ "actor=maintainer",
520
+ ].join("\n"),
521
+ );
522
+ });
523
+
524
+ test("native abandon refuses the legacy v1 authorization binding before process launch", async () => {
525
+ const { adapter, calls } = queuedAdapter([]);
526
+ await assert.rejects(
527
+ () => new NativeReviewCliV214(adapter).abandon!({ ...ABANDON_REQUEST, maintainerAuthorization: LEGACY_V1_ABANDON_AUTHORIZATION }),
528
+ TypeError,
529
+ );
530
+ assert.equal(calls.length, 0);
531
+ });
532
+
533
+ test("native abandon validates discarded-work inputs before process launch", async () => {
534
+ const { adapter, calls } = queuedAdapter([]);
535
+ const cli = new NativeReviewCliV214(adapter);
536
+ await assert.rejects(() => cli.abandon!({ ...ABANDON_REQUEST, capturedLensResults: "00-risk.json" as unknown as string[] }), /capturedLensResults/);
537
+ await assert.rejects(() => cli.abandon!({ ...ABANDON_REQUEST, capturedLensResults: ["00-risk.json", " padded "] }), /capturedLensResults/);
538
+ await assert.rejects(() => cli.abandon!({ ...ABANDON_REQUEST, findingsPresent: "true" as unknown as boolean }), /findingsPresent/);
539
+ await assert.rejects(() => cli.abandon!({ ...ABANDON_REQUEST, evidenceRecordsPresent: undefined as unknown as boolean }), /evidenceRecordsPresent/);
540
+ assert.equal(calls.length, 0);
383
541
  });
384
542
 
385
543
  test("native reconcile-authority refuses a mismatched authorization before process launch", async () => {
@@ -524,24 +682,23 @@ test("RESET without a native client fails closed as unavailable", async () => {
524
682
  assert.equal(details.mutation_performed, false);
525
683
  });
526
684
 
527
- test("RECOVER maps to native review recover with the successor authority binding", async () => {
528
- const { native, calls } = fakeRecoveryNative(RECOVER_RECORD);
529
- const details = await runControllerOperation({
530
- operation: "recover",
531
- input: JSON.stringify({
532
- repositoryId: "repo-id",
533
- commonDirHash: "c".repeat(64),
534
- inventoryHash: "d".repeat(64),
535
- confirmation: "DESTROY REVIEW AUTHORITY repo-id",
536
- predecessorLineage: "broken",
537
- expectedPredecessorRevision: "rev-1",
538
- successorLineage: "successor",
539
- disposition: "invalidated",
540
- actor: "maintainer",
541
- reason: "invalid authority",
542
- maintainerAuthorization: "binding",
543
- }),
544
- }, native);
685
+ // Issue #212: `authorizeDestructiveReviewOperation` classified native RECOVER as
686
+ // a legacy repository-wide RESET, so a correct six-field native recovery was
687
+ // refused with "Review controller recover requires an exact string repositoryId"
688
+ // before it ever reached the native router. INSPECT does not publish that
689
+ // quartet for compact-v2 recovery, which left the only supported flow
690
+ // unreachable. Reported by @PwnLabsmx, independently reproduced on macOS by
691
+ // @e-Evolution and again by the maintainer.
692
+ //
693
+ // Every RECOVER test below drives the registered tool rather than
694
+ // `executeReviewControllerOperation`, because the defect lived in the
695
+ // authorization preflight the tool runs before that function.
696
+ test("RECOVER accepts the six canonical native inputs without the legacy RESET challenge", async () => {
697
+ const { native, calls, statusReads } = fakeRecoveryNative(RECOVER_RECORD);
698
+ const run = recoveryController(native, "gentle-pi-recover-six-field-");
699
+ const prompts: string[] = [];
700
+ const details = await run("recover", { operation: "recover", input: JSON.stringify(RECOVER_INPUT) }, interactiveContext(prompts));
701
+ assert.equal(details.status, undefined, "the native six-field recovery contract is complete on its own");
545
702
  assert.equal(details.native_operation, "review recover");
546
703
  assert.equal(details.mutation_performed, true);
547
704
  assert.deepEqual(details.result, RECOVER_RECORD);
@@ -551,26 +708,144 @@ test("RECOVER maps to native review recover with the successor authority binding
551
708
  assert.equal(calls[0]?.request.expectedPredecessorRevision, "rev-1");
552
709
  assert.equal(calls[0]?.request.successorLineage, "successor");
553
710
  assert.equal(calls[0]?.request.disposition, "invalidated");
554
- assert.equal(calls[0]?.request.maintainerAuthorization, "binding");
711
+ // Pi derives the binding; it is never the caller's to supply.
712
+ assert.equal(calls[0]?.request.maintainerAuthorization, RECOVER_AUTHORIZATION);
713
+ assert.equal(prompts.length, 1);
714
+ assert.match(prompts[0]!, /gentle-ai\.review-recovery-authorization\/v1/);
715
+ assert.match(prompts[0]!, new RegExp(`target_identity=${RECOVER_TARGET_IDENTITY}`));
716
+ assert.match(prompts[0]!, /Provider-selected disposition: invalidated/);
717
+ assert.equal(prompts[0]!.includes("repositoryId"), false, "the legacy reset challenge has no place in native recovery");
718
+ // Status is read before approval and read again before mutation.
719
+ assert.deepEqual(statusReads, ["broken", "broken"]);
555
720
  });
556
721
 
557
- test("RECOVER surfaces every missing successor input including an unsupported disposition", async () => {
722
+ test("RECOVER rejects a caller-supplied maintainerAuthorization before reading or prompting", async () => {
723
+ const { native, calls, statusReads } = fakeRecoveryNative(RECOVER_RECORD);
724
+ const run = recoveryController(native, "gentle-pi-recover-caller-authorization-");
725
+ const prompts: string[] = [];
726
+ const details = await run(
727
+ "recover",
728
+ { operation: "recover", input: JSON.stringify({ ...RECOVER_INPUT, maintainerAuthorization: RECOVER_AUTHORIZATION }) },
729
+ interactiveContext(prompts),
730
+ );
731
+ assert.equal(details.status, "blocked");
732
+ assert.equal(details.outcome, "native-recovery-caller-authorization-rejected");
733
+ assert.equal(details.mutation_performed, false);
734
+ assert.equal(details.mutation_outcome, "none");
735
+ assert.equal(details.next_action, "resubmit-without-maintainer-authorization");
736
+ assert.equal(calls.length, 0);
737
+ assert.deepEqual(statusReads, []);
738
+ assert.deepEqual(prompts, []);
739
+ });
740
+
741
+ test("RECOVER fails closed without an interactive Pi UI", async () => {
742
+ const { native, calls, statusReads } = fakeRecoveryNative(RECOVER_RECORD);
743
+ const run = recoveryController(native, "gentle-pi-recover-headless-");
744
+ await assert.rejects(
745
+ run("recover", { operation: "recover", input: JSON.stringify(RECOVER_INPUT) }, HEADLESS_CONTEXT),
746
+ /Review controller RECOVER requires fresh explicit authorization through the interactive Pi UI; headless execution fails closed/,
747
+ );
748
+ assert.equal(calls.length, 0);
749
+ // Fresh native evidence is read, but nothing mutates without a human.
750
+ assert.deepEqual(statusReads, ["broken"]);
751
+ });
752
+
753
+ test("RECOVER refuses to mutate when the human declines the derived binding", async () => {
558
754
  const { native, calls } = fakeRecoveryNative(RECOVER_RECORD);
559
- const details = await runControllerOperation({
560
- operation: "recover",
561
- input: JSON.stringify({
562
- repositoryId: "repo-id",
563
- commonDirHash: "c".repeat(64),
564
- inventoryHash: "d".repeat(64),
565
- confirmation: "DESTROY REVIEW AUTHORITY repo-id",
566
- predecessorLineage: "broken",
567
- disposition: "not-a-disposition",
568
- }),
569
- }, native);
755
+ const run = recoveryController(native, "gentle-pi-recover-declined-");
756
+ const prompts: string[] = [];
757
+ await assert.rejects(
758
+ run("recover", { operation: "recover", input: JSON.stringify(RECOVER_INPUT) }, interactiveContext(prompts, false)),
759
+ /Review controller RECOVER was not explicitly authorized/,
760
+ );
761
+ assert.equal(prompts.length, 1);
762
+ assert.equal(calls.length, 0);
763
+ });
764
+
765
+ test("RECOVER refuses a foreign, stale, or no-longer-recoverable authority before prompting", async () => {
766
+ for (const [label, status] of [
767
+ ["foreign", recoveryTargetStatus("someone-elses-lineage")],
768
+ ["stale", recoveryTargetStatus("broken", { authority: { lineageId: "broken", revision: "rev-2" } })],
769
+ ["not recovery-eligible", recoveryTargetStatus("broken", { action: "start", actionDisposition: undefined })],
770
+ ] as const) {
771
+ const calls: Record<string, unknown>[] = [];
772
+ const native = {
773
+ async recover(request: Record<string, unknown>) { calls.push(request); return { record: RECOVER_RECORD }; },
774
+ async targetStatus() { return status; },
775
+ } as unknown as NativeReviewCli;
776
+ const run = recoveryController(native, `gentle-pi-recover-${label.replace(/[^a-z]+/g, "-")}-`);
777
+ const prompts: string[] = [];
778
+ const details = await run("recover", { operation: "recover", input: JSON.stringify(RECOVER_INPUT) }, interactiveContext(prompts));
779
+ assert.equal(details.status, "blocked", label);
780
+ assert.equal(details.outcome, "native-recovery-status-mismatch", label);
781
+ assert.equal(details.mutation_performed, false, label);
782
+ assert.equal(calls.length, 0, label);
783
+ assert.deepEqual(prompts, [], label);
784
+ }
785
+ });
786
+
787
+ test("RECOVER refuses a disposition the provider did not select", async () => {
788
+ const { native, calls } = fakeRecoveryNative(RECOVER_RECORD);
789
+ const run = recoveryController(native, "gentle-pi-recover-disposition-");
790
+ const prompts: string[] = [];
791
+ const details = await run("recover", { operation: "recover", input: JSON.stringify({ ...RECOVER_INPUT, disposition: "escalated" }) }, interactiveContext(prompts));
792
+ assert.equal(details.status, "blocked");
793
+ assert.equal(details.outcome, "native-recovery-disposition-mismatch");
794
+ assert.equal(details.provider_disposition, "invalidated");
795
+ assert.equal(details.mutation_performed, false);
796
+ assert.equal(calls.length, 0);
797
+ assert.deepEqual(prompts, []);
798
+ });
799
+
800
+ // The human deliberates for an unbounded interval. An authority that advanced
801
+ // while they were deciding was never the one they approved, so the approval and
802
+ // its derived binding are pinned and re-checked against a fresh read.
803
+ test("RECOVER refuses to mutate when the authority changes between approval and mutation", async () => {
804
+ for (const [label, drifted] of [
805
+ ["revision advanced", recoveryTargetStatus("broken", { authority: { lineageId: "broken", revision: "rev-2" } })],
806
+ ["target identity moved", recoveryTargetStatus("broken", { targetIdentity: `sha256:${"f".repeat(64)}` })],
807
+ ["disposition changed", recoveryTargetStatus("broken", { actionDisposition: "escalated" })],
808
+ ["no longer recoverable", recoveryTargetStatus("broken", { action: "start", actionDisposition: undefined })],
809
+ ] as const) {
810
+ const calls: Record<string, unknown>[] = [];
811
+ let reads = 0;
812
+ const native = {
813
+ async recover(request: Record<string, unknown>) { calls.push(request); return { record: RECOVER_RECORD }; },
814
+ async targetStatus() {
815
+ reads += 1;
816
+ return reads === 1 ? recoveryTargetStatus("broken") : drifted;
817
+ },
818
+ } as unknown as NativeReviewCli;
819
+ const run = recoveryController(native, `gentle-pi-recover-toctou-${label.replace(/[^a-z]+/g, "-")}-`);
820
+ const prompts: string[] = [];
821
+ const details = await run("recover", { operation: "recover", input: JSON.stringify(RECOVER_INPUT) }, interactiveContext(prompts));
822
+ assert.equal(prompts.length, 1, label);
823
+ assert.equal(reads, 2, label);
824
+ assert.equal(details.status, "blocked", label);
825
+ assert.equal(details.outcome, "native-recovery-authority-changed", label);
826
+ assert.equal(details.mutation_performed, false, label);
827
+ assert.equal(details.mutation_outcome, "none", label);
828
+ assert.equal(details.next_action, "reinspect-and-reauthorize-recovery", label);
829
+ assert.equal(calls.length, 0, label);
830
+ }
831
+ });
832
+
833
+ test("RECOVER surfaces every missing successor input including an unsupported disposition", async () => {
834
+ const { native, calls, statusReads } = fakeRecoveryNative(RECOVER_RECORD);
835
+ const run = recoveryController(native, "gentle-pi-recover-missing-input-");
836
+ const prompts: string[] = [];
837
+ const details = await run(
838
+ "recover",
839
+ { operation: "recover", input: JSON.stringify({ predecessorLineage: "broken", disposition: "not-a-disposition" }) },
840
+ interactiveContext(prompts),
841
+ );
570
842
  assert.equal(details.outcome, "native-input-required");
571
843
  assert.equal(details.native_operation, "review recover");
844
+ // The missing inputs are the native six, never the legacy reset quartet.
572
845
  assert.deepEqual(details.missing_input, ["expectedPredecessorRevision", "successorLineage", "disposition", "actor", "reason"]);
573
846
  assert.equal(calls.length, 0);
847
+ assert.deepEqual(statusReads, []);
848
+ assert.deepEqual(prompts, []);
574
849
  });
575
850
 
576
851
  test("RECONCILE_AUTHORITY routes one exact native mutation and returns its audit record", async () => {
@@ -720,6 +995,48 @@ test("destructive RESET still fails closed without fresh interactive authorizati
720
995
  assert.equal(calls.length, 0);
721
996
  });
722
997
 
998
+ // Regression guard for issue #212: separating RECOVER out must leave the legacy
999
+ // repository-wide RESET challenge exactly as it was.
1000
+ test("RESET still demands the complete legacy repository challenge and its own fresh approval", async () => {
1001
+ const tools = new Map<string, { execute: (id: string, params: unknown, signal: undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<unknown> }>();
1002
+ const pi = { on() {}, registerTool(definition: { name: string; execute: never }) { tools.set(definition.name, definition as never); }, registerCommand() {} } as unknown as ExtensionAPI;
1003
+ const { native, calls } = fakeRecoveryNative(RECLAIM_RECORD);
1004
+ createGentleAiExtension({ nativeReviewCli: native })(pi);
1005
+ const controller = tools.get("gentle_review");
1006
+ assert.ok(controller);
1007
+ const cwd = scratchDir("gentle-pi-native-reset-challenge-");
1008
+ const challenge = {
1009
+ repositoryId: "repo-id",
1010
+ commonDirHash: "c".repeat(64),
1011
+ inventoryHash: "d".repeat(64),
1012
+ confirmation: "DESTROY REVIEW AUTHORITY repo-id",
1013
+ };
1014
+ const reclaim = { lineage: "stuck", actor: "maintainer", reason: "incomplete" };
1015
+ for (const key of ["repositoryId", "commonDirHash", "inventoryHash", "confirmation"] as const) {
1016
+ const incomplete: Record<string, unknown> = { ...challenge, ...reclaim };
1017
+ delete incomplete[key];
1018
+ await assert.rejects(
1019
+ controller.execute(`reset-missing-${key}`, { operation: "reset", input: JSON.stringify(incomplete) }, undefined, undefined, {
1020
+ cwd, hasUI: true, ui: { confirm: async () => true },
1021
+ } as unknown as ExtensionContext),
1022
+ new RegExp(`Review controller reset requires an exact string ${key}`),
1023
+ );
1024
+ }
1025
+ assert.equal(calls.length, 0);
1026
+ let prompt = "";
1027
+ const approved = await controller.execute("approved-reset", { operation: "reset", input: JSON.stringify({ ...challenge, ...reclaim }) }, undefined, undefined, {
1028
+ cwd,
1029
+ hasUI: true,
1030
+ ui: { confirm: async (_title: string, message: string) => { prompt = message; return true; } },
1031
+ } as unknown as ExtensionContext) as { details: Record<string, unknown> };
1032
+ assert.match(prompt, /Repository: repo-id/);
1033
+ assert.match(prompt, /Exact challenge: DESTROY REVIEW AUTHORITY repo-id/);
1034
+ assert.match(prompt, /This invalidates all prior review authority for this repository\./);
1035
+ assert.equal(approved.details.mutation_performed, true);
1036
+ assert.equal(calls.length, 1);
1037
+ assert.equal(calls[0]?.operation, "reclaim");
1038
+ });
1039
+
723
1040
  test("RECONCILE_AUTHORITY requires fresh Pi approval for the exact seven-line binding", async () => {
724
1041
  const tools = new Map<string, { execute: (id: string, params: unknown, signal: undefined, onUpdate: undefined, ctx: ExtensionContext) => Promise<unknown> }>();
725
1042
  const pi = {
@@ -767,12 +1084,26 @@ test("published maintenance controller actions require exact inputs and fresh UI
767
1084
  const controller = tools.get("gentle_review");
768
1085
  assert.ok(controller);
769
1086
  const cwd = scratchDir("gentle-pi-v219-maintenance-");
770
- const abandon = { operation: "abandon", input: JSON.stringify({ lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", actor: "maintainer", reason: "retire pristine lineage" }) };
1087
+ const abandon = { operation: "abandon", input: JSON.stringify({ lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ...ABANDON_DISCARDED_WORK, actor: "maintainer", reason: "retire pristine lineage" }) };
771
1088
  await assert.rejects(controller.execute("headless-abandon", abandon, undefined, undefined, { cwd, hasUI: false, ui: { confirm: async () => true } } as unknown as ExtensionContext), /interactive Pi UI.*fails closed/i);
772
1089
  await assert.rejects(controller.execute("denied-abandon", abandon, undefined, undefined, { cwd, hasUI: true, ui: { confirm: async () => false } } as unknown as ExtensionContext), /not explicitly authorized/);
773
- const approved = await controller.execute("approved-abandon", abandon, undefined, undefined, { cwd, hasUI: true, ui: { confirm: async () => true } } as unknown as ExtensionContext) as { details: Record<string, unknown> };
1090
+ let abandonPrompt = "";
1091
+ const approved = await controller.execute("approved-abandon", abandon, undefined, undefined, { cwd, hasUI: true, ui: { confirm: async (_title: string, message: string) => { abandonPrompt = message; return true; } } } as unknown as ExtensionContext) as { details: Record<string, unknown> };
774
1092
  assert.equal(approved.details.mutation_outcome, "committed");
1093
+ assert.match(abandonPrompt, /gentle-ai\.review-abandon-authorization\/v2/);
1094
+ assert.match(abandonPrompt, /captured_lens_results=00-risk\.json,01-readability\.json/);
1095
+ assert.match(abandonPrompt, /findings_present=true\nevidence_records_present=false\nactor=maintainer/);
775
1096
  assert.equal(calls[0]?.operation, "abandon");
1097
+ assert.equal((calls[0]?.request as { maintainerAuthorization?: string }).maintainerAuthorization, ABANDON_AUTHORIZATION);
1098
+ assert.deepEqual(calls[0]?.request.capturedLensResults, ["00-risk.json", "01-readability.json"]);
1099
+ assert.equal(calls[0]?.request.findingsPresent, true);
1100
+ assert.equal(calls[0]?.request.evidenceRecordsPresent, false);
1101
+ const missingDiscarded = await runControllerOperation({ operation: "abandon", input: JSON.stringify({ lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", actor: "maintainer", reason: "retire pristine lineage" }) }, native);
1102
+ assert.equal(missingDiscarded.outcome, "native-input-required");
1103
+ assert.deepEqual(missingDiscarded.missing_input, ["capturedLensResults", "findingsPresent", "evidenceRecordsPresent"]);
1104
+ const invalidDiscarded = await runControllerOperation({ operation: "abandon", input: JSON.stringify({ lineage: "pristine", expectedRevision: "revision", snapshotIdentity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", capturedLensResults: ["00-risk.json", " padded "], findingsPresent: true, evidenceRecordsPresent: false, actor: "maintainer", reason: "retire pristine lineage" }) }, native);
1105
+ assert.equal(invalidDiscarded.outcome, "native-input-required");
1106
+ assert.deepEqual(invalidDiscarded.missing_input, ["capturedLensResults"]);
776
1107
  const legacy = await controller.execute("approved-legacy-quarantine", { operation: "quarantine-legacy", input: JSON.stringify({ repository: "/repo", lineage: "legacy", expectedRevision: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", diagnostic: LEGACY_FREEZE_DIAGNOSTIC, disposition: LEGACY_FREEZE_DISPOSITION, actor: "maintainer", reason: "quarantine malformed legacy freeze" }) }, undefined, undefined, { cwd, hasUI: true, ui: { confirm: async () => true } } as unknown as ExtensionContext) as { details: Record<string, unknown> };
777
1108
  assert.equal(legacy.details.mutation_outcome, "committed");
778
1109
  assert.equal(calls[1]?.operation, "quarantineLegacy");
@@ -845,13 +1176,20 @@ test("REPAIR_LEGACY_ALIAS derives fixed inputs from fresh inventory and requires
845
1176
  // Pi passes the transition's tokens through verbatim and adds only --input.
846
1177
  test("captureResult passes the provider tokens through verbatim and carries no --contract", async (t) => {
847
1178
  t.after(() => clearNativeReviewCapabilitiesCacheForTesting());
1179
+ // The full live envelope shape, confirmed against real capture-result runs
1180
+ // on both the pinned line and gentle-ai main (tests/fixtures/devbinary/
1181
+ // result-artifact-v2.captured.json): the opaque locator prefix is rart1_.
848
1182
  const manifest = {
849
1183
  schema: "gentle-ai.review-result-artifact/v2",
850
1184
  capability: "review.native_result_artifact",
1185
+ reference: "rart1_" + "b".repeat(64),
1186
+ sha256: "sha256:" + "f".repeat(64),
1187
+ lineage_id: "review-1d5aadacc600e167",
1188
+ target_identity: "sha256:" + "d".repeat(64),
1189
+ lens: "review-reliability",
1190
+ selected_order: 0,
851
1191
  subject_hash: "sha256:" + "a".repeat(64),
852
1192
  admission_decision: "completed",
853
- lens: "review-reliability",
854
- reference: "rref1_" + "b".repeat(64),
855
1193
  };
856
1194
  const { adapter, calls } = queuedAdapter([{ stdout: JSON.stringify(manifest) }]);
857
1195
  const cli = new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
@@ -884,6 +1222,45 @@ test("captureResult passes the provider tokens through verbatim and carries no -
884
1222
  assert.equal(argv.length, 2 + tokens.length + 2);
885
1223
  });
886
1224
 
1225
+ // The admission answer routes through the exact-identity forward decoder:
1226
+ // a manifest missing its binding fields (the shape no real binary ever
1227
+ // emitted) or carrying a foreign locator prefix is refused, never partially
1228
+ // consumed. This is the decoder-freshness discipline reaching the consumer.
1229
+ test("captureResult refuses an under-specified or foreign-locator manifest", async (t) => {
1230
+ t.after(() => clearNativeReviewCapabilitiesCacheForTesting());
1231
+ const full = {
1232
+ schema: "gentle-ai.review-result-artifact/v2",
1233
+ capability: "review.native_result_artifact",
1234
+ reference: "rart1_" + "b".repeat(64),
1235
+ sha256: "sha256:" + "f".repeat(64),
1236
+ lineage_id: "review-1d5aadacc600e167",
1237
+ target_identity: "sha256:" + "d".repeat(64),
1238
+ lens: "review-reliability",
1239
+ selected_order: 0,
1240
+ subject_hash: "sha256:" + "a".repeat(64),
1241
+ admission_decision: "completed",
1242
+ };
1243
+ const legacyPartial = {
1244
+ schema: full.schema,
1245
+ capability: full.capability,
1246
+ subject_hash: full.subject_hash,
1247
+ admission_decision: full.admission_decision,
1248
+ lens: full.lens,
1249
+ reference: full.reference,
1250
+ };
1251
+ for (const body of [
1252
+ legacyPartial,
1253
+ { ...full, reference: "rref1_" + "b".repeat(64) },
1254
+ { ...full, unadvertised: true },
1255
+ ]) {
1256
+ const cli = new NativeReviewCliV216(async () => ({ stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false }), "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
1257
+ await assert.rejects(cli.captureResult({
1258
+ argumentTokens: ["--lineage=review-1d5aadacc600e167", "--repository-context=rctx1_" + "e".repeat(64)],
1259
+ resultDocument: JSON.stringify({ subject_hash: full.subject_hash, inspection: { status: "completed", paths: ["a.ts"] }, findings: [], evidence: ["reviewed the complete frozen candidate scope"] }),
1260
+ }));
1261
+ }
1262
+ });
1263
+
887
1264
  test("captureEvidence stages exact bytes, uses the closed outcome argv, and decodes the native record", async (t) => {
888
1265
  t.after(() => clearNativeReviewCapabilitiesCacheForTesting());
889
1266
  const capabilities = v2Fixture<Record<string, unknown>>("capabilities.fixture.json");
@@ -959,3 +1336,222 @@ test("negotiated finalize never emits the retired --result flag", async (t) => {
959
1336
  assert.ok(argv.some((token) => token === "--captured-results" || token.startsWith("--captured-results=")), "finalize must tell the provider to discover the captured results");
960
1337
  assert.ok(argv.includes("--contract"), "finalize IS negotiated, unlike capture-result");
961
1338
  });
1339
+
1340
+ // gentle-pi#311 P4-roles: the provider-rendered self-contained role vectors
1341
+ // execute EXACTLY as rendered — one CLI invocation, verbatim tokens in
1342
+ // provider order, no --cwd, no --contract, no --input. Go materializes the
1343
+ // role prompt, runs its own locked-down pi process, and admits the verdict.
1344
+ test("captureProviderRole executes the exact rendered vector and decodes only the strict artifact", async () => {
1345
+ const artifact = {
1346
+ schema: "gentle-ai.review-provider-role-capture/v1",
1347
+ lineage_id: "review-1d5aadacc600e167",
1348
+ target_identity: `sha256:${"9".repeat(64)}`,
1349
+ role: "refuter",
1350
+ captured: true,
1351
+ };
1352
+ const calls: Array<{ arguments: readonly string[] }> = [];
1353
+ const cli = new NativeReviewCliV216(async (request) => {
1354
+ calls.push({ arguments: request.arguments });
1355
+ return { stdout: JSON.stringify(artifact), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1356
+ }, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
1357
+ const tokens = [
1358
+ "--lineage=review-1d5aadacc600e167",
1359
+ `--expected-revision=sha256:${"a".repeat(64)}`,
1360
+ `--target=sha256:${"9".repeat(64)}`,
1361
+ `--repository-context=rctx1_${"c".repeat(64)}`,
1362
+ "--agent=pi",
1363
+ "--execute=true",
1364
+ ];
1365
+ const captured = await cli.captureProviderRole({ captureOperation: "review.capture-refuter", argumentTokens: tokens, cwd: "/repo" });
1366
+ assert.equal(captured.role, "refuter");
1367
+ assert.equal(captured.lineageId, "review-1d5aadacc600e167");
1368
+ assert.deepEqual(calls[0]!.arguments, ["review", "capture-refuter", ...tokens], "the vector runs verbatim: nothing added, removed, or reordered");
1369
+
1370
+ await assert.rejects(
1371
+ cli.captureProviderRole({ captureOperation: "review.capture-result", argumentTokens: tokens, cwd: "/repo" }),
1372
+ /supports only review\.capture-refuter and review\.capture-validation/,
1373
+ );
1374
+ assert.equal(calls.length, 1, "an unknown role operation must fail before any native launch");
1375
+ });
1376
+
1377
+ test("captureProviderRole refuses an uncaptured or malformed role artifact", async () => {
1378
+ const base = {
1379
+ schema: "gentle-ai.review-provider-role-capture/v1",
1380
+ lineage_id: "review-1d5aadacc600e167",
1381
+ target_identity: `sha256:${"9".repeat(64)}`,
1382
+ role: "targeted-validator",
1383
+ captured: true,
1384
+ };
1385
+ for (const body of [
1386
+ { ...base, captured: false },
1387
+ { ...base, role: "reviewer" },
1388
+ { ...base, schema: "gentle-ai.review-result-artifact/v2" },
1389
+ { ...base, extra: true },
1390
+ ]) {
1391
+ const cli = new NativeReviewCliV216(async () => ({ stdout: JSON.stringify(body), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false }), "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
1392
+ await assert.rejects(cli.captureProviderRole({ captureOperation: "review.capture-validation", argumentTokens: ["--agent=pi", "--execute=true"], cwd: "/repo" }));
1393
+ }
1394
+ });
1395
+
1396
+ // gentle-pi#311 P5: the provider-driven FINALIZE executes the rendered
1397
+ // transition tokens verbatim and decodes both answer shapes (the negotiated
1398
+ // operation envelope and the plain review/finalize shape a contract-less
1399
+ // transition produces).
1400
+ test("finalizeTransition runs the provider-rendered tokens verbatim and decodes both answer shapes", async (t) => {
1401
+ t.after(() => clearNativeReviewCapabilitiesCacheForTesting());
1402
+ const capabilities = v2Fixture<Record<string, unknown>>("capabilities.fixture.json");
1403
+ const capabilitiesBody = { ...capabilities, package: { ...(capabilities.package as Record<string, unknown>), version: GENTLE_AI_VERSION } };
1404
+ const plainBody = { operation: "review/finalize", lineage_id: "review-1d5aadacc600e167", state: "approved", action: "validate delivery", store_revision: `sha256:${"f".repeat(64)}` };
1405
+ const envelopeBody = {
1406
+ schema: "gentle-ai.review-integration.operation/v2",
1407
+ contract: "gentle-ai.review-integration/v2",
1408
+ operation: "review.finalize",
1409
+ result: { operation: "review/finalize", lineage_id: "review-1d5aadacc600e167", state: "approved", action: "validate delivery", store_revision: `sha256:${"f".repeat(64)}` },
1410
+ };
1411
+ const { adapter, calls } = queuedAdapter([
1412
+ { stdout: JSON.stringify(capabilitiesBody) },
1413
+ { stdout: JSON.stringify(plainBody) },
1414
+ { stdout: JSON.stringify(envelopeBody) },
1415
+ ]);
1416
+ const cli = new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
1417
+ const tokens = ["--lineage=review-1d5aadacc600e167", "--captured-results=true"];
1418
+ const plain = await cli.finalizeTransition({ cwd: "/repo", argumentTokens: tokens });
1419
+ assert.equal(plain.state, "approved");
1420
+ assert.deepEqual(calls[1]!.arguments, ["review", "finalize", ...tokens], "no --contract, --cwd, or document flag is invented for a rendered transition");
1421
+ const negotiated = await cli.finalizeTransition({ cwd: "/repo", argumentTokens: tokens });
1422
+ assert.equal(negotiated.storeRevision, `sha256:${"f".repeat(64)}`);
1423
+ await assert.rejects(cli.finalizeTransition({ cwd: "/repo", argumentTokens: [] }), /requires the provider-rendered argument tokens/);
1424
+ });
1425
+
1426
+ // Field defect (fambig, 2026-08-16): the evidence collect slot renders the
1427
+ // exact `review capture-evidence` submission tokens — fix-diff `--target`,
1428
+ // `--expected-revision`, and an opaque cwd-independent `--repository-context` —
1429
+ // with `{{outcome}}`/`{{input}}` slots. Satisfying the slot means executing
1430
+ // those tokens verbatim with only the two slot substitutions, exactly like
1431
+ // captureResult; identities are never reconstructed on the client.
1432
+ test("captureEvidenceSubmission executes the provider-rendered submission tokens verbatim with slot substitution", async (t) => {
1433
+ t.after(() => clearNativeReviewCapabilitiesCacheForTesting());
1434
+ const capabilities = v2Fixture<Record<string, unknown>>("capabilities.fixture.json");
1435
+ const capabilitiesBody = { ...capabilities, package: { ...(capabilities.package as Record<string, unknown>), version: GENTLE_AI_VERSION } };
1436
+ const record = {
1437
+ schema: "gentle-ai.review-verification-evidence/v2",
1438
+ version: 2,
1439
+ lineage_id: "review-evidence-lineage",
1440
+ authority_revision: `sha256:${"a".repeat(64)}`,
1441
+ target_identity: `sha256:${"b".repeat(64)}`,
1442
+ candidate_tree: "c".repeat(40),
1443
+ paths_digest: `sha256:${"d".repeat(64)}`,
1444
+ paths: ["calc.go"],
1445
+ ledger_ids: ["R3-1"],
1446
+ raw_payload_sha256: `sha256:${"e".repeat(64)}`,
1447
+ raw_payload_bytes: 24,
1448
+ outcome: "passed",
1449
+ record_digest: `sha256:${"f".repeat(64)}`,
1450
+ };
1451
+ const argumentTokens = [
1452
+ `--lineage=${record.lineage_id}`,
1453
+ `--expected-revision=${record.authority_revision}`,
1454
+ `--target=${record.target_identity}`,
1455
+ `--repository-context=rctx1_${"e".repeat(64)}`,
1456
+ "--outcome={{outcome}}",
1457
+ "--input={{input}}",
1458
+ ];
1459
+ let staged = "";
1460
+ const calls: Array<{ arguments: readonly string[] }> = [];
1461
+ const cli = new NativeReviewCliV216(async (request) => {
1462
+ calls.push({ arguments: request.arguments });
1463
+ if (request.arguments[1] === "capabilities") return { stdout: JSON.stringify(capabilitiesBody), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1464
+ const inputToken = request.arguments.find((token) => token.startsWith("--input="));
1465
+ assert.ok(inputToken !== undefined);
1466
+ staged = readFileSync(inputToken.slice("--input=".length), "utf8");
1467
+ return { stdout: JSON.stringify(record), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1468
+ }, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
1469
+ const evidence = "verification: go test ./... passed\n";
1470
+ const captured = await cli.captureEvidenceSubmission({
1471
+ argumentTokens,
1472
+ outcomeSubstitutionLocation: 4,
1473
+ inputSubstitutionLocation: 5,
1474
+ outcome: "passed",
1475
+ evidenceDocument: evidence,
1476
+ });
1477
+ assert.equal(staged, evidence, "the evidence bytes must be staged exactly");
1478
+ assert.equal(captured.recordDigest, record.record_digest);
1479
+ assert.equal(captured.targetIdentity, record.target_identity);
1480
+ const argv = calls[1]!.arguments;
1481
+ assert.deepEqual(argv.slice(0, 2), ["review", "capture-evidence"]);
1482
+ assert.deepEqual(argv.slice(2, 6), argumentTokens.slice(0, 4), "the identity-bearing tokens must pass through verbatim, in provider order");
1483
+ assert.equal(argv[6], "--outcome=passed");
1484
+ assert.match(String(argv[7]), /^--input=./);
1485
+ assert.equal(argv.length, 8);
1486
+ assert.equal(argv.includes("--cwd"), false, "the repository context is authoritative and cwd-independent");
1487
+ assert.equal(argv.includes("--contract"), false);
1488
+ await assert.rejects(
1489
+ cli.captureEvidenceSubmission({ cwd: "/repo", argumentTokens, outcomeSubstitutionLocation: 4, inputSubstitutionLocation: 5, outcome: "passed", evidenceDocument: evidence }),
1490
+ /repository context or --cwd, never both/,
1491
+ );
1492
+ await assert.rejects(
1493
+ cli.captureEvidenceSubmission({ argumentTokens, outcomeSubstitutionLocation: 4, inputSubstitutionLocation: 5, outcome: "failed" as never, evidenceDocument: evidence }),
1494
+ /outcome must be passed, verification_failed, or procedural_tooling_failed/,
1495
+ );
1496
+ await assert.rejects(
1497
+ cli.captureEvidenceSubmission({ argumentTokens: argumentTokens.slice(0, 4), outcomeSubstitutionLocation: 0, inputSubstitutionLocation: 1, outcome: "passed", evidenceDocument: evidence }),
1498
+ /\{\{outcome\}\}/,
1499
+ );
1500
+ assert.equal(calls.length, 2, "every rejected submission must fail before another native launch");
1501
+ });
1502
+
1503
+ test("finalizeSubmission executes the rendered finalize submission tokens verbatim with the {{value}} substitution", async (t) => {
1504
+ t.after(() => clearNativeReviewCapabilitiesCacheForTesting());
1505
+ const capabilities = v2Fixture<Record<string, unknown>>("capabilities.fixture.json");
1506
+ const capabilitiesBody = { ...capabilities, package: { ...(capabilities.package as Record<string, unknown>), version: GENTLE_AI_VERSION } };
1507
+ const finalizeBody = {
1508
+ schema: "gentle-ai.review-integration.operation/v2",
1509
+ contract: "gentle-ai.review-integration/v2",
1510
+ operation: "review.finalize",
1511
+ result: { operation: "review/finalize", lineage_id: "review-1d5aadacc600e167", state: "correction_required", action: "continue the current review state", store_revision: `sha256:${"f".repeat(64)}` },
1512
+ };
1513
+ const planTokens = [
1514
+ "--contract=gentle-ai.review-integration/v2",
1515
+ "--lineage=review-1d5aadacc600e167",
1516
+ `--expected-revision=sha256:${"a".repeat(64)}`,
1517
+ `--target=sha256:${"b".repeat(64)}`,
1518
+ `--request-hash=sha256:${"c".repeat(64)}`,
1519
+ `--repository-context=rctx1_${"e".repeat(64)}`,
1520
+ "--correction-lines={{value}}",
1521
+ ];
1522
+ const calls: Array<{ arguments: readonly string[] }> = [];
1523
+ let staged: string | undefined;
1524
+ const cli = new NativeReviewCliV216(async (request) => {
1525
+ calls.push({ arguments: request.arguments });
1526
+ if (request.arguments[1] === "capabilities") return { stdout: JSON.stringify(capabilitiesBody), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1527
+ const validationToken = request.arguments.find((token) => token.startsWith("--validation="));
1528
+ if (validationToken !== undefined) staged = readFileSync(validationToken.slice("--validation=".length), "utf8");
1529
+ return { stdout: JSON.stringify(finalizeBody), stderr: "", exitCode: 0, signal: null, timedOut: false, outputLimitExceeded: false };
1530
+ }, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => "dcc846103b16d365eaeeb9d7f289c23fc4f2897f23def1cb3fe7f05557b64705");
1531
+
1532
+ // Plan form: literal substitution, every other token verbatim.
1533
+ const planned = await cli.finalizeSubmission({ cwd: "/repo", argumentTokens: planTokens, valueSubstitutionLocation: 6, valueLiteral: "2" });
1534
+ assert.equal(planned.state, "correction_required");
1535
+ const planArgv = calls[1]!.arguments;
1536
+ assert.deepEqual(planArgv.slice(0, 2), ["review", "finalize"]);
1537
+ assert.deepEqual(planArgv.slice(2, 8), planTokens.slice(0, 6), "identity-bearing tokens must pass through verbatim, in provider order");
1538
+ assert.equal(planArgv[8], "--correction-lines=2");
1539
+ assert.equal(planArgv.length, 9);
1540
+
1541
+ // Validation form: the document is staged to a 0o600 artifact whose path substitutes {{value}}.
1542
+ const validationTokens = [...planTokens.slice(0, 6), "--validation={{value}}"];
1543
+ const document = JSON.stringify({ targeted_validation_request_hash: `sha256:${"9".repeat(64)}`, original_criteria: { passed: true, evidence: ["ok"] } });
1544
+ await cli.finalizeSubmission({ cwd: "/repo", argumentTokens: validationTokens, valueSubstitutionLocation: 6, valueDocument: document });
1545
+ assert.equal(staged, document, "the validation document must be staged byte-exact");
1546
+
1547
+ // Guards: exactly one substitution form, and a real {{value}} slot.
1548
+ await assert.rejects(
1549
+ cli.finalizeSubmission({ cwd: "/repo", argumentTokens: planTokens, valueSubstitutionLocation: 6, valueLiteral: "2", valueDocument: document }),
1550
+ /exactly one of valueLiteral or valueDocument/,
1551
+ );
1552
+ await assert.rejects(
1553
+ cli.finalizeSubmission({ cwd: "/repo", argumentTokens: planTokens.slice(0, 6), valueSubstitutionLocation: 0, valueLiteral: "2" }),
1554
+ /\{\{value\}\}/,
1555
+ );
1556
+ assert.equal(calls.length, 3, "rejected submissions must fail before another native launch");
1557
+ });