gentle-pi 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/README.md +76 -79
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-explore.md +1 -0
  8. package/assets/agents/sdd-proposal.md +2 -0
  9. package/assets/agents/sdd-research.md +54 -0
  10. package/assets/agents/sdd-status.md +4 -4
  11. package/assets/agents/sdd-tasks.md +2 -3
  12. package/assets/agents/sdd-verify.md +21 -1
  13. package/assets/chains/sdd-full.chain.md +1 -1
  14. package/assets/chains/sdd-verify.chain.md +1 -1
  15. package/assets/orchestrator-delegation.md +56 -249
  16. package/assets/orchestrator-memory.md +2 -0
  17. package/assets/orchestrator.md +20 -40
  18. package/assets/sdd-orchestrator-workflow.md +30 -37
  19. package/assets/support/sdd-status-contract.md +7 -7
  20. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  21. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  22. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  23. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  24. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
  25. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
  26. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
  27. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
  28. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
  29. package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
  30. package/docs/native-authority-architecture.md +9 -11
  31. package/docs/review-integration.md +27 -373
  32. package/extensions/ask-user-choice.ts +151 -0
  33. package/extensions/gentle-ai.ts +2349 -3785
  34. package/extensions/quiet-tools.ts +515 -32
  35. package/extensions/sdd-init.ts +4 -8
  36. package/lib/agent-home.ts +8 -0
  37. package/lib/gentle-ai-renderer.ts +70 -0
  38. package/lib/model-routing-authority.ts +133 -0
  39. package/lib/native-review-cli.ts +387 -940
  40. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  41. package/lib/provider-contract-bundle.ts +88 -6
  42. package/lib/review-candidate-view.ts +341 -132
  43. package/lib/review-host-relay.ts +324 -96
  44. package/lib/review-integration-v2.ts +868 -307
  45. package/lib/review-last-event-controller.ts +35 -0
  46. package/lib/sdd-preflight.ts +179 -68
  47. package/lib/sdd-status.ts +66 -111
  48. package/lib/terminal-theme.ts +1 -1
  49. package/package.json +83 -82
  50. package/runtime/gentle-ai-binary.mjs +1 -1
  51. package/runtime/native-review-cli.mjs +337 -890
  52. package/runtime/review-integration-v2.mjs +816 -255
  53. package/runtime/review-relay-contract.mjs +1 -1
  54. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  55. package/scripts/gentle-ai-installer.mjs +75 -22
  56. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  57. package/scripts/test-packed-runner.mjs +4 -7
  58. package/scripts/verify-package-files.mjs +23 -23
  59. package/skills/_shared/review-ledger-contract.md +14 -14
  60. package/skills/chained-pr/SKILL.md +3 -0
  61. package/skills/cognitive-doc-design/SKILL.md +1 -1
  62. package/skills/comment-writer/SKILL.md +1 -1
  63. package/skills/gentle-ai/SKILL.md +7 -74
  64. package/skills/issue-creation/SKILL.md +53 -93
  65. package/skills/judgment-day/SKILL.md +5 -7
  66. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  67. package/skills/release/SKILL.md +3 -3
  68. package/skills/skill-registry/SKILL.md +1 -1
  69. package/skills/work-unit-commits/SKILL.md +3 -1
  70. package/tests/artifact-language.test.ts +24 -11
  71. package/tests/ask-user-choice.test.ts +264 -0
  72. package/tests/codegraph-tools.test.ts +3 -3
  73. package/tests/crosslane/cross-lane.mjs +15 -1168
  74. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  75. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  76. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  77. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  78. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  79. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  80. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  81. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  82. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  83. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  84. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  85. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  86. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  87. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  88. package/tests/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
  89. package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
  90. package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
  91. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
  92. package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
  93. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
  94. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
  95. package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -0
  96. package/tests/gentle-ai-binary.test.ts +83 -13
  97. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  98. package/tests/gentle-ai-installer.test.ts +137 -47
  99. package/tests/gentle-ai.test.ts +821 -131
  100. package/tests/gentle-theme.test.ts +133 -0
  101. package/tests/issue-creation-skill.test.ts +103 -0
  102. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  103. package/tests/model-routing-authority.test.ts +257 -0
  104. package/tests/native-review-capability-contract.test.ts +45 -1
  105. package/tests/native-review-cli.test.ts +586 -962
  106. package/tests/native-review-consent.test.ts +150 -66
  107. package/tests/native-review-parity-runtime.test.ts +80 -359
  108. package/tests/native-review-parity.test.ts +674 -925
  109. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  110. package/tests/orchestrator-budget.test.ts +132 -88
  111. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  112. package/tests/package-manifest.test.ts +152 -36
  113. package/tests/provider-contract-bundle.test.ts +76 -0
  114. package/tests/provider-contract-mirror.test.ts +19 -0
  115. package/tests/provider-defect-handoff.test.ts +15 -118
  116. package/tests/quiet-tool-rendering.test.ts +1055 -28
  117. package/tests/review-agent-end-preflight.test.ts +408 -0
  118. package/tests/review-authority-recovery-docs.test.ts +1 -2
  119. package/tests/review-candidate-view.test.ts +409 -3
  120. package/tests/review-compact-contract.test.ts +4 -30
  121. package/tests/review-contract-prompt.test.ts +139 -0
  122. package/tests/review-controller-lock-status.test.ts +2 -2
  123. package/tests/review-controller-native-recovery.test.ts +363 -1450
  124. package/tests/review-controller-native-routing.test.ts +1251 -5642
  125. package/tests/review-controller-retired-ops.test.ts +1 -1
  126. package/tests/review-controller-workspace-root.test.ts +222 -71
  127. package/tests/review-controller.test.ts +26 -816
  128. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  129. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  130. package/tests/review-gate.test.ts +0 -45
  131. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  132. package/tests/review-host-relay-routing.test.ts +328 -91
  133. package/tests/review-host-relay.test.ts +436 -14
  134. package/tests/review-integration-v2-forward.test.ts +623 -553
  135. package/tests/review-integration-v2.test.ts +239 -165
  136. package/tests/review-last-event-closure.test.ts +408 -0
  137. package/tests/review-ledger-contract.test.ts +97 -35
  138. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  139. package/tests/review-relay-transport-agent.test.ts +119 -46
  140. package/tests/review-snapshot.test.ts +3 -2
  141. package/tests/runtime-harness.mjs +362 -196
  142. package/tests/sdd-agent-tools.test.ts +36 -0
  143. package/tests/sdd-preflight.test.ts +81 -15
  144. package/tests/sdd-status.test.ts +109 -110
  145. package/tests/skill-collision-prefixes.test.ts +5 -8
  146. package/tests/writer-edit-surface-scope.test.ts +366 -0
  147. package/themes/Gentleman-Cute.json +94 -0
  148. package/themes/Gentleman-Sexy.json +92 -0
  149. package/lib/git-commit-transaction.ts +0 -861
  150. package/runtime/git-commit-transaction.mjs +0 -862
  151. package/scripts/run-git-commit-transaction.mjs +0 -35
  152. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  153. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  154. package/tests/git-commit-transaction.test.ts +0 -530
  155. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
  156. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
  157. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
  158. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
  159. /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
  160. /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
