gentle-pi 2.2.0 → 2.4.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 (160) hide show
  1. package/README.md +76 -79
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-explore.md +1 -0
  8. package/assets/agents/sdd-proposal.md +2 -0
  9. package/assets/agents/sdd-research.md +54 -0
  10. package/assets/agents/sdd-status.md +4 -4
  11. package/assets/agents/sdd-tasks.md +2 -3
  12. package/assets/agents/sdd-verify.md +21 -1
  13. package/assets/chains/sdd-full.chain.md +1 -1
  14. package/assets/chains/sdd-verify.chain.md +1 -1
  15. package/assets/orchestrator-delegation.md +56 -249
  16. package/assets/orchestrator-memory.md +2 -0
  17. package/assets/orchestrator.md +20 -40
  18. package/assets/sdd-orchestrator-workflow.md +30 -37
  19. package/assets/support/sdd-status-contract.md +7 -7
  20. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  21. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  22. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  23. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  24. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  25. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  26. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  27. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  28. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  29. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  30. package/docs/native-authority-architecture.md +9 -11
  31. package/docs/review-integration.md +27 -373
  32. package/extensions/ask-user-choice.ts +151 -0
  33. package/extensions/gentle-ai.ts +2349 -3785
  34. package/extensions/quiet-tools.ts +515 -32
  35. package/extensions/sdd-init.ts +4 -8
  36. package/lib/agent-home.ts +8 -0
  37. package/lib/gentle-ai-renderer.ts +70 -0
  38. package/lib/model-routing-authority.ts +133 -0
  39. package/lib/native-review-cli.ts +387 -940
  40. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  41. package/lib/provider-contract-bundle.ts +88 -6
  42. package/lib/review-candidate-view.ts +341 -132
  43. package/lib/review-host-relay.ts +324 -96
  44. package/lib/review-integration-v2.ts +868 -307
  45. package/lib/review-last-event-controller.ts +35 -0
  46. package/lib/sdd-preflight.ts +179 -68
  47. package/lib/sdd-status.ts +66 -111
  48. package/lib/terminal-theme.ts +1 -1
  49. package/package.json +83 -82
  50. package/runtime/gentle-ai-binary.mjs +1 -1
  51. package/runtime/native-review-cli.mjs +337 -890
  52. package/runtime/review-integration-v2.mjs +816 -255
  53. package/runtime/review-relay-contract.mjs +1 -1
  54. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  55. package/scripts/gentle-ai-installer.mjs +75 -22
  56. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  57. package/scripts/test-packed-runner.mjs +4 -7
  58. package/scripts/verify-package-files.mjs +23 -23
  59. package/skills/_shared/review-ledger-contract.md +14 -14
  60. package/skills/chained-pr/SKILL.md +3 -0
  61. package/skills/cognitive-doc-design/SKILL.md +1 -1
  62. package/skills/comment-writer/SKILL.md +1 -1
  63. package/skills/gentle-ai/SKILL.md +7 -74
  64. package/skills/issue-creation/SKILL.md +53 -93
  65. package/skills/judgment-day/SKILL.md +5 -7
  66. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  67. package/skills/release/SKILL.md +3 -3
  68. package/skills/skill-registry/SKILL.md +1 -1
  69. package/skills/work-unit-commits/SKILL.md +3 -1
  70. package/tests/artifact-language.test.ts +24 -11
  71. package/tests/ask-user-choice.test.ts +264 -0
  72. package/tests/codegraph-tools.test.ts +3 -3
  73. package/tests/crosslane/cross-lane.mjs +15 -1168
  74. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  75. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  76. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  77. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  78. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  79. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  80. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  81. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  82. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  83. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  84. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  85. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  86. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  87. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  89. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  92. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  93. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  94. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  95. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  96. package/tests/gentle-ai-binary.test.ts +83 -13
  97. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  98. package/tests/gentle-ai-installer.test.ts +137 -47
  99. package/tests/gentle-ai.test.ts +821 -131
  100. package/tests/gentle-theme.test.ts +133 -0
  101. package/tests/issue-creation-skill.test.ts +103 -0
  102. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  103. package/tests/model-routing-authority.test.ts +257 -0
  104. package/tests/native-review-capability-contract.test.ts +45 -1
  105. package/tests/native-review-cli.test.ts +586 -962
  106. package/tests/native-review-consent.test.ts +150 -66
  107. package/tests/native-review-parity-runtime.test.ts +80 -359
  108. package/tests/native-review-parity.test.ts +674 -925
  109. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  110. package/tests/orchestrator-budget.test.ts +132 -88
  111. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  112. package/tests/package-manifest.test.ts +152 -36
  113. package/tests/provider-contract-bundle.test.ts +76 -0
  114. package/tests/provider-contract-mirror.test.ts +19 -0
  115. package/tests/provider-defect-handoff.test.ts +15 -118
  116. package/tests/quiet-tool-rendering.test.ts +1055 -28
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-authority-recovery-docs.test.ts +1 -2
  119. package/tests/review-candidate-view.test.ts +409 -3
  120. package/tests/review-compact-contract.test.ts +4 -30
  121. package/tests/review-contract-prompt.test.ts +139 -0
  122. package/tests/review-controller-lock-status.test.ts +2 -2
  123. package/tests/review-controller-native-recovery.test.ts +363 -1450
  124. package/tests/review-controller-native-routing.test.ts +1251 -5642
  125. package/tests/review-controller-retired-ops.test.ts +1 -1
  126. package/tests/review-controller-workspace-root.test.ts +222 -71
  127. package/tests/review-controller.test.ts +26 -816
  128. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  129. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  130. package/tests/review-gate.test.ts +0 -45
  131. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  132. package/tests/review-host-relay-routing.test.ts +328 -91
  133. package/tests/review-host-relay.test.ts +436 -14
  134. package/tests/review-integration-v2-forward.test.ts +623 -553
  135. package/tests/review-integration-v2.test.ts +239 -165
  136. package/tests/review-last-event-closure.test.ts +408 -0
  137. package/tests/review-ledger-contract.test.ts +97 -35
  138. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  139. package/tests/review-relay-transport-agent.test.ts +119 -46
  140. package/tests/review-snapshot.test.ts +3 -2
  141. package/tests/runtime-harness.mjs +362 -196
  142. package/tests/sdd-agent-tools.test.ts +36 -0
  143. package/tests/sdd-preflight.test.ts +81 -15
  144. package/tests/sdd-status.test.ts +109 -110
  145. package/tests/skill-collision-prefixes.test.ts +5 -8
  146. package/tests/writer-edit-surface-scope.test.ts +366 -0
  147. package/themes/Gentleman-Cute.json +94 -0
  148. package/themes/Gentleman-Sexy.json +92 -0
  149. package/lib/git-commit-transaction.ts +0 -861
  150. package/runtime/git-commit-transaction.mjs +0 -862
  151. package/scripts/run-git-commit-transaction.mjs +0 -35
  152. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  153. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  154. package/tests/git-commit-transaction.test.ts +0 -530
  155. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  156. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  157. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  158. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  159. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  160. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -1,16 +1,16 @@
