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,16 +1,16 @@
1
1
  // The thin Pi host relay (gentle-pi#311 P4; provider contract gentle-ai#3249).
2
2
  //
3
3
  // gentle-ai owns prompt materialization, role and schema selection, byte
4
- // budgets, parsing, admission, immutable capture, retry and correction
5
- // accounting, receipts, and delivery gates. This host boundary is
4
+ // budgets, parsing, admission, immutable capture, retry, correction
5
+ // accounting, and receipt state. This host boundary is
6
6
  // intentionally narrow:
7
7
  //
8
8
  // 1. Run the exact provider-issued capture binding with `--agent pi
9
9
  // --materialize` and take stdout as opaque prompt BYTES, verbatim.
10
- // 2. Launch a brand-new locked-down print-mode `pi` subprocess in a fresh
11
- // empty scratch directory, pipe the prompt through stdin, and take
12
- // stdout as raw final bytes. Model/provider/profile selection stays
13
- // user-owned: no --model, no --provider, environment untouched.
10
+ // 2. Pass those prompt bytes to the pure opaque Pi adapter, which owns its
11
+ // locked-down print-mode subprocess and fresh empty scratch directory;
12
+ // take its stdout as raw final bytes. Model/provider/profile selection
13
+ // stays user-owned: no --model, no --provider, environment untouched.
14
14
  // 3. Submit those bytes untouched through the provider-owned `submission`
15
15
  // form carried by the collect input: execute its exact operation and
16
16
  // argument tokens with only the tempfile path substituted into the
@@ -30,25 +30,18 @@ import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
30
30
  import { tmpdir } from "node:os";
31
31
  import { isAbsolute, join } from "node:path";
32
32
  import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
33
+ import {
34
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
35
+ OpaquePiReviewerTransportError,
36
+ runOpaquePiReviewer,
37
+ type OpaquePiReviewerResult,
38
+ } from "./opaque-pi-reviewer-adapter.ts";
33
39
  import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
34
40
  import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
35
41
 
36
- // The complete pinned lockdown argv for the reviewer `pi` subprocess: print
37
- // mode, text output, and every discovery surface disabled. Nothing may be
38
- // added or removed here without a new relay contract — in particular no
39
- // --model/--provider/--profile, which remain user-owned.
40
- export const REVIEW_HOST_RELAY_PI_ARGV = Object.freeze([
41
- "--print",
42
- "--mode", "text",
43
- "--no-session",
44
- "--no-tools",
45
- "--no-extensions",
46
- "--no-skills",
47
- "--no-prompt-templates",
48
- "--no-themes",
49
- "--no-context-files",
50
- "--no-approve",
51
- ] as const);
42
+ // Compatibility export for existing relay consumers. The pure adapter owns the
43
+ // fixed Pi process boundary and its locked-down argv.
44
+ export { OPAQUE_PI_REVIEWER_ARGV as REVIEW_HOST_RELAY_PI_ARGV } from "./opaque-pi-reviewer-adapter.ts";
52
45
 
53
46
  export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
54
47
  "provider relay requires a gentle-ai build with the pi host relay surface";
@@ -61,6 +54,11 @@ export const REVIEW_HOST_RELAY_FAILURE = {
61
54
  EMPTY_PROMPT: "empty-prompt",
62
55
  PI_LAUNCH_FAILED: "pi-launch-failed",
63
56
  PI_FAILED: "pi-failed",
57
+ // gentle-pi#367: a reviewer killed by the relay bound is not a crash. It
58
+ // is the one failure class that a byte-identical relaunch cannot survive,
59
+ // so it carries its own kind, its own elapsed/limit evidence, and its own
60
+ // continuation instead of hiding inside `pi-failed`.
61
+ PI_TIMED_OUT: "pi-timed-out",
64
62
  PI_EMPTY_OUTPUT: "pi-empty-output",
65
63
  SUBMISSION_REFUSED: "submission-refused",
66
64
  } as const;