@@ -7,28 +7,39 @@ import { createNodeExecFileAdapter } from "../lib/native-review-cli.ts";
7
7
  import {
8
8
  REVIEW_HOST_RELAY_FAILURE,
9
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,
10
14
  REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE,
11
15
  REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE,
12
16
  ReviewHostRelayError,
13
17
  classifyReviewHostRelayRefusal,
18
+ resolveReviewHostRelayPiTimeoutMs,
14
19
  resolveReviewHostRelaySubmission,
15
20
  reviewHostRelaySlots,
21
+ prepareReviewHostRelaySlot,
22
+ runReviewHostRelayReviewerGroup,
16
23
  runReviewHostRelaySlot,
24
+ submitReviewHostRelayPreparedResult,
25
+ type ReviewHostRelayPreparedResult,
26
+ type ReviewHostRelayRequest,
17
27
  } from "../lib/review-host-relay.ts";
18
28
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../lib/review-relay-contract.ts";
19
29
  import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "../lib/review-integration-v2.ts";
20
30
 
21
31
  // ---------------------------------------------------------------------------
22
32
  // Fake binaries. Following the repo's fake-executable idiom (shell/git
23
- // wrappers in review-gate/git-commit-transaction tests), these are
33
+ // wrappers in related review tests), these are
24
34
  // shebang scripts written into a scratch directory; node scripts are used so
25
35
  // binary-unsafe bytes survive verbatim.
26
36
  // ---------------------------------------------------------------------------
27
37
 