1
1
  // The thin Pi host relay (gentle-pi#311 P4; provider contract gentle-ai#3249).
2
2
  //
3
3
  // gentle-ai owns prompt materialization, role and schema selection, byte
4
- // budgets, parsing, admission, immutable capture, retry and correction
5
- // accounting, receipts, and delivery gates. This host boundary is
4
+ // budgets, parsing, admission, immutable capture, retry, correction
5
+ // accounting, and receipt state. This host boundary is
6
6
  // intentionally narrow:
7
7
  //
8
8
  // 1. Run the exact provider-issued capture binding with `--agent pi
9
9
  // --materialize` and take stdout as opaque prompt BYTES, verbatim.
10
- // 2. Launch a brand-new locked-down print-mode `pi` subprocess in a fresh
11
- // empty scratch directory, pipe the prompt through stdin, and take
12
- // stdout as raw final bytes. Model/provider/profile selection stays
13
- // user-owned: no --model, no --provider, environment untouched.
10
+ // 2. Pass those prompt bytes to the pure opaque Pi adapter, which owns its
11
+ // locked-down print-mode subprocess and fresh empty scratch directory;
12
+ // take its stdout as raw final bytes. Model/provider/profile selection
13
+ // stays user-owned: no --model, no --provider, environment untouched.
14
14
  // 3. Submit those bytes untouched through the provider-owned `submission`
15
15
  // form carried by the collect input: execute its exact operation and
16
16
  // argument tokens with only the tempfile path substituted into the
@@ -30,25 +30,18 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
30
30
  import { tmpdir } from "node:os";
31
31
  import { isAbsolute, join } from "node:path";
32
32
  import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
33
+ import {
34
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
35
+ OpaquePiReviewerTransportError,
36
+ runOpaquePiReviewer,
37
+ type OpaquePiReviewerResult,
38
+ } from "./opaque-pi-reviewer-adapter.ts";
33
39
  import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
34
40
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
35
41
 
