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,754 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { createNodeExecFileAdapter } from "../lib/native-review-cli.ts";
7
+ import {
8
+ REVIEW_HOST_RELAY_FAILURE,
9
+ REVIEW_HOST_RELAY_PI_ARGV,
10
+ REVIEW_HOST_RELAY_PI_TIMEOUT_ENV,
11
+ REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS,
12
+ REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS,
13
+ REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS,
14
+ REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
15
+ REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
16
+ ReviewHostRelayError,
17
+ classifyReviewHostRelayRefusal,
18
+ resolveReviewHostRelayPiTimeoutMs,
19
+ resolveReviewHostRelaySubmission,
20
+ reviewHostRelaySlots,
21
+ runReviewHostRelaySlot,
22
+ } from "../lib/review-host-relay.ts";
23
+ import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
24
+ import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "../lib/review-integration-v2.ts";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Fake binaries. Following the repo's fake-executable idiom (shell/git
28
+ // wrappers in related review tests), these are
29
+ // shebang scripts written into a scratch directory; node scripts are used so
30
+ // binary-unsafe bytes survive verbatim.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const FAKE_GENTLE_AI = `#!/usr/bin/env node
34
+ const fs = require("node:fs");
35
+ const path = require("node:path");
36
+ const argv = process.argv.slice(2);
37
+ if (process.env.RELAY_FAKE_LOG) fs.appendFileSync(process.env.RELAY_FAKE_LOG, JSON.stringify({ argv, cwd: process.cwd(), contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }) + "\\n");
38
+ if (argv.some((token) => token === "--materialize" || token.startsWith("--materialize="))) {
39
+ const mode = process.env.RELAY_FAKE_MATERIALIZE_MODE || "ok";
40
+ if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PROMPT_B64 || "", "base64")); process.exit(0); }
41
+ if (mode === "empty") process.exit(0);
42
+ if (mode === "unknown-flag") { process.stderr.write("flag provided but not defined: -materialize\\nUsage of gentle-ai review capture-result:\\n"); process.exit(2); }
43
+ if (mode === "handshake") { process.stderr.write(process.env.RELAY_FAKE_HANDSHAKE_STDERR || "the active runtime is not eligible for immutable receipt review"); process.exit(1); }
44
+ process.stderr.write("materialize exploded\\n"); process.exit(3);
45
+ }
46
+ const inputToken = argv.find((token) => token === "--input" || token.startsWith("--input="));
47
+ if (inputToken !== undefined) {
48
+ const mode = process.env.RELAY_FAKE_SUBMIT_MODE || "ok";
49
+ const inputPath = inputToken.startsWith("--input=") ? inputToken.slice("--input=".length) : argv[argv.indexOf("--input") + 1];
50
+ if (mode === "ok" || mode === "cleanup-fail") {
51
+ const bytes = fs.readFileSync(inputPath);
52
+ if (process.env.RELAY_FAKE_SUBMIT_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_SUBMIT_CAPTURE, bytes);
53
+ const accepted = JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" });
54
+ if (mode === "cleanup-fail") {
55
+ process.stdout.write(accepted, () => {
56
+ fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
57
+ process.exit(0);
58
+ });
59
+ return;
60
+ }
61
+ process.stdout.write(accepted);
62
+ process.exit(0);
63
+ }
64
+ if (mode === "refuse-cleanup-fail") fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
65
+ if (mode === "admit") {
66
+ // Go's admission refusal shape: a schema-bounded failure/v2 envelope on
67
+ // stdout, the operator line with the typed code suffix on stderr, exit 1.
68
+ const bytes = fs.readFileSync(inputPath);
69
+ let parsed;
70
+ try { parsed = JSON.parse(bytes.toString("utf8")); } catch { parsed = undefined; }
71
+ const refuse = (cause) => {
72
+ process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-integration.failure/v2", contract: "gentle-ai.review-integration/v2", operation: "review.capture-result", phase: "preflight", code: "invalid_request", message: "The negotiated review request is invalid.", mutation_outcome: "not_started", authority_applicability: "not_evaluated", retry_safe: true, replayability: "not_replayable", required_inputs: [], next_action: "correct_request", cause }));
73
+ process.stderr.write("Error: " + cause + " [invalid_request]\\n");
74
+ process.exit(1);
75
+ };
76
+ if (parsed === undefined || typeof parsed !== "object") refuse("lens provider result admission incomplete: reviewer payload contains no complete JSON object: no object start was found in " + bytes.length + " bytes; the rejected reviewer payload was preserved at " + inputPath + ".rejected");
77
+ if (parsed.subject_hash !== process.env.RELAY_FAKE_EXPECTED_SUBJECT) refuse("reviewer artifact admission binding_mismatch: reviewer result echoed a different artifact subject: the rejected admission did not consume the lens slot, so re-run the lens and invoke gentle-ai review capture-result again on the same lineage with a result that echoes the binding's top-level subject_hash, which is " + process.env.RELAY_FAKE_EXPECTED_SUBJECT);
78
+ process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
79
+ process.exit(0);
80
+ }
81
+ process.stderr.write("capture binding does not match the current reviewing authority\\n");
82
+ process.exit(1);
83
+ }
84
+ process.stderr.write("unexpected fake gentle-ai invocation\\n");
85
+ process.exit(9);
86
+ `;
87
+
88
+ const FAKE_PI = `#!/usr/bin/env node
89
+ const fs = require("node:fs");
90
+ const path = require("node:path");
91
+ const argv = process.argv.slice(2);
92
+ if (process.env.RELAY_FAKE_PI_LOG) fs.appendFileSync(process.env.RELAY_FAKE_PI_LOG, JSON.stringify({ argv, cwd: process.cwd(), entries: fs.readdirSync(process.cwd()), contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }) + "\\n");
93
+ const chunks = [];
94
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
95
+ process.stdin.on("end", () => {
96
+ const stdin = Buffer.concat(chunks);
97
+ if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
98
+ const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
99
+ if (process.env.RELAY_FAKE_PI_BREAK_CLEANUP === "true") fs.chmodSync(path.dirname(process.cwd()), 0o500);
100
+ if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
101
+ if (mode === "empty") process.exit(0);
102
+ if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
103
+ process.stderr.write("pi exploded\\n");
104
+ process.exit(4);
105
+ });
106
+ `;
107
+
108
+ interface RelayHarness {
109
+ directory: string;
110
+ gentleAi: string;
111
+ pi: string;
112
+ logPath: string;
113
+ piLogPath: string;
114
+ stdinCapturePath: string;
115
+ submitCapturePath: string;
116
+ targetCwd: string;
117
+ environment: NodeJS.ProcessEnv;
118
+ }
119
+
120
+ function harness(t: test.TestContext, overrides: Record<string, string> = {}): RelayHarness {
121
+ const directory = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-")));
122
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
123
+ const gentleAi = join(directory, "gentle-ai");
124
+ const pi = join(directory, "pi");
125
+ writeFileSync(gentleAi, FAKE_GENTLE_AI);
126
+ chmodSync(gentleAi, 0o755);
127
+ writeFileSync(pi, FAKE_PI);
128
+ chmodSync(pi, 0o755);
129
+ const logPath = join(directory, "gentle-ai.log");
130
+ const piLogPath = join(directory, "pi.log");
131
+ const stdinCapturePath = join(directory, "pi-stdin.bin");
132
+ const submitCapturePath = join(directory, "submitted.bin");
133
+ const targetCwd = join(directory, "target-worktree");
134
+ mkdirSync(targetCwd);
135
+ const environment: NodeJS.ProcessEnv = {
136
+ ...process.env,
137
+ RELAY_FAKE_LOG: logPath,
138
+ RELAY_FAKE_PI_LOG: piLogPath,
139
+ RELAY_FAKE_PI_STDIN_CAPTURE: stdinCapturePath,
140
+ RELAY_FAKE_SUBMIT_CAPTURE: submitCapturePath,
141
+ ...overrides,
142
+ };
143
+ // The relay itself must add the handshake; the base environment never
144
+ // carries it, so the fake-binary log proves the injection.
145
+ delete environment[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
146
+ return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, targetCwd, environment };
147
+ }
148
+
149
+ function readLog(path: string): Array<{ argv: string[]; contract: string | null; cwd?: string; entries?: string[] }> {
150
+ if (!existsSync(path)) return [];
151
+ return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
152
+ }
153
+
154
+ const BINDING_TOKENS = Object.freeze([
155
+ "--lineage=review-1d5aadacc600e167",
156
+ `--expected-revision=sha256:${"c".repeat(64)}`,
157
+ `--target=sha256:${"d".repeat(64)}`,
158
+ `--repository-context=rctx1_${"e".repeat(64)}`,
159
+ "--lens=review-reliability",
160
+ "--order=0",
161
+ `--subject-hash=sha256:${"a".repeat(64)}`,
162
+ ]);
163
+ const CAPTURE_TOKENS = Object.freeze([...BINDING_TOKENS, "--agent=pi", "--materialize=true"]);
164
+
165
+ // The provider-owned completing form: exact operation and argument tokens
166
+ // with one declared {{value}} substitution slot for the artifact path.
167
+ const SUBMISSION: ReviewCaptureSubmissionV1 = Object.freeze({
168
+ operationToken: "capture-result",
169
+ argumentTokens: Object.freeze([...BINDING_TOKENS, "--input={{value}}"]),
170
+ values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]),
171
+ });
172
+
173
+ // Prompt and result bytes deliberately include binary-unsafe content: NUL,
174
+ // control bytes, quotes, backslashes, CRLF, multi-byte UTF-8, and bytes that
175
+ // are not valid UTF-8 at all. The relay must move them verbatim.
176
+ const PROMPT_BYTES = Buffer.concat([
177
+ Buffer.from('GENTLE_AI_REVIEW_BINDING {"lineage":"review-1d5aadacc600e167"}\n"quotes" \\backslash\r\n\u00e9\u{1F3A9}\n', "utf8"),
178
+ Buffer.from([0x00, 0x01, 0x07, 0xff, 0xfe, 0x00]),
179
+ ]);
180
+ const PI_OUTPUT_BYTES = Buffer.concat([
181
+ Buffer.from(`{"subject_hash":"sha256:${"a".repeat(64)}","findings":[]}\n`, "utf8"),
182
+ Buffer.from([0x00, 0xf0, 0x9f, 0x8e, 0xa9, 0xff, 0x0d, 0x0a]),
183
+ ]);
184
+
185
+ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown> = {}) {
186
+ return {
187
+ captureArgumentTokens: CAPTURE_TOKENS,
188
+ submission: SUBMISSION,
189
+ gentleAiExecutable: fixture.gentleAi,
190
+ piExecutable: fixture.pi,
191
+ targetCwd: fixture.targetCwd,
192
+ environment: {
193
+ ...fixture.environment,
194
+ RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
195
+ RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
196
+ },
197
+ gentleAiTimeoutMs: 30_000,
198
+ piTimeoutMs: 30_000,
199
+ ...overrides,
200
+ };
201
+ }
202
+
203
+ async function rejectsWithRelayError(promise: Promise<unknown>, kind: string, stage: string): Promise<ReviewHostRelayError> {
204
+ let caught: ReviewHostRelayError | undefined;
205
+ await assert.rejects(promise, (error: unknown) => {
206
+ assert.ok(error instanceof ReviewHostRelayError, `expected ReviewHostRelayError, received ${String(error)}`);
207
+ caught = error;
208
+ return error.name === "ReviewHostRelayError" && error.kind === kind && error.stage === stage;
209
+ });
210
+ return caught!;
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Handshake — every gentle-ai CLI spawn carries the compiled declaration.
215
+ // ---------------------------------------------------------------------------
216
+
217
+ test("the central native CLI runner declares the relay contract on every gentle-ai spawn", async (t) => {
218
+ const fixture = harness(t);
219
+ const probe = join(fixture.directory, "env-probe");
220
+ writeFileSync(probe, `#!/usr/bin/env node\nprocess.stdout.write(JSON.stringify({ contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }));\n`);
221
+ chmodSync(probe, 0o755);
222
+ const hadContract = Object.prototype.hasOwnProperty.call(process.env, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV);
223
+ const previous = process.env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
224
+ delete process.env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
225
+ t.after(() => {
226
+ if (hadContract) process.env[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV] = previous;
227
+ });
228
+ const adapter = createNodeExecFileAdapter();
229
+ for (const argv of [["version"], ["review", "status", "--cwd", fixture.directory]]) {
230
+ const result = await adapter({ file: probe, arguments: argv, cwd: fixture.directory, timeoutMs: 10_000, maxBufferBytes: 1024 * 1024 });
231
+ assert.equal(result.exitCode, 0);
232
+ assert.deepEqual(JSON.parse(result.stdout), { contract: GENTLE_PI_REVIEW_RELAY_CONTRACT });
233
+ }
234
+ });
235
+
236
+ test("relay contract constants are the compiled gentle-ai handshake values", () => {
237
+ assert.equal(GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV, "GENTLE_PI_REVIEW_RELAY_CONTRACT");
238
+ assert.equal(GENTLE_PI_REVIEW_RELAY_CONTRACT, "gentle-pi.review-relay/v1");
239
+ });
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Happy path
243
+ // ---------------------------------------------------------------------------
244
+
245
+ test("relay happy path moves prompt and result bytes verbatim through a fresh empty scratch pi subprocess", async (t) => {
246
+ const fixture = harness(t);
247
+ const result = await runReviewHostRelaySlot(relayRequest(fixture));
248
+
249
+ assert.equal(result.promptByteLength, PROMPT_BYTES.length);
250
+ assert.equal(result.resultByteLength, PI_OUTPUT_BYTES.length);
251
+ assert.equal(JSON.parse(result.submission).admission_decision, "completed");
252
+
253
+ // Prompt bytes reached pi stdin verbatim.
254
+ assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
255
+ // Submission --input file bytes are EXACTLY the pi stdout bytes.
256
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
257
+
258
+ const gentleAiCalls = readLog(fixture.logPath);
259
+ assert.equal(gentleAiCalls.length, 2);
260
+ // (a) exact provider tokens, verbatim, in provider order.
261
+ assert.deepEqual(gentleAiCalls[0]!.argv, ["review", "capture-result", ...CAPTURE_TOKENS]);
262
+ // (d) the provider-owned submission form, verbatim: its exact operation
263
+ // and argument tokens with only the artifact path substituted into the
264
+ // declared {{value}} slot. No agent/materialize, nothing synthesized.
265
+ assert.deepEqual(gentleAiCalls[1]!.argv.slice(0, 2 + BINDING_TOKENS.length), ["review", SUBMISSION.operationToken, ...BINDING_TOKENS]);
266
+ const substituted = gentleAiCalls[1]!.argv.at(-1)!;
267
+ assert.match(substituted, /^--input=\S+$/);
268
+ assert.equal(substituted.includes("{{value}}"), false);
269
+ assert.equal(existsSync(substituted.slice("--input=".length)), false, "the coordinator removes its temporary result file after provider submission");
270
+ assert.equal(gentleAiCalls[1]!.argv.length, 2 + SUBMISSION.argumentTokens.length);
271
+ assert.equal(gentleAiCalls[1]!.argv.some((token) => token.includes("--agent") || token.includes("--materialize")), false);
272
+ // Handshake declared on both gentle-ai invocations even though the base
273
+ // environment carried none.
274
+ assert.deepEqual(gentleAiCalls.map((call) => call.contract), [GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT]);
275
+ assert.deepEqual(gentleAiCalls.map((call) => call.cwd), [fixture.targetCwd, fixture.targetCwd]);
276
+
277
+ const piCalls = readLog(fixture.piLogPath);
278
+ assert.equal(piCalls.length, 1);
279
+ // The pi environment stays untouched: no relay handshake is injected.
280
+ assert.equal(piCalls[0]!.contract, null);
281
+ // Fresh EMPTY scratch cwd, removed after the run.
282
+ assert.deepEqual(piCalls[0]!.entries, []);
283
+ assert.notEqual(piCalls[0]!.cwd, process.cwd());
284
+ assert.equal(existsSync(piCalls[0]!.cwd!), false);
285
+ });
286
+
287
+ test("the pi lockdown argv is pinned exactly with no model or provider selection", async (t) => {
288
+ const fixture = harness(t);
289
+ await runReviewHostRelaySlot(relayRequest(fixture));
290
+ const piCalls = readLog(fixture.piLogPath);
291
+ const expected = [
292
+ "--print",
293
+ "--mode", "text",
294
+ "--no-session",
295
+ "--no-tools",
296
+ "--no-extensions",
297
+ "--no-skills",
298
+ "--no-prompt-templates",
299
+ "--no-themes",
300
+ "--no-context-files",
301
+ "--no-approve",
302
+ ];
303
+ assert.deepEqual([...REVIEW_HOST_RELAY_PI_ARGV], expected);
304
+ assert.deepEqual(piCalls[0]!.argv, expected);
305
+ assert.equal(piCalls[0]!.argv.some((token) => token.startsWith("--model") || token.startsWith("--provider") || token.startsWith("--profile")), false);
306
+ });
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Fail-closed legs — a typed transport error and NO submission.
310
+ // ---------------------------------------------------------------------------
311
+
312
+ test("materialize nonzero exit fails closed with a typed error and never launches pi or submits", async (t) => {
313
+ const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "fail" });
314
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize");
315
+ assert.equal(error.exitCode, 3);
316
+ assert.equal(error.mutationOutcome, "none");
317
+ assert.equal(readLog(fixture.logPath).length, 1);
318
+ assert.equal(readLog(fixture.piLogPath).length, 0);
319
+ assert.equal(existsSync(fixture.submitCapturePath), false);
320
+ });
321
+
322
+ test("an empty materialized prompt fails closed before pi launches", async (t) => {
323
+ const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "empty" });
324
+ await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize");
325
+ assert.equal(readLog(fixture.piLogPath).length, 0);
326
+ assert.equal(existsSync(fixture.submitCapturePath), false);
327
+ });
328
+
329
+ test("pi nonzero exit fails closed with a typed error and no submission", async (t) => {
330
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
331
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
332
+ assert.equal(error.exitCode, 4);
333
+ assert.equal(error.mutationOutcome, "none");
334
+ assert.equal(readLog(fixture.logPath).length, 1, "no submission invocation after pi failure");
335
+ assert.equal(existsSync(fixture.submitCapturePath), false);
336
+ });
337
+
338
+ test("empty pi stdout fails closed with a typed error and no submission", async (t) => {
339
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "empty" });
340
+ await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi");
341
+ assert.equal(readLog(fixture.logPath).length, 1);
342
+ assert.equal(existsSync(fixture.submitCapturePath), false);
343
+ });
344
+
345
+ test("pi timeout fails closed with a typed error and no submission", async (t) => {
346
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
347
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
348
+ assert.equal(error.timedOut, true);
349
+ assert.equal(readLog(fixture.logPath).length, 1);
350
+ assert.equal(existsSync(fixture.submitCapturePath), false);
351
+ });
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // gentle-pi#367 — the reviewer bound is reachable from production, and a
355
+ // reviewer killed by it says so with both measurements.
356
+ // ---------------------------------------------------------------------------
357
+
358
+ test("the reviewer bound scales with materialized prompt bytes instead of one fixed number", () => {
359
+ const empty: NodeJS.ProcessEnv = {};
360
+ // A tiny prompt still gets the model-latency floor.
361
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(0, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS);
362
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(1, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + 1);
363
+ // One mebibyte of prompt buys exactly one linear allowance.
364
+ assert.equal(
365
+ resolveReviewHostRelayPiTimeoutMs(1024 * 1024, empty),
366
+ REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS,
367
+ );
368
+ // The reported field candidate: ~1.58 MB of prompt, whose reviewer needed
369
+ // 478s and was killed by the old fixed 600s bound. The derived bound must
370
+ // clear that measurement with real margin.
371
+ const reported = resolveReviewHostRelayPiTimeoutMs(1_580_000, empty);
372
+ assert.ok(reported > 600_000, `derived bound ${reported} must exceed the old fixed 600000ms bound`);
373
+ assert.ok(reported > 478_000 * 3, `derived bound ${reported} must keep real margin over the measured 478000ms reviewer run`);
374
+ // Never unbounded, however large the prompt gets.
375
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(Number.MAX_SAFE_INTEGER, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
376
+ });
377
+
378
+ test("the reviewer bound honours the environment override and ignores malformed values", () => {
379
+ const bytes = 4 * 1024 * 1024;
380
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "1234" }), 1234);
381
+ // The override is clamped by the same hard ceiling as the derived bound.
382
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "999999999" }), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
383
+ const derived = resolveReviewHostRelayPiTimeoutMs(bytes, {});
384
+ for (const malformed of ["", "0", "-1", "12.5", "abc", " 600000", "1e6"]) {
385
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: malformed }), derived, `malformed ${JSON.stringify(malformed)} must fall back to the derived bound`);
386
+ }
387
+ });
388
+
389
+ test("the production relay path resolves the reviewer bound from the environment, with no injected timeout", async (t) => {
390
+ // The regression: piTimeoutMs was reachable only through the test seam, so
391
+ // production always ran against the fixed 600s bound. This request injects
392
+ // no timeout at all — the override must reach the real spawn.
393
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300" });
394
+ const error = await rejectsWithRelayError(
395
+ runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: undefined })),
396
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
397
+ "pi",
398
+ );
399
+ assert.equal(error.timeoutMs, 300);
400
+ assert.equal(error.timedOut, true);
401
+ assert.equal(readLog(fixture.logPath).length, 1, "no submission after a reviewer timeout");
402
+ assert.equal(existsSync(fixture.submitCapturePath), false);
403
+ });
404
+
405
+ test("a relay Pi timeout keeps its typed mapping and timing evidence when opaque scratch cleanup also fails", async (t) => {
406
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", RELAY_FAKE_PI_BREAK_CLEANUP: "true" });
407
+ const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-pi-primary-failure-")));
408
+ const originalTmpdir = process.env.TMPDIR;
409
+ process.env.TMPDIR = scratchParent;
410
+ try {
411
+ const error = await rejectsWithRelayError(
412
+ runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })),
413
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
414
+ "pi",
415
+ );
416
+ assert.equal(error.timedOut, true);
417
+ assert.equal(error.timeoutMs, 300);
418
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250);
419
+ } finally {
420
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
421
+ else process.env.TMPDIR = originalTmpdir;
422
+ chmodSync(scratchParent, 0o700);
423
+ rmSync(scratchParent, { recursive: true, force: true });
424
+ }
425
+ });
426
+
427
+ test("a killed reviewer reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
428
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
429
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
430
+ assert.equal(error.timeoutMs, 300);
431
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250, `elapsed ${error.elapsedMs} must record the real wall time`);
432
+ assert.ok(error.elapsedMs! < 10_000, "the reviewer was killed at the bound, not left to finish");
433
+ assert.match(error.message, /exceeded the relay bound/);
434
+ assert.match(error.message, new RegExp(String(error.elapsedMs)));
435
+ assert.match(error.message, /limit for a \d+-byte materialized prompt/);
436
+ assert.match(error.message, new RegExp(REVIEW_HOST_RELAY_PI_TIMEOUT_ENV));
437
+ assert.equal(error.mutationOutcome, "none");
438
+ });
439
+
440
+ test("a crashed reviewer stays distinguishable from a killed one and still carries its measurements", async (t) => {
441
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
442
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 30_000 })), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
443
+ assert.equal(error.timedOut, false);
444
+ assert.equal(error.exitCode, 4);
445
+ assert.equal(error.timeoutMs, 30_000);
446
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
447
+ });
448
+
449
+ test("submission refusal is a typed error whose outcome is unknown pending STATUS", async (t) => {
450
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
451
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
452
+ assert.equal(error.mutationOutcome, "unknown");
453
+ assert.match(error.stderr, /capture binding does not match/);
454
+ assert.equal(readLog(fixture.logPath).length, 2);
455
+ });
456
+
457
+ // gentle-pi#522 / #524: Go refuses a reviewer submission at admission with a
458
+ // typed [invalid_request] refusal and states that the lens slot was not
459
+ // consumed. That is a proven non-mutation, not an unknown outcome, and the
460
+ // refusal text is the only thing that tells the host what to change.
461
+ function admittingRequest(fixture: RelayHarness, reviewerOutput: Buffer) {
462
+ return relayRequest(fixture, {
463
+ environment: {
464
+ ...fixture.environment,
465
+ RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
466
+ RELAY_FAKE_PI_OUTPUT_B64: reviewerOutput.toString("base64"),
467
+ },
468
+ });
469
+ }
470
+
471
+ test("a reviewer printing garbage is refused at admission as a proven non-mutation carrying Go's refusal", async (t) => {
472
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
473
+ const error = await rejectsWithRelayError(
474
+ runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from("not json at all", "utf8"))),
475
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
476
+ "submit",
477
+ );
478
+ assert.equal(error.mutationOutcome, "none");
479
+ assert.equal(error.exitCode, 1);
480
+ assert.equal(error.timedOut, false);
481
+ assert.match(error.stderr, /reviewer payload contains no complete JSON object/);
482
+ assert.match(error.stderr, /\[invalid_request\]/);
483
+ assert.match(error.message, /reviewer payload contains no complete JSON object/);
484
+ assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
485
+ });
486
+
487
+ test("a reviewer echoing a different subject is refused at admission as a proven non-mutation carrying Go's continuation", async (t) => {
488
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
489
+ const wrongSubject = Buffer.from(JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8");
490
+ const error = await rejectsWithRelayError(
491
+ runReviewHostRelaySlot(admittingRequest(fixture, wrongSubject)),
492
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
493
+ "submit",
494
+ );
495
+ assert.equal(error.mutationOutcome, "none");
496
+ assert.equal(error.exitCode, 1);
497
+ assert.match(error.stderr, /binding_mismatch/);
498
+ assert.match(error.stderr, /did not consume the lens slot/);
499
+ assert.match(error.stderr, /\[invalid_request\]/);
500
+ assert.match(error.message, /did not consume the lens slot/);
501
+ assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
502
+ });
503
+
504
+ test("a submission the fake admits with the expected subject still completes", async (t) => {
505
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
506
+ const result = await runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from(JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8")));
507
+ assert.equal(JSON.parse(result.submission).admission_decision, "completed");
508
+ });
509
+
510
+ test("submission refusal preserves its primary evidence when result staging cleanup also fails", async (t) => {
511
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse-cleanup-fail" });
512
+ const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-primary-failure-")));
513
+ const originalTmpdir = process.env.TMPDIR;
514
+ process.env.TMPDIR = scratchParent;
515
+ try {
516
+ const error = await rejectsWithRelayError(
517
+ runReviewHostRelaySlot(relayRequest(fixture)),
518
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
519
+ "submit",
520
+ );
521
+ assert.equal(error.exitCode, 1);
522
+ assert.equal(error.timedOut, false);
523
+ assert.equal(error.timeoutMs, 30_000);
524
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
525
+ assert.match(error.stderr, /capture binding does not match/);
526
+ assert.doesNotMatch(error.message, /staging cleanup/i);
527
+ } finally {
528
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
529
+ else process.env.TMPDIR = originalTmpdir;
530
+ chmodSync(scratchParent, 0o700);
531
+ rmSync(scratchParent, { recursive: true, force: true });
532
+ }
533
+ });
534
+
535
+ test("relay scratch and staging directories are removed after failures too", async (t) => {
536
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
537
+ await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
538
+ const piCalls = readLog(fixture.piLogPath);
539
+ assert.equal(piCalls.length, 1);
540
+ assert.equal(existsSync(piCalls[0]!.cwd!), false);
541
+ });
542
+
543
+ test("a result staging cleanup failure remains a typed submit failure", async (t) => {
544
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "cleanup-fail" });
545
+ const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-cleanup-")));
546
+ const originalTmpdir = process.env.TMPDIR;
547
+ process.env.TMPDIR = scratchParent;
548
+ try {
549
+ const error = await rejectsWithRelayError(
550
+ runReviewHostRelaySlot(relayRequest(fixture)),
551
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
552
+ "submit",
553
+ );
554
+ assert.equal(error.mutationOutcome, "unknown");
555
+ assert.match(error.message, /staging cleanup/i);
556
+ } finally {
557
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
558
+ else process.env.TMPDIR = originalTmpdir;
559
+ chmodSync(scratchParent, 0o700);
560
+ rmSync(scratchParent, { recursive: true, force: true });
561
+ }
562
+ });
563
+
564
+ // ---------------------------------------------------------------------------
565
+ // Capability detection — typed refusal classes, no version sniffing.
566
+ // ---------------------------------------------------------------------------
567
+
568
+ test("an old binary's unknown-flag refusal classifies as relay-unavailable with the exact report", async (t) => {
569
+ const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "unknown-flag" });
570
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize");
571
+ assert.equal(error.message, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE);
572
+ assert.match(error.stderr, /flag provided but not defined: -materialize/);
573
+ assert.equal(readLog(fixture.piLogPath).length, 0);
574
+ assert.equal(existsSync(fixture.submitCapturePath), false);
575
+ });
576
+
577
+ test("a handshake refusal surfaces the provider refusal verbatim", async (t) => {
578
+ const refusal = "review capture-result --agent pi: the active runtime is not eligible for immutable receipt review; supported immutable review runtimes: claude-code, codex, opencode";
579
+ const fixture = harness(t, { RELAY_FAKE_MATERIALIZE_MODE: "handshake", RELAY_FAKE_HANDSHAKE_STDERR: refusal });
580
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize");
581
+ assert.equal(error.message, refusal);
582
+ assert.equal(error.stderr, refusal);
583
+ assert.equal(readLog(fixture.piLogPath).length, 0);
584
+ });
585
+
586
+ test("refusal classification distinguishes unknown-flag, handshake, and other", () => {
587
+ assert.equal(classifyReviewHostRelayRefusal("flag provided but not defined: -materialize\nUsage:"), "unknown-flag");
588
+ assert.equal(classifyReviewHostRelayRefusal("flag provided but not defined: -agent"), "unknown-flag");
589
+ assert.equal(classifyReviewHostRelayRefusal("the active runtime is not eligible for immutable receipt review"), "handshake");
590
+ assert.equal(classifyReviewHostRelayRefusal("declare GENTLE_PI_REVIEW_RELAY_CONTRACT=gentle-pi.review-relay/v1"), "handshake");
591
+ assert.equal(classifyReviewHostRelayRefusal("some unrelated explosion"), "other");
592
+ });
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // Slot detection — the provider decides; nothing is inferred.
596
+ // ---------------------------------------------------------------------------
597
+
598
+ function collectInput(overrides: Partial<{ captureOperation: string; arguments: ReviewCollectInputV3["arguments"]; submission: ReviewCaptureSubmissionV1 | undefined }> = {}): ReviewCollectInputV3 {
599
+ const argumentsList: ReviewCollectInputV3["arguments"] = overrides.arguments ?? [
600
+ { name: "lineage", value: "review-1d5aadacc600e167", token: "--lineage=review-1d5aadacc600e167" },
601
+ { name: "expected-revision", value: `sha256:${"c".repeat(64)}`, token: `--expected-revision=sha256:${"c".repeat(64)}` },
602
+ { name: "target", value: `sha256:${"d".repeat(64)}`, token: `--target=sha256:${"d".repeat(64)}` },
603
+ { name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
604
+ { name: "lens", value: "review-reliability", token: "--lens=review-reliability" },
605
+ { name: "order", value: "0", token: "--order=0" },
606
+ { name: "subject-hash", value: `sha256:${"a".repeat(64)}`, token: `--subject-hash=sha256:${"a".repeat(64)}` },
607
+ { name: "agent", value: "pi", token: "--agent=pi" },
608
+ { name: "materialize", value: "true", token: "--materialize=true" },
609
+ ];
610
+ return {
611
+ name: "reviewer_result",
612
+ schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
613
+ captureOperation: overrides.captureOperation ?? "review.capture-result",
614
+ arguments: argumentsList,
615
+ ...("submission" in overrides ? (overrides.submission === undefined ? {} : { submission: overrides.submission }) : { submission: SUBMISSION }),
616
+ };
617
+ }
618
+
619
+ test("only provider-issued pi --materialize capture-result inputs become relay slots", () => {
620
+ const slots = reviewHostRelaySlots([collectInput()]);
621
+ assert.equal(slots.length, 1);
622
+ assert.deepEqual(slots[0]!.captureArgumentTokens, [...CAPTURE_TOKENS]);
623
+ // The provider-owned completing form passes through verbatim.
624
+ assert.deepEqual(slots[0]!.submission, SUBMISSION);
625
+ assert.equal(slots[0]!.lens, "review-reliability");
626
+ assert.equal(slots[0]!.order, "0");
627
+
628
+ // A materialize slot whose provider omitted the submission still becomes
629
+ // a slot (the provider decided the route); the relay then fails closed.
630
+ const missingSubmission = reviewHostRelaySlots([collectInput({ submission: undefined })]);
631
+ assert.equal(missingSubmission.length, 1);
632
+ assert.equal(missingSubmission[0]!.submission, undefined);
633
+
634
+ const withoutMaterialize = collectInput({ arguments: collectInput().arguments.filter((argument) => argument.name !== "materialize") });
635
+ assert.deepEqual(reviewHostRelaySlots([withoutMaterialize]), []);
636
+
637
+ const withoutAgent = collectInput({ arguments: collectInput().arguments.filter((argument) => argument.name !== "agent") });
638
+ assert.deepEqual(reviewHostRelaySlots([withoutAgent]), []);
639
+
640
+ const foreignAgent = collectInput({ arguments: collectInput().arguments.map((argument) => argument.name === "agent" ? { ...argument, value: "codex", token: "--agent=codex" } : argument) });
641
+ assert.deepEqual(reviewHostRelaySlots([foreignAgent]), []);
642
+
643
+ const evidence = collectInput({ captureOperation: "review.capture-evidence" });
644
+ assert.deepEqual(reviewHostRelaySlots([evidence]), []);
645
+ });
646
+
647
+ test("relay input validation rejects empty or malformed inputs before any process launches", async () => {
648
+ await assert.rejects(runReviewHostRelaySlot({ captureArgumentTokens: [], submission: SUBMISSION }), TypeError);
649
+ await assert.rejects(runReviewHostRelaySlot({ captureArgumentTokens: [""], submission: SUBMISSION }), TypeError);
650
+ await assert.rejects(runReviewHostRelaySlot({ captureArgumentTokens: CAPTURE_TOKENS, submission: SUBMISSION, gentleAiExecutable: "gentle-ai" }), TypeError);
651
+ });
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Provider-owned submission form — consumed verbatim, never synthesized.
655
+ // ---------------------------------------------------------------------------
656
+
657
+ test("a materialize slot without a provider submission fails closed before any process launches", async (t) => {
658
+ const fixture = harness(t);
659
+ const error = await rejectsWithRelayError(
660
+ runReviewHostRelaySlot(relayRequest(fixture, { submission: undefined })),
661
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH,
662
+ "binding",
663
+ );
664
+ assert.equal(error.message, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
665
+ assert.equal(error.mutationOutcome, "none");
666
+ assert.equal(readLog(fixture.logPath).length, 0, "no gentle-ai invocation was launched");
667
+ assert.equal(readLog(fixture.piLogPath).length, 0, "no pi subprocess was launched");
668
+ assert.equal(existsSync(fixture.submitCapturePath), false);
669
+ });
670
+
671
+ test("a submission form the relay cannot bind is a typed contract mismatch, never a repaired invocation", async (t) => {
672
+ const fixture = harness(t);
673
+ const twoValues: ReviewCaptureSubmissionV1 = {
674
+ ...SUBMISSION,
675
+ values: [...SUBMISSION.values, { slot: "extra", domain: "artifact_path_or_stdin", substitutionLocation: 0 }],
676
+ };
677
+ await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: twoValues })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
678
+ const noSlot: ReviewCaptureSubmissionV1 = {
679
+ ...SUBMISSION,
680
+ argumentTokens: [...BINDING_TOKENS, "--input=/etc/somewhere"],
681
+ };
682
+ await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: noSlot })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
683
+ const outOfBounds: ReviewCaptureSubmissionV1 = {
684
+ ...SUBMISSION,
685
+ values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: SUBMISSION.argumentTokens.length }],
686
+ };
687
+ await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { submission: outOfBounds })), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding");
688
+ assert.equal(readLog(fixture.logPath).length, 0);
689
+ assert.equal(readLog(fixture.piLogPath).length, 0);
690
+ });
691
+
692
+ test("resolveReviewHostRelaySubmission returns the provider binding untouched", () => {
693
+ const binding = resolveReviewHostRelaySubmission(SUBMISSION);
694
+ assert.equal(binding.operationToken, "capture-result");
695
+ assert.deepEqual(binding.argumentTokens, SUBMISSION.argumentTokens);
696
+ assert.equal(binding.substitutionLocation, BINDING_TOKENS.length);
697
+ assert.throws(() => resolveReviewHostRelaySubmission(undefined), (error: unknown) =>
698
+ error instanceof ReviewHostRelayError && error.kind === REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH && error.message === REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
699
+ });
700
+
701
+ test("the negotiated decoder carries the provider submission through the capture-result collect input", () => {
702
+ const lineageId = "review-1d5aadacc600e167";
703
+ const sha = `sha256:${"c".repeat(64)}`;
704
+ const tree = "3".repeat(40);
705
+ const rawSubmission = {
706
+ operation_token: "capture-result",
707
+ argument_tokens: [...BINDING_TOKENS, "--input={{value}}"],
708
+ values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitution_location: BINDING_TOKENS.length }],
709
+ };
710
+ const rawInput = {
711
+ name: "reviewer_result",
712
+ schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
713
+ capture_operation: "review.capture-result",
714
+ arguments: [
715
+ { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
716
+ { name: "agent", value: "pi", token: "--agent=pi" },
717
+ { name: "materialize", value: "true", token: "--materialize=true" },
718
+ ],
719
+ artifact_subject: {
720
+ schema: "gentle-ai.review-artifact-subject/v2",
721
+ subject_hash: sha,
722
+ lineage_id: lineageId,
723
+ authority_revision: sha,
724
+ target_identity: sha,
725
+ base_tree: tree,
726
+ candidate_tree: tree,
727
+ changed_path_manifest_sha256: sha,
728
+ lens: "review-reliability",
729
+ selected_order: 0,
730
+ },
731
+ base_tree: tree,
732
+ candidate_tree: tree,
733
+ changed_path_manifest: [{ path: "app.ts", status: "M", old_mode: "100644", new_mode: "100644", deleted: false, type_changed: false, mode_only: false, intended_untracked: false }],
734
+ submission: rawSubmission,
735
+ };
736
+ const decoded = decodeReviewNextTransitionV3({ kind: "collect", reason_code: "reviewer_results_required", collect: { inputs: [rawInput] } });
737
+ assert.equal(decoded.collect!.inputs[0]!.submission!.operationToken, "capture-result");
738
+ assert.deepEqual(decoded.collect!.inputs[0]!.submission!.argumentTokens, rawSubmission.argument_tokens);
739
+ assert.deepEqual(decoded.collect!.inputs[0]!.submission!.values, [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]);
740
+
741
+ // Strict rejections: submission outside capture-result, and a
742
+ // substitution location outside its own argument tokens.
743
+ assert.throws(() => decodeReviewNextTransitionV3({ kind: "collect", reason_code: "verification_evidence_required", collect: { inputs: [{
744
+ name: "verification_evidence",
745
+ schema: "gentle-ai.review-verification-evidence/v2",
746
+ capture_operation: "review.capture-evidence",
747
+ arguments: [{ name: "lineage", value: lineageId }],
748
+ submission: rawSubmission,
749
+ }] } }), /submission is only valid for review\.capture-result/);
750
+ assert.throws(() => decodeReviewNextTransitionV3({ kind: "collect", reason_code: "reviewer_results_required", collect: { inputs: [{
751
+ ...rawInput,
752
+ submission: { ...rawSubmission, values: [{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitution_location: rawSubmission.argument_tokens.length }] },
753
+ }] } }), /substitution_location/);
754
+ });