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,20 +1,20 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFileSync } from "node:child_process";
3
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import test from "node:test";
7
7
  import { __testing } from "../extensions/gentle-ai.ts";
8
8
  import type { NativeReviewCli } from "../lib/native-review-cli.ts";
9
- import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
10
- import type { ReviewCaptureSubmissionV1, ReviewCollectInputV3, ReviewStatusV3 } from "../lib/review-integration-v2.ts";
9
+ import { REVIEW_HOST_RELAY_FAILURE, REVIEW_HOST_RELAY_PI_TIMEOUT_ENV, REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, ReviewHostRelayError, type ReviewHostRelayRequest } from "../lib/review-host-relay.ts";
10
+ import { decodeReviewStatusV3, type ReviewArtifactSubjectV2, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3, type ReviewStatusV3 } from "../lib/review-integration-v2.ts";
11
11
 
12
- // Wiring contract (gentle-pi#311 P4): the compact controller FINALIZE lane
13
- // routes pi-slot capture inputs through the host relay ONLY when the
12
+ // One-slot capture routing: the host relay runs only when the selected
14
13
  // provider-returned collect input carries the --materialize token. Every
15
- // other lane and slot stays untouched.
14
+ // other capture form stays untouched.
16
15
 
17
16
  const SHA = `sha256:${"1".repeat(64)}`;
17
+ const PHASE_REVISION = `sha256:${"3".repeat(64)}`;
18
18
  const TREE = "2".repeat(40);
19
19
 