36
- // The complete pinned lockdown argv for the reviewer `pi` subprocess: print
37
- // mode, text output, and every discovery surface disabled. Nothing may be
38
- // added or removed here without a new relay contract — in particular no
39
- // --model/--provider/--profile, which remain user-owned.
40
- export const REVIEW_HOST_RELAY_PI_ARGV = Object.freeze([
41
- "--print",
42
- "--mode", "text",
43
- "--no-session",
44
- "--no-tools",
45
- "--no-extensions",
46
- "--no-skills",
47
- "--no-prompt-templates",
48
- "--no-themes",
49
- "--no-context-files",
50
- "--no-approve",
51
- ] as const);
42
+ // Compatibility export for existing relay consumers. The pure adapter owns the
43
+ // fixed Pi process boundary and its locked-down argv.
44
+ export { OPAQUE_PI_REVIEWER_ARGV as REVIEW_HOST_RELAY_PI_ARGV } from "./opaque-pi-reviewer-adapter.ts";
52
45
 
53
46
  export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
54
47
  "provider relay requires a gentle-ai build with the pi host relay surface";
@@ -61,6 +54,11 @@ export const REVIEW_HOST_RELAY_FAILURE = {
61
54
  EMPTY_PROMPT: "empty-prompt",
62
55
  PI_LAUNCH_FAILED: "pi-launch-failed",
63
56
  PI_FAILED: "pi-failed",
57
+ // gentle-pi#367: a reviewer killed by the relay bound is not a crash. It
58
+ // is the one failure class that a byte-identical relaunch cannot survive,
59
+ // so it carries its own kind, its own elapsed/limit evidence, and its own
60
+ // continuation instead of hiding inside `pi-failed`.
61
+ PI_TIMED_OUT: "pi-timed-out",
64
62
  PI_EMPTY_OUTPUT: "pi-empty-output",
65
63
  SUBMISSION_REFUSED: "submission-refused",
66
64
  } as const;
@@ -79,11 +77,20 @@ export class ReviewHostRelayError extends Error {
79
77
  readonly exitCode: number | null;
80
78
  readonly stderr: string;
81
79
  readonly timedOut: boolean;
80
+ // Wall time the killed or failed child actually consumed, and the bound it
81
+ // was measured against. Both are null only when no child process ran.
82
+ // Without them a transport failure cannot be told apart from a crash, which
83
+ // is what forced the gentle-pi#367 reporter to measure the relay by hand.
84
+ readonly elapsedMs: number | null;
85
+ readonly timeoutMs: number | null;
82
86
  // "none" until the submission invocation launches; a launched submission
83
87
  // whose outcome could not be read is "unknown" and the caller reconciles
84
- // through negotiated STATUS, never through a blind retry.
88
+ // through negotiated STATUS, never through a blind retry. A launched
89
+ // submission that gentle-ai refused with its typed admission refusal is
90
+ // "none" again: the provider states that the lens slot was not consumed
91
+ // (gentle-pi#522 / #524).
85
92
  readonly mutationOutcome: "none" | "unknown";
86
- constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean }) {
93
+ constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) {
87
94
  super(message);
88
95
  this.name = "ReviewHostRelayError";
89
96
  this.kind = kind;
@@ -91,10 +98,23 @@ export class ReviewHostRelayError extends Error {
91
98
  this.exitCode = details?.exitCode ?? null;
92
99
  this.stderr = details?.stderr ?? "";
93
100
  this.timedOut = details?.timedOut ?? false;
94
- this.mutationOutcome = stage === "submit" ? "unknown" : "none";
101
+ this.elapsedMs = details?.elapsedMs ?? null;
102
+ this.timeoutMs = details?.timeoutMs ?? null;
103
+ this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
95
104
  }
96
105
  }
97
106
 
107
+ // gentle-pi#522 / #524: gentle-ai refuses a reviewer submission before any
108
+ // admission with exit 1 and its typed operator line, `<reason> [invalid_request]`.
109
+ // That code is the provider's preflight class: the request was refused as
110
+ // sent and the lens slot was not consumed. The relay recognises only that
111
+ // typed shape; it never parses the reason, and it never retries.
112
+ const ADMISSION_REFUSAL = /\[invalid_request\]/;
113
+
114
+ export function isReviewHostRelayAdmissionRefusal(capture: { exitCode: number | null; timedOut: boolean }, stderr: string): boolean {
115
+ return capture.exitCode === 1 && !capture.timedOut && ADMISSION_REFUSAL.test(stderr);
116
+ }
117
+
98
118
  // Refusal classification for the materialize invocation. The installed