28
38
  const FAKE_GENTLE_AI = `#!/usr/bin/env node
29
39
  const fs = require("node:fs");
40
+ const path = require("node:path");
30
41
  const argv = process.argv.slice(2);
31
- if (process.env.RELAY_FAKE_LOG) fs.appendFileSync(process.env.RELAY_FAKE_LOG, JSON.stringify({ argv, contract: process.env.${GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV} ?? null }) + "\\n");
42
+ 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");
32
43
  if (argv.some((token) => token === "--materialize" || token.startsWith("--materialize="))) {
33
44
  const mode = process.env.RELAY_FAKE_MATERIALIZE_MODE || "ok";
34
45
  if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PROMPT_B64 || "", "base64")); process.exit(0); }
@@ -40,10 +51,37 @@ if (argv.some((token) => token === "--materialize" || token.startsWith("--materi
40
51
  const inputToken = argv.find((token) => token === "--input" || token.startsWith("--input="));
41
52
  if (inputToken !== undefined) {
42
53
  const mode = process.env.RELAY_FAKE_SUBMIT_MODE || "ok";
43
- if (mode === "ok") {
44
- const inputPath = inputToken.startsWith("--input=") ? inputToken.slice("--input=".length) : argv[argv.indexOf("--input") + 1];
54
+ const inputPath = inputToken.startsWith("--input=") ? inputToken.slice("--input=".length) : argv[argv.indexOf("--input") + 1];
55
+ const submitDelayMs = Number(process.env.RELAY_FAKE_SUBMIT_DELAY_MS || "0");
56
+ if (Number.isSafeInteger(submitDelayMs) && submitDelayMs > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, submitDelayMs);
57
+ if (mode === "ok" || mode === "cleanup-fail") {
45
58
  const bytes = fs.readFileSync(inputPath);
46
59
  if (process.env.RELAY_FAKE_SUBMIT_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_SUBMIT_CAPTURE, bytes);
60
+ const accepted = JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" });
61
+ if (mode === "cleanup-fail") {
62
+ process.stdout.write(accepted, () => {
63
+ fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
64
+ process.exit(0);
65
+ });
66
+ return;
67
+ }
68
+ process.stdout.write(accepted);
69
+ process.exit(0);
70
+ }
71
+ if (mode === "refuse-cleanup-fail") fs.chmodSync(path.dirname(path.dirname(inputPath)), 0o500);
72
+ if (mode === "admit") {
73
+ // Go's admission refusal shape: a schema-bounded failure/v2 envelope on
74
+ // stdout, the operator line with the typed code suffix on stderr, exit 1.
75
+ const bytes = fs.readFileSync(inputPath);
76
+ let parsed;
77
+ try { parsed = JSON.parse(bytes.toString("utf8")); } catch { parsed = undefined; }
78
+ const refuse = (cause) => {
79
+ 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 }));
80
+ process.stderr.write("Error: " + cause + " [invalid_request]\\n");
81
+ process.exit(1);
82
+ };
83
+ 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");
84
+ 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);
47
85
  process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
48
86
  process.exit(0);
49
87
  }
@@ -56,6 +94,7 @@ process.exit(9);
56
94
 
57
95
  const FAKE_PI = `#!/usr/bin/env node
58
96
  const fs = require("node:fs");
97
+ const path = require("node:path");
59
98
  const argv = process.argv.slice(2);
60
99
  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");
61
100
  const chunks = [];
@@ -63,12 +102,32 @@ process.stdin.on("data", (chunk) => chunks.push(chunk));
63
102
  process.stdin.on("end", () => {
64
103
  const stdin = Buffer.concat(chunks);
65
104
  if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
66
- const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
67
- if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
68
- if (mode === "empty") process.exit(0);
69
- if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
70
- process.stderr.write("pi exploded\\n");
71
- process.exit(4);
105
+ const finish = () => {
106
+ const mode = process.env.RELAY_FAKE_PI_MODE || "ok";
107
+ if (process.env.RELAY_FAKE_PI_BREAK_CLEANUP === "true") fs.chmodSync(path.dirname(process.cwd()), 0o500);
108
+ if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
109
+ if (mode === "empty") process.exit(0);
110
+ if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
111
+ process.stderr.write("pi exploded\\n");
112
+ process.exit(4);
113
+ };
114
+ const barrierDirectory = process.env.RELAY_FAKE_PI_BARRIER_DIR;
115
+ if (barrierDirectory === undefined) { finish(); return; }
116
+ fs.mkdirSync(barrierDirectory, { recursive: true });
117
+ fs.writeFileSync(path.join(barrierDirectory, String(process.pid)), "started");
118
+ const expected = Number(process.env.RELAY_FAKE_PI_BARRIER_COUNT || "4");
119
+ const deadline = Date.now() + 1_000;
120
+ const releaseFile = process.env.RELAY_FAKE_PI_RELEASE_FILE;
121
+ const waitForGroup = () => {
122
+ if (fs.readdirSync(barrierDirectory).length < expected) {
123
+ if (Date.now() >= deadline) { process.stderr.write("reviewer barrier timed out\\n"); process.exit(5); return; }
124
+ setTimeout(waitForGroup, 10);
125
+ return;
126
+ }
127
+ if (releaseFile !== undefined && !fs.existsSync(releaseFile)) { setTimeout(waitForGroup, 10); return; }
128
+ finish();
129
+ };
130
+ waitForGroup();
72
131
  });
