gentle-pi 2.2.0 → 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 (130) hide show
  1. package/README.md +51 -80
  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-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -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,10 +7,15 @@ 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,
16
21
  runReviewHostRelaySlot,
@@ -20,15 +25,16 @@ import { decodeReviewNextTransitionV3, type ReviewCaptureSubmissionV1, type Revi
20
25
 
21
26
  // ---------------------------------------------------------------------------
22
27
  // Fake binaries. Following the repo's fake-executable idiom (shell/git
23
- // wrappers in review-gate/git-commit-transaction tests), these are
28
+ // wrappers in related review tests), these are
24
29
  // shebang scripts written into a scratch directory; node scripts are used so
25
30
  // binary-unsafe bytes survive verbatim.
26
31
  // ---------------------------------------------------------------------------
27
32
 
28
33
  const FAKE_GENTLE_AI = `#!/usr/bin/env node
29
34
  const fs = require("node:fs");
35
+ const path = require("node:path");
30
36
  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");
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");
32
38
  if (argv.some((token) => token === "--materialize" || token.startsWith("--materialize="))) {
33
39
  const mode = process.env.RELAY_FAKE_MATERIALIZE_MODE || "ok";
34
40
  if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PROMPT_B64 || "", "base64")); process.exit(0); }
@@ -40,10 +46,35 @@ if (argv.some((token) => token === "--materialize" || token.startsWith("--materi
40
46
  const inputToken = argv.find((token) => token === "--input" || token.startsWith("--input="));
41
47
  if (inputToken !== undefined) {
42
48
  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];
49
+ const inputPath = inputToken.startsWith("--input=") ? inputToken.slice("--input=".length) : argv[argv.indexOf("--input") + 1];
50
+ if (mode === "ok" || mode === "cleanup-fail") {
45
51
  const bytes = fs.readFileSync(inputPath);
46
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);
47
78
  process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
48
79
  process.exit(0);
49
80
  }
@@ -56,6 +87,7 @@ process.exit(9);
56
87
 
57
88
  const FAKE_PI = `#!/usr/bin/env node
58
89
  const fs = require("node:fs");
90
+ const path = require("node:path");
59
91
  const argv = process.argv.slice(2);
60
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");
61
93
  const chunks = [];
@@ -64,6 +96,7 @@ process.stdin.on("end", () => {
64
96
  const stdin = Buffer.concat(chunks);
65
97
  if (process.env.RELAY_FAKE_PI_STDIN_CAPTURE) fs.writeFileSync(process.env.RELAY_FAKE_PI_STDIN_CAPTURE, stdin);
66
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);
67
100
  if (mode === "ok") { process.stdout.write(Buffer.from(process.env.RELAY_FAKE_PI_OUTPUT_B64 || "", "base64")); process.exit(0); }
68
101
  if (mode === "empty") process.exit(0);
69
102
  if (mode === "hang") { setTimeout(() => process.exit(0), 10_000); return; }
@@ -80,11 +113,12 @@ interface RelayHarness {
80
113
  piLogPath: string;
81
114
  stdinCapturePath: string;
82
115
  submitCapturePath: string;
116
+ targetCwd: string;
83
117
  environment: NodeJS.ProcessEnv;
84
118
  }
85
119
 
86
120
  function harness(t: test.TestContext, overrides: Record<string, string> = {}): RelayHarness {
87
- const directory = mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-"));
121
+ const directory = realpathSync(mkdtempSync(join(tmpdir(), "gentle-pi-relay-harness-")));
88
122
  t.after(() => rmSync(directory, { recursive: true, force: true }));
89
123
  const gentleAi = join(directory, "gentle-ai");
90
124
  const pi = join(directory, "pi");
@@ -96,6 +130,8 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
96
130
  const piLogPath = join(directory, "pi.log");
97
131
  const stdinCapturePath = join(directory, "pi-stdin.bin");
98
132
  const submitCapturePath = join(directory, "submitted.bin");
133
+ const targetCwd = join(directory, "target-worktree");
134
+ mkdirSync(targetCwd);
99
135
  const environment: NodeJS.ProcessEnv = {
100
136
  ...process.env,
101
137
  RELAY_FAKE_LOG: logPath,
@@ -107,7 +143,7 @@ function harness(t: test.TestContext, overrides: Record<string, string> = {}): R
107
143
  // The relay itself must add the handshake; the base environment never
108
144
  // carries it, so the fake-binary log proves the injection.
109
145
  delete environment[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV];
110
- return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, environment };
146
+ return { directory, gentleAi, pi, logPath, piLogPath, stdinCapturePath, submitCapturePath, targetCwd, environment };
111
147
  }
112
148
 
113
149
  function readLog(path: string): Array<{ argv: string[]; contract: string | null; cwd?: string; entries?: string[] }> {
@@ -152,6 +188,7 @@ function relayRequest(fixture: RelayHarness, overrides: Record<string, unknown>
152
188
  submission: SUBMISSION,
153
189
  gentleAiExecutable: fixture.gentleAi,
154
190
  piExecutable: fixture.pi,
191
+ targetCwd: fixture.targetCwd,
155
192
  environment: {
156
193
  ...fixture.environment,
157
194
  RELAY_FAKE_PROMPT_B64: PROMPT_BYTES.toString("base64"),
@@ -229,11 +266,13 @@ test("relay happy path moves prompt and result bytes verbatim through a fresh em
229
266
  const substituted = gentleAiCalls[1]!.argv.at(-1)!;
230
267
  assert.match(substituted, /^--input=\S+$/);
231
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");
232
270
  assert.equal(gentleAiCalls[1]!.argv.length, 2 + SUBMISSION.argumentTokens.length);
233
271
  assert.equal(gentleAiCalls[1]!.argv.some((token) => token.includes("--agent") || token.includes("--materialize")), false);
234
272
  // Handshake declared on both gentle-ai invocations even though the base
235
273
  // environment carried none.
236
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]);
237
276
 
238
277
  const piCalls = readLog(fixture.piLogPath);
239
278
  assert.equal(piCalls.length, 1);
@@ -305,12 +344,108 @@ test("empty pi stdout fails closed with a typed error and no submission", async
305
344
 
306
345
  test("pi timeout fails closed with a typed error and no submission", async (t) => {
307
346
  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");
347
+ const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture, { piTimeoutMs: 300 })), REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT, "pi");
309
348
  assert.equal(error.timedOut, true);
310
349
  assert.equal(readLog(fixture.logPath).length, 1);
311
350
  assert.equal(existsSync(fixture.submitCapturePath), false);
312
351
  });
313
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
+
314
449
  test("submission refusal is a typed error whose outcome is unknown pending STATUS", async (t) => {
315
450
  const fixture = harness(t, { RELAY_FAKE_SUBMIT_MODE: "refuse" });
316
451
  const error = await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit");
@@ -319,6 +454,84 @@ test("submission refusal is a typed error whose outcome is unknown pending STATU
319
454
  assert.equal(readLog(fixture.logPath).length, 2);
320
455
  });
321
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
+
322
535
  test("relay scratch and staging directories are removed after failures too", async (t) => {
323
536
  const fixture = harness(t, { RELAY_FAKE_PI_MODE: "fail" });
324
537
  await rejectsWithRelayError(runReviewHostRelaySlot(relayRequest(fixture)), REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi");
@@ -327,6 +540,27 @@ test("relay scratch and staging directories are removed after failures too", asy
327
540
  assert.equal(existsSync(piCalls[0]!.cwd!), false);
328
541
  });
329
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
+
330
564
  // ---------------------------------------------------------------------------
331
565
  // Capability detection — typed refusal classes, no version sniffing.
332
566
  // ---------------------------------------------------------------------------