99
119
  // gentle-ai is the only authority on whether the materialize form exists; Pi
100
120
  // never version-sniffs. Two typed refusal classes are distinguished:
@@ -237,6 +257,8 @@ export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmis
237
257
 
238
258
  export interface ReviewHostRelayRequest {
239
259
  readonly captureArgumentTokens: readonly string[];
260
+ /** Canonical target worktree for coordinator-only native materialize/submit calls. */
261
+ readonly targetCwd?: string;
240
262
  /** The provider-owned completing form; absent means contract mismatch. */
241
263
  readonly submission?: ReviewCaptureSubmissionV1;
242
264
  /** Absolute path; defaults to the verified package-local binary. */
@@ -245,6 +267,12 @@ export interface ReviewHostRelayRequest {
245
267
  readonly piExecutable?: string;
246
268
  readonly environment?: NodeJS.ProcessEnv;
247
269
  readonly gentleAiTimeoutMs?: number;
270
+ /**
271
+ * Overrides the reviewer bound entirely. Production leaves it unset and the
272
+ * relay derives the bound from the materialized prompt bytes and
273
+ * {@link REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}; this seam exists so tests can
274
+ * exercise the timeout leg without a wall-clock wait.
275
+ */
248
276
  readonly piTimeoutMs?: number;
249
277
  readonly signal?: AbortSignal;
250
278
  }
@@ -256,24 +284,88 @@ export interface ReviewHostRelayResult {
256
284
  readonly submission: string;
257
285
  }
258
286
 
287
+ /** Opaque materialize-and-review result, not yet submitted to the provider. */
288
+ export interface ReviewHostRelayPreparedResult {
289
+ /** Copy-safe request snapshot captured before materialization starts. */
290
+ readonly request: ReviewHostRelayRequest;
291
+ readonly promptByteLength: number;
292
+ readonly resultByteLength: number;
293
+ }
294
+
295
+ const preparedResultBytes = new WeakMap<ReviewHostRelayPreparedResult, Buffer>();
296
+
259
297
  export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
298
+ export type ReviewHostRelayPreparationRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayPreparedResult>;
299
+ export type ReviewHostRelaySubmissionRunner = (prepared: ReviewHostRelayPreparedResult) => Promise<ReviewHostRelayResult>;
260
300
 
261
301
  const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
262
- const DEFAULT_PI_TIMEOUT_MS = 600_000;
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // The reviewer subprocess bound (gentle-pi#367).
305
+ //
306
+ // The previous bound was a single hardcoded 600_000 ms reachable only through
307
+ // the test-injectable runner. A field-measured lens legitimately needed 478s
308
+ // against a ~1.58 MB materialized prompt: it survived by hand and was killed
309
+ // under the relay, and the sanctioned continuation then re-spent every lens to
310
+ // reach the same wall. One fixed number cannot serve a prompt class that
311
+ // varies by orders of magnitude, so the bound is derived instead:
312
+ //
313
+ // floor + ceil(promptBytes / MiB * perMebibyte), clamped to the ceiling
314
+ //
315
+ // The floor covers model latency that does not depend on prompt size; the
316
+ // linear term covers the part that does. At the measured 1.58 MB the derived
317
+ // bound is ~37 minutes, roughly a 4.7x margin over the 478s the reviewer
318
+ // actually needed — deliberately generous, because the reviewer model and
319
+ // provider are user-owned and the relay cannot know their throughput.
320
+ //
321
+ // GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS replaces the derived bound entirely for
322
+ // callers who know their own configuration. It follows the repository's
323
+ // established numeric-override shape (GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS,
324
+ // GENTLE_PI_REVIEW_MAX_BUFFER_BYTES): a positive decimal, silently ignored
325
+ // when malformed, and clamped to the same hard ceiling so no configuration can
326
+ // turn a foreground FINALIZE into an unbounded child process.
327
+ // ---------------------------------------------------------------------------
328
+
329
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS";
330
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS = 900_000;
331
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS = 900_000;
332
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS = 7_200_000;
333
+ const BYTES_PER_MEBIBYTE = 1024 * 1024;
334
+
335
+ export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, environment: NodeJS.ProcessEnv = process.env): number {
336
+ const configured = environment[REVIEW_HOST_RELAY_PI_TIMEOUT_ENV];
337
+ if (configured !== undefined && /^[1-9]\d*$/.test(configured)) {
338
+ const parsed = Number(configured);
339
+ if (Number.isSafeInteger(parsed)) return Math.min(parsed, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
340
+ }
341
+ const bytes = Number.isSafeInteger(promptByteLength) && promptByteLength > 0 ? promptByteLength : 0;
342
+ const scaled = REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + Math.ceil((bytes / BYTES_PER_MEBIBYTE) * REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS);
343
+ return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
344
+ }
345
+
346
+ // The reviewer ran out of time; it did not crash. The message states both
347
+ // measurements and names the two things that can change the outcome, because
348
+ // the one thing that cannot is relaunching the identical slot.
349
+ export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string {
350
+ return `pi reviewer subprocess exceeded the relay bound: killed after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. `
351
+ + `Relaunching the same slot unchanged reaches the same wall. Raise ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV} above the reviewer's real wall time (ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}ms) or reduce the candidate scope so the materialized prompt is smaller.`;
352
+ }
263
353
 
264
354
  interface ProcessCapture {
265
355
  stdout: Buffer;
266
356
  stderr: Buffer;
267
357
  exitCode: number | null;
268
358
  timedOut: boolean;
359
+ elapsedMs: number;
269
360
  }
270
361
 
271
- function collectProcess(
362
+ function collectGentleAiProcess(
272
363
  file: string,
273
364
  arguments_: readonly string[],
274
365
  options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal },
275
366
  ): Promise<ProcessCapture> {
276
367
  return new Promise((resolve, reject) => {
368
+ const startedAt = Date.now();
277
369
  const child = spawn(file, [...arguments_], {
278
370
  cwd: options.cwd,
279
371
  env: options.env,
@@ -305,7 +397,7 @@ function collectProcess(
305
397
  if (settled) return;
306
398
  settled = true;
307
399
  if (timer !== undefined) clearTimeout(timer);
308
- resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut });
400
+ resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut, elapsedMs: Date.now() - startedAt });
309
401
  });
310
402
  if (options.stdin === undefined) {
311
403
  child.stdin.end();
@@ -316,6 +408,45 @@ function collectProcess(
316
408
  });
317
409
  }