20
20
  function repository(t: test.TestContext): string {
@@ -27,10 +27,10 @@ function repository(t: test.TestContext): string {
27
27
  return cwd;
28
28
  }
29
29
 
30
- function bindingArguments(lineageId: string, lens: string, order: number): ReviewCollectInputV3["arguments"] {
30
+ function bindingArguments(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCollectInputV3["arguments"] {
31
31
  return [
32
32
  { name: "lineage", value: lineageId, token: `--lineage=${lineageId}` },
33
- { name: "expected-revision", value: SHA, token: `--expected-revision=${SHA}` },
33
+ { name: "expected-revision", value: revision, token: `--expected-revision=${revision}` },
34
34
  { name: "target", value: SHA, token: `--target=${SHA}` },
35
35
  { name: "repository-context", value: `rctx1_${"e".repeat(64)}`, token: `--repository-context=rctx1_${"e".repeat(64)}` },
36
36
  { name: "lens", value: lens, token: `--lens=${lens}` },
@@ -39,8 +39,8 @@ function bindingArguments(lineageId: string, lens: string, order: number): Revie
39
39
  ];
40
40
  }
41
41
 
42
- function providerSubmission(lineageId: string, lens: string, order: number): ReviewCaptureSubmissionV1 {
43
- const bindingTokens = bindingArguments(lineageId, lens, order).map((argument) => argument.token!);
42
+ function providerSubmission(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, revision = SHA): ReviewCaptureSubmissionV1 {
43
+ const bindingTokens = bindingArguments(lineageId, lens, order, revision).map((argument) => argument.token!);
44
44
  return {
45
45
  operationToken: "capture-result",
46
46
  argumentTokens: [...bindingTokens, "--input={{value}}"],
@@ -48,29 +48,77 @@ function providerSubmission(lineageId: string, lens: string, order: number): Rev
48
48
  };
49
49
  }
50
50
 
51
- function relayCollectInput(lineageId: string, lens: string, order: number, materialize = true, submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider"): ReviewCollectInputV3 {
51
+ function relayCollectInput(lineageId: string, lens: ReviewArtifactSubjectV2["lens"], order: number, materialize = true, submission: ReviewCaptureSubmissionV1 | "provider" | "absent" = "provider", revision = SHA): ReviewCollectInputV3 {
52
52
  return {
53
53
  name: "reviewer_result",
54
54
  schema: "https://gentle-ai.dev/schema/review/reviewer/v1",
55
55
  captureOperation: "review.capture-result",
56
56
  arguments: [
57
- ...bindingArguments(lineageId, lens, order),
57
+ ...bindingArguments(lineageId, lens, order, revision),
58
58
  ...(materialize ? [
59
59
  { name: "agent", value: "pi", token: "--agent=pi" },
60
60
  { name: "materialize", value: "true", token: "--materialize=true" },
61
61
  ] : []),
62
62
  ],
63
- ...(materialize && submission !== "absent" ? { submission: submission === "provider" ? providerSubmission(lineageId, lens, order) : submission } : {}),
63
+ artifactSubject: {
64
+ schema: "gentle-ai.review-artifact-subject/v2", subjectHash: `sha256:${String(order).repeat(64)}`,
65
+ lineageId, authorityRevision: revision, targetIdentity: SHA, baseTree: TREE, candidateTree: TREE,
66
+ changedPathManifestSha256: SHA, lens, selectedOrder: order,
67
+ },
68
+ baseTree: TREE, candidateTree: TREE, changedPathManifest: [],
69
+ ...(materialize && submission !== "absent" ? { submission: submission === "provider" ? providerSubmission(lineageId, lens, order, revision) : submission } : {}),
64
70
  };
65
71
  }
66
72
 
73
+ function capturedGroupedStatus(lineageId: string, authorityRevision = SHA): ReviewStatusV3 {
74
+ const raw = JSON.parse(readFileSync(new URL("./fixtures/devbinary/status-v5-capture-result-submission.captured.json", import.meta.url), "utf8")) as Record<string, unknown>;
75
+ // The captured binary's forward-only `finalize` action is not accepted by
76
+ // this checked-out decoder; retain the v5 capture and normalize only that
77
+ // unrelated routing action before decoding its provider-owned group.
78
+ raw.action = "stop";
79
+ const authority = raw.authority as Record<string, unknown>, repositoryContext = raw.repository_context as Record<string, unknown>;
80
+ authority.lineage_id = lineageId;
81
+ authority.revision = authorityRevision;
82
+ const phaseRevision = String(repositoryContext.revision), targetIdentity = String(raw.target_identity);
83
+ const source = ((((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs as Array<Record<string, unknown>>)[0]!);
84
+ const lenses = ["review-risk", "review-resilience", "review-readability", "review-reliability"];
85
+ ((raw.next_transition as Record<string, unknown>).collect as Record<string, unknown>).inputs = lenses.map((lens, order) => {
86
+ const input = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
87
+ const subjectHash = `sha256:${String(order + 1).repeat(64)}`;
88
+ const arguments_ = input.arguments as Array<Record<string, unknown>>;
89
+ for (const argument of arguments_) {
90
+ const value = argument.name === "lineage" ? lineageId
91
+ : argument.name === "lens" ? lens
92
+ : argument.name === "order" ? String(order)
93
+ : argument.name === "subject-hash" ? subjectHash
94
+ : argument.value;
95
+ argument.value = value;
96
+ argument.token = `--${String(argument.name)}=${String(value)}`;
97
+ }
98
+ const submission = input.submission as Record<string, unknown>;
99
+ submission.argument_tokens = [...arguments_.filter((argument) => argument.name !== "agent" && argument.name !== "materialize").map((argument) => argument.token), "--input={{value}}"];
100
+ const artifactSubject = input.artifact_subject as Record<string, unknown>;
101
+ artifactSubject.subject_hash = subjectHash;
102
+ artifactSubject.lineage_id = lineageId;
103
+ artifactSubject.authority_revision = phaseRevision;
104
+ artifactSubject.target_identity = targetIdentity;
105
+ artifactSubject.lens = lens;
106
+ artifactSubject.selected_order = order;
107
+ return input;
108
+ });
109
+ return decodeReviewStatusV3(raw);
110
+ }
111
+
112
+ function replaceArgument(input: ReviewCollectInputV3, name: string, value: string): void {
113
+ input.arguments = input.arguments.map((argument) => argument.name === name ? { ...argument, value, token: `--${name}=${value}` } : argument);
114
+ }
115
+
67
116
  function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV3[]): ReviewStatusV3 {
68
117
  return {
69
118
  contract: "gentle-ai.review-integration/v2",
70
119
  applicability: "current_target",
71
120
  authority: { version: "compact-v2", lineageId, state: "reviewing", generation: 1, revision: SHA },
72
- receipt: { status: "none" },
73
- action: "finalize",
121
+ action: "stop",
74
122
  replayability: "unknown",
75
123
  targetIdentity: SHA,
76
124
  projection: {
@@ -88,15 +136,15 @@ function finalizeStatus(lineageId: string, inputs?: readonly ReviewCollectInputV
88
136
  currentSnapshotIdentity: SHA,
89
137
  },
90
138
  candidates: [],
139
+ repositoryContext: { capability: "review.opaque_repository_context", handle: `rctx1_${"e".repeat(64)}`, revision: SHA, targetIdentity: SHA },
91
140
  ...(inputs === undefined ? {} : { nextTransition: { kind: "collect", reasonCode: "reviewer_results_required", collect: { inputs: [...inputs] } } }),
92
- raw: { schema: "gentle-ai.review-integration.status/v3", action: "finalize", lineage_id: lineageId },
141
+ raw: { schema: "gentle-ai.review-integration.status/v3", action: "stop", lineage_id: lineageId },
93
142
  } as unknown as ReviewStatusV3;
94
143
  }
95
144
 
96
145
  interface RoutingHarness {
97
146
  statusQueue: ReviewStatusV3[];
98
147
  statusCalls: Array<{ cwd: string; lineageId?: string }>;
99
- finalizeCalls: number;
100
148
  native: NativeReviewCli;
101
149
  }
102
150
 
@@ -104,19 +152,9 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness {
104
152
  const harness: RoutingHarness = {
105
153
  statusQueue: [...statuses],
106
154
  statusCalls: [],
107
- finalizeCalls: 0,
108
155
  native: undefined as unknown as NativeReviewCli,
109
156
  };
110
157
  harness.native = {
111
- start: async () => { throw new Error("unexpected start"); },
112
- finalize: async () => {
113
- harness.finalizeCalls += 1;
114
- return { lineageId: "relay-lineage", state: "approved", action: "approved", storeRevision: "r1" };
115
- },
116
- validate: async () => { throw new Error("unexpected validate"); },
117
- bindSdd: async () => { throw new Error("unexpected bindSdd"); },
118
- sddStatus: async () => ({ ready: false, artifactStore: "none", artifacts: {} as never, nextRecommended: "" }),
119
- reviewStatus: async () => { throw new Error("unexpected reviewStatus"); },
120
158
  targetStatus: async (request) => {
121
159
  harness.statusCalls.push({ cwd: request.cwd, ...(request.lineageId === undefined ? {} : { lineageId: request.lineageId }) });
122
160
  const next = harness.statusQueue.shift();
@@ -127,63 +165,41 @@ function nativeHarness(statuses: readonly ReviewStatusV3[]): RoutingHarness {
127
165
  return harness;
128
166
  }
129
167
 
130
- function finalizeParameters(lineageId: string, input: Record<string, unknown> = {}): Record<string, unknown> {
131
- return { operation: "finalize", lineageId, input: JSON.stringify(input) };
132
- }
133
-
134
- // Relay ROUTING is the subject here, not spend authorization: a host-relay
135
- // finalize now forecasts its reviewer model runs and spends nothing until the
136
- // caller acknowledges them (see review-relay-transport-agent.test.ts), so these
137
- // routing cases authorize the run up front and keep asserting where the slots
138
- // go. A case that must observe the forecast passes the acknowledgement off.
139
- async function runFinalize(cwd: string, harness: RoutingHarness, lineageId: string, input: Record<string, unknown> = { reviewer_run_acknowledged: true }): Promise<Record<string, unknown>> {
140
- return await __testing.executeReviewControllerOperation(
141
- finalizeParameters(lineageId, input),
168
+ async function runCapture(cwd: string, harness: RoutingHarness, lineageId: string, input: Record<string, unknown> = { reviewerRunAcknowledged: true }): Promise<Record<string, unknown>> {
169
+ const selected = harness.statusQueue[0]?.nextTransition?.collect?.inputs[0];
170
+ if (selected === undefined) throw new Error("capture test requires one current collect input");
171
+ return await __testing.executeReviewCaptureOperation(
172
+ { lineageId, collectBinding: JSON.stringify(selected), ...input },
142
173
  cwd,
143
- new Map(),
144
174
  harness.native,
145
175
  ) as Record<string, unknown>;
146
176
  }
147
177
 
148
- test("materialize-marked pi slots route through the host relay in provider order and re-query STATUS", async (t) => {
178
+ test("one materialize binding routes exactly one provider slot through the host relay", async (t) => {
149
179
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
150
180
  const cwd = repository(t);
151
181
  const lineageId = "relay-lineage";
152
- const inputs = [
153
- relayCollectInput(lineageId, "review-risk", 0),
154
- relayCollectInput(lineageId, "review-reliability", 1),
155
- ];
156
- const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId)]);
182
+ const input = relayCollectInput(lineageId, "review-risk", 0);
183
+ const harness = nativeHarness([finalizeStatus(lineageId, [input])]);
157
184
  const relayed: ReviewHostRelayRequest[] = [];
158
185
  __testing.setReviewHostRelayRunnerForTesting(async (request: ReviewHostRelayRequest) => {
159
186
  relayed.push(request);
160
187
  return { promptByteLength: 64, resultByteLength: 32, submission: '{"admission_decision":"completed"}' };
161
188
  });
162
189
 
163
- const result = await runFinalize(cwd, harness, lineageId);
190
+ const result = await runCapture(cwd, harness, lineageId);
164
191
 
165
- assert.equal(relayed.length, 2);
166
- assert.deepEqual(relayed[0]!.captureArgumentTokens, inputs[0]!.arguments.map((argument) => argument.token));
167
- // The provider-owned completing form reaches the relay verbatim.
192
+ assert.equal(relayed.length, 1);
193
+ assert.deepEqual(relayed[0]!.captureArgumentTokens, input.arguments.map((argument) => argument.token));
168
194
  assert.deepEqual(relayed[0]!.submission, providerSubmission(lineageId, "review-risk", 0));
169
- assert.deepEqual(relayed[1]!.captureArgumentTokens, inputs[1]!.arguments.map((argument) => argument.token));
170
- assert.deepEqual(relayed[1]!.submission, providerSubmission(lineageId, "review-reliability", 1));
171
-
172
- assert.equal(harness.finalizeCalls, 0, "the relay never invokes native finalize itself");
173
- assert.equal(harness.statusCalls.length, 2, "STATUS is re-queried after all slots are captured");
174
- assert.deepEqual(harness.statusCalls[1], { cwd, lineageId });
175
-
176
- const hostRelay = result.host_relay as { transport: string; captured_slots: Array<Record<string, unknown>> };
177
- assert.equal(hostRelay.transport, "pi_host_relay");
178
- assert.equal(hostRelay.captured_slots.length, 2);
179
- assert.deepEqual(hostRelay.captured_slots.map((slot) => slot.lens), ["review-risk", "review-reliability"]);
180
- assert.equal(result.status, "in-progress");
195
+ assert.equal(harness.statusCalls.length, 1, "a nonterminal capture does not auto-follow STATUS");
196
+ assert.equal(result.status, "captured");
197
+ assert.equal((result.host_relay as { transport: string }).transport, "pi_host_relay");
181
198
  });
182
199
 
183
- test("Pi-authored review documents are rejected at the FINALIZE input boundary", async (t) => {
184
- // gentle-pi#311 P5: `review_result` is no longer a legal FINALIZE input at
185
- // all — the parse fails closed before any relay launch or native call, so
186
- // a Pi-authored reviewer document can never race a host-mediated slot.
200
+ test("Pi-authored review documents are rejected at the capture input boundary", async (t) => {
201
+ // The narrow capture schema rejects a caller-authored reviewer document
202
+ // before any relay launch or native call.
187
203
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
188
204
  const cwd = repository(t);
189
205
  const lineageId = "relay-lineage";
@@ -195,11 +211,161 @@ test("Pi-authored review documents are rejected at the FINALIZE input boundary",
195
211
  });
196
212
 
197
213
  await assert.rejects(
198
- () => runFinalize(cwd, harness, lineageId, { review_result: { lens_results: [{ findings: [], evidence: ["reviewed"] }] } }),
199
- /unknown field review_result/,
214
+ () => runCapture(cwd, harness, lineageId, { review_result: { lens_results: [{ findings: [], evidence: ["reviewed"] }] } }),
215
+ /does not accept review_result/,
200
216
  );
201
217
  assert.equal(relayCalls, 0);
202
- assert.equal(harness.finalizeCalls, 0);
218
+ });
219
+
220
+ function groupInputs(lineageId: string, revision = SHA): ReviewCollectInputV3[] { return ["review-risk", "review-resilience", "review-readability", "review-reliability"].map((lens, order) => relayCollectInput(lineageId, lens, order, true, "provider", revision)); }
221
+
222
+ async function runCaptureGroup(cwd: string, harness: RoutingHarness, lineageId: string, inputs: readonly ReviewCollectInputV3[], reviewerRunAcknowledged = true): Promise<Record<string, unknown>> { return await __testing.executeReviewCaptureGroupOperation({ lineageId, collectBindings: inputs.map((input) => JSON.stringify(input)), reviewerRunAcknowledged }, cwd, harness.native) as Record<string, unknown>; }
223
+
224
+ function prepared(request: ReviewHostRelayRequest) { return { request, promptByteLength: 64, resultByteLength: 32 }; }
225
+
226
+ test("grouped capture forecasts once, reaches a four-reviewer barrier, and reconciles unclosed submissions", async (t) => {
227
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
228
+ const cwd = repository(t), lineageId = "relay-group", inputs = groupInputs(lineageId), lenses = inputs.map((input) => input.arguments.find((argument) => argument.name === "lens")!.value);
229
+ const harness = nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), ...inputs.map((_input, index) => finalizeStatus(lineageId, index === 1 ? [...inputs.slice(index), { name: "unrelated", schema: "example", captureOperation: "external.example", arguments: [] }] : inputs.slice(index))), finalizeStatus(lineageId)]);
230
+ let ready!: () => void;
231
+ const allStarted = new Promise<void>((resolve) => { ready = resolve; });
232
+ const releases = new Map<string, () => void>(), completed: string[] = [], submitted: string[] = [];
233
+ const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => await Promise.all(requests.map(async (request) => {
234
+ const lens = request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7);
235
+ await new Promise<void>((resolve) => { releases.set(lens, resolve); if (releases.size === requests.length) ready(); });
236
+ completed.push(lens); return prepared(request);
237
+ }));
238
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => {
239
+ submitted.push(result.request.captureArgumentTokens.find((token) => token.startsWith("--lens="))!.slice(7));
240
+ return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" };
241
+ });
242
+ const forecast = await runCaptureGroup(cwd, harness, lineageId, inputs, false);
243
+ assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses });
244
+ const run = runCaptureGroup(cwd, harness, lineageId, inputs);
245
+ await allStarted;
246
+ assert.deepEqual([...releases.keys()], lenses, "all reviewers start before the group waits");
247
+ for (const lens of [...lenses].reverse()) releases.get(lens!)!();
248
+ const result = await run;
249
+ assert.deepEqual(completed, [...lenses].reverse(), "reviewer completion order is not submission order");
250
+ assert.deepEqual(submitted, lenses);
251
+ assert.deepEqual({ outcome: result.outcome, statusCalls: harness.statusCalls.length, providerAction: result.provider_action }, { outcome: "native-reviewer-group-status-reconciled", statusCalls: 7, providerAction: "stop" });
252
+ assert.equal(result.next_transition, undefined);
253
+ });
254
+
255
+ test("captured v5 STATUS accepts capture-phase Pn when authority has advanced to Rn at the forecast boundary", async (t) => {
256
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
257
+ const cwd = repository(t), lineageId = "relay-group-phase-revision", status = capturedGroupedStatus(lineageId, SHA), inputs = status.nextTransition!.collect!.inputs!;
258
+ let launches = 0;
259
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
260
+
261
+ assert.notEqual(status.authority!.revision, status.repositoryContext!.revision, "captured v5 group keeps capture-phase Pn distinct from authority Rn");
262
+ const forecast = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs, false);
263
+
264
+ assert.equal(forecast.outcome, "reviewer-model-run-forecast");
265
+ assert.deepEqual(forecast.cost_forecast, { transport: "pi_host_relay", model_runs: 4, lenses: ["review-risk", "review-resilience", "review-readability", "review-reliability"] });
266
+ assert.equal(launches, 0, "decoder-valid capture-phase Pn must pass admission before any reviewer model launch");
267
+ });
268
+
269
+ test("group rejects STATUS repository target mismatch and per-slot revision, context, or target drift before launch", async (t) => {
270
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
271
+ const cwd = repository(t), lineageId = "relay-group-slot-consistency";
272
+ const repositoryTargetMismatch = capturedGroupedStatus(lineageId, SHA);
273
+ repositoryTargetMismatch.repositoryContext!.targetIdentity = SHA;
274
+ const revisionMismatch = capturedGroupedStatus(lineageId, SHA);
275
+ replaceArgument(revisionMismatch.nextTransition!.collect!.inputs![1]!, "expected-revision", SHA);
276
+ const contextMismatch = capturedGroupedStatus(lineageId, SHA);
277
+ replaceArgument(contextMismatch.nextTransition!.collect!.inputs![1]!, "repository-context", `rctx1_${"f".repeat(64)}`);
278
+ const targetMismatch = capturedGroupedStatus(lineageId, SHA);
279
+ replaceArgument(targetMismatch.nextTransition!.collect!.inputs![1]!, "target", SHA);
280
+ let launches = 0;
281
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
282
+
283
+ // Each call forwards this STATUS's own current set, so rejection proves an
284
+ // internal STATUS guard rather than stale or caller/canonical mismatch.
285
+ const repositoryTargetResult = await runCaptureGroup(cwd, nativeHarness([repositoryTargetMismatch]), lineageId, repositoryTargetMismatch.nextTransition!.collect!.inputs!);
286
+ assert.deepEqual({ outcome: repositoryTargetResult.outcome, reason: repositoryTargetResult.reason }, {
287
+ outcome: "capture-group-rejected",
288
+ reason: "current STATUS repository context does not match the reviewer group binding",
289
+ });
290
+ const revisionResult = await runCaptureGroup(cwd, nativeHarness([revisionMismatch]), lineageId, revisionMismatch.nextTransition!.collect!.inputs!);
291
+ assert.deepEqual({ outcome: revisionResult.outcome, reason: revisionResult.reason }, {
292
+ outcome: "capture-group-rejected",
293
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
294
+ });
295
+ const contextResult = await runCaptureGroup(cwd, nativeHarness([contextMismatch]), lineageId, contextMismatch.nextTransition!.collect!.inputs!);
296
+ assert.deepEqual({ outcome: contextResult.outcome, reason: contextResult.reason }, {
297
+ outcome: "capture-group-rejected",
298
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
299
+ });
300
+ const targetResult = await runCaptureGroup(cwd, nativeHarness([targetMismatch]), lineageId, targetMismatch.nextTransition!.collect!.inputs!);
301
+ assert.deepEqual({ outcome: targetResult.outcome, reason: targetResult.reason }, {
302
+ outcome: "capture-group-rejected",
303
+ reason: "current STATUS carries an incomplete or mismatched materialize reviewer binding",
304
+ });
305
+ assert.equal(launches, 0, "current STATUS consistency checks reject before reviewer model launch");
306
+ });
307
+
308
+ test("group rejects absent or mismatched current repository context before launch", async (t) => {
309
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
310
+ const cwd = repository(t), lineageId = "relay-group-context-reject", inputs = groupInputs(lineageId, PHASE_REVISION);
311
+ const revisionMismatch = finalizeStatus(lineageId, inputs);
312
+ const handleMismatch = finalizeStatus(lineageId, inputs);
313
+ const absent = finalizeStatus(lineageId, inputs);
314
+ handleMismatch.repositoryContext = { capability: "review.opaque_repository_context", handle: `rctx1_${"f".repeat(64)}`, revision: PHASE_REVISION, targetIdentity: SHA };
315
+ delete absent.repositoryContext;
316
+ let launches = 0;
317
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
318
+
319
+ for (const status of [revisionMismatch, handleMismatch, absent]) {
320
+ const result = await runCaptureGroup(cwd, nativeHarness([status]), lineageId, inputs);
321
+ assert.equal(result.outcome, "capture-group-rejected");
322
+ }
323
+ assert.equal(launches, 0, "repository-context validation must reject before reviewer model launch");
324
+ });
325
+
326
+ test("group rejects partial, duplicate, reordered, stale, mixed, and late-invalid bindings before launch", async (t) => {
327
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
328
+ const cwd = repository(t), lineageId = "relay-group-reject", inputs = groupInputs(lineageId);
329
+ const stale = JSON.parse(JSON.stringify(inputs[3]!)) as ReviewCollectInputV3;
330
+ stale.arguments = [...stale.arguments, { name: "replacement", value: "stale", token: "--replacement=stale" }];
331
+ const mixed = [...inputs.slice(0, 3), relayCollectInput(lineageId, "review-reliability", 3, false)];
332
+ const malformed = JSON.parse(JSON.stringify(inputs)) as ReviewCollectInputV3[];
333
+ malformed[3]!.submission!.argumentTokens = ["--input={{value}}"];
334
+ const cases = [
335
+ { bindings: inputs.slice(0, 3), current: inputs }, { bindings: [...inputs].reverse(), current: inputs },
336
+ { bindings: [...inputs.slice(0, 3), inputs[2]!], current: inputs }, { bindings: [...inputs.slice(0, 3), stale], current: inputs },
337
+ { bindings: mixed, current: mixed }, { bindings: malformed, current: malformed },
338
+ ];
339
+ let launches = 0;
340
+ __testing.setReviewHostRelayGroupRunnersForTesting(async (requests) => { launches += requests.length; return requests.map(prepared); });
341
+ for (const entry of cases) {
342
+ const result = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, entry.current)]), lineageId, entry.bindings);
343
+ assert.equal(result.outcome, "capture-group-rejected");
344
+ }
345
+ assert.equal(launches, 0);
346
+ });
347
+
348
+ test("group preserves earlier admission when a later STATUS drifts, and stops on closure or unknown outcome", async (t) => {
349
+ t.after(() => __testing.setReviewHostRelayGroupRunnersForTesting());
350
+ const cwd = repository(t), lineageId = "relay-group-stop", inputs = groupInputs(lineageId);
351
+ const reviewerGroup = async (requests: readonly ReviewHostRelayRequest[]) => requests.map(prepared);
352
+ let submissions = 0;
353
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: "{}" }; });
354
+ const staleStatus = finalizeStatus(lineageId, inputs), stale = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), staleStatus]), lineageId, inputs);
355
+ assert.deepEqual({ outcome: stale.outcome, submissions, mutation: stale.mutation_performed, mutationOutcome: stale.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
356
+ assert.equal(stale.reconciliation, staleStatus.raw);
357
+ submissions = 0;
358
+ const drift = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs.slice(2))]), lineageId, inputs);
359
+ assert.deepEqual({ outcome: drift.outcome, submissions, mutation: drift.mutation_performed, mutationOutcome: drift.mutation_outcome }, { outcome: "capture-group-authority-drift", submissions: 1, mutation: true, mutationOutcome: "partial" });
360
+ assert.deepEqual(((drift.host_relay as { reviewers: Array<{ lens: string }> }).reviewers).map((reviewer) => reviewer.lens), ["review-risk"]);
361
+ submissions = 0;
362
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async (result) => { submissions += 1; return { promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submission: JSON.stringify({ schema: "gentle-ai.review-last-event-closure/v1", operation: "review/capture-result", lineage_id: lineageId, state: "approved", store_revision: SHA, action: "native last event closed the review" }) }; });
363
+ const terminal = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
364
+ assert.deepEqual({ status: terminal.status, submissions }, { status: "closed", submissions: 1 });
365
+ submissions = 0;
366
+ __testing.setReviewHostRelayGroupRunnersForTesting(reviewerGroup, async () => { submissions += 1; throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "unknown", { timedOut: true }); });
367
+ const unknown = await runCaptureGroup(cwd, nativeHarness([finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs), finalizeStatus(lineageId, inputs)]), lineageId, inputs);
368
+ assert.deepEqual({ status: unknown.status, submissions }, { status: "reconciled", submissions: 1 });
203
369
  });
