gentle-pi 2.5.0 → 2.6.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 (189) hide show
  1. package/README.md +139 -30
  2. package/assets/agents/gentle-ai-worker.md +4 -0
  3. package/assets/agents/jd-fix-agent.md +18 -0
  4. package/assets/agents/jd-judge-a.md +1 -1
  5. package/assets/agents/jd-judge-b.md +1 -1
  6. package/assets/agents/sdd-apply.md +7 -5
  7. package/assets/agents/sdd-archive.md +5 -3
  8. package/assets/agents/sdd-design.md +4 -0
  9. package/assets/agents/sdd-explore.md +4 -0
  10. package/assets/agents/sdd-init.md +4 -0
  11. package/assets/agents/sdd-onboard.md +4 -0
  12. package/assets/agents/sdd-proposal.md +4 -0
  13. package/assets/agents/sdd-remediate.md +37 -0
  14. package/assets/agents/sdd-research.md +26 -3
  15. package/assets/agents/sdd-spec.md +4 -0
  16. package/assets/agents/sdd-status.md +9 -75
  17. package/assets/agents/sdd-sync.md +4 -0
  18. package/assets/agents/sdd-tasks.md +4 -0
  19. package/assets/agents/sdd-verify.md +5 -3
  20. package/assets/chains/sdd-full.chain.md +4 -0
  21. package/assets/chains/sdd-plan.chain.md +4 -0
  22. package/assets/chains/sdd-verify.chain.md +4 -0
  23. package/assets/migrations/managed-assets-v2.5.0.json +7 -0
  24. package/assets/orchestrator-delegation.md +21 -3
  25. package/assets/sdd-orchestrator-workflow.md +54 -21
  26. package/assets/support/sdd-status-contract.md +34 -90
  27. package/contracts/telemetry/runtime-aggregate-v1.schema.json +65 -0
  28. package/docs/telemetry.md +57 -1
  29. package/docs/windows-startup-console-visibility.md +18 -0
  30. package/extensions/ask-user-choice.ts +143 -15
  31. package/extensions/codegraph-tools.ts +1 -0
  32. package/extensions/gentle-agents.ts +799 -50
  33. package/extensions/gentle-ai.ts +2033 -322
  34. package/extensions/gentle-shell.ts +145 -42
  35. package/extensions/gentle-todo.ts +47 -12
  36. package/extensions/quiet-tools.ts +1 -0
  37. package/extensions/runtime-metrics.ts +130 -0
  38. package/extensions/sdd-init.ts +2 -2
  39. package/extensions/startup-banner.ts +52 -75
  40. package/lib/agent-profiles.ts +550 -0
  41. package/lib/agents-completion-delivery.ts +72 -0
  42. package/lib/agents-config.ts +7 -10
  43. package/lib/agents-history.ts +9 -1
  44. package/lib/agents-messaging.ts +187 -0
  45. package/lib/agents-protocol.ts +77 -5
  46. package/lib/agents-runner.ts +548 -26
  47. package/lib/agents-thread-view.ts +57 -0
  48. package/lib/agents-view-layout.ts +40 -0
  49. package/lib/agents-view.ts +548 -191
  50. package/lib/agents-widget.ts +33 -14
  51. package/lib/gentle-ai-binary.ts +3 -1
  52. package/lib/gentle-ai-renderer.ts +8 -6
  53. package/lib/native-review-cli.ts +283 -1
  54. package/lib/orchestrator-presence.ts +337 -0
  55. package/lib/profiles-orchestrator.ts +203 -0
  56. package/lib/review-candidate-view-owner.ts +296 -46
  57. package/lib/review-candidate-view.ts +30 -20
  58. package/lib/review-consent-component.ts +247 -0
  59. package/lib/review-consent-ui.ts +53 -8
  60. package/lib/review-host-relay.ts +28 -0
  61. package/lib/review-integration-v2.ts +187 -5
  62. package/lib/review-last-event-controller.ts +7 -4
  63. package/lib/review-reminder-receipt.ts +74 -0
  64. package/lib/review-session-standing-permission.ts +27 -6
  65. package/lib/runtime-metrics-children.ts +199 -0
  66. package/lib/runtime-metrics-delivery.ts +68 -0
  67. package/lib/runtime-metrics-native.ts +166 -0
  68. package/lib/runtime-metrics-pi-identity.ts +113 -0
  69. package/lib/runtime-metrics-policy.ts +51 -0
  70. package/lib/runtime-metrics.ts +255 -0
  71. package/lib/sdd-preflight.ts +362 -81
  72. package/lib/sdd-research-capabilities.ts +228 -0
  73. package/lib/sdd-status.ts +29 -7
  74. package/lib/session-worktree-registry.ts +118 -0
  75. package/lib/shell-bar.ts +47 -1
  76. package/lib/shell-card.ts +1 -4
  77. package/lib/shell-changes-view.ts +362 -37
  78. package/lib/shell-changes.ts +81 -1
  79. package/lib/shell-prompt.ts +11 -15
  80. package/lib/shell-sidebar-banner.ts +11 -0
  81. package/lib/shell-sidebar-layout.ts +213 -0
  82. package/lib/shell-sidebar.ts +41 -0
  83. package/lib/shell-todo.ts +28 -11
  84. package/lib/telemetry-trigger.ts +2 -0
  85. package/package.json +6 -3
  86. package/runtime/gentle-ai-binary.mjs +3 -1
  87. package/runtime/native-review-cli.mjs +283 -1
  88. package/runtime/review-integration-v2.mjs +187 -5
  89. package/runtime/telemetry-trigger.mjs +2 -0
  90. package/scripts/build-runtime-modules.mjs +9 -1
  91. package/scripts/check-types.mjs +125 -0
  92. package/scripts/gentle-ai-installer.mjs +10 -10
  93. package/scripts/install-gentle-ai.mjs +12 -0
  94. package/scripts/install-tui-mode-setting.mjs +114 -0
  95. package/scripts/test-packed-runner.mjs +16 -2
  96. package/scripts/types-baseline.json +99 -0
  97. package/scripts/verify-package-files.mjs +4 -2
  98. package/skills/_shared/review-ledger-contract.md +17 -1
  99. package/skills/issue-creation/SKILL.md +3 -3
  100. package/skills/judgment-day/SKILL.md +17 -3
  101. package/skills/judgment-day/references/prompts-and-formats.md +14 -3
  102. package/tests/agent-profiles.test.ts +722 -0
  103. package/tests/agents-completion-delivery.test.ts +94 -0
  104. package/tests/agents-config.test.ts +62 -0
  105. package/tests/agents-fake-child.ts +15 -1
  106. package/tests/agents-grouping.test.ts +179 -0
  107. package/tests/agents-integration.test.ts +100 -0
  108. package/tests/agents-messaging.test.ts +94 -0
  109. package/tests/agents-protocol.test.ts +45 -0
  110. package/tests/agents-queries.test.ts +190 -0
  111. package/tests/agents-responsive.test.ts +43 -0
  112. package/tests/agents-runner.test.ts +571 -14
  113. package/tests/agents-thread-view.test.ts +45 -0
  114. package/tests/agents-view.test.ts +476 -65
  115. package/tests/agents-widget.test.ts +31 -1
  116. package/tests/artifact-language.test.ts +25 -2
  117. package/tests/ask-user-choice.test.ts +169 -3
  118. package/tests/asset-installation-runtime.test.ts +108 -0
  119. package/tests/autonomous-guard.test.ts +116 -1
  120. package/tests/codegraph-tools.test.ts +2 -1
  121. package/tests/delegated-key-learnings-contract.test.ts +1 -1
  122. package/tests/devbinary/native-review-parity.devtest.ts +2 -0
  123. package/tests/feature-request-form.test.ts +67 -0
  124. package/tests/fixtures/agents-messaging-child.mjs +5 -0
  125. package/tests/fixtures/runtime-metrics-native-batches.json +6 -0
  126. package/tests/gentle-agents.test.ts +1460 -33
  127. package/tests/gentle-ai-binary.test.ts +7 -2
  128. package/tests/gentle-ai-installer.test.ts +47 -47
  129. package/tests/gentle-ai-renderer.test.ts +38 -0
  130. package/tests/gentle-ai.test.ts +944 -4
  131. package/tests/gentle-shell.test.ts +303 -12
  132. package/tests/gentle-todo.test.ts +54 -10
  133. package/tests/install-tui-mode-setting.test.ts +324 -0
  134. package/tests/issue-creation-skill.test.ts +22 -0
  135. package/tests/model-routing-authority.test.ts +12 -0
  136. package/tests/native-review-capability-contract.test.ts +12 -1
  137. package/tests/native-review-cli.test.ts +277 -3
  138. package/tests/native-review-parity.test.ts +14 -7
  139. package/tests/native-sdd-attempt-authority.test.ts +7 -2
  140. package/tests/orchestrator-presence.test.ts +389 -0
  141. package/tests/package-manifest.test.ts +232 -7
  142. package/tests/profiles-orchestrator.test.ts +208 -0
  143. package/tests/quiet-tool-rendering.test.ts +1 -0
  144. package/tests/rdd-aware-verification-contract.test.ts +10 -0
  145. package/tests/review-agent-end-preflight.test.ts +332 -24
  146. package/tests/review-candidate-view.test.ts +304 -6
  147. package/tests/review-consent-ui.test.ts +352 -0
  148. package/tests/review-contract-prompt.test.ts +14 -0
  149. package/tests/review-controller-native-routing.test.ts +563 -3
  150. package/tests/review-controller.test.ts +1 -1
  151. package/tests/review-host-relay-restart-parity.test.ts +142 -1
  152. package/tests/review-host-relay-routing.test.ts +364 -4
  153. package/tests/review-host-relay.test.ts +29 -0
  154. package/tests/review-integration-v2-forward.test.ts +44 -0
  155. package/tests/review-integration-v2.test.ts +164 -0
  156. package/tests/review-last-event-closure.test.ts +105 -1
  157. package/tests/review-ledger-contract.test.ts +61 -6
  158. package/tests/review-reminder-receipt.test.ts +62 -0
  159. package/tests/review-session-standing-permission-controller.test.ts +52 -4
  160. package/tests/review-session-standing-permission.test.ts +30 -0
  161. package/tests/runtime-harness.mjs +447 -39
  162. package/tests/runtime-metrics-children.test.ts +206 -0
  163. package/tests/runtime-metrics-delivery.test.ts +85 -0
  164. package/tests/runtime-metrics-extension.test.ts +187 -0
  165. package/tests/runtime-metrics-native.test.ts +209 -0
  166. package/tests/runtime-metrics-pi-identity.test.ts +113 -0
  167. package/tests/runtime-metrics-policy.test.ts +62 -0
  168. package/tests/runtime-metrics.test.ts +184 -0
  169. package/tests/sdd-agent-tools.test.ts +10 -1
  170. package/tests/sdd-execution-routing-contract.test.ts +28 -0
  171. package/tests/sdd-managed-runtime-settlement.test.ts +331 -0
  172. package/tests/sdd-native-managed-uptake.test.ts +253 -0
  173. package/tests/sdd-planning-routing-contract.test.ts +45 -0
  174. package/tests/sdd-preflight.test.ts +252 -8
  175. package/tests/sdd-research-capabilities.test.ts +256 -0
  176. package/tests/sdd-research-live.test.ts +241 -0
  177. package/tests/sdd-selection-transport.test.ts +504 -0
  178. package/tests/sdd-status.test.ts +51 -0
  179. package/tests/session-worktree-registry.test.ts +135 -0
  180. package/tests/shell-card.test.ts +24 -3
  181. package/tests/shell-changes-view.test.ts +471 -8
  182. package/tests/shell-changes.test.ts +168 -0
  183. package/tests/shell-prompt.test.ts +28 -6
  184. package/tests/shell-sidebar-banner.test.ts +23 -0
  185. package/tests/shell-sidebar-layout.test.ts +387 -0
  186. package/tests/shell-sidebar.test.ts +50 -0
  187. package/tests/shell-todo.test.ts +100 -11
  188. package/tests/startup-banner.test.ts +126 -0
  189. package/tests/telemetry-trigger.test.ts +3 -1