318
410
 
411
+ function relayPiTransportError(error: unknown, promptByteLength: number, piTimeoutMs: number): ReviewHostRelayError {
412
+ if (!(error instanceof OpaquePiReviewerTransportError)) {
413
+ return new ReviewHostRelayError(
414
+ REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
415
+ "pi",
416
+ `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`,
417
+ );
418
+ }
419
+ const details = {
420
+ exitCode: error.exitCode,
421
+ stderr: error.stderr.toString("utf8"),
422
+ timedOut: error.timedOut,
423
+ ...(error.elapsedMs === null ? {} : { elapsedMs: error.elapsedMs }),
424
+ ...(error.timeoutMs === null ? {} : { timeoutMs: error.timeoutMs }),
425
+ };
426
+ if (
427
+ error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT
428
+ && error.elapsedMs !== null
429
+ && error.timeoutMs !== null
430
+ ) {
431
+ return new ReviewHostRelayError(
432
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
433
+ "pi",
434
+ reviewHostRelayPiTimeoutMessage(error.elapsedMs, error.timeoutMs, promptByteLength),
435
+ { ...details, timedOut: true, elapsedMs: error.elapsedMs, timeoutMs: error.timeoutMs },
436
+ );
437
+ }
438
+ if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
439
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", details);
440
+ }
441
+ if (
442
+ error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
443
+ || error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED
444
+ ) {
445
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error.message}`, details);
446
+ }
447
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details);
448
+ }
449
+
319
450
  function assertTokens(name: string, tokens: readonly string[]): void {
320
451
  if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`);
321
452
  if (tokens.some((token) => typeof token !== "string" || token.length === 0)) {
@@ -323,38 +454,54 @@ function assertTokens(name: string, tokens: readonly string[]): void {
323
454
  }
324
455
  }
325
456
 