204
370
 
205
371
  test("an old binary reports the relay as unavailable without touching existing behavior", async (t) => {
@@ -211,14 +377,13 @@ test("an old binary reports the relay as unavailable without touching existing b
211
377
  throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: 2, stderr: "flag provided but not defined: -materialize" });
212
378
  });
213
379
 
214
- const result = await runFinalize(cwd, harness, lineageId);
380
+ const result = await runCapture(cwd, harness, lineageId);
215
381
 
216
382
  assert.equal(result.status, "blocked");
217
383
  assert.equal(result.outcome, "pi-host-relay-unavailable");
218
384
  assert.equal(result.reason, REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE);
219
385
  assert.equal(result.mutation_performed, false);
220
386
  assert.equal(result.mutation_outcome, "none");
221
- assert.equal(harness.finalizeCalls, 0);
222
387
  });
223
388
 
224
389
  test("a handshake refusal surfaces the provider refusal verbatim through the controller envelope", async (t) => {
@@ -231,43 +396,116 @@ test("a handshake refusal surfaces the provider refusal verbatim through the con
231
396
  throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", refusal, { exitCode: 1, stderr: refusal });
232
397
  });
233
398
 
234
- const result = await runFinalize(cwd, harness, lineageId);
399
+ const result = await runCapture(cwd, harness, lineageId);
235
400
 
236
401
  assert.equal(result.outcome, "pi-host-relay-handshake-refused");
237
402
  assert.equal(result.reason, refusal);
238
403
  assert.equal(result.refusal, refusal);
239
404
  });