@@ -72,6 +72,21 @@ function relayCollectInput(lineageId, lens, order) {
72
72
  { name: "agent", value: "pi", token: "--agent=pi" },
73
73
  { name: "materialize", value: "true", token: "--materialize=true" },
74
74
  ],
75
+ // gentle-pi#638: the artifact subject is what makes the slot declarable —
76
+ // its subject_hash is the --request-hash the capture-unachievable verb
77
+ // binds to. A slot without one cannot be declared, only retried.
78
+ artifactSubject: {
79
+ schema: "gentle-ai.review-artifact-subject/v2",
80
+ subjectHash: `sha256:${String(order).repeat(64)}`,
81
+ lineageId,
82
+ authorityRevision: SHA,
83
+ targetIdentity: SHA,
84
+ baseTree: TREE,
85
+ candidateTree: TREE,
86
+ changedPathManifestSha256: SHA,
87
+ lens,
88
+ selectedOrder: order,
89
+ },
75
90
  submission: providerSubmission(lineageId, lens, order),
76
91
  };
77
92
  }
@@ -146,6 +161,7 @@ const { ReviewHostRelayError, REVIEW_HOST_RELAY_FAILURE } = await import(${JSON.
146
161
  const statusQueue = JSON.parse(await readFile(statusFile, "utf8"));
147
162
  const relayRequests = [];
148
163
  const statusCalls = [];
164
+ const unachievableCalls = [];
149
165
  let captureCalls = 0;
150
166
 
151
167
  __testing.setReviewHostRelayRunnerForTesting(async (request) => {
@@ -156,6 +172,9 @@ __testing.setReviewHostRelayRunnerForTesting(async (request) => {
156
172
  if (mode === "capture-fail") {
157
173
  throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: 4 });
158
174
  }
175
+ if (mode === "capture-timeout") {
176
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2256004, timeoutMs: 2256000 });
177
+ }
159
178
  if (mode === "capture-ambiguous") {
160
179
  throw Object.assign(new Error("capture response lost"), { mutationOutcome: "unknown", nextAction: "review.status" });
161
180
  }
@@ -172,6 +191,27 @@ const nativeReviewCli = {
172
191
  if (next === undefined) throw new Error("status queue exhausted");
173
192
  return next;
174
193
  },
194
+ captureUnachievableLens: async (request) => {
195
+ unachievableCalls.push({
196
+ cwd: request.cwd,
197
+ lineageId: request.lineageId,
198
+ targetIdentity: request.targetIdentity,
199
+ expectedRevision: request.expectedRevision,
200
+ requestHash: request.requestHash,
201
+ reason: request.reason,
202
+ ...(request.detail === undefined ? {} : { detail: request.detail }),
203
+ ...(request.repositoryContext === undefined ? {} : { repositoryContext: request.repositoryContext }),
204
+ });
205
+ return {
206
+ schema: "gentle-ai.review-capture-unachievable/v1",
207
+ lineageId: request.lineageId,
208
+ targetIdentity: request.targetIdentity,
209
+ lens: ${JSON.stringify(LENS)},
210
+ selectedOrder: ${JSON.stringify(ORDER)},
211
+ reason: request.reason,
212
+ recorded: true,
213
+ };
214
+ },
175
215
  };
176
216
 
177
217
  let result;
@@ -199,7 +239,7 @@ try {
199
239
  error = caught instanceof Error ? { name: caught.name, message: caught.message } : String(caught);
200
240
  }
201
241
 
202
- await writeFile(outFile, JSON.stringify({ result, inspectResult, captureBinding, error, relayRequests, statusCalls, captureCalls }, null, 2));
242
+ await writeFile(outFile, JSON.stringify({ result, inspectResult, captureBinding, error, relayRequests, statusCalls, captureCalls, unachievableCalls }, null, 2));
203
243
  `;
204
244
  }
205
245
 
@@ -225,6 +265,107 @@ function inspectCollectBinding(inspectResult) {
225
265
  const PENDING = collectStatus(LINEAGE, [relayCollectInput(LINEAGE, LENS, ORDER)]);
226
266
  const CONVERGED = collectStatus(LINEAGE, []);
227
267
 
268
+ // gentle-pi#638: the typed stop the provider renders once the slot is declared
269
+ // unachievable — no collect inputs, one withdraw binding per declared slot.
270
+ // Built in the same plain-object style as collectStatus: typed fields for the
271
+ // controller, raw fields for the envelope passthrough.
272
+ function unachievableStopStatus(lineageId, lens, order) {
273
+ const subjectHash = `sha256:${String(order).repeat(64)}`;
274
+ const repositoryContext = `rctx1_${"e".repeat(64)}`;
275
+ const withdrawArguments = [
276
+ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
277
+ { name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` },
278
+ { name: "target", value: SHA, token: `--target=${SHA}` },
279
+ { name: "repository-context", value: repositoryContext, token: `--repository-context=${repositoryContext}` },
280
+ { name: "request-hash", value: subjectHash, token: `--request-hash=${subjectHash}` },
281
+ { name: "withdraw", value: "true", token: "--withdraw=true" },
282
+ ];
283
+ const withdrawCommand = `gentle-ai review capture-unachievable --lineage=${lineageId} --expected-revision=${SHA} --target=${SHA} --repository-context=${repositoryContext} --request-hash=${subjectHash} --withdraw=true`;
284
+ return {
285
+ ...collectStatus(lineageId, []),
286
+ nextTransition: {
287
+ kind: "stop",
288
+ reasonCode: "unachievable_lens_slot",
289
+ unachievableLensSlots: [
290
+ {
291
+ lens,
292
+ selectedOrder: order,
293
+ subjectHash,
294
+ reason: "relay_transport_bound_exceeded",
295
+ withdraw: {
296
+ operation: "review.capture-unachievable",
297
+ command: withdrawCommand,
298
+ arguments: withdrawArguments,
299
+ binding: { targetIdentity: SHA, lineageId, revision: SHA },
300
+ },
301
+ },
302
+ ],
303
+ },
304
+ raw: {
305
+ schema: "gentle-ai.review-integration.status/v5",
306
+ action: "stop",
307
+ lineage_id: lineageId,
308
+ target_identity: SHA,
309
+ next_transition: {
310
+ kind: "stop",
311
+ reason_code: "unachievable_lens_slot",
312
+ unachievable_lens_slots: [
313
+ {
314
+ lens,
315
+ selected_order: order,
316
+ subject_hash: subjectHash,
317
+ reason: "relay_transport_bound_exceeded",
318
+ withdraw: {
319
+ operation: "review.capture-unachievable",
320
+ command: withdrawCommand,
321
+ arguments: withdrawArguments,
322
+ binding: { lineage_id: lineageId, revision: SHA, target_identity: SHA, repository_context: repositoryContext },
323
+ },
324
+ },
325
+ ],
326
+ },
327
+ },
328
+ };
329
+ }
330
+ const UNACHIEVABLE_STOP = unachievableStopStatus(LINEAGE, LENS, ORDER);
331
+
332
+ // gentle-pi#638: the declared slot must not survive the restart as a reoffer.
333
+ // Process A declares after the deterministic bound kill; fresh Process B sees
334
+ // the provider's typed stop with the withdraw hint instead of the public
335
+ // collectBinding it would have seen before the declaration.
336
+ test("an unachievable declaration survives the controller restart as a typed stop, not a reoffer", async (t) => {
337
+ const cwd = repository(t);
338
+
339
+ // Process A: the deterministic relay bound kills the reviewer; the capture
340
+ // path declares the slot unachievable and renders the provider's stop. The
341
+ // worker always runs INSPECT first, so the queue carries the pending collect
342
+ // twice (inspect + capture selection) before the post-declaration stop.
343
+ const declared = runWorker(t, cwd, [PENDING, PENDING, UNACHIEVABLE_STOP], "capture-timeout");
344
+ assert.equal(declared.error, undefined, `declared run threw: ${JSON.stringify(declared.error)}`);
345
+ assert.equal(declared.captureCalls, 1, "Process A invokes one capture");
346
+ assert.equal(declared.relayRequests.length, 1, "Process A launches one relay transport");
347
+ assert.equal(declared.unachievableCalls.length, 1, "the declaration is recorded exactly once");
348
+ assert.deepEqual(declared.unachievableCalls, [{ cwd, lineageId: LINEAGE, targetIdentity: SHA, expectedRevision: SHA, requestHash: `sha256:${String(ORDER).repeat(64)}`, reason: "relay_transport_bound_exceeded", detail: "killed after 2256004ms against a 2256000ms relay bound", repositoryContext: `rctx1_${"e".repeat(64)}` }]);
349
+ assert.equal(declared.statusCalls.length, 3, "inspect and selection STATUS plus exactly one bound re-query");
350
+ assert.equal(declared.result.tool, "gentle_review_capture");
351
+ assert.equal(declared.result.status, "blocked");
352
+ assert.equal(declared.result.outcome, "unachievable-lens-slot-declared");
353
+ assert.equal(declared.result.mutation_performed, true);
354
+ assert.match(String(declared.result.next_action), /withdraw/);
355
+
356
+ // Process B (fresh): the provider's STATUS no longer reoffers the binding;
357
+ // INSPECT renders the typed stop whose hint is the withdraw command.
358
+ const inspect = runWorker(t, cwd, [UNACHIEVABLE_STOP], "inspect");
359
+ assert.equal(inspect.error, undefined, `inspect run threw: ${JSON.stringify(inspect.error)}`);
360
+ assert.equal(inspect.captureCalls, 0, "no capture runs without a reoffered binding");
361
+ assert.equal(inspect.relayRequests.length, 0, "INSPECT never launches the relay");
362
+ assert.equal(inspect.unachievableCalls.length, 0, "INSPECT never declares");
363
+ assert.equal(inspectCollectBinding(inspect.result), undefined, "the declared slot is not reoffered after restart");
364
+ assert.equal(inspect.result.status, "blocked");
365
+ assert.match(String(inspect.result.hint), /capture-unachievable/);
366
+ assert.match(String(inspect.result.hint), /--withdraw=true/);
367
+ });
368
+
228
369
  test("a fresh controller process reoffers the exact public binding before one capture closes", async (t) => {
229
370
  const cwd = repository(t);
230
371
 
@@ -1,13 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { __testing } from "../extensions/gentle-ai.ts";
8
- import type { NativeReviewCli } from "../lib/native-review-cli.ts";
8
+ import type { NativeReviewCli, NativeReviewUnachievableLensCaptureArtifact, NativeReviewUnachievableLensCaptureRequest } from "../lib/native-review-cli.ts";
9
9
  import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
10
- import { decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
10
+ import { decodeReviewNextTransitionV3, decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
11
11
 
12
12
  // One-slot capture routing: the host relay runs only when the selected
13
13
  // provider-returned collect input carries the --materialize token. Every
@@ -158,13 +158,18 @@ function providerRefuterRequiredStatus(lineageId: string): ReviewStatusV3 {
158
158
  interface RoutingHarness {
159
159
  statusQueue: ReviewStatusV3[];
160
160
  statusCalls: Array<{ cwd: string; lineageId?: string; agent?: "pi" }>;
161
+ unachievableCalls: NativeReviewUnachievableLensCaptureRequest[];
161
162
  native: NativeReviewCli;
162
163
  }
163
164
 
164
- function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness {
165
+ type UnachievableResponder = (request: NativeReviewUnachievableLensCaptureRequest) => Promise<NativeReviewUnachievableLensCaptureArtifact>;
166
+
167
+ // The declaration verb stays OPT-IN: an absent captureUnachievableLens is the invocation-adjacent capability gate, so every pre-existing failure test keeps its exact envelope through the unchanged fall-back.
168
+ function nativeHarness(statuses: readonly ReviewStatusV3[], unachievableResponder?: UnachievableResponder): RoutingHarness {
165
169
  const harness: RoutingHarness = {
166
170
  statusQueue: [...statuses],
167
171
  statusCalls: [],
172
+ unachievableCalls: [],
168
173
  native: undefined as unknown as NativeReviewCli,
169
174
  };
170
175
  harness.native = {
@@ -174,6 +179,7 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness {
174
179
  if (next === undefined) throw new Error("status queue exhausted");
175
180
  return next;
176
181
  },
182
+ ...(unachievableResponder === undefined ? {} : { captureUnachievableLens: async (request: NativeReviewUnachievableLensCaptureRequest) => { harness.unachievableCalls.push(request); return await unachievableResponder(request); } }),
177
183
  };
178
184
  return harness;
179
185
  }
@@ -506,6 +512,360 @@ test("a relay timeout reports its one-slot measurements and no auto-follow", asy
506
512
  assert.equal(harness.statusCalls.length, 1);
507
513
  });
508
514
 
515
+ // gentle-pi#638: the typed stop a provider renders after a host declared a
516
+ // slot unachievable, built through the production decoder so the routing
517
+ // tests exercise the same typed shape the extension consumes.
518
+ function unachievableStopStatus(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, reason = "relay_transport_bound_exceeded"): ReviewStatusV3 {
519
+ const subjectHash = `sha256:${String(order).repeat(64)}`;
520
+ const repositoryContext = `rctx1_${"e".repeat(64)}`;
521
+ const rawTransition = {
522
+ kind: "stop",
523
+ reason_code: "unachievable_lens_slot",
524
+ unachievable_lens_slots: [
525
+ {
526
+ lens,
527
+ selected_order: order,
528
+ subject_hash: subjectHash,
529
+ reason,
530
+ withdraw: {
531
+ operation: "review.capture-unachievable",
532
+ command: `gentle-ai review capture-unachievable --lineage=${lineageId} --expected-revision=${SHA} --target=${SHA} --repository-context=${repositoryContext} --request-hash=${subjectHash} --withdraw=true`,
533
+ arguments: [
534
+ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
535
+ { name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` },
536
+ { name: "target", value: SHA, token: `--target=${SHA}` },
537
+ { name: "repository-context", value: repositoryContext, token: `--repository-context=${repositoryContext}` },
538
+ { name: "request-hash", value: subjectHash, token: `--request-hash=${subjectHash}` },
539
+ { name: "withdraw", value: "true", token: "--withdraw=true" },
540
+ ],
541
+ binding: { lineage_id: lineageId, revision: SHA, target_identity: SHA, repository_context: repositoryContext },
542
+ },
543
+ },
544
+ ],
545
+ };
546
+ return {
547
+ ...finalizeStatus(lineageId),
548
+ nextTransition: decodeReviewNextTransitionV3(rawTransition),
549
+ raw: { schema: "gentle-ai.review-integration.status/v5", action: "stop", lineage_id: lineageId, target_identity: SHA, next_transition: rawTransition },
550
+ } as unknown as ReviewStatusV3;
551
+ }
552
+
553
+ function unachievableArtifact(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, reason = "relay_transport_bound_exceeded"): NativeReviewUnachievableLensCaptureArtifact {
554
+ return { schema: "gentle-ai.review-capture-unachievable/v1", lineageId, targetIdentity: SHA, lens, selectedOrder: order, reason, recorded: true };
555
+ }
556
+
557
+ // gentle-pi#638: a deterministic pi timeout is declared unachievable through
558
+ // the native verb, and one bound STATUS re-query renders the typed stop with
559
+ // its withdraw binding instead of reoffering the same slot.
560
+ test("a deterministic pi timeout declares the slot unachievable and renders the typed stop", async (t) => {
561
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
562
+ const cwd = repository(t);
563
+ const lineageId = "relay-lineage";
564
+ const input = relayCollectInput(lineageId, "review-risk", 0);
565
+ const stop = unachievableStopStatus(lineageId, "review-risk", 0);
566
+ const harness = nativeHarness([finalizeStatus(lineageId, [input]), stop], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
567
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
568
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
569
+ });
570
+
571
+ const result = await runCapture(cwd, harness, lineageId);
572
+
573
+ assert.equal(result.status, "blocked");
574
+ assert.equal(result.outcome, "unachievable-lens-slot-declared");
575
+ assert.deepEqual(result.failure, { kind: "pi-timed-out", stage: "pi", exit_code: null, timed_out: true, elapsed_ms: 2_256_004, timeout_ms: 2_256_000 });
576
+ assert.deepEqual(harness.unachievableCalls, [{ cwd: realpathSync(cwd), lineageId, targetIdentity: SHA, expectedRevision: SHA, requestHash: `sha256:${"0".repeat(64)}`, reason: "relay_transport_bound_exceeded", detail: "killed after 2256004ms against a 2256000ms relay bound", repositoryContext: `rctx1_${"e".repeat(64)}` }]);
577
+ assert.deepEqual(result.declaration, { lens: "review-risk", selected_order: 0, subject_hash: `sha256:${"0".repeat(64)}`, reason: "relay_transport_bound_exceeded" });
578
+ assert.deepEqual(result.result, stop.raw);
579
+ const slots = result.unachievable_lens_slots as Array<{ lens: string; subject_hash: string; withdraw: string }>;
580
+ assert.equal(slots.length, 1);
581
+ assert.equal(slots[0]!.lens, "review-risk");
582
+ assert.equal(slots[0]!.subject_hash, `sha256:${"0".repeat(64)}`);
583
+ assert.match(slots[0]!.withdraw, /capture-unachievable.*--withdraw=true/);
584
+ assert.match(String(result.next_action), /withdraw/);
585
+ assert.equal(result.mutation_performed, true);
586
+ // gentle-pi#822: the native declaration was recorded, so the flow reports the mutation as committed, not none.
587
+ assert.equal(result.mutation_outcome, "committed");
588
+ assert.equal(harness.statusCalls.length, 2, "one selection STATUS plus exactly one bound re-query");
589
+ assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "the bound re-query preserves the Pi host runtime");
590
+ });
591
+
592
+ // gentle-pi#638: generic admission rejections describe the submitted reviewer
593
+ // bytes, not a deterministic failure of the provider-bound slot. A fresh
594
+ // reviewer can repair malformed JSON or a binding mismatch, so both retain the
595
+ // ordinary exact-reoffer path and must never declare the slot unachievable.
596
+ test("repairable submission refusals retain the exact-slot retry path", async (t) => {
597
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
598
+ const cwd = repository(t);
599
+ const lineageId = "relay-lineage";
600
+ for (const refusal of [
601
+ "Error: reviewer payload contains no complete JSON object [invalid_request]\n",
602
+ "Error: reviewer artifact admission binding_mismatch [invalid_request]\n",
603
+ ]) {
604
+ const input = relayCollectInput(lineageId, "review-reliability", 0);
605
+ const harness = nativeHarness([finalizeStatus(lineageId, [input])], async () => {
606
+ throw new Error("a repairable refusal must not call capture-unachievable");
607
+ });
608
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
609
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", refusal.trim(), { exitCode: 1, stderr: refusal, mutationOutcome: "none" });
610
+ });
611
+
612
+ const result = await runCapture(cwd, harness, lineageId);
613
+
614
+ assert.equal(result.outcome, "pi-host-relay-transport-failure");
615
+ assert.equal(result.mutation_performed, false);
616
+ assert.equal(result.mutation_outcome, "none");
617
+ assert.match(String(result.next_action), /fresh STATUS/);
618
+ assert.deepEqual(harness.unachievableCalls, []);
619
+ assert.equal(harness.statusCalls.length, 1, "a repairable non-mutation needs no reconciliation before the provider reoffers it");
620
+ }
621
+ });
622
+
623
+ // gentle-pi#822: a stop carrying slots that do not match the identity this
624
+ // session declared is a reconciliation failure — never a success rendering
625
+ // someone else's withdraw command.
626
+ test("a stop whose slots do not match the declared identity reports a reconciliation failure", async (t) => {
627
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
628
+ const cwd = repository(t);
629
+ const lineageId = "relay-lineage";
630
+ const input = relayCollectInput(lineageId, "review-risk", 0);
631
+ const foreignStop = unachievableStopStatus(lineageId, "review-risk", 1);
632
+ const harness = nativeHarness([finalizeStatus(lineageId, [input]), foreignStop], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
633
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
634
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
635
+ });
636
+
637
+ const result = await runCapture(cwd, harness, lineageId);
638
+
639
+ assert.equal(result.status, "blocked");
640
+ assert.equal(result.outcome, "unachievable-lens-declaration-reconciliation-failed");
641
+ assert.deepEqual(result.declaration, { lens: "review-risk", selected_order: 0, subject_hash: `sha256:${"0".repeat(64)}`, reason: "relay_transport_bound_exceeded" });
642
+ assert.equal(result.unachievable_lens_slots, undefined, "a foreign slot is never exposed as this session's withdraw command");
643
+ const reconciliationFailure = result.reconciliation_failure as { outcome?: string; reason?: string } | undefined;
644
+ assert.equal(reconciliationFailure?.outcome, "unachievable-lens-slot-declaration-unmatched");
645
+ assert.match(String(reconciliationFailure?.reason), /no unachievable_lens_slots entry matches the declared slot identity/);
646
+ assert.equal(result.mutation_performed, true);
647
+ assert.equal(result.mutation_outcome, "committed");
648
+ assert.match(String(result.next_action), /refused the unachievable declaration/);
649
+ assert.equal(harness.statusCalls.length, 2, "the bound re-query still runs exactly once");
650
+ });
651
+
652
+ // gentle-pi#822: with several declared entries on the stop, only the matching
653
+ // identity is exposed — never the withdraw commands of other runs.
654
+ test("a stop carrying several declared slots exposes only the one this session declared", async (t) => {
655
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
656
+ const cwd = repository(t);
657
+ const lineageId = "relay-lineage";
658
+ const input = relayCollectInput(lineageId, "review-risk", 0);
659
+ const stop = unachievableStopStatus(lineageId, "review-risk", 0);
660
+ const foreign = unachievableStopStatus(lineageId, "review-reliability", 2).nextTransition?.unachievableLensSlots?.[0];
661
+ assert.ok(foreign !== undefined);
662
+ stop.nextTransition = { ...stop.nextTransition!, unachievableLensSlots: [...stop.nextTransition!.unachievableLensSlots!, foreign] };
663
+ const harness = nativeHarness([finalizeStatus(lineageId, [input]), stop], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
664
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
665
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
666
+ });
667
+
668
+ const result = await runCapture(cwd, harness, lineageId);
669
+
670
+ assert.equal(result.outcome, "unachievable-lens-slot-declared");
671
+ const slots = result.unachievable_lens_slots as Array<{ lens: string; selected_order: number; subject_hash: string }>;
672
+ assert.equal(slots.length, 1, "only the declared entry is exposed");
673
+ assert.equal(slots[0]!.lens, "review-risk");
674
+ assert.equal(slots[0]!.selected_order, 0);
675
+ assert.equal(slots[0]!.subject_hash, `sha256:${"0".repeat(64)}`);
676
+ assert.equal(result.mutation_outcome, "committed");
677
+ });
678
+
679
+ // gentle-pi#822 (CodeRabbit finding): success must be proven by the unachievable_lens_slot stop itself — a bound STATUS that comes back with a collect reoffer instead of the stop is a reconciliation failure, never a silent success with no withdraw command.
680
+ test("a bound STATUS that reoffers the collect transition instead of the stop reports a reconciliation failure", async (t) => {
681
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
682
+ const cwd = repository(t);
683
+ const lineageId = "relay-lineage";
684
+ const input = relayCollectInput(lineageId, "review-risk", 0);
685
+ const harness = nativeHarness([finalizeStatus(lineageId, [input]), finalizeStatus(lineageId, [input])], () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0)));
686
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
687
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
688
+ });
689
+
690
+ const result = await runCapture(cwd, harness, lineageId);
691
+
692
+ assert.equal(result.status, "blocked");
693
+ assert.equal(result.outcome, "unachievable-lens-declaration-reconciliation-failed");
694
+ assert.equal(result.unachievable_lens_slots, undefined, "no stop means no withdraw command to expose");
695
+ const reconciliationFailure = result.reconciliation_failure as { outcome?: string; reason?: string };
696
+ assert.equal(reconciliationFailure?.outcome, "unachievable-lens-slot-declaration-unmatched");
697
+ assert.match(String(reconciliationFailure?.reason), /did not return the unachievable_lens_slot stop/);
698
+ assert.equal(result.mutation_performed, true, "the declaration was recorded");
699
+ assert.equal(result.mutation_outcome, "committed");
700
+ assert.equal(harness.statusCalls.length, 2, "the bound re-query still runs exactly once");
701
+ });
702
+
703
+ // gentle-pi#822 (CodeRabbit finding): the same proof requirement covers every STATUS shape that is not the stop — a foreign stop reason code or no transition at all.
704
+ test("a bound STATUS stop with a foreign reason code or no transition reports a reconciliation failure", async (t) => {
705
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
706
+ const cwd = repository(t);
707
+ const lineageId = "relay-lineage";
708
+ const input = relayCollectInput(lineageId, "review-risk", 0);
709
+ const responder = () => Promise.resolve(unachievableArtifact(lineageId, "review-risk", 0));
710
+ const relayFailure = async () => {
711
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
712
+ };
713
+
714
+ const foreignStop = { ...finalizeStatus(lineageId), nextTransition: { kind: "stop", reasonCode: "rdd_disabled" } } as unknown as ReviewStatusV3;
715
+ const foreignHarness = nativeHarness([finalizeStatus(lineageId, [input]), foreignStop], responder);
716
+ __testing.setReviewHostRelayRunnerForTesting(relayFailure);
717
+ const foreign = await runCapture(cwd, foreignHarness, lineageId);
718
+
719
+ assert.equal(foreign.outcome, "unachievable-lens-declaration-reconciliation-failed");
720
+ assert.equal(foreign.unachievable_lens_slots, undefined);
721
+ const foreignFailure = foreign.reconciliation_failure as { outcome?: string; reason?: string };
722
+ assert.match(String(foreignFailure?.reason), /did not return the unachievable_lens_slot stop/);
723
+ assert.equal(foreign.mutation_performed, true);
724
+ assert.equal(foreign.mutation_outcome, "committed");
725
+
726
+ const bareHarness = nativeHarness([finalizeStatus(lineageId, [input]), finalizeStatus(lineageId)], responder);
727
+ __testing.setReviewHostRelayRunnerForTesting(relayFailure);
728
+ const bare = await runCapture(cwd, bareHarness, lineageId);
729
+
730
+ assert.equal(bare.outcome, "unachievable-lens-declaration-reconciliation-failed");
731
+ assert.equal(bare.unachievable_lens_slots, undefined);
732
+ const bareFailure = bare.reconciliation_failure as { outcome?: string; reason?: string };
733
+ assert.match(String(bareFailure?.reason), /did not return the unachievable_lens_slot stop/);
734
+ assert.equal(bare.mutation_outcome, "committed");
735
+ assert.equal(bareHarness.statusCalls.length, 2);
736
+ });
737
+
738
+ // gentle-pi#822: STATUS for one lineage renders only that lineage's withdraw
739
+ // command as the hint; a request for an unlisted lineage omits the hint rather
740
+ // than surfacing a potentially unrelated withdraw command.
741
+ test("an unachievable stop renders the withdraw hint only for the requested lineage", async (t) => {
742
+ const cwd = repository(t);
743
+ const matchedHarness = nativeHarness([unachievableStopStatus("other-lineage", "review-risk", 0)]);
744
+ const matched = (await __testing.executeReviewControllerOperation({ operation: "status", lineageId: "other-lineage" }, cwd, matchedHarness.native)) as Record<string, unknown>;
745
+ assert.match(String(matched.hint), /capture-unachievable.*--withdraw=true/);
746
+ assert.equal(matched.requested_lineage_id, "other-lineage");
747
+
748
+ const unmatchedHarness = nativeHarness([unachievableStopStatus("other-lineage", "review-risk", 0)]);
749
+ const unmatched = (await __testing.executeReviewControllerOperation({ operation: "status", lineageId: "relay-lineage" }, cwd, unmatchedHarness.native)) as Record<string, unknown>;
750
+ assert.equal(unmatched.hint, undefined, "no hint when no slot matches the requested lineage");
751
+ assert.equal(unmatched.requested_lineage_id, "relay-lineage");
752
+ });
753
+
754
+ // The fail-open capability gate: an older binary that refuses the whole verb
755
+ // keeps today's transport-failure envelope untouched.
756
+ test("an unknown-verb refusal falls back to today's transport-failure behavior", async (t) => {
757
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
758
+ const cwd = repository(t);
759
+ const lineageId = "relay-lineage";
760
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
761
+ throw Object.assign(new Error("native command returned empty output"), { diagnostics: { stderr: 'Error: unknown review command "capture-unachievable"\n' } });
762
+ });
763
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
764
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
765
+ });
766
+
767
+ const result = await runCapture(cwd, harness, lineageId);
768
+
769
+ assert.equal(result.status, "blocked");
770
+ assert.equal(result.outcome, "pi-host-relay-timeout");
771
+ assert.equal(harness.unachievableCalls.length, 1, "the verb was attempted exactly once");
772
+ assert.equal(harness.statusCalls.length, 1, "the fall-back performs no bound STATUS re-query");
773
+ assert.match(String(result.next_action), new RegExp(`${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}=<milliseconds>`));
774
+ });
775
+
776
+ // Every non-capability declaration failure — here a typed binding-mismatch
777
+ // refusal — is surfaced with both failures, never hidden behind the relay
778
+ // failure it followed.
779
+ test("a refused declaration surfaces both failures instead of the transport fall-back", async (t) => {
780
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
781
+ const cwd = repository(t);
782
+ const lineageId = "relay-lineage";
783
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
784
+ throw Object.assign(new Error("review capture-unachievable binding does not match the current reviewing authority"), { diagnostics: { stderr: "Error: review capture-unachievable binding does not match [invalid_request]" } });
785
+ });
786
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
787
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
788
+ });
789
+
790
+ const result = await runCapture(cwd, harness, lineageId);
791
+
792
+ assert.equal(result.status, "blocked");
793
+ assert.equal(result.outcome, "unachievable-lens-declaration-failed");
794
+ assert.equal(result.mutation_performed, false);
795
+ assert.equal(result.mutation_outcome, "none");
796
+ assert.deepEqual(result.failure, { kind: "pi-timed-out", stage: "pi", exit_code: null, timed_out: true, elapsed_ms: 2_256_004, timeout_ms: 2_256_000 });
797
+ assert.ok(result.declaration_failure, "the declaration refusal rides the envelope");
798
+ assert.equal(harness.statusCalls.length, 1, "a refused declaration performs no bound STATUS re-query");
799
+ assert.match(String(result.next_action), /fresh STATUS/);
800
+ });
801
+
802
+ // gentle-pi#822 (outside-diff finding): the declaration failure's own envelope carries the mutation truth — a failure AFTER the provider recorded the declaration reports committed, never a hardcoded none.
803
+ test("a declaration failure whose envelope already committed propagates its mutation state", async (t) => {
804
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
805
+ const cwd = repository(t);
806
+ const lineageId = "relay-lineage";
807
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
808
+ throw Object.assign(new Error("review capture-unachievable failed after recording"), { failureEnvelope: { raw: { code: "invalid_request", message: "review capture-unachievable failed after recording" }, mutationOutcome: "committed" } });
809
+ });
810
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
811
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
812
+ });
813
+
814
+ const result = await runCapture(cwd, harness, lineageId);
815
+
816
+ assert.equal(result.status, "blocked");
817
+ assert.equal(result.outcome, "unachievable-lens-declaration-failed");
818
+ assert.equal(result.mutation_performed, true);
819
+ assert.equal(result.mutation_outcome, "committed");
820
+ assert.ok(result.declaration_failure, "the declaration failure still rides the envelope");
821
+ assert.equal(harness.statusCalls.length, 1, "a committed outcome needs no reconciliation re-query");
822
+ });
823
+
824
+ // gentle-pi#822 (outside-diff finding): an unknown declaration outcome is proven or disproven by one bound STATUS re-query without ever changing the failure outcome.
825
+ test("an unknown declaration outcome is proven committed by one bound STATUS re-query", async (t) => {
826
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
827
+ const cwd = repository(t);
828
+ const lineageId = "relay-lineage";
829
+ const input = relayCollectInput(lineageId, "review-risk", 0);
830
+ const stop = unachievableStopStatus(lineageId, "review-risk", 0);
831
+ const harness = nativeHarness([finalizeStatus(lineageId, [input]), stop], async () => {
832
+ throw Object.assign(new Error("native command timed out while recording the declaration"), { failureEnvelope: { raw: { code: "deadline_exceeded", message: "native command timed out" }, mutationOutcome: "unknown" } });
833
+ });
834
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
835
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
836
+ });
837
+
838
+ const result = await runCapture(cwd, harness, lineageId);
839
+
840
+ assert.equal(result.status, "blocked");
841
+ assert.equal(result.outcome, "unachievable-lens-declaration-failed", "the failure outcome never changes");
842
+ assert.equal(result.mutation_performed, true);
843
+ assert.equal(result.mutation_outcome, "committed");
844
+ assert.equal(harness.statusCalls.length, 2, "one selection STATUS plus exactly one bound reconciliation re-query");
845
+ assert.equal(harness.statusCalls.at(-1)?.agent, "pi", "the reconciliation re-query preserves the Pi host runtime");
846
+ });
847
+
848
+ // gentle-pi#822 (outside-diff finding): a malformed declaration error with no envelope at all keeps the conservative none outcome and performs no re-query.
849
+ test("a malformed declaration error without an envelope keeps mutation none", async (t) => {
850
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
851
+ const cwd = repository(t);
852
+ const lineageId = "relay-lineage";
853
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])], async () => {
854
+ throw new TypeError("cannot read properties of undefined (reading 'detail')");
855
+ });
856
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
857
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
858
+ });
859
+
860
+ const result = await runCapture(cwd, harness, lineageId);
861
+
862
+ assert.equal(result.status, "blocked");
863
+ assert.equal(result.outcome, "unachievable-lens-declaration-failed");
864
+ assert.equal(result.mutation_performed, false);
865
+ assert.equal(result.mutation_outcome, "none");
866
+ assert.equal(harness.statusCalls.length, 1, "no envelope means no unknown outcome and no re-query");
867
+ });
868
+
509
869
  test("a non-timeout transport failure keeps its generic continuation and now carries its measurements", async (t) => {
510
870
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
511
871
  const cwd = repository(t);
@@ -18,6 +18,8 @@ import {
18
18
  resolveReviewHostRelayPiTimeoutMs,
19
19
  resolveReviewHostRelaySubmission,
20
20
  reviewHostRelaySlots,
21
+ reviewHostRelayUnachievableDetail,
22
+ reviewHostRelayUnachievableReason,
21
23
  prepareReviewHostRelaySlot,
22
24
  runReviewHostRelayReviewerGroup,
23
25
  runReviewHostRelaySlot,
@@ -779,6 +781,33 @@ test("refusal classification distinguishes unknown-flag, handshake, and other",
779
781
  assert.equal(classifyReviewHostRelayRefusal("some unrelated explosion"), "other");
780
782
  });
781
783
 
784
+ // gentle-pi#638: only a reviewer killed by the scaled relay bound is proven
785
+ // deterministic for this exact slot. Generic admission refusals are repairable
786
+ // by a fresh reviewer and retain the ordinary exact-reoffer behavior.
787
+ test("the unachievable predicate names only deterministic slot failures", () => {
788
+ const killed = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "pi reviewer subprocess exceeded the relay bound", { timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 });
789
+ assert.equal(reviewHostRelayUnachievableReason(killed), "relay_transport_bound_exceeded");
790
+ assert.equal(reviewHostRelayUnachievableDetail(killed), "killed after 2256004ms against a 2256000ms relay bound");
791
+
792
+ for (const refusal of ["reviewer payload contains no complete JSON object", "reviewer artifact admission binding_mismatch"]) {
793
+ const admitted = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `${refusal} [invalid_request]`, { exitCode: 1, mutationOutcome: "none" });
794
+ assert.equal(reviewHostRelayUnachievableReason(admitted), undefined, `${refusal} is repairable by a fresh reviewer`);
795
+ assert.equal(reviewHostRelayUnachievableDetail(admitted), undefined);
796
+ }
797
+
798
+ const unknownOutcome = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "gentle-ai capture submission exceeded its bound", { timedOut: true });
799
+ assert.equal(reviewHostRelayUnachievableReason(unknownOutcome), undefined, "an unknown mutation outcome is never deterministic");
800
+ assert.equal(reviewHostRelayUnachievableDetail(unknownOutcome), undefined);
801
+
802
+ for (const kind of [REVIEW_HOST_RELAY_FAILURE.PI_FAILED, REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH]) {
803
+ assert.equal(reviewHostRelayUnachievableReason(new ReviewHostRelayError(kind, "pi", "transient")), undefined, `${kind} stays transient`);
804
+ }
805
+
806
+ const unmeasured = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi", "killed at the bound");
807
+ assert.equal(reviewHostRelayUnachievableReason(unmeasured), "relay_transport_bound_exceeded");
808
+ assert.equal(reviewHostRelayUnachievableDetail(unmeasured), undefined, "no measurements means no detail, never a fabricated one");
809
+ });
810
+
782
811
  // ---------------------------------------------------------------------------
783
812
  // Slot detection — the provider decides; nothing is inferred.
784
813
  // ---------------------------------------------------------------------------