73
132
  `;
74
133
 
@@ -80,11 +139,12 @@ interface RelayHarness {
80
139
  piLogPath: string;
81
140
  stdinCapturePath: string;
82
141
  submitCapturePath: string;
142
+ targetCwd: string;
83
143
  environment: NodeJS.ProcessEnv;
84
144
  }
85
145
 
86
146
  function harness(t: test.TestContext, overrides: Record<string, string> = {}): RelayHarness {
87
- const directory = mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-"));
147
+ const directory = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-")));
88
148
  t.after(() => rmSync(directory, { recursive: true, force: true }));
89
149
  const gentleAi = join(directory, "gentle-ai");
90
150
  const pi = join(directory, "pi");
@@ -96,6 +156,8 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
96
156
  const piLogPath = join(directory, "pi.log");
97
157
  const stdinCapturePath = join(directory, "pi-stdin.bin");
98
158
  const submitCapturePath = join(directory, "submitted.bin");
159
+ const targetCwd = join(directory, "target-worktree");
160
+ mkdirSync(targetCwd);
99
161
  const environment: NodeJS.ProcessEnv = {
100
162
  ...process.env,
101
163
  RELAY_FAKE_LOG: logPath,
@@ -107,7 +169,7 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
107
169
  // The relay itself must add the handshake; the base environment never
108
170
  // carries it, so the fake-binary log proves the injection.
109
171
  delete environment[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
110
- return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, environment };
172
+ return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, targetCwd, environment };
111
173
  }
112
174
 
113
175
  function readLog(path: string): Array<{ argv: string[]; contract: string | null; cwd?: string; entries?: string[] }> {
@@ -115,6 +177,14 @@ function readLog(path: string): Array<{ argv: string[]; contract: string | null;
115
177
  return readFileSync(path, "utf8").split("\n").filter((line) => line.length > 0).map((line) => JSON.parse(line));
116
178
  }
117
179
 
180
+ async function waitFor(condition: () => boolean, message: string): Promise<void> {
181
+ for (let attempt = 0; attempt < 100; attempt += 1) {
182
+ if (condition()) return;
183
+ await new Promise<void>((resolve) => setTimeout(resolve, 10));
184
+ }
185
+ throw new Error(message);
186
+ }
187
+
118
188
  const BINDING_TOKENS = Object.freeze([
119
189
  "--lineage=review-1d5aadacc600e167",
120
190
  `--expected-revision=sha256:${"c".repeat(64)}`,
@@ -125,6 +195,7 @@ const BINDING_TOKENS = Object.freeze([
125
195
  `--subject-hash=sha256:${"a".repeat(64)}`,
126
196
  ]);
127
197
  const CAPTURE_TOKENS = Object.freeze([...BINDING_TOKENS, "--agent=pi", "--materialize=true"]);
198
+ const REVIEWER_GROUP_LENSES = ["review-risk", "review-resilience", "review-readability", "review-reliability"] as const;
128
199
 
129
200
  // The provider-owned completing form: exact operation and argument tokens
130
201
  // with one declared {{value}} substitution slot for the artifact path.
@@ -152,6 +223,7 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
152
223
  submission: SUBMISSION,
153
224
  gentleAiExecutable: fixture.gentleAi,
154
225
  piExecutable: fixture.pi,
226
+ targetCwd: fixture.targetCwd,
155
227
  environment: {
156
228
  ...fixture.environment,
157
229
  RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
@@ -163,6 +235,20 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
163
235
  };
164
236
  }
165
237
 
238
+ function reviewerGroupRequests(fixture: RelayHarness): ReviewHostRelayRequest[] {
239
+ return REVIEWER_GROUP_LENSES.map((lens, order) => relayRequest(fixture, {
240
+ captureArgumentTokens: CAPTURE_TOKENS.map((token) => token === "--lens=review-reliability"
241
+ ? `--lens=${lens}`
242
+ : token === "--order=0" ? `--order=${order}` : token),
243
+ environment: {
244
+ ...fixture.environment,
245
+ RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
246
+ RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
247
+ },
248
+ }));
249
+ }
250
+
251
+
166
252
  async function rejectsWithRelayError(promise: Promise<unknown>, kind: string, stage: string): Promise<ReviewHostRelayError> {
167
253
  let caught: ReviewHostRelayError | undefined;
168
254
  await assert.rejects(promise, (error: unknown) => {
@@ -229,11 +315,13 @@ test("relay happy path moves prompt and result bytes verbatim through a fresh em
229
315
  const substituted = gentleAiCalls[1]!.argv.at(-1)!;
230
316
  assert.match(substituted, /^--input=\S+$/);
231
317
  assert.equal(substituted.includes("{{value}}"), false);
318
+ assert.equal(existsSync(substituted.slice("--input=".length)), false, "the coordinator removes its temporary result file after provider submission");
232
319
  assert.equal(gentleAiCalls[1]!.argv.length, 2 + SUBMISSION.argumentTokens.length);
233
320
  assert.equal(gentleAiCalls[1]!.argv.some((token) => token.includes("--agent") || token.includes("--materialize")), false);
234
321
  // Handshake declared on both gentle-ai invocations even though the base
235
322
  // environment carried none.
236
323
  assert.deepEqual(gentleAiCalls.map((call) => call.contract), [GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT]);
324
+ assert.deepEqual(gentleAiCalls.map((call) => call.cwd), [fixture.targetCwd, fixture.targetCwd]);
237
325
 
238
326
  const piCalls = readLog(fixture.piLogPath);
239
327
  assert.equal(piCalls.length, 1);
@@ -266,6 +354,145 @@ test("the pi lockdown argv is pinned exactly with no model or provider selection
266
354
  assert.equal(piCalls[0]!.argv.some((token) => token.startsWith("--model") || token.startsWith("--provider") || token.startsWith("--profile")), false);
267
355
  });
268
356
 
357
+ test("preparation snapshots mutable submission tokens and values before materialization", async (t) => {
358
+ const fixture = harness(t);
359
+ const barrierDirectory = join(fixture.directory, "submission-snapshot-barrier");
360
+ const releaseFile = join(fixture.directory, "submission-snapshot-release");
361
+ const submissionTokens = [...SUBMISSION.argumentTokens];
362
+ const submissionValues = SUBMISSION.values.map((value) => ({ ...value }));
363
+ const submission: ReviewCaptureSubmissionV1 = { ...SUBMISSION, argumentTokens: submissionTokens, values: submissionValues };
364
+ const prepared = prepareReviewHostRelaySlot(relayRequest(fixture, {
365
+ submission,
366
+ environment: {
367
+ ...fixture.environment,
368
+ RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
369
+ RELAY_FAKE_PI_OUTPUT_B64: PI_OUTPUT_BYTES.toString("base64"),
370
+ RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
371
+ RELAY_FAKE_PI_BARRIER_COUNT: "1",
372
+ RELAY_FAKE_PI_RELEASE_FILE: releaseFile,
373
+ },
374
+ }));
375
+ await waitFor(() => readLog(fixture.piLogPath).length === 1, "reviewer did not reach the snapshot barrier");
376
+ submissionTokens[submissionTokens.length - 1] = "--input=mutated";
377
+ submissionValues[0]!.slot = "mutated";
378
+ writeFileSync(releaseFile, "release");
379
+
380
+ const result = await prepared;
381
+ assert.deepEqual(result.request.submission, SUBMISSION);
382
+ await submitReviewHostRelayPreparedResult(result);
383
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
384
+ });
385
+
386
+ test("preparation keeps reviewer bytes private through deferred submission", async (t) => {
387
+ const fixture = harness(t);
388
+ const reviewerBytes = Buffer.from(PI_OUTPUT_BYTES);
389
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture), async () => ({
390
+ stdout: reviewerBytes,
391
+ promptByteLength: PROMPT_BYTES.length,
392
+ stdoutByteLength: reviewerBytes.length,
393
+ }));
394
+ reviewerBytes.fill(0);
395
+ const mutablePrepared = prepared as unknown as { resultBytes?: Buffer };
396
+ mutablePrepared.resultBytes?.fill(0);
397
+ assert.throws(() => { mutablePrepared.resultBytes = Buffer.from("fabricated"); }, TypeError);
398
+ await submitReviewHostRelayPreparedResult(prepared);
399
+ assert.deepEqual(readFileSync(fixture.submitCapturePath), PI_OUTPUT_BYTES);
400
+
401
+ const fabricated = { ...prepared, resultBytes: Buffer.from("fabricated") } as unknown as ReviewHostRelayPreparedResult;
402
+ await assert.rejects(submitReviewHostRelayPreparedResult(fabricated), /recognized prepared result/);
403
+ assert.equal(readLog(fixture.logPath).length, 2, "unrecognized results must not launch provider submission");
404
+ });
405
+
406
+ test("the supplied AbortSignal stays live through deferred submission", async (t) => {
407
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_DELAY_MS: "1000" });
408
+ const controller = new AbortController();
409
+ const prepared = await prepareReviewHostRelaySlot(relayRequest(fixture, { signal: controller.signal }));
410
+ const submission = submitReviewHostRelayPreparedResult(prepared);
411
+ await waitFor(() => readLog(fixture.logPath).length === 2, "submission did not start");
412
+ controller.abort();
413
+ await rejectsWithRelayError(submission, REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
414
+ });
415
+
416
+ test("four reviewers cross a shared barrier before any result submission can begin", async (t) => {
417
+ const fixture = harness(t);
418
+ const barrierDirectory = join(fixture.directory, "reviewer-barrier");
419
+ const requests = reviewerGroupRequests(fixture).map((request) => ({
420
+ ...request,
421
+ environment: {
422
+ ...request.environment,
423
+ RELAY_FAKE_PI_BARRIER_DIR: barrierDirectory,
424
+ RELAY_FAKE_PI_BARRIER_COUNT: String(REVIEWER_GROUP_LENSES.length),
425
+ },
426
+ }));
427
+
428
+ const prepared = await runReviewHostRelayReviewerGroup(requests);
429
+
430
+ assert.equal(prepared.length, REVIEWER_GROUP_LENSES.length);
431
+ assert.ok(prepared.every((result) => result.resultByteLength === PI_OUTPUT_BYTES.length));
432
+ assert.equal(readLog(fixture.piLogPath).length, REVIEWER_GROUP_LENSES.length);
433
+ assert.equal(readLog(fixture.logPath).length, REVIEWER_GROUP_LENSES.length, "preparation materializes only; it does not submit");
434
+ });
435
+
436
+ test("four reviewer results retain provider order when their preparation resolves in reverse", async (t) => {
437
+ const requests = reviewerGroupRequests(harness(t));
438
+ const started: number[] = [];
439
+ const complete: Array<() => void> = [];
440
+ const group = runReviewHostRelayReviewerGroup(requests, (request) => new Promise<ReviewHostRelayPreparedResult>((resolve) => {
441
+ const index = requests.indexOf(request);
442
+ assert.notEqual(index, -1);
443
+ started.push(index);
444
+ complete.push(() => resolve({
445
+ request,
446
+ promptByteLength: index + 1,
447
+ resultByteLength: index + 1,
448
+ }));
449
+ }));
450
+
451
+ assert.deepEqual(started, [0, 1, 2, 3], "every reviewer starts before the group waits");
452
+ for (const finish of [...complete].reverse()) finish();
453
+ const prepared = await group;
454
+
455
+ assert.deepEqual(prepared.map((result) => result.resultByteLength), [1, 2, 3, 4]);
456
+ });
457
+
458
+ test("partial reviewer failures wait for a later pending transport and report the first provider-ordered error", async (t) => {
459
+ const requests = reviewerGroupRequests(harness(t));
460
+ const started: number[] = [];
461
+ let groupSettled = false;
462
+ let releaseLaterReviewer: (() => void) | undefined, rejectFirstProvider: ((reason?: unknown) => void) | undefined;
463
+ const firstProviderError = new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "first provider error");
464
+
465
+ const group = runReviewHostRelayReviewerGroup(requests, (request) => {
466
+ const index = requests.indexOf(request);
467
+ assert.notEqual(index, -1);
468
+ started.push(index);
469
+ if (index === 1) return new Promise<ReviewHostRelayPreparedResult>((_resolve, reject) => { rejectFirstProvider = reject; });
470
+ if (index === 2) return Promise.reject(new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "later provider error"));
471
+ if (index === 3) {
472
+ return new Promise<ReviewHostRelayPreparedResult>((resolve) => {
473
+ releaseLaterReviewer = () => resolve({
474
+ request,
475
+ promptByteLength: index + 1,
476
+ resultByteLength: index + 1,
477
+ });
478
+ });
479
+ }
480
+ return Promise.resolve({
481
+ request,
482
+ promptByteLength: index + 1,
483
+ resultByteLength: index + 1,
484
+ });
485
+ });
486
+ void group.then(() => { groupSettled = true; }, () => { groupSettled = true; });
487
+
488
+ await new Promise<void>((resolve) => setImmediate(resolve));
489
+ assert.deepEqual(started, [0, 1, 2, 3], "one failed reviewer does not prevent later reviewers from starting");
490
+ rejectFirstProvider!(firstProviderError); await new Promise<void>((resolve) => setImmediate(resolve));
491
+ assert.equal(groupSettled, false, "the group must remain pending until the later reviewer settles");
492
+ releaseLaterReviewer!();
493
+ await assert.rejects(group, (error: unknown) => error === firstProviderError);
494
+ });
495
+
269
496
  // ---------------------------------------------------------------------------
270
497
  // Fail-closed legs — a typed transport error and NO submission.
271
498
  // ---------------------------------------------------------------------------
@@ -305,12 +532,108 @@ test("empty pi stdout fails closed with a typed error and no submission", async
305
532
 
306
533
  test("pi timeout fails closed with a typed error and no submission", async (t) => {
307
534
  const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
308
- const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
535
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
309
536
  assert.equal(error.timedOut, true);
310
537
  assert.equal(readLog(fixture.logPath).length, 1);
311
538
  assert.equal(existsSync(fixture.submitCapturePath), false);
312
539
  });
313
540
 
541
+ // ---------------------------------------------------------------------------
542
+ // gentle-pi#367 — the reviewer bound is reachable from production, and a
543
+ // reviewer killed by it says so with both measurements.
544
+ // ---------------------------------------------------------------------------
545
+
546
+ test("the reviewer bound scales with materialized prompt bytes instead of one fixed number", () => {
547
+ const empty: NodeJS.ProcessEnv = {};
548
+ // A tiny prompt still gets the model-latency floor.
549
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(0, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS);
550
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(1, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + 1);
551
+ // One mebibyte of prompt buys exactly one linear allowance.
552
+ assert.equal(
553
+ resolveReviewHostRelayPiTimeoutMs(1024 * 1024, empty),
554
+ REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS,
555
+ );
556
+ // The reported field candidate: ~1.58 MB of prompt, whose reviewer needed
557
+ // 478s and was killed by the old fixed 600s bound. The derived bound must
558
+ // clear that measurement with real margin.
559
+ const reported = resolveReviewHostRelayPiTimeoutMs(1_580_000, empty);
560
+ assert.ok(reported > 600_000, `derived bound ${reported} must exceed the old fixed 600000ms bound`);
561
+ assert.ok(reported > 478_000 * 3, `derived bound ${reported} must keep real margin over the measured 478000ms reviewer run`);
562
+ // Never unbounded, however large the prompt gets.
563
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(Number.MAX_SAFE_INTEGER, empty), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
564
+ });
565
+
566
+ test("the reviewer bound honours the environment override and ignores malformed values", () => {
567
+ const bytes = 4 * 1024 * 1024;
568
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "1234" }), 1234);
569
+ // The override is clamped by the same hard ceiling as the derived bound.
570
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "999999999" }), REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
571
+ const derived = resolveReviewHostRelayPiTimeoutMs(bytes, {});
572
+ for (const malformed of ["", "0", "-1", "12.5", "abc", " 600000", "1e6"]) {
573
+ assert.equal(resolveReviewHostRelayPiTimeoutMs(bytes, { [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: malformed }), derived, `malformed ${JSON.stringify(malformed)} must fall back to the derived bound`);
574
+ }
575
+ });
576
+
577
+ test("the production relay path resolves the reviewer bound from the environment, with no injected timeout", async (t) => {
578
+ // The regression: piTimeoutMs was reachable only through the test seam, so
579
+ // production always ran against the fixed 600s bound. This request injects
580
+ // no timeout at all — the override must reach the real spawn.
581
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", [REVIEW_HOST_RELAY_PI_TIMEOUT_ENV]: "300" });
582
+ const error = await rejectsWithRelayError(
583
+ runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: undefined })),
584
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
585
+ "pi",
586
+ );
587
+ assert.equal(error.timeoutMs, 300);
588
+ assert.equal(error.timedOut, true);
589
+ assert.equal(readLog(fixture.logPath).length, 1, "no submission after a reviewer timeout");
590
+ assert.equal(existsSync(fixture.submitCapturePath), false);
591
+ });
592
+
593
+ test("a relay Pi timeout keeps its typed mapping and timing evidence when opaque scratch cleanup also fails", async (t) => {
594
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang", RELAY_FAKE_PI_BREAK_CLEANUP: "true" });
595
+ const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-pi-primary-failure-")));
596
+ const originalTmpdir = process.env.TMPDIR;
597
+ process.env.TMPDIR = scratchParent;
598
+ try {
599
+ const error = await rejectsWithRelayError(
600
+ runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })),
601
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
602
+ "pi",
603
+ );
604
+ assert.equal(error.timedOut, true);
605
+ assert.equal(error.timeoutMs, 300);
606
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250);
607
+ } finally {
608
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
609
+ else process.env.TMPDIR = originalTmpdir;
610
+ chmodSync(scratchParent, 0o700);
611
+ rmSync(scratchParent, { recursive: true, force: true });
612
+ }
613
+ });
614
+
615
+ test("a killed reviewer reports elapsed, limit, and what to change instead of an opaque transport failure", async (t) => {
616
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "hang" });
617
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
618
+ assert.equal(error.timeoutMs, 300);
619
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 250, `elapsed ${error.elapsedMs} must record the real wall time`);
620
+ assert.ok(error.elapsedMs! < 10_000, "the reviewer was killed at the bound, not left to finish");
621
+ assert.match(error.message, /exceeded the relay bound/);
622
+ assert.match(error.message, new RegExp(String(error.elapsedMs)));
623
+ assert.match(error.message, /limit for a \d+-byte materialized prompt/);
624
+ assert.match(error.message, new RegExp(REVIEW_HOST_RELAY_PI_TIMEOUT_ENV));
625
+ assert.equal(error.mutationOutcome, "none");
626
+ });
627
+
628
+ test("a crashed reviewer stays distinguishable from a killed one and still carries its measurements", async (t) => {
629
+ const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
630
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 30_000 })), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
631
+ assert.equal(error.timedOut, false);
632
+ assert.equal(error.exitCode, 4);
633
+ assert.equal(error.timeoutMs, 30_000);
634
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
635
+ });
636
+
314
637
  test("submission refusal is a typed error whose outcome is unknown pending STATUS", async (t) => {
315
638
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
316
639
  const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
@@ -319,6 +642,84 @@ test("submission refusal is a typed error whose outcome is unknown pending STATU
319
642
  assert.equal(readLog(fixture.logPath).length, 2);
320
643
  });
321
644
 
645
+ // gentle-pi#522 / #524: Go refuses a reviewer submission at admission with a
646
+ // typed [invalid_request] refusal and states that the lens slot was not
647
+ // consumed. That is a proven non-mutation, not an unknown outcome, and the
648
+ // refusal text is the only thing that tells the host what to change.
649
+ function admittingRequest(fixture: RelayHarness, reviewerOutput: Buffer) {
650
+ return relayRequest(fixture, {
651
+ environment: {
652
+ ...fixture.environment,
653
+ RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
654
+ RELAY_FAKE_PI_OUTPUT_B64: reviewerOutput.toString("base64"),
655
+ },
656
+ });
657
+ }
658
+
659
+ test("a reviewer printing garbage is refused at admission as a proven non-mutation carrying Go's refusal", async (t) => {
660
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
661
+ const error = await rejectsWithRelayError(
662
+ runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from("not json at all", "utf8"))),
663
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
664
+ "submit",
665
+ );
666
+ assert.equal(error.mutationOutcome, "none");
667
+ assert.equal(error.exitCode, 1);
668
+ assert.equal(error.timedOut, false);
669
+ assert.match(error.stderr, /reviewer payload contains no complete JSON object/);
670
+ assert.match(error.stderr, /\[invalid_request\]/);
671
+ assert.match(error.message, /reviewer payload contains no complete JSON object/);
672
+ assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
673
+ });
674
+
675
+ test("a reviewer echoing a different subject is refused at admission as a proven non-mutation carrying Go's continuation", async (t) => {
676
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
677
+ const wrongSubject = Buffer.from(JSON.stringify({ subject_hash: `sha256:${"0".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8");
678
+ const error = await rejectsWithRelayError(
679
+ runReviewHostRelaySlot(admittingRequest(fixture, wrongSubject)),
680
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
681
+ "submit",
682
+ );
683
+ assert.equal(error.mutationOutcome, "none");
684
+ assert.equal(error.exitCode, 1);
685
+ assert.match(error.stderr, /binding_mismatch/);
686
+ assert.match(error.stderr, /did not consume the lens slot/);
687
+ assert.match(error.stderr, /\[invalid_request\]/);
688
+ assert.match(error.message, /did not consume the lens slot/);
689
+ assert.equal(readLog(fixture.logPath).length, 2, "the refusal must not be retried by the relay");
690
+ });
691
+
692
+ test("a submission the fake admits with the expected subject still completes", async (t) => {
693
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "admit", RELAY_FAKE_EXPECTED_SUBJECT: `sha256:${"a".repeat(64)}` });
694
+ const result = await runReviewHostRelaySlot(admittingRequest(fixture, Buffer.from(JSON.stringify({ subject_hash: `sha256:${"a".repeat(64)}`, inspection: { status: "completed", paths: [] }, findings: [], evidence: ["x"] }), "utf8")));
695
+ assert.equal(JSON.parse(result.submission).admission_decision, "completed");
696
+ });
697
+
698
+ test("submission refusal preserves its primary evidence when result staging cleanup also fails", async (t) => {
699
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse-cleanup-fail" });
700
+ const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-primary-failure-")));
701
+ const originalTmpdir = process.env.TMPDIR;
702
+ process.env.TMPDIR = scratchParent;
703
+ try {
704
+ const error = await rejectsWithRelayError(
705
+ runReviewHostRelaySlot(relayRequest(fixture)),
706
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
707
+ "submit",
708
+ );
709
+ assert.equal(error.exitCode, 1);
710
+ assert.equal(error.timedOut, false);
711
+ assert.equal(error.timeoutMs, 30_000);
712
+ assert.ok(error.elapsedMs !== null && error.elapsedMs >= 0);
713
+ assert.match(error.stderr, /capture binding does not match/);
714
+ assert.doesNotMatch(error.message, /staging cleanup/i);
715
+ } finally {
716
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
717
+ else process.env.TMPDIR = originalTmpdir;
718
+ chmodSync(scratchParent, 0o700);
719
+ rmSync(scratchParent, { recursive: true, force: true });
720
+ }
721
+ });
722
+
322
723
  test("relay scratch and staging directories are removed after failures too", async (t) => {
323
724
  const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
324
725
  await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
@@ -327,6 +728,27 @@ test("relay scratch and staging directories are removed after failures too", asy
327
728
  assert.equal(existsSync(piCalls[0]!.cwd!), false);
328
729
  });
329
730
 
731
+ test("a result staging cleanup failure remains a typed submit failure", async (t) => {
732
+ const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "cleanup-fail" });
733
+ const scratchParent = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-cleanup-")));
734
+ const originalTmpdir = process.env.TMPDIR;
735
+ process.env.TMPDIR = scratchParent;
736
+ try {
737
+ const error = await rejectsWithRelayError(
738
+ runReviewHostRelaySlot(relayRequest(fixture)),
739
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
740
+ "submit",
741
+ );
742
+ assert.equal(error.mutationOutcome, "unknown");
743
+ assert.match(error.message, /staging cleanup/i);
744
+ } finally {
745
+ if (originalTmpdir === undefined) delete process.env.TMPDIR;
746
+ else process.env.TMPDIR = originalTmpdir;
747
+ chmodSync(scratchParent, 0o700);
748
+ rmSync(scratchParent, { recursive: true, force: true });
749
+ }
750
+ });
751
+
330
752
  // ---------------------------------------------------------------------------
331
753
  // Capability detection — typed refusal classes, no version sniffing.
332
754
  // ---------------------------------------------------------------------------