@@ -79,11 +77,20 @@ export class ReviewHostRelayError extends Error {
79
77
  readonly exitCode: number | null;
80
78
  readonly stderr: string;
81
79
  readonly timedOut: boolean;
80
+ // Wall time the killed or failed child actually consumed, and the bound it
81
+ // was measured against. Both are null only when no child process ran.
82
+ // Without them a transport failure cannot be told apart from a crash, which
83
+ // is what forced the gentle-pi#367 reporter to measure the relay by hand.
84
+ readonly elapsedMs: number | null;
85
+ readonly timeoutMs: number | null;
82
86
  // "none" until the submission invocation launches; a launched submission
83
87
  // whose outcome could not be read is "unknown" and the caller reconciles
84
- // through negotiated STATUS, never through a blind retry.
88
+ // through negotiated STATUS, never through a blind retry. A launched
89
+ // submission that gentle-ai refused with its typed admission refusal is
90
+ // "none" again: the provider states that the lens slot was not consumed
91
+ // (gentle-pi#522 / #524).
85
92
  readonly mutationOutcome: "none" | "unknown";
86
- constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean }) {
93
+ constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean; elapsedMs?: number; timeoutMs?: number; mutationOutcome?: "none" | "unknown" }) {
87
94
  super(message);
88
95
  this.name = "ReviewHostRelayError";
89
96
  this.kind = kind;
@@ -91,10 +98,23 @@ export class ReviewHostRelayError extends Error {
91
98
  this.exitCode = details?.exitCode ?? null;
92
99
  this.stderr = details?.stderr ?? "";
93
100
  this.timedOut = details?.timedOut ?? false;
94
- this.mutationOutcome = stage === "submit" ? "unknown" : "none";
101
+ this.elapsedMs = details?.elapsedMs ?? null;
102
+ this.timeoutMs = details?.timeoutMs ?? null;
103
+ this.mutationOutcome = details?.mutationOutcome ?? (stage === "submit" ? "unknown" : "none");
95
104
  }
96
105
  }
97
106
 
107
+ // gentle-pi#522 / #524: gentle-ai refuses a reviewer submission before any
108
+ // admission with exit 1 and its typed operator line, `<reason> [invalid_request]`.
109
+ // That code is the provider's preflight class: the request was refused as
110
+ // sent and the lens slot was not consumed. The relay recognises only that
111
+ // typed shape; it never parses the reason, and it never retries.
112
+ const ADMISSION_REFUSAL = /\[invalid_request\]/;
113
+
114
+ export function isReviewHostRelayAdmissionRefusal(capture: { exitCode: number | null; timedOut: boolean }, stderr: string): boolean {
115
+ return capture.exitCode === 1 && !capture.timedOut && ADMISSION_REFUSAL.test(stderr);
116
+ }
117
+
98
118
  // Refusal classification for the materialize invocation. The installed
99
119
  // gentle-ai is the only authority on whether the materialize form exists; Pi
100
120
  // never version-sniffs. Two typed refusal classes are distinguished:
@@ -237,6 +257,8 @@ export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmis
237
257
 