457
+ function snapshotReviewHostRelayRequest(request: ReviewHostRelayRequest): ReviewHostRelayRequest {
458
+ assertTokens("capture", request.captureArgumentTokens);
459
+ // The completing form is validated before any process launches: a materialize
460
+ // slot without a provider-owned submission is a typed contract mismatch,
461
+ // never a synthesized invocation.
462
+ resolveReviewHostRelaySubmission(request.submission);
463
+ const gentleAiExecutable = request.gentleAiExecutable ?? resolveGentleAiBinary();
464
+ if (!isAbsolute(gentleAiExecutable)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
465
+ const environment = Object.freeze({ ...(request.environment ?? process.env) }) as NodeJS.ProcessEnv;
466
+ const submission = request.submission === undefined ? undefined : Object.freeze({
467
+ operationToken: request.submission.operationToken,
468
+ argumentTokens: Object.freeze([...request.submission.argumentTokens]),
469
+ values: Object.freeze(request.submission.values.map((value) => Object.freeze({ ...value }))),
470
+ });
471
+ return Object.freeze({
472
+ ...request,
473
+ captureArgumentTokens: Object.freeze([...request.captureArgumentTokens]),
474
+ ...(submission === undefined ? {} : { submission }),
475
+ gentleAiExecutable,
476
+ environment,
477
+ gentleAiTimeoutMs: request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS,
478
+ targetCwd: request.targetCwd ?? process.cwd(),
479
+ });
480
+ }
481
+
326
482
  /**
327
- * Runs one complete host-relay capture for one provider-bound slot:
328
- * materialize → fresh locked-down pi subprocess → submit. Throws a typed
329
- * {@link ReviewHostRelayError} on every failure leg and submits nothing after
330
- * a failure; the caller re-queries negotiated STATUS instead of retrying.
483
+ * Materializes one provider-bound reviewer prompt and runs its opaque Pi
484
+ * subprocess. It does not submit anything, so independent reviewer work can
485
+ * finish before the caller performs provider-ordered admission.
331
486
  */
332
- export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
333
- assertTokens("capture", request.captureArgumentTokens);
334
- // The completing form is validated before any process launches: a
335
- // materialize slot without a provider-owned submission is a typed
336
- // contract mismatch, never a synthesized invocation.
337
- const submissionBinding = resolveReviewHostRelaySubmission(request.submission);
338
- const gentleAi = request.gentleAiExecutable ?? resolveGentleAiBinary();
339
- if (!isAbsolute(gentleAi)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
340
- const baseEnvironment = request.environment ?? process.env;
341
- // Every gentle-ai invocation the relay makes carries the handshake; the
342
- // pi subprocess environment stays exactly as the user configured it.
343
- const gentleAiEnvironment = { ...baseEnvironment, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
344
- const gentleAiTimeoutMs = request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS;
345
- const piTimeoutMs = request.piTimeoutMs ?? DEFAULT_PI_TIMEOUT_MS;
346
-
347
- // (a) Materialize the Go-issued opaque prompt. This invocation is also the
348
- // capability detection: an old binary's unknown-flag refusal proves the
349
- // relay surface is absent, and the provider's handshake refusal surfaces
350
- // verbatim. No version sniffing.
487
+ export async function prepareReviewHostRelaySlot(
488
+ request: ReviewHostRelayRequest,
489
+ reviewer: typeof runOpaquePiReviewer = runOpaquePiReviewer,
490
+ ): Promise<ReviewHostRelayPreparedResult> {
491
+ // Copy mutable transport configuration before the first async boundary. The
492
+ // supplied AbortSignal intentionally stays live across materialize, reviewer,
493
+ // and submit, preserving the established cancellation behavior.
494
+ const preparedRequest = snapshotReviewHostRelayRequest(request);
495
+
496
+ // The provider materializes the opaque prompt and detects whether this relay
497
+ // surface is available. No version sniffing or prompt reconstruction occurs.
351
498
  let materialized: ProcessCapture;
352
499
  try {
353
- materialized = await collectProcess(gentleAi, ["review", "capture-result", ...request.captureArgumentTokens], {
354
- cwd: process.cwd(),
355
- env: gentleAiEnvironment,
356
- timeoutMs: gentleAiTimeoutMs,
357
- ...(request.signal === undefined ? {} : { signal: request.signal }),
500
+ materialized = await collectGentleAiProcess(preparedRequest.gentleAiExecutable!, ["review", "capture-result", ...preparedRequest.captureArgumentTokens], {
501
+ cwd: preparedRequest.targetCwd!,
502
+ env: { ...preparedRequest.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT },
503
+ timeoutMs: preparedRequest.gentleAiTimeoutMs!,
504
+ ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
358
505
  });
359
506
  } catch (error) {
360
507
  throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`);
@@ -362,75 +509,156 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
362
509
  if (materialized.exitCode !== 0 || materialized.timedOut) {
363
510
  const stderr = materialized.stderr.toString("utf8");
364
511
  const refusal = classifyReviewHostRelayRefusal(stderr);
512
+ const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: preparedRequest.gentleAiTimeoutMs! };
365
513
  if (refusal === "unknown-flag") {
366
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
514
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, {
515
+ exitCode: materialized.exitCode,
516
+ stderr,
517
+ timedOut: materialized.timedOut,
518
+ ...timing,
519
+ });
367
520
  }
368
521
  if (refusal === "handshake") {
369
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
522
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, {
523
+ exitCode: materialized.exitCode,
524
+ stderr,
525
+ timedOut: materialized.timedOut,
526
+ ...timing,
527
+ });
370
528
  }
371
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
529
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", materialized.timedOut
530
+ ? `gentle-ai prompt materialization exceeded its ${preparedRequest.gentleAiTimeoutMs!}ms bound after ${materialized.elapsedMs}ms`
531
+ : "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
372
532
  }
373
533
  const promptBytes = materialized.stdout;
374
534
  if (promptBytes.length === 0) {
375
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { exitCode: 0, stderr: materialized.stderr.toString("utf8") });
535
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", {
536
+ exitCode: 0,
537
+ stderr: materialized.stderr.toString("utf8"),
538
+ elapsedMs: materialized.elapsedMs,
539
+ timeoutMs: preparedRequest.gentleAiTimeoutMs!,
540
+ });
541
+ }
542
+ // The reviewer bound is derived from the prompt the provider actually
543
+ // materialized. The explicit request timeout is a test seam that wins over
544
+ // both the user-owned environment override and the scale-derived bound.
545
+ const piTimeoutMs = preparedRequest.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, preparedRequest.environment);
546
+
547
+ // The pure adapter owns the fresh isolated Pi process. Its input and output
548
+ // are opaque bytes; this coordinator only maps transport failures.
549
+ let piResult: OpaquePiReviewerResult;
550
+ try {
551
+ piResult = await reviewer(promptBytes, {
552
+ ...(preparedRequest.piExecutable === undefined ? {} : { piExecutable: preparedRequest.piExecutable }),
553
+ environment: preparedRequest.environment,
554
+ timeoutMs: piTimeoutMs,
555
+ ...(preparedRequest.signal === undefined ? {} : { signal: preparedRequest.signal }),
556
+ });
557
+ } catch (error) {
558
+ throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
559
+ }
560
+ const prepared = Object.freeze({
561
+ request: preparedRequest,
562
+ promptByteLength: promptBytes.length,
563
+ resultByteLength: piResult.stdoutByteLength,
564
+ });
565
+ preparedResultBytes.set(prepared, Buffer.from(piResult.stdout));
566
+ return prepared;
567
+ }
568
+
569
+ /**
570
+ * Starts every reviewer before awaiting any result. If one or more reviewers
571
+ * fail, it rejects only after every started transport has settled and reports
572
+ * the earliest failed request in provider order.
573
+ */
574
+ export async function runReviewHostRelayReviewerGroup(
575
+ requests: readonly ReviewHostRelayRequest[],
576
+ prepare: ReviewHostRelayPreparationRunner = prepareReviewHostRelaySlot,
577
+ ): Promise<readonly ReviewHostRelayPreparedResult[]> {
578
+ if (requests.length === 0) {
579
+ throw new TypeError("Pi host relay reviewer group requires at least one provider-bound request");
376
580
  }
581
+ const settled = await Promise.allSettled(requests.map(async (request) => await prepare(request)));
582
+ const failed = settled.find((result) => result.status === "rejected");
583
+ if (failed?.status === "rejected") throw failed.reason;
584
+ return settled.map((result) => (result as PromiseFulfilledResult<ReviewHostRelayPreparedResult>).value);
585
+ }
377
586
 
378
- // (b)/(c) Fresh locked-down pi subprocess in an empty scratch directory.
379
- const scratchDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-scratch-"));
587
+ /**
588
+ * Submits one already-reviewed opaque result through the exact provider-owned
589
+ * completing form. Only the provider-declared artifact slot is substituted.
590
+ */
591
+ export async function submitReviewHostRelayPreparedResult(prepared: ReviewHostRelayPreparedResult): Promise<ReviewHostRelayResult> {
592
+ const resultBytes = preparedResultBytes.get(prepared);
593
+ if (resultBytes === undefined) throw new TypeError("Pi host relay requires a recognized prepared result");
594
+ const { request } = prepared;
595
+ assertTokens("capture", request.captureArgumentTokens);
596
+ const submissionBinding = resolveReviewHostRelaySubmission(request.submission);
380
597
  const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
598
+ let primaryFailure = false;
381
599
  try {
382
- await chmod(scratchDirectory, 0o700);
383
600
  await chmod(stagingDirectory, 0o700);
384
- let piRun: ProcessCapture;
385
- try {
386
- piRun = await collectProcess(request.piExecutable ?? "pi", REVIEW_HOST_RELAY_PI_ARGV, {
387
- cwd: scratchDirectory,
388
- env: baseEnvironment,
389
- stdin: promptBytes,
390
- timeoutMs: piTimeoutMs,
391
- ...(request.signal === undefined ? {} : { signal: request.signal }),
392
- });
393
- } catch (error) {
394
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`);
395
- }
396
- if (piRun.exitCode !== 0 || piRun.timedOut) {
397
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: piRun.exitCode, stderr: piRun.stderr.toString("utf8"), timedOut: piRun.timedOut });
398
- }
399
- const resultBytes = piRun.stdout;
400
- if (resultBytes.length === 0) {
401
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", { exitCode: 0, stderr: piRun.stderr.toString("utf8") });
402
- }
403
-
404
- // (d) Submit the raw final bytes untouched through the provider-owned
405
- // completing form: its exact operation and argument tokens, with only
406
- // the artifact path substituted into the declared {{value}} slot.
407
601
  const resultFile = join(stagingDirectory, "result.raw");
408
602
  await writeFile(resultFile, resultBytes, { mode: 0o600 });
409
603
  await chmod(resultFile, 0o600);
410
604
  const submitTokens = submissionBinding.argumentTokens.map((token, index) =>
411
- index === submissionBinding.substitutionLocation ? token.split(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT).join(resultFile) : token,
605
+ index === submissionBinding.substitutionLocation
606
+ ? token.split(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT).join(resultFile)
607
+ : token,
412
608
  );
413
609
  let submission: ProcessCapture;
414
610
  try {
415
- submission = await collectProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
416
- cwd: process.cwd(),
417
- env: gentleAiEnvironment,
418
- timeoutMs: gentleAiTimeoutMs,
611
+ submission = await collectGentleAiProcess(request.gentleAiExecutable!, ["review", submissionBinding.operationToken, ...submitTokens], {
612
+ cwd: request.targetCwd!,
613
+ env: { ...request.environment!, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT },
614
+ timeoutMs: request.gentleAiTimeoutMs!,
419
615
  ...(request.signal === undefined ? {} : { signal: request.signal }),
420
616
  });
421
617
  } catch (error) {
422
618
  throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `gentle-ai capture submission could not start: ${error instanceof Error ? error.message : String(error)}`);
423
619
  }
424
620
  if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) {
425
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "gentle-ai refused the relayed capture submission", { exitCode: submission.exitCode, stderr: submission.stderr.toString("utf8"), timedOut: submission.timedOut });
621
+ const stderr = submission.stderr.toString("utf8");
622
+ const details = { exitCode: submission.exitCode, stderr, timedOut: submission.timedOut, elapsedMs: submission.elapsedMs, timeoutMs: request.gentleAiTimeoutMs! };
623
+ // A typed admission refusal proves the provider consumed no slot.
624
+ // Every other launched submission stays unknown pending fresh STATUS.
625
+ if (isReviewHostRelayAdmissionRefusal(submission, stderr)) {
626
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", stderr.trim(), {
627
+ ...details,
628
+ mutationOutcome: "none",
629
+ });
630
+ }
631
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", submission.timedOut
632
+ ? `gentle-ai capture submission exceeded its ${request.gentleAiTimeoutMs!}ms bound after ${submission.elapsedMs}ms`
633
+ : "gentle-ai refused the relayed capture submission", details);
426
634
  }