240
405
 
241
- test("a transport failure mid-collection stops immediately and directs STATUS re-query", async (t) => {
406
+ test("a transport failure stops the selected capture without auto-follow", async (t) => {
242
407
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
243
408
  const cwd = repository(t);
244
409
  const lineageId = "relay-lineage";
245
- const inputs = [
246
- relayCollectInput(lineageId, "review-risk", 0),
247
- relayCollectInput(lineageId, "review-reliability", 1),
248
- ];
249
- const harness = nativeHarness([finalizeStatus(lineageId, inputs)]);
250
- let calls = 0;
410
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
251
411
  __testing.setReviewHostRelayRunnerForTesting(async () => {
252
- calls += 1;
253
- if (calls === 1) return { promptByteLength: 8, resultByteLength: 8, submission: '{"admission_decision":"completed"}' };
254
412
  throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: 4 });
255
413
  });
256
414
 
257
- const result = await runFinalize(cwd, harness, lineageId);
415
+ const result = await runCapture(cwd, harness, lineageId);
258
416
 
259
- assert.equal(calls, 2);
260
417
  assert.equal(result.status, "blocked");
261
418
  assert.equal(result.outcome, "pi-host-relay-transport-failure");
262
419
  assert.deepEqual(result.failure, { kind: "pi-failed", stage: "pi", exit_code: 4, timed_out: false });
263
- assert.equal((result.captured_slots as unknown[]).length, 1);
264
- assert.equal(result.mutation_performed, true);
265
- assert.match(String(result.next_action), /Re-query negotiated STATUS/);
266
- assert.match(String(result.next_action), /exact same bound slot/);
267
- assert.equal(harness.finalizeCalls, 0);
420
+ assert.match(String(result.next_action), /fresh STATUS/);
268
421
  assert.equal(harness.statusCalls.length, 1, "no automatic relaunch after transport failure");
269
422
  });