238
258
  export interface ReviewHostRelayRequest {
239
259
  readonly captureArgumentTokens: readonly string[];
260
+ /** Canonical target worktree for coordinator-only native materialize/submit calls. */
261
+ readonly targetCwd?: string;
240
262
  /** The provider-owned completing form; absent means contract mismatch. */
241
263
  readonly submission?: ReviewCaptureSubmissionV1;
242
264
  /** Absolute path; defaults to the verified package-local binary. */
@@ -245,6 +267,12 @@ export interface ReviewHostRelayRequest {
245
267
  readonly piExecutable?: string;
246
268
  readonly environment?: NodeJS.ProcessEnv;
247
269
  readonly gentleAiTimeoutMs?: number;
270
+ /**
271
+ * Overrides the reviewer bound entirely. Production leaves it unset and the
272
+ * relay derives the bound from the materialized prompt bytes and
273
+ * {@link REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}; this seam exists so tests can
274
+ * exercise the timeout leg without a wall-clock wait.
275
+ */
248
276
  readonly piTimeoutMs?: number;
249
277
  readonly signal?: AbortSignal;
250
278
  }
@@ -259,21 +287,73 @@ export interface ReviewHostRelayResult {
259
287
  export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
260
288
 
261
289
  const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
262
- const DEFAULT_PI_TIMEOUT_MS = 600_000;
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // The reviewer subprocess bound (gentle-pi#367).
293
+ //
294
+ // The previous bound was a single hardcoded 600_000 ms reachable only through
295
+ // the test-injectable runner. A field-measured lens legitimately needed 478s
296
+ // against a ~1.58 MB materialized prompt: it survived by hand and was killed
297
+ // under the relay, and the sanctioned continuation then re-spent every lens to
298
+ // reach the same wall. One fixed number cannot serve a prompt class that
299
+ // varies by orders of magnitude, so the bound is derived instead:
300
+ //
301
+ // floor + ceil(promptBytes / MiB * perMebibyte), clamped to the ceiling
302
+ //
303
+ // The floor covers model latency that does not depend on prompt size; the
304
+ // linear term covers the part that does. At the measured 1.58 MB the derived
305
+ // bound is ~37 minutes, roughly a 4.7x margin over the 478s the reviewer
306
+ // actually needed — deliberately generous, because the reviewer model and
307
+ // provider are user-owned and the relay cannot know their throughput.
308
+ //
309
+ // GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS replaces the derived bound entirely for
310
+ // callers who know their own configuration. It follows the repository's
311
+ // established numeric-override shape (GENTLE_PI_CANDIDATE_GIT_TIMEOUT_MS,
312
+ // GENTLE_PI_REVIEW_MAX_BUFFER_BYTES): a positive decimal, silently ignored
313
+ // when malformed, and clamped to the same hard ceiling so no configuration can
314
+ // turn a foreground FINALIZE into an unbounded child process.
315
+ // ---------------------------------------------------------------------------
316
+
317
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_ENV = "GENTLE_PI_REVIEW_RELAY_PI_TIMEOUT_MS";
318
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS = 900_000;
319
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS = 900_000;
320
+ export const REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS = 7_200_000;
321
+ const BYTES_PER_MEBIBYTE = 1024 * 1024;
322
+
323
+ export function resolveReviewHostRelayPiTimeoutMs(promptByteLength: number, environment: NodeJS.ProcessEnv = process.env): number {
324
+ const configured = environment[REVIEW_HOST_RELAY_PI_TIMEOUT_ENV];
325
+ if (configured !== undefined && /^[1-9]\d*$/.test(configured)) {
326
+ const parsed = Number(configured);
327
+ if (Number.isSafeInteger(parsed)) return Math.min(parsed, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
328
+ }
329
+ const bytes = Number.isSafeInteger(promptByteLength) && promptByteLength > 0 ? promptByteLength : 0;
330
+ const scaled = REVIEW_HOST_RELAY_PI_TIMEOUT_FLOOR_MS + Math.ceil((bytes / BYTES_PER_MEBIBYTE) * REVIEW_HOST_RELAY_PI_TIMEOUT_PER_MEBIBYTE_MS);
331
+ return Math.min(scaled, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS);
332
+ }
333
+
334
+ // The reviewer ran out of time; it did not crash. The message states both
335
+ // measurements and names the two things that can change the outcome, because
336
+ // the one thing that cannot is relaunching the identical slot.
337
+ export function reviewHostRelayPiTimeoutMessage(elapsedMs: number, timeoutMs: number, promptByteLength: number): string {
338
+ return `pi reviewer subprocess exceeded the relay bound: killed after ${elapsedMs}ms against a ${timeoutMs}ms limit for a ${promptByteLength}-byte materialized prompt. `
339
+ + `Relaunching the same slot unchanged reaches the same wall. Raise ${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV} above the reviewer's real wall time (ceiling ${REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS}ms) or reduce the candidate scope so the materialized prompt is smaller.`;
340
+ }
263
341
 
264
342
  interface ProcessCapture {
265
343
  stdout: Buffer;
266
344
  stderr: Buffer;
267
345
  exitCode: number | null;
268
346
  timedOut: boolean;
347
+ elapsedMs: number;
269
348
  }
270
349
 
271
- function collectProcess(
350
+ function collectGentleAiProcess(
272
351
  file: string,
273
352
  arguments_: readonly string[],
274
353
  options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal },
275
354
  ): Promise<ProcessCapture> {
276
355
  return new Promise((resolve, reject) => {
356
+ const startedAt = Date.now();
277
357
  const child = spawn(file, [...arguments_], {
278
358
  cwd: options.cwd,
279
359
  env: options.env,
@@ -305,7 +385,7 @@ function collectProcess(
305
385
  if (settled) return;
306
386
  settled = true;
307
387
  if (timer !== undefined) clearTimeout(timer);
308
- resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut });
388
+ resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut, elapsedMs: Date.now() - startedAt });
309
389
  });
310
390
  if (options.stdin === undefined) {
311
391
  child.stdin.end();
@@ -316,6 +396,45 @@ function collectProcess(
316
396
  });
317
397
  }
318
398
 
399
+ function relayPiTransportError(error: unknown, promptByteLength: number, piTimeoutMs: number): ReviewHostRelayError {
400
+ if (!(error instanceof OpaquePiReviewerTransportError)) {
401
+ return new ReviewHostRelayError(
402
+ REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED,
403
+ "pi",
404
+ `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`,
405
+ );
406
+ }
407
+ const details = {
408
+ exitCode: error.exitCode,
409
+ stderr: error.stderr.toString("utf8"),
410
+ timedOut: error.timedOut,
411
+ ...(error.elapsedMs === null ? {} : { elapsedMs: error.elapsedMs }),
412
+ ...(error.timeoutMs === null ? {} : { timeoutMs: error.timeoutMs }),
413
+ };
414
+ if (
415
+ error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT
416
+ && error.elapsedMs !== null
417
+ && error.timeoutMs !== null
418
+ ) {
419
+ return new ReviewHostRelayError(
420
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
421
+ "pi",
422
+ reviewHostRelayPiTimeoutMessage(error.elapsedMs, error.timeoutMs, promptByteLength),
423
+ { ...details, timedOut: true, elapsedMs: error.elapsedMs, timeoutMs: error.timeoutMs },
424
+ );
425
+ }
426
+ if (error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT) {
427
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", details);
428
+ }
429
+ if (
430
+ error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED
431
+ || error.kind === OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED
432
+ ) {
433
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error.message}`, details);
434
+ }
435
+ return new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", details);
436
+ }
437
+
319
438
  function assertTokens(name: string, tokens: readonly string[]): void {
320
439
  if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`);
321
440
  if (tokens.some((token) => typeof token !== "string" || token.length === 0)) {
@@ -325,7 +444,7 @@ function assertTokens(name: string, tokens: readonly string[]): void {
325
444
 
326
445
  /**
327
446
  * Runs one complete host-relay capture for one provider-bound slot:
328
- * materialize → fresh locked-down pi subprocess → submit. Throws a typed
447
+ * materialize → opaque Pi adapter → submit. Throws a typed
329
448
  * {@link ReviewHostRelayError} on every failure leg and submits nothing after
330
449
  * a failure; the caller re-queries negotiated STATUS instead of retrying.
331
450
  */
@@ -342,7 +461,7 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
342
461
  // pi subprocess environment stays exactly as the user configured it.
343
462
  const gentleAiEnvironment = { ...baseEnvironment, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
344
463
  const gentleAiTimeoutMs = request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS;
345
- const piTimeoutMs = request.piTimeoutMs ?? DEFAULT_PI_TIMEOUT_MS;
464
+ const targetCwd = request.targetCwd ?? process.cwd();
346
465
 
347
466
  // (a) Materialize the Go-issued opaque prompt. This invocation is also the
348
467
  // capability detection: an old binary's unknown-flag refusal proves the
@@ -350,8 +469,8 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
350
469
  // verbatim. No version sniffing.
351
470
  let materialized: ProcessCapture;
352
471
  try {
353
- materialized = await collectProcess(gentleAi, ["review", "capture-result", ...request.captureArgumentTokens], {
354
- cwd: process.cwd(),
472
+ materialized = await collectGentleAiProcess(gentleAi, ["review", "capture-result", ...request.captureArgumentTokens], {
473
+ cwd: targetCwd,
355
474
  env: gentleAiEnvironment,
356
475
  timeoutMs: gentleAiTimeoutMs,
357
476
  ...(request.signal === undefined ? {} : { signal: request.signal }),
@@ -362,48 +481,49 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
362
481
  if (materialized.exitCode !== 0 || materialized.timedOut) {
363
482
  const stderr = materialized.stderr.toString("utf8");
364
483
  const refusal = classifyReviewHostRelayRefusal(stderr);
484
+ const timing = { elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs };
365
485
  if (refusal === "unknown-flag") {
366
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
486
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
367
487
  }
368
488
  if (refusal === "handshake") {
369
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
489
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
370
490
  }
371
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
491
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", materialized.timedOut
492
+ ? `gentle-ai prompt materialization exceeded its ${gentleAiTimeoutMs}ms bound after ${materialized.elapsedMs}ms`
493
+ : "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut, ...timing });
372
494
  }
373
495
  const promptBytes = materialized.stdout;
374
496
  if (promptBytes.length === 0) {
375
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { exitCode: 0, stderr: materialized.stderr.toString("utf8") });
497
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { exitCode: 0, stderr: materialized.stderr.toString("utf8"), elapsedMs: materialized.elapsedMs, timeoutMs: gentleAiTimeoutMs });
376
498
  }
499
+ // The reviewer bound is derived from the prompt the provider actually
500
+ // materialized, so it can only be resolved here. An explicit request
501
+ // timeout (the test seam) still wins over both the override and the scale.
502
+ const piTimeoutMs = request.piTimeoutMs ?? resolveReviewHostRelayPiTimeoutMs(promptBytes.length, baseEnvironment);
503
+
504
+ // (b) The pure adapter owns the fresh isolated Pi process. Its input and
505
+ // output are opaque bytes; this coordinator only maps transport failures to
506
+ // the established relay boundary.
507
+ let piResult: OpaquePiReviewerResult;
508
+ try {
509
+ piResult = await runOpaquePiReviewer(promptBytes, {
510
+ ...(request.piExecutable === undefined ? {} : { piExecutable: request.piExecutable }),
511
+ environment: baseEnvironment,
512
+ timeoutMs: piTimeoutMs,
513
+ ...(request.signal === undefined ? {} : { signal: request.signal }),
514
+ });
515
+ } catch (error) {
516
+ throw relayPiTransportError(error, promptBytes.length, piTimeoutMs);
517
+ }
518
+ const resultBytes = piResult.stdout;
377
519
 
378
- // (b)/(c) Fresh locked-down pi subprocess in an empty scratch directory.
379
- const scratchDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-scratch-"));
520
+ // (c) Submit the raw final bytes untouched through the provider-owned
521
+ // completing form: its exact operation and argument tokens, with only the
522
+ // artifact path substituted into the declared {{value}} slot.
380
523
  const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
524
+ let primaryFailure = false;
381
525
  try {
382
- await chmod(scratchDirectory, 0o700);
383
526
  await chmod(stagingDirectory, 0o700);
384
- let piRun: ProcessCapture;
385
- try {
386
- piRun = await collectProcess(request.piExecutable ?? "pi", REVIEW_HOST_RELAY_PI_ARGV, {
387
- cwd: scratchDirectory,
388
- env: baseEnvironment,
389
- stdin: promptBytes,
390
- timeoutMs: piTimeoutMs,
391
- ...(request.signal === undefined ? {} : { signal: request.signal }),
392
- });
393
- } catch (error) {
394
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`);
395
- }
396
- if (piRun.exitCode !== 0 || piRun.timedOut) {
397
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: piRun.exitCode, stderr: piRun.stderr.toString("utf8"), timedOut: piRun.timedOut });
398
- }
399
- const resultBytes = piRun.stdout;
400
- if (resultBytes.length === 0) {
401
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", { exitCode: 0, stderr: piRun.stderr.toString("utf8") });
402
- }
403
-
404
- // (d) Submit the raw final bytes untouched through the provider-owned
405
- // completing form: its exact operation and argument tokens, with only
406
- // the artifact path substituted into the declared {{value}} slot.
407
527
  const resultFile = join(stagingDirectory, "result.raw");
408
528
  await writeFile(resultFile, resultBytes, { mode: 0o600 });
409
529
  await chmod(resultFile, 0o600);
@@ -412,8 +532,8 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
412
532
  );
413
533
  let submission: ProcessCapture;
414
534
  try {
415
- submission = await collectProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
416
- cwd: process.cwd(),
535
+ submission = await collectGentleAiProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
536
+ cwd: targetCwd,
417
537
  env: gentleAiEnvironment,
418
538
  timeoutMs: gentleAiTimeoutMs,
419
539
  ...(request.signal === undefined ? {} : { signal: request.signal }),
@@ -422,15 +542,37 @@ export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): P
422
542
  throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `gentle-ai capture submission could not start: ${error instanceof Error ? error.message : String(error)}`);
423
543
  }
424
544
  if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) {
425
- throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "gentle-ai refused the relayed capture submission", { exitCode: submission.exitCode, stderr: submission.stderr.toString("utf8"), timedOut: submission.timedOut });
545
+ const stderr = submission.stderr.toString("utf8");
546
+ const details = { exitCode: submission.exitCode, stderr, timedOut: submission.timedOut, elapsedMs: submission.elapsedMs, timeoutMs: gentleAiTimeoutMs };
547
+ // A typed admission refusal is a proven non-mutation whose reason is
548
+ // the refusal text itself; everything else that launched (timeout,
549
+ // signal, untyped exit) stays unknown pending STATUS.
550
+ if (isReviewHostRelayAdmissionRefusal(submission, stderr)) {
551
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", stderr.trim(), { ...details, mutationOutcome: "none" });
552
+ }
553
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", submission.timedOut
554
+ ? `gentle-ai capture submission exceeded its ${gentleAiTimeoutMs}ms bound after ${submission.elapsedMs}ms`
555
+ : "gentle-ai refused the relayed capture submission", details);
426
556
  }
427
557
  return {
428
558
  promptByteLength: promptBytes.length,
429
- resultByteLength: resultBytes.length,
559
+ resultByteLength: piResult.stdoutByteLength,
430
560
  submission: submission.stdout.toString("utf8"),
431
561
  };
562
+ } catch (error) {
563
+ primaryFailure = true;
564
+ throw error;
432
565
  } finally {
433
- await rm(scratchDirectory, { recursive: true, force: true }).catch(() => undefined);
434
- await rm(stagingDirectory, { recursive: true, force: true }).catch(() => undefined);
566
+ try {
567
+ await rm(stagingDirectory, { recursive: true, force: true });
568
+ } catch (error) {
569
+ if (!primaryFailure) {
570
+ throw new ReviewHostRelayError(
571
+ REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED,
572
+ "submit",
573
+ `Pi host relay result staging cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
574
+ );
575
+ }
576
+ }
435
577
  }
436
578
  }