gentle-pi 2.1.2 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -0,0 +1,578 @@
1
+ // The thin Pi host relay (gentle-pi#311 P4; provider contract gentle-ai#3249).
2
+ //
3
+ // gentle-ai owns prompt materialization, role and schema selection, byte
4
+ // budgets, parsing, admission, immutable capture, retry, correction
5
+ // accounting, and receipt state. This host boundary is
6
+ // intentionally narrow:
7
+ //
8
+ // 1. Run the exact provider-issued capture binding with `--agent pi
9
+ // --materialize` and take stdout as opaque prompt BYTES, verbatim.
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
+ // 3. Submit those bytes untouched through the provider-owned `submission`
15
+ // form carried by the collect input: execute its exact operation and
16
+ // argument tokens with only the tempfile path substituted into the
17
+ // declared {{value}} slot (BOM-less: the buffer is written
18
+ // byte-for-byte). The host never synthesizes or filters the completing
19
+ // form; a materialize slot without a provider submission is a typed
20
+ // contract mismatch, never a rebuilt invocation.
21
+ //
22
+ // On any failure the relay returns a TYPED transport error and submits
23
+ // nothing further. After a transport failure the caller re-queries negotiated
24
+ // STATUS and relaunches only if the exact same bound slot is reoffered —
25
+ // never from transcript inference. The relay never parses or rebuilds
26
+ // binding, evidence, prompt, schema, budgets, or admission.
27
+
28
+ import { spawn } from "node:child_process";
29
+ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
30
+ import { tmpdir } from "node:os";
31
+ import { isAbsolute, join } from "node:path";
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";
39
+ import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
40
+ import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
41
+
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";
45
+
46
+ export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
47
+ "provider relay requires a gentle-ai build with the pi host relay surface";
48
+
49
+ export const REVIEW_HOST_RELAY_FAILURE = {
50
+ RELAY_UNAVAILABLE: "relay-unavailable",
51
+ HANDSHAKE_REFUSED: "handshake-refused",
52
+ SUBMISSION_CONTRACT_MISMATCH: "submission-contract-mismatch",
53
+ MATERIALIZE_FAILED: "materialize-failed",
54
+ EMPTY_PROMPT: "empty-prompt",
55
+ PI_LAUNCH_FAILED: "pi-launch-failed",
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",
62
+ PI_EMPTY_OUTPUT: "pi-empty-output",
63
+ SUBMISSION_REFUSED: "submission-refused",
64
+ } as const;
65
+ export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
66
+
67
+ export type ReviewHostRelayStage = "binding" | "materialize" | "pi" | "submit";
68
+
69
+ export const REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT = "{{value}}";
70
+
71
+ export const REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE =
72
+ "provider contract mismatch: the materialize capture input carries no provider-owned submission form; the host never synthesizes the completing form";
73
+
74
+ export class ReviewHostRelayError extends Error {
75
+ readonly kind: ReviewHostRelayFailureKind;
76
+ readonly stage: ReviewHostRelayStage;
77
+ readonly exitCode: number | null;
78
+ readonly stderr: string;
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;
86
+ // "none" until the submission invocation launches; a launched submission
87
+ // whose outcome could not be read is "unknown" and the caller reconciles
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).
92
+ readonly mutationOutcome: "none" | "unknown";
93
+ constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) {
94
+ super(message);
95
+ this.name = "ReviewHostRelayError";
96
+ this.kind = kind;
97
+ this.stage = stage;
98
+ this.exitCode = details?.exitCode ?? null;
99
+ this.stderr = details?.stderr ?? "";
100
+ this.timedOut = details?.timedOut ?? false;
101
+ this.elapsedMs = details?.elapsedMs ?? null;
102
+ this.timeoutMs = details?.timeoutMs ?? null;
103
+ this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
104
+ }
105
+ }
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
+
118
+ // Refusal classification for the materialize invocation. The installed
119
+ // gentle-ai is the only authority on whether the materialize form exists; Pi
120
+ // never version-sniffs. Two typed refusal classes are distinguished:
121
+ //
122
+ // unknown-flag the Go flag package's exact refusal for a flag the binary
123
+ // does not define (any binary older than v2.4.0) —
124
+ // the relay is unavailable and existing behavior stays
125
+ // untouched.
126
+ // handshake the provider's pre-authority pi admission refusal — always
127
+ // surfaced verbatim, never worked around.
128
+ const UNKNOWN_FLAG_REFUSAL = /flag provided but not defined: -{1,2}(?:materialize|agent)\b/;
129
+ const HANDSHAKE_REFUSAL = new RegExp(
130
+ [
131
+ GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV,
132
+ GENTLE_PI_REVIEW_RELAY_CONTRACT.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
133
+ "not eligible for immutable receipt review",
134
+ ].join("|"),
135
+ );
136
+
137
+ export function classifyReviewHostRelayRefusal(stderr: string): "unknown-flag" | "handshake" | "other" {
138
+ if (UNKNOWN_FLAG_REFUSAL.test(stderr)) return "unknown-flag";
139
+ if (HANDSHAKE_REFUSAL.test(stderr)) return "handshake";
140
+ return "other";
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Slot detection — the provider decides. A collect input routes through the
145
+ // host relay ONLY when the provider itself issued the `--materialize` token
146
+ // (with the pi runtime identity) on a `review.capture-result` collection
147
+ // input. Nothing is ever inferred from state prose, risk, or transcript.
148
+ // ---------------------------------------------------------------------------
149
+
150
+ export interface ReviewHostRelaySlot {
151
+ /** Every provider-issued argument token, verbatim, in provider order. */
152
+ readonly captureArgumentTokens: readonly string[];
153
+ /**
154
+ * The provider-owned completing form, verbatim. Absent only when the
155
+ * provider violated its own contract; the relay then fails closed with a
156
+ * typed submission-contract-mismatch error instead of synthesizing one.
157
+ */
158
+ readonly submission?: ReviewCaptureSubmissionV1;
159
+ readonly lens?: string;
160
+ readonly order?: string;
161
+ readonly subjectHash?: string;
162
+ }
163
+
164
+ function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined {
165
+ const matches = input.arguments.filter((argument) => argument.name === name);
166
+ return matches.length === 1 ? matches[0]!.value : undefined;
167
+ }
168
+
169
+ function renderToken(argument: ReviewCollectInputV3["arguments"][number]): string {
170
+ return argument.token ?? `--${argument.name}=${argument.value}`;
171
+ }
172
+
173
+ export function isReviewHostRelayCollectInput(input: ReviewCollectInputV3): boolean {
174
+ return input.captureOperation === "review.capture-result"
175
+ && argumentValue(input, "materialize") === "true"
176
+ && argumentValue(input, "agent") === "pi";
177
+ }
178
+
179
+ export function reviewHostRelaySlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelaySlot[] {
180
+ return inputs.filter((input) => isReviewHostRelayCollectInput(input)).map((input) => ({
181
+ captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)),
182
+ ...(input.submission === undefined ? {} : { submission: input.submission }),
183
+ ...(argumentValue(input, "lens") === undefined ? {} : { lens: argumentValue(input, "lens") }),
184
+ ...(argumentValue(input, "order") === undefined ? {} : { order: argumentValue(input, "order") }),
185
+ ...(input.artifactSubject === undefined ? {} : { subjectHash: input.artifactSubject.subjectHash }),
186
+ }));
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Provider role vectors (gentle-pi#311 P4-roles) — the two Go-owned non-lens
191
+ // adversarial role capture operations. Unlike the lens materialize slots
192
+ // above, these vectors are SELF-CONTAINED: the provider renders binding
193
+ // tokens plus `--agent=pi --execute=true`, and executing the exact rendered
194
+ // invocation makes Go materialize the role prompt, spawn its own locked-down
195
+ // pi subprocess, and admit the raw verdict into the compact slot. The host
196
+ // never materializes, launches pi, or submits anything for these slots — it
197
+ // runs one CLI invocation verbatim and re-queries negotiated STATUS.
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export interface ReviewProviderRoleVectorSlot {
201
+ /** The provider-named capture operation, e.g. `review.capture-refuter`. */
202
+ readonly captureOperation: (typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION)[keyof typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION];
203
+ /** Every provider-issued argument token, verbatim, in provider order. */
204
+ readonly argumentTokens: readonly string[];
205
+ /** The provider-declared input name, e.g. `provider_refuter`. */
206
+ readonly name: string;
207
+ }
208
+
209
+ export function isReviewProviderRoleVectorInput(input: ReviewCollectInputV3): boolean {
210
+ return (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(input.captureOperation)
211
+ && argumentValue(input, "execute") === "true"
212
+ && argumentValue(input, "agent") === "pi";
213
+ }
214
+
215
+ export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewProviderRoleVectorSlot[] {
216
+ return inputs.filter((input) => isReviewProviderRoleVectorInput(input)).map((input) => ({
217
+ captureOperation: input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"],
218
+ argumentTokens: input.arguments.map((argument) => renderToken(argument)),
219
+ name: input.name,
220
+ }));
221
+ }
222
+
223
+ // Resolves the provider-owned submission form into an executable binding.
224
+ // Fails closed with a typed contract-mismatch error whenever the completing
225
+ // form is absent or cannot bind exactly one artifact value; the relay never
226
+ // repairs, filters, or synthesizes it.
227
+ export interface ReviewHostRelaySubmissionBinding {
228
+ readonly operationToken: string;
229
+ readonly argumentTokens: readonly string[];
230
+ readonly substitutionLocation: number;
231
+ }
232
+
233
+ export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmissionV1 | undefined): ReviewHostRelaySubmissionBinding {
234
+ if (submission === undefined) {
235
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
236
+ }
237
+ if (submission.operationToken.length === 0 || submission.argumentTokens.length === 0 || submission.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) {
238
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission form carries an empty operation or argument token");
239
+ }
240
+ if (submission.values.length !== 1) {
241
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission form must bind exactly one artifact value, received ${submission.values.length}`);
242
+ }
243
+ const value = submission.values[0]!;
244
+ const location = value.substitutionLocation;
245
+ if (!Number.isSafeInteger(location) || location < 0 || location >= submission.argumentTokens.length) {
246
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission substitution location is outside its argument tokens");
247
+ }
248
+ if (!submission.argumentTokens[location]!.includes(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT)) {
249
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission token at location ${location} carries no ${REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT} slot`);
250
+ }
251
+ return { operationToken: submission.operationToken, argumentTokens: submission.argumentTokens, substitutionLocation: location };
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Relay execution
256
+ // ---------------------------------------------------------------------------
257
+
258
+ export interface ReviewHostRelayRequest {
259
+ readonly captureArgumentTokens: readonly string[];
260
+ /** Canonical target worktree for coordinator-only native materialize/submit calls. */
261
+ readonly targetCwd?: string;
262
+ /** The provider-owned completing form; absent means contract mismatch. */
263
+ readonly submission?: ReviewCaptureSubmissionV1;
264
+ /** Absolute path; defaults to the verified package-local binary. */
265
+ readonly gentleAiExecutable?: string;
266
+ /** User-owned pi launcher; defaults to `pi` on PATH. */
267
+ readonly piExecutable?: string;
268
+ readonly environment?: NodeJS.ProcessEnv;
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
+ */
276
+ readonly piTimeoutMs?: number;
277
+ readonly signal?: AbortSignal;
278
+ }
279
+
280
+ export interface ReviewHostRelayResult {
281
+ readonly promptByteLength: number;
282
+ readonly resultByteLength: number;
283
+ /** Raw submission stdout (the provider's admitted-manifest JSON), opaque. */
284
+ readonly submission: string;
285
+ }
286
+
287
+ export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
288
+
289
+ const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // The reviewer subprocess bound (gentle-pi#367).
293
+ //
294
+ // The previous bound was a single hardcoded 600_000 ms reachable only through
295
+ // the test-injectable runner. A field-measured lens legitimately needed 478s
296
+ // against a ~1.58 MB materialized prompt: it survived by hand and was killed
297
+ // under the relay, and the sanctioned continuation then re-spent every lens to
298
+ // reach the same wall. One fixed number cannot serve a prompt class that
299
+ // varies by orders of magnitude, so the bound is derived instead:
300
+ //
301
+ // floor + ceil(promptBytes / MiB * perMebibyte), clamped to the ceiling
302
+ //
303
+ // The floor covers model latency that does not depend on prompt size; the
304
+ // linear term covers the part that does. At the measured 1.58 MB the derived
305
+ // bound is ~37 minutes, roughly a 4.7x margin over the 478s the reviewer
306
+ // actually needed — deliberately generous, because the reviewer model and
307
+ // provider are user-owned and the relay cannot know their throughput.
308
+ //
309
+ // GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS replaces the derived bound entirely for
310
+ // callers who know their own configuration. It follows the repository's
311
+ // established numeric-override shape (GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS,
312
+ // GENTLE_PI_REVIEW_MAX_BUFFER_BYTES): a positive decimal, silently ignored
313
+ // when malformed, and clamped to the same hard ceiling so no configuration can
314
+ // turn a foreground FINALIZE into an unbounded child process.
315
+ // ---------------------------------------------------------------------------
316
+
317
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS";
318
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS = 900_000;
319
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS = 900_000;
320
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS = 7_200_000;
321
+ const BYTES_PER_MEBIBYTE = 1024 * 1024;
322
+
323
+ export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, environment: NodeJS.ProcessEnv = process.env): number {
324
+ const configured = environment[REVIEW_HOST_RELAY_PI_TIMEOUT_ENV];
325
+ if (configured !== undefined && /^[1-9]\d*$/.test(configured)) {
326
+ const parsed = Number(configured);
327
+ if (Number.isSafeInteger(parsed)) return Math.min(parsed, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
328
+ }
329
+ const bytes = Number.isSafeInteger(promptByteLength) && promptByteLength > 0 ? promptByteLength : 0;
330
+ const scaled = REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + Math.ceil((bytes / BYTES_PER_MEBIBYTE) * REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS);
331
+ return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
332
+ }
333
+
334
+ // The reviewer ran out of time; it did not crash. The message states both
335
+ // measurements and names the two things that can change the outcome, because
336
+ // the one thing that cannot is relaunching the identical slot.
337
+ export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string {
338
+ return `pi reviewer subprocess exceeded the relay bound: killed after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. `
339
+ + `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.`;
340
+ }
341
+
342
+ interface ProcessCapture {
343
+ stdout: Buffer;
344
+ stderr: Buffer;
345
+ exitCode: number | null;
346
+ timedOut: boolean;
347
+ elapsedMs: number;
348
+ }
349
+
350
+ function collectGentleAiProcess(
351
+ file: string,
352
+ arguments_: readonly string[],
353
+ options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal },
354
+ ): Promise<ProcessCapture> {
355
+ return new Promise((resolve, reject) => {
356
+ const startedAt = Date.now();
357
+ const child = spawn(file, [...arguments_], {
358
+ cwd: options.cwd,
359
+ env: options.env,
360
+ stdio: ["pipe", "pipe", "pipe"],
361
+ shell: false,
362
+ windowsHide: true,
363
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
364
+ });
365
+ const stdout: Buffer[] = [];
366
+ const stderr: Buffer[] = [];
367
+ let timedOut = false;
368
+ let settled = false;
369
+ const timer = options.timeoutMs > 0
370
+ ? setTimeout(() => {
371
+ timedOut = true;
372
+ child.kill("SIGKILL");
373
+ }, options.timeoutMs)
374
+ : undefined;
375
+ timer?.unref();
376
+ child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
377
+ child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
378
+ child.on("error", (error) => {
379
+ if (settled) return;
380
+ settled = true;
381
+ if (timer !== undefined) clearTimeout(timer);
382
+ reject(error);
383
+ });
384
+ child.on("close", (code) => {
385
+ if (settled) return;
386
+ settled = true;
387
+ if (timer !== undefined) clearTimeout(timer);
388
+ resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut, elapsedMs: Date.now() - startedAt });
389
+ });
390
+ if (options.stdin === undefined) {
391
+ child.stdin.end();
392
+ } else {
393
+ child.stdin.on("error", () => undefined);
394
+ child.stdin.end(options.stdin);
395
+ }
396
+ });
397
+ }
398
+
399
+ function relayPiTransportError(error: unknown, promptByteLength: number, piTimeoutMs: number): ReviewHostRelayError {
400
+ if (!(error instanceof OpaquePiReviewerTransportError)) {
401
+ return new ReviewHostRelayError(
402
+ REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
403
+ "pi",
404
+ `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`,
405
+ );
406
+ }
407
+ const details = {
408
+ exitCode: error.exitCode,
409
+ stderr: error.stderr.toString("utf8"),
410
+ timedOut: error.timedOut,
411
+ ...(error.elapsedMs === null ? {} : { elapsedMs: error.elapsedMs }),
412
+ ...(error.timeoutMs === null ? {} : { timeoutMs: error.timeoutMs }),
413
+ };
414
+ if (
415
+ error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT
416
+ && error.elapsedMs !== null
417
+ && error.timeoutMs !== null
418
+ ) {
419
+ return new ReviewHostRelayError(
420
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
421
+ "pi",
422
+ reviewHostRelayPiTimeoutMessage(error.elapsedMs, error.timeoutMs, promptByteLength),
423
+ { ...details, timedOut: true, elapsedMs: error.elapsedMs, timeoutMs: error.timeoutMs },
424
+ );
425
+ }
426
+ if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
427
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", details);
428
+ }
429
+ if (
430
+ error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
431
+ || error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED
432
+ ) {
433
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error.message}`, details);
434
+ }
435
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details);
436
+ }
437
+
438
+ function assertTokens(name: string, tokens: readonly string[]): void {
439
+ if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`);
440
+ if (tokens.some((token) => typeof token !== "string" || token.length === 0)) {
441
+ throw new TypeError(`Pi host relay ${name} tokens must all be non-empty strings`);
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Runs one complete host-relay capture for one provider-bound slot:
447
+ * materialize → opaque Pi adapter → submit. Throws a typed
448
+ * {@link ReviewHostRelayError} on every failure leg and submits nothing after
449
+ * a failure; the caller re-queries negotiated STATUS instead of retrying.
450
+ */
451
+ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
452
+ assertTokens("capture", request.captureArgumentTokens);
453
+ // The completing form is validated before any process launches: a
454
+ // materialize slot without a provider-owned submission is a typed
455
+ // contract mismatch, never a synthesized invocation.
456
+ const submissionBinding = resolveReviewHostRelaySubmission(request.submission);
457
+ const gentleAi = request.gentleAiExecutable ?? resolveGentleAiBinary();
458
+ if (!isAbsolute(gentleAi)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
459
+ const baseEnvironment = request.environment ?? process.env;
460
+ // Every gentle-ai invocation the relay makes carries the handshake; the
461
+ // pi subprocess environment stays exactly as the user configured it.
462
+ const gentleAiEnvironment = { ...baseEnvironment, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
463
+ const gentleAiTimeoutMs = request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS;
464
+ const targetCwd = request.targetCwd ?? process.cwd();
465
+
466
+ // (a) Materialize the Go-issued opaque prompt. This invocation is also the
467
+ // capability detection: an old binary's unknown-flag refusal proves the
468
+ // relay surface is absent, and the provider's handshake refusal surfaces
469
+ // verbatim. No version sniffing.
470
+ let materialized: ProcessCapture;
471
+ try {
472
+ materialized = await collectGentleAiProcess(gentleAi, ["review", "capture-result", ...request.captureArgumentTokens], {
473
+ cwd: targetCwd,
474
+ env: gentleAiEnvironment,
475
+ timeoutMs: gentleAiTimeoutMs,
476
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
477
+ });
478
+ } catch (error) {
479
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`);
480
+ }
481
+ if (materialized.exitCode !== 0 || materialized.timedOut) {
482
+ const stderr = materialized.stderr.toString("utf8");
483
+ const refusal = classifyReviewHostRelayRefusal(stderr);
484
+ const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs };
485
+ if (refusal === "unknown-flag") {
486
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
487
+ }
488
+ if (refusal === "handshake") {
489
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
490
+ }
491
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", materialized.timedOut
492
+ ? `gentle-ai prompt materialization exceeded its ${gentleAiTimeoutMs}ms bound after ${materialized.elapsedMs}ms`
493
+ : "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
494
+ }
495
+ const promptBytes = materialized.stdout;
496
+ if (promptBytes.length === 0) {
497
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { exitCode: 0, stderr: materialized.stderr.toString("utf8"), elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs });
498
+ }
499
+ // The reviewer bound is derived from the prompt the provider actually
500
+ // materialized, so it can only be resolved here. An explicit request
501
+ // timeout (the test seam) still wins over both the override and the scale.
502
+ const piTimeoutMs = request.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, baseEnvironment);
503
+
504
+ // (b) The pure adapter owns the fresh isolated Pi process. Its input and
505
+ // output are opaque bytes; this coordinator only maps transport failures to
506
+ // the established relay boundary.
507
+ let piResult: OpaquePiReviewerResult;
508
+ try {
509
+ piResult = await runOpaquePiReviewer(promptBytes, {
510
+ ...(request.piExecutable === undefined ? {} : { piExecutable: request.piExecutable }),
511
+ environment: baseEnvironment,
512
+ timeoutMs: piTimeoutMs,
513
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
514
+ });
515
+ } catch (error) {
516
+ throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
517
+ }
518
+ const resultBytes = piResult.stdout;
519
+
520
+ // (c) Submit the raw final bytes untouched through the provider-owned
521
+ // completing form: its exact operation and argument tokens, with only the
522
+ // artifact path substituted into the declared {{value}} slot.
523
+ const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
524
+ let primaryFailure = false;
525
+ try {
526
+ await chmod(stagingDirectory, 0o700);
527
+ const resultFile = join(stagingDirectory, "result.raw");
528
+ await writeFile(resultFile, resultBytes, { mode: 0o600 });
529
+ await chmod(resultFile, 0o600);
530
+ const submitTokens = submissionBinding.argumentTokens.map((token, index) =>
531
+ index === submissionBinding.substitutionLocation ? token.split(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT).join(resultFile) : token,
532
+ );
533
+ let submission: ProcessCapture;
534
+ try {
535
+ submission = await collectGentleAiProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
536
+ cwd: targetCwd,
537
+ env: gentleAiEnvironment,
538
+ timeoutMs: gentleAiTimeoutMs,
539
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
540
+ });
541
+ } catch (error) {
542
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `gentle-ai capture submission could not start: ${error instanceof Error ? error.message : String(error)}`);
543
+ }
544
+ if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) {
545
+ const stderr = submission.stderr.toString("utf8");
546
+ const details = { exitCode: submission.exitCode, stderr, timedOut: submission.timedOut, elapsedMs: submission.elapsedMs, timeoutMs: gentleAiTimeoutMs };
547
+ // A typed admission refusal is a proven non-mutation whose reason is
548
+ // the refusal text itself; everything else that launched (timeout,
549
+ // signal, untyped exit) stays unknown pending STATUS.
550
+ if (isReviewHostRelayAdmissionRefusal(submission, stderr)) {
551
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", stderr.trim(), { ...details, mutationOutcome: "none" });
552
+ }
553
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", submission.timedOut
554
+ ? `gentle-ai capture submission exceeded its ${gentleAiTimeoutMs}ms bound after ${submission.elapsedMs}ms`
555
+ : "gentle-ai refused the relayed capture submission", details);
556
+ }
557
+ return {
558
+ promptByteLength: promptBytes.length,
559
+ resultByteLength: piResult.stdoutByteLength,
560
+ submission: submission.stdout.toString("utf8"),
561
+ };
562
+ } catch (error) {
563
+ primaryFailure = true;
564
+ throw error;
565
+ } finally {
566
+ try {
567
+ await rm(stagingDirectory, { recursive: true, force: true });
568
+ } catch (error) {
569
+ if (!primaryFailure) {
570
+ throw new ReviewHostRelayError(
571
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
572
+ "submit",
573
+ `Pi host relay result staging cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
574
+ );
575
+ }
576
+ }
577
+ }
578
+ }