270
423
 
424
+ // gentle-pi#522 / #524: a submission Go refused at admission is a proven
425
+ // non-mutation. The model must see the refusal text, mutation_outcome none,
426
+ // and a continuation for the reoffered slot, never an unknown outcome that the
427
+ // contract forbids replaying.
428
+ test("an admission refusal reaches the model as a proven non-mutation carrying the refusal and a continuation", async (t) => {
429
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
430
+ const cwd = repository(t);
431
+ const lineageId = "relay-lineage";
432
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
433
+ const refusal = "Error: 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 [invalid_request]\n";
434
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
435
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", refusal.trim(), { exitCode: 1, stderr: refusal, elapsedMs: 40, timeoutMs: 120_000, mutationOutcome: "none" });
436
+ });
437
+
438
+ const result = await runCapture(cwd, harness, lineageId);
439
+
440
+ assert.equal(result.status, "blocked");
441
+ assert.equal(result.outcome, "pi-host-relay-transport-failure");
442
+ assert.deepEqual(result.failure, { kind: "submission-refused", stage: "submit", exit_code: 1, timed_out: false, elapsed_ms: 40, timeout_ms: 120_000, stderr: refusal });
443
+ assert.equal(result.reason, refusal.trim());
444
+ assert.equal(result.mutation_performed, false);
445
+ assert.equal(result.mutation_outcome, "none");
446
+ assert.match(String(result.next_action), /did not consume the lens slot/);
447
+ assert.match(String(result.next_action), /fresh STATUS/);
448
+ assert.equal(harness.statusCalls.length, 1, "a proven non-mutation needs no STATUS reconciliation and no relaunch");
449
+ });
450
+
451
+ test("a submission whose outcome is genuinely indeterminate still reconciles through STATUS and carries its evidence", async (t) => {
452
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
453
+ const cwd = repository(t);
454
+ const lineageId = "relay-lineage";
455
+ const pending = finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)]);
456
+ const harness = nativeHarness([pending, pending]);
457
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
458
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "gentle-ai capture submission exceeded its 120000ms bound after 120004ms", { exitCode: null, stderr: "", timedOut: true, elapsedMs: 120_004, timeoutMs: 120_000 });
459
+ });
460
+
461
+ const result = await runCapture(cwd, harness, lineageId);
462
+
463
+ assert.equal(result.status, "reconciled");
464
+ assert.equal(result.outcome, "native-capture-outcome-unknown");
465
+ assert.deepEqual(result.failure, { kind: "submission-refused", stage: "submit", exit_code: null, timed_out: true, elapsed_ms: 120_004, timeout_ms: 120_000 });
466
+ assert.match(String(result.reason), /exceeded its 120000ms bound/);
467
+ assert.equal(harness.statusCalls.length, 2, "an indeterminate submission reconciles exactly once through STATUS");
468
+ });
469
+
470
+ test("a relay timeout reports its one-slot measurements and no auto-follow", async (t) => {
471
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
472
+ const cwd = repository(t);
473
+ const lineageId = "relay-lineage";
474
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-risk", 0)])]);
475
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
476
+ throw new ReviewHostRelayError(
477
+ REVIEW_HOST_RELAY_FAILURE.PI_TIMED_OUT,
478
+ "pi",
479
+ "pi reviewer subprocess exceeded the relay bound",
480
+ { exitCode: null, timedOut: true, elapsedMs: 2_256_004, timeoutMs: 2_256_000 },
481
+ );
482
+ });
483
+
484
+ const result = await runCapture(cwd, harness, lineageId);
485
+ assert.equal(result.status, "blocked");
486
+ assert.equal(result.outcome, "pi-host-relay-timeout");
487
+ assert.deepEqual(result.failure, { kind: "pi-timed-out", stage: "pi", exit_code: null, timed_out: true, elapsed_ms: 2_256_004, timeout_ms: 2_256_000 });
488
+ assert.match(String(result.next_action), new RegExp(`${REVIEW_HOST_RELAY_PI_TIMEOUT_ENV}=<milliseconds>`));
489
+ assert.match(String(result.next_action), new RegExp(String(REVIEW_HOST_RELAY_PI_TIMEOUT_MAX_MS)));
490
+ assert.equal(harness.statusCalls.length, 1);
491
+ });
492
+
493
+ test("a non-timeout transport failure keeps its generic continuation and now carries its measurements", async (t) => {
494
+ t.after(() => __testing.setReviewHostRelayRunnerForTesting());
495
+ const cwd = repository(t);
496
+ const lineageId = "relay-lineage";
497
+ const harness = nativeHarness([finalizeStatus(lineageId, [relayCollectInput(lineageId, "review-reliability", 0)])]);
498
+ __testing.setReviewHostRelayRunnerForTesting(async () => {
499
+ throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: 4, elapsedMs: 1_200, timeoutMs: 900_000 });
500
+ });
501
+
502
+ const result = await runCapture(cwd, harness, lineageId);
503
+
504
+ assert.equal(result.outcome, "pi-host-relay-transport-failure");
505
+ assert.deepEqual(result.failure, { kind: "pi-failed", stage: "pi", exit_code: 4, timed_out: false, elapsed_ms: 1_200, timeout_ms: 900_000 });
506
+ assert.match(String(result.next_action), /fresh STATUS/);
507
+ });
508
+
271
509
  test("materialize tokens without a provider submission fail closed as a typed contract mismatch", async (t) => {
272
510
  t.after(() => __testing.setReviewHostRelayRunnerForTesting());
273
511
  const cwd = repository(t);
@@ -279,7 +517,7 @@ test("materialize tokens without a provider submission fail closed as a typed co
279
517
  return { promptByteLength: 1, resultByteLength: 1, submission: "{}" };
280
518
  });
281
519
 
282
- const result = await runFinalize(cwd, harness, lineageId);
520
+ const result = await runCapture(cwd, harness, lineageId);
283
521
 
284
522
  assert.equal(relayCalls, 0, "the completing form is never synthesized, so nothing launches");
285
523
  assert.equal(result.status, "blocked");
@@ -288,7 +526,6 @@ test("materialize tokens without a provider submission fail closed as a typed co
288
526
  assert.equal(result.reason, REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
289
527
  assert.equal(result.mutation_performed, false);
290
528
  assert.equal(result.mutation_outcome, "none");
291
- assert.equal(harness.finalizeCalls, 0);
292
529
  assert.equal(harness.statusCalls.length, 1, "no relaunch and no post-capture STATUS after the contract mismatch");
293
530
  });
294
531
 
@@ -309,7 +546,7 @@ test("collect inputs without the provider-issued materialize token never reach t
309
546
  // The existing lane may fail on the synthetic projection; only the relay
310
547
  // boundary is under test here: it must never be consulted.
311
548
  try {
312
- await runFinalize(cwd, harness, lineageId);
549
+ await runCapture(cwd, harness, lineageId);
313
550
  } catch {
314
551
  // Existing-lane behavior for this synthetic fixture is out of scope.
315
552
  }