427
635
  return {
428
- promptByteLength: promptBytes.length,
429
- resultByteLength: resultBytes.length,
636
+ promptByteLength: prepared.promptByteLength,
637
+ resultByteLength: prepared.resultByteLength,
430
638
  submission: submission.stdout.toString("utf8"),
431
639
  };
640
+ } catch (error) {
641
+ primaryFailure = true;
642
+ throw error;
432
643
  } finally {
433
- await rm(scratchDirectory, { recursive: true, force: true }).catch(() => undefined);
434
- await rm(stagingDirectory, { recursive: true, force: true }).catch(() => undefined);
644
+ try {
645
+ await rm(stagingDirectory, { recursive: true, force: true });
646
+ } catch (error) {
647
+ if (!primaryFailure) {
648
+ throw new ReviewHostRelayError(
649
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
650
+ "submit",
651
+ `Pi host relay result staging cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
652
+ );
653
+ }
654
+ }
435
655
  }
436
656
  }
657
+
658
+ /**
659
+ * Compatibility one-binding path: materialize → opaque Pi adapter → submit.
660
+ * It preserves the established API and its typed failure behavior exactly.
661
+ */
662
+ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
663
+ return await submitReviewHostRelayPreparedResult(await prepareReviewHostRelaySlot(request));
664
+ }