gentle-pi 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/README.md +70 -12
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -1
  7. package/assets/agents/review-reliability.md +4 -1
  8. package/assets/agents/review-resilience.md +4 -1
  9. package/assets/agents/review-risk.md +4 -1
  10. package/assets/agents/sdd-apply.md +6 -1
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +6 -1
  17. package/assets/agents/sdd-spec.md +6 -1
  18. package/assets/agents/sdd-status.md +6 -1
  19. package/assets/agents/sdd-sync.md +6 -1
  20. package/assets/agents/sdd-tasks.md +6 -1
  21. package/assets/agents/sdd-verify.md +6 -1
  22. package/assets/chains/4r-review.chain.md +2 -0
  23. package/assets/chains/sdd-full.chain.md +1 -1
  24. package/assets/chains/sdd-plan.chain.md +1 -1
  25. package/assets/chains/sdd-verify.chain.md +1 -1
  26. package/assets/orchestrator-delegation.md +246 -67
  27. package/assets/orchestrator.md +7 -14
  28. package/assets/sdd-orchestrator-workflow.md +154 -9
  29. package/assets/support/sdd-status-contract.md +19 -1
  30. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  31. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  32. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  33. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  34. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  35. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  36. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  37. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  38. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  39. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  40. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  41. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  42. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  43. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  44. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  45. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  46. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  47. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  48. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  49. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  50. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  51. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  52. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  58. package/docs/native-authority-architecture.md +5 -5
  59. package/docs/review-integration.md +22 -2
  60. package/extensions/gentle-ai.ts +1595 -201
  61. package/extensions/sdd-init.ts +19 -6
  62. package/extensions/skill-registry.ts +10 -2
  63. package/extensions/startup-banner.ts +10 -4
  64. package/lib/gentle-ai-binary.ts +173 -2
  65. package/lib/git-commit-transaction.ts +77 -17
  66. package/lib/native-review-cli.ts +528 -65
  67. package/lib/provider-contract-bundle.ts +704 -0
  68. package/lib/review-candidate-view.ts +527 -18
  69. package/lib/review-compact-contract.ts +59 -248
  70. package/lib/review-host-relay.ts +436 -0
  71. package/lib/review-integration-v2.ts +537 -36
  72. package/lib/review-relay-contract.ts +16 -0
  73. package/lib/sdd-preflight.ts +53 -1
  74. package/package.json +5 -2
  75. package/runtime/gentle-ai-binary.mjs +173 -2
  76. package/runtime/git-commit-transaction.mjs +75 -15
  77. package/runtime/native-review-cli.mjs +524 -61
  78. package/runtime/review-integration-v2.mjs +536 -35
  79. package/runtime/review-relay-contract.mjs +17 -0
  80. package/scripts/build-git-commit-transaction-runner.mjs +1 -0
  81. package/scripts/check-provider-contract.mjs +138 -0
  82. package/scripts/gentle-ai-installer.mjs +23 -13
  83. package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
  84. package/scripts/mirror-provider-contract.mjs +143 -0
  85. package/scripts/test-packed-runner.mjs +16 -2
  86. package/scripts/verify-package-files.mjs +110 -33
  87. package/skills/_shared/review-ledger-contract.md +4 -6
  88. package/skills/gentle-ai/SKILL.md +4 -4
  89. package/skills/issue-creation/SKILL.md +94 -168
  90. package/skills/judgment-day/SKILL.md +7 -1
  91. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  92. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  93. package/tests/background-subagents.test.ts +771 -0
  94. package/tests/crosslane/cross-lane.mjs +1169 -0
  95. package/tests/delegated-key-learnings-contract.test.ts +238 -0
  96. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  97. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  98. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  99. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  100. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  101. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  102. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  103. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  104. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  105. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  106. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  107. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  108. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  109. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  110. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  111. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  112. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  113. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  114. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  115. package/tests/gentle-ai-binary.test.ts +1 -1
  116. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  117. package/tests/gentle-ai-dev-binary.test.ts +336 -0
  118. package/tests/gentle-ai-installer.test.ts +46 -46
  119. package/tests/git-commit-transaction.test.ts +229 -1
  120. package/tests/maintainer/provider-relay.maintest.ts +265 -0
  121. package/tests/native-review-capability-contract.test.ts +48 -2
  122. package/tests/native-review-cli.test.ts +56 -0
  123. package/tests/native-review-consent.test.ts +164 -3
  124. package/tests/native-review-parity-runtime.test.ts +37 -0
  125. package/tests/native-review-parity.test.ts +218 -15
  126. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  127. package/tests/orchestrator-budget.test.ts +30 -5
  128. package/tests/package-manifest.test.ts +98 -72
  129. package/tests/provider-contract-bundle.test.ts +385 -0
  130. package/tests/provider-contract-mirror.test.ts +206 -0
  131. package/tests/provider-defect-handoff.test.ts +355 -0
  132. package/tests/review-actor-tool-deny.test.ts +12 -13
  133. package/tests/review-candidate-view.test.ts +489 -9
  134. package/tests/review-compact-contract.test.ts +52 -119
  135. package/tests/review-controller-native-recovery.test.ts +643 -47
  136. package/tests/review-controller-native-routing.test.ts +1667 -222
  137. package/tests/review-controller-workspace-root.test.ts +17 -2
  138. package/tests/review-corrected-finalize-binding.test.ts +175 -0
  139. package/tests/review-dispatch-hydration-gap.test.ts +197 -0
  140. package/tests/review-host-relay-routing.test.ts +317 -0
  141. package/tests/review-host-relay.test.ts +520 -0
  142. package/tests/review-integration-v2-forward.test.ts +631 -0
  143. package/tests/review-integration-v2.test.ts +114 -0
  144. package/tests/review-ledger-contract.test.ts +12 -28
  145. package/tests/review-recovered-lineage-routing.test.ts +246 -0
  146. package/tests/review-relay-transport-agent.test.ts +249 -0
  147. package/tests/runtime-harness.mjs +242 -14
  148. package/tests/sdd-agent-tools.test.ts +18 -33
  149. package/tests/skill-collision-prefixes.test.ts +1 -0
  150. package/tests/skill-registry.test.ts +50 -1
  151. package/tests/verify-package-files.test.ts +62 -0
  152. package/assets/agents/review-refuter.md +0 -40
  153. package/assets/agents/review-validator.md +0 -23
  154. package/lib/native-review-remediation.ts +0 -49
  155. package/lib/review-compact.ts +0 -947
  156. package/lib/review-refuter-adapter.ts +0 -129
  157. package/lib/review-runtime-contract.ts +0 -68
  158. package/prompts/gcl.md +0 -54
  159. package/prompts/gis.md +0 -25
  160. package/prompts/gpr.md +0 -41
  161. package/prompts/gwr.md +0 -31
  162. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  163. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  164. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  165. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  166. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  167. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  168. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  169. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  170. package/tests/review-compact.test.ts +0 -243
  171. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -0,0 +1,436 @@
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 and correction
5
+ // accounting, receipts, and delivery gates. 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. 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.
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 { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
34
+ import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
35
+
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);
52
+
53
+ export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
54
+ "provider relay requires a gentle-ai build with the pi host relay surface";
55
+
56
+ export const REVIEW_HOST_RELAY_FAILURE = {
57
+ RELAY_UNAVAILABLE: "relay-unavailable",
58
+ HANDSHAKE_REFUSED: "handshake-refused",
59
+ SUBMISSION_CONTRACT_MISMATCH: "submission-contract-mismatch",
60
+ MATERIALIZE_FAILED: "materialize-failed",
61
+ EMPTY_PROMPT: "empty-prompt",
62
+ PI_LAUNCH_FAILED: "pi-launch-failed",
63
+ PI_FAILED: "pi-failed",
64
+ PI_EMPTY_OUTPUT: "pi-empty-output",
65
+ SUBMISSION_REFUSED: "submission-refused",
66
+ } as const;
67
+ export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
68
+
69
+ export type ReviewHostRelayStage = "binding" | "materialize" | "pi" | "submit";
70
+
71
+ export const REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT = "{{value}}";
72
+
73
+ export const REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE =
74
+ "provider contract mismatch: the materialize capture input carries no provider-owned submission form; the host never synthesizes the completing form";
75
+
76
+ export class ReviewHostRelayError extends Error {
77
+ readonly kind: ReviewHostRelayFailureKind;
78
+ readonly stage: ReviewHostRelayStage;
79
+ readonly exitCode: number | null;
80
+ readonly stderr: string;
81
+ readonly timedOut: boolean;
82
+ // "none" until the submission invocation launches; a launched submission
83
+ // whose outcome could not be read is "unknown" and the caller reconciles
84
+ // through negotiated STATUS, never through a blind retry.
85
+ readonly mutationOutcome: "none" | "unknown";
86
+ constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean }) {
87
+ super(message);
88
+ this.name = "ReviewHostRelayError";
89
+ this.kind = kind;
90
+ this.stage = stage;
91
+ this.exitCode = details?.exitCode ?? null;
92
+ this.stderr = details?.stderr ?? "";
93
+ this.timedOut = details?.timedOut ?? false;
94
+ this.mutationOutcome = stage === "submit" ? "unknown" : "none";
95
+ }
96
+ }
97
+
98
+ // Refusal classification for the materialize invocation. The installed
99
+ // gentle-ai is the only authority on whether the materialize form exists; Pi
100
+ // never version-sniffs. Two typed refusal classes are distinguished:
101
+ //
102
+ // unknown-flag the Go flag package's exact refusal for a flag the binary
103
+ // does not define (any binary older than v2.4.0) —
104
+ // the relay is unavailable and existing behavior stays
105
+ // untouched.
106
+ // handshake the provider's pre-authority pi admission refusal — always
107
+ // surfaced verbatim, never worked around.
108
+ const UNKNOWN_FLAG_REFUSAL = /flag provided but not defined: -{1,2}(?:materialize|agent)\b/;
109
+ const HANDSHAKE_REFUSAL = new RegExp(
110
+ [
111
+ GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV,
112
+ GENTLE_PI_REVIEW_RELAY_CONTRACT.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
113
+ "not eligible for immutable receipt review",
114
+ ].join("|"),
115
+ );
116
+
117
+ export function classifyReviewHostRelayRefusal(stderr: string): "unknown-flag" | "handshake" | "other" {
118
+ if (UNKNOWN_FLAG_REFUSAL.test(stderr)) return "unknown-flag";
119
+ if (HANDSHAKE_REFUSAL.test(stderr)) return "handshake";
120
+ return "other";
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Slot detection — the provider decides. A collect input routes through the
125
+ // host relay ONLY when the provider itself issued the `--materialize` token
126
+ // (with the pi runtime identity) on a `review.capture-result` collection
127
+ // input. Nothing is ever inferred from state prose, risk, or transcript.
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export interface ReviewHostRelaySlot {
131
+ /** Every provider-issued argument token, verbatim, in provider order. */
132
+ readonly captureArgumentTokens: readonly string[];
133
+ /**
134
+ * The provider-owned completing form, verbatim. Absent only when the
135
+ * provider violated its own contract; the relay then fails closed with a
136
+ * typed submission-contract-mismatch error instead of synthesizing one.
137
+ */
138
+ readonly submission?: ReviewCaptureSubmissionV1;
139
+ readonly lens?: string;
140
+ readonly order?: string;
141
+ readonly subjectHash?: string;
142
+ }
143
+
144
+ function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined {
145
+ const matches = input.arguments.filter((argument) => argument.name === name);
146
+ return matches.length === 1 ? matches[0]!.value : undefined;
147
+ }
148
+
149
+ function renderToken(argument: ReviewCollectInputV3["arguments"][number]): string {
150
+ return argument.token ?? `--${argument.name}=${argument.value}`;
151
+ }
152
+
153
+ export function isReviewHostRelayCollectInput(input: ReviewCollectInputV3): boolean {
154
+ return input.captureOperation === "review.capture-result"
155
+ && argumentValue(input, "materialize") === "true"
156
+ && argumentValue(input, "agent") === "pi";
157
+ }
158
+
159
+ export function reviewHostRelaySlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelaySlot[] {
160
+ return inputs.filter((input) => isReviewHostRelayCollectInput(input)).map((input) => ({
161
+ captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)),
162
+ ...(input.submission === undefined ? {} : { submission: input.submission }),
163
+ ...(argumentValue(input, "lens") === undefined ? {} : { lens: argumentValue(input, "lens") }),
164
+ ...(argumentValue(input, "order") === undefined ? {} : { order: argumentValue(input, "order") }),
165
+ ...(input.artifactSubject === undefined ? {} : { subjectHash: input.artifactSubject.subjectHash }),
166
+ }));
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Provider role vectors (gentle-pi#311 P4-roles) — the two Go-owned non-lens
171
+ // adversarial role capture operations. Unlike the lens materialize slots
172
+ // above, these vectors are SELF-CONTAINED: the provider renders binding
173
+ // tokens plus `--agent=pi --execute=true`, and executing the exact rendered
174
+ // invocation makes Go materialize the role prompt, spawn its own locked-down
175
+ // pi subprocess, and admit the raw verdict into the compact slot. The host
176
+ // never materializes, launches pi, or submits anything for these slots — it
177
+ // runs one CLI invocation verbatim and re-queries negotiated STATUS.
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export interface ReviewProviderRoleVectorSlot {
181
+ /** The provider-named capture operation, e.g. `review.capture-refuter`. */
182
+ readonly captureOperation: (typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION)[keyof typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION];
183
+ /** Every provider-issued argument token, verbatim, in provider order. */
184
+ readonly argumentTokens: readonly string[];
185
+ /** The provider-declared input name, e.g. `provider_refuter`. */
186
+ readonly name: string;
187
+ }
188
+
189
+ export function isReviewProviderRoleVectorInput(input: ReviewCollectInputV3): boolean {
190
+ return (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(input.captureOperation)
191
+ && argumentValue(input, "execute") === "true"
192
+ && argumentValue(input, "agent") === "pi";
193
+ }
194
+
195
+ export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewProviderRoleVectorSlot[] {
196
+ return inputs.filter((input) => isReviewProviderRoleVectorInput(input)).map((input) => ({
197
+ captureOperation: input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"],
198
+ argumentTokens: input.arguments.map((argument) => renderToken(argument)),
199
+ name: input.name,
200
+ }));
201
+ }
202
+
203
+ // Resolves the provider-owned submission form into an executable binding.
204
+ // Fails closed with a typed contract-mismatch error whenever the completing
205
+ // form is absent or cannot bind exactly one artifact value; the relay never
206
+ // repairs, filters, or synthesizes it.
207
+ export interface ReviewHostRelaySubmissionBinding {
208
+ readonly operationToken: string;
209
+ readonly argumentTokens: readonly string[];
210
+ readonly substitutionLocation: number;
211
+ }
212
+
213
+ export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmissionV1 | undefined): ReviewHostRelaySubmissionBinding {
214
+ if (submission === undefined) {
215
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
216
+ }
217
+ if (submission.operationToken.length === 0 || submission.argumentTokens.length === 0 || submission.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) {
218
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission form carries an empty operation or argument token");
219
+ }
220
+ if (submission.values.length !== 1) {
221
+ 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}`);
222
+ }
223
+ const value = submission.values[0]!;
224
+ const location = value.substitutionLocation;
225
+ if (!Number.isSafeInteger(location) || location < 0 || location >= submission.argumentTokens.length) {
226
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission substitution location is outside its argument tokens");
227
+ }
228
+ if (!submission.argumentTokens[location]!.includes(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT)) {
229
+ 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`);
230
+ }
231
+ return { operationToken: submission.operationToken, argumentTokens: submission.argumentTokens, substitutionLocation: location };
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Relay execution
236
+ // ---------------------------------------------------------------------------
237
+
238
+ export interface ReviewHostRelayRequest {
239
+ readonly captureArgumentTokens: readonly string[];
240
+ /** The provider-owned completing form; absent means contract mismatch. */
241
+ readonly submission?: ReviewCaptureSubmissionV1;
242
+ /** Absolute path; defaults to the verified package-local binary. */
243
+ readonly gentleAiExecutable?: string;
244
+ /** User-owned pi launcher; defaults to `pi` on PATH. */
245
+ readonly piExecutable?: string;
246
+ readonly environment?: NodeJS.ProcessEnv;
247
+ readonly gentleAiTimeoutMs?: number;
248
+ readonly piTimeoutMs?: number;
249
+ readonly signal?: AbortSignal;
250
+ }
251
+
252
+ export interface ReviewHostRelayResult {
253
+ readonly promptByteLength: number;
254
+ readonly resultByteLength: number;
255
+ /** Raw submission stdout (the provider's admitted-manifest JSON), opaque. */
256
+ readonly submission: string;
257
+ }
258
+
259
+ export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
260
+
261
+ const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
262
+ const DEFAULT_PI_TIMEOUT_MS = 600_000;
263
+
264
+ interface ProcessCapture {
265
+ stdout: Buffer;
266
+ stderr: Buffer;
267
+ exitCode: number | null;
268
+ timedOut: boolean;
269
+ }
270
+
271
+ function collectProcess(
272
+ file: string,
273
+ arguments_: readonly string[],
274
+ options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal },
275
+ ): Promise<ProcessCapture> {
276
+ return new Promise((resolve, reject) => {
277
+ const child = spawn(file, [...arguments_], {
278
+ cwd: options.cwd,
279
+ env: options.env,
280
+ stdio: ["pipe", "pipe", "pipe"],
281
+ shell: false,
282
+ windowsHide: true,
283
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
284
+ });
285
+ const stdout: Buffer[] = [];
286
+ const stderr: Buffer[] = [];
287
+ let timedOut = false;
288
+ let settled = false;
289
+ const timer = options.timeoutMs > 0
290
+ ? setTimeout(() => {
291
+ timedOut = true;
292
+ child.kill("SIGKILL");
293
+ }, options.timeoutMs)
294
+ : undefined;
295
+ timer?.unref();
296
+ child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
297
+ child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
298
+ child.on("error", (error) => {
299
+ if (settled) return;
300
+ settled = true;
301
+ if (timer !== undefined) clearTimeout(timer);
302
+ reject(error);
303
+ });
304
+ child.on("close", (code) => {
305
+ if (settled) return;
306
+ settled = true;
307
+ if (timer !== undefined) clearTimeout(timer);
308
+ resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut });
309
+ });
310
+ if (options.stdin === undefined) {
311
+ child.stdin.end();
312
+ } else {
313
+ child.stdin.on("error", () => undefined);
314
+ child.stdin.end(options.stdin);
315
+ }
316
+ });
317
+ }
318
+
319
+ function assertTokens(name: string, tokens: readonly string[]): void {
320
+ if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`);
321
+ if (tokens.some((token) => typeof token !== "string" || token.length === 0)) {
322
+ throw new TypeError(`Pi host relay ${name} tokens must all be non-empty strings`);
323
+ }
324
+ }
325
+
326
+ /**
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.
331
+ */
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.
351
+ let materialized: ProcessCapture;
352
+ 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 }),
358
+ });
359
+ } catch (error) {
360
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`);
361
+ }
362
+ if (materialized.exitCode !== 0 || materialized.timedOut) {
363
+ const stderr = materialized.stderr.toString("utf8");
364
+ const refusal = classifyReviewHostRelayRefusal(stderr);
365
+ 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 });
367
+ }
368
+ if (refusal === "handshake") {
369
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
370
+ }
371
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
372
+ }
373
+ const promptBytes = materialized.stdout;
374
+ 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") });
376
+ }
377
+
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-"));
380
+ const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
381
+ try {
382
+ await chmod(scratchDirectory, 0o700);
383
+ 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
+ const resultFile = join(stagingDirectory, "result.raw");
408
+ await writeFile(resultFile, resultBytes, { mode: 0o600 });
409
+ await chmod(resultFile, 0o600);
410
+ const submitTokens = submissionBinding.argumentTokens.map((token, index) =>
411
+ index === submissionBinding.substitutionLocation ? token.split(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT).join(resultFile) : token,
412
+ );
413
+ let submission: ProcessCapture;
414
+ try {
415
+ submission = await collectProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
416
+ cwd: process.cwd(),
417
+ env: gentleAiEnvironment,
418
+ timeoutMs: gentleAiTimeoutMs,
419
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
420
+ });
421
+ } catch (error) {
422
+ 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
+ }
424
+ 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 });
426
+ }
427
+ return {
428
+ promptByteLength: promptBytes.length,
429
+ resultByteLength: resultBytes.length,
430
+ submission: submission.stdout.toString("utf8"),
431
+ };
432
+ } finally {
433
+ await rm(scratchDirectory, { recursive: true, force: true }).catch(() => undefined);
434
+ await rm(stagingDirectory, { recursive: true, force: true }).catch(() => undefined);
435
+ }
436
+ }