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
@@ -117,6 +117,139 @@ test("package manifest exposes bundled themes to Pi discovery", () => {
117
117
  );
118
118
  });
119
119
 
120
+ test("bundled Gentleman-Sexy Pi theme is available under its exact name", () => {
121
+ const theme = readJson<GentleThemeJson>(
122
+ join(PACKAGE_ROOT, "themes", "Gentleman-Sexy.json"),
123
+ );
124
+
125
+ assert.equal(theme.name, "Gentleman-Sexy");
126
+ });
127
+
128
+ test("bundled Gentleman-Cute Pi theme is available under its exact name", () => {
129
+ const theme = readJson<GentleThemeJson>(
130
+ join(PACKAGE_ROOT, "themes", "Gentleman-Cute.json"),
131
+ );
132
+
133
+ assert.equal(theme.name, "Gentleman-Cute");
134
+ });
135
+
136
+ test("bundled Gentleman-Sexy Pi theme defines complete brand and semantic color mappings", () => {
137
+ const theme = readJson<GentleThemeJson>(
138
+ join(PACKAGE_ROOT, "themes", "Gentleman-Sexy.json"),
139
+ );
140
+ const colors = theme.colors ?? {};
141
+
142
+ assert.deepEqual(
143
+ REQUIRED_THEME_COLOR_KEYS.filter((key) => !(key in colors)),
144
+ [],
145
+ );
146
+ assert.deepEqual(
147
+ ["scrollbarThumb", "searchMatchBg", "searchMatchText", "thinkingMax"].filter(
148
+ (key) => !(key in colors),
149
+ ),
150
+ [],
151
+ );
152
+
153
+ assertResolvedThemeColor(theme, "accent", "#F43888");
154
+ assert.equal(colors.borderAccent, "activePink");
155
+ assertResolvedThemeColor(theme, "borderAccent", "#FF4F9A");
156
+ assertResolvedThemeColor(theme, "selectedBg", "#28121E");
157
+ assertResolvedThemeColor(theme, "text", "#F6EFF3");
158
+ assertResolvedThemeColor(theme, "success", "#D2CBD0");
159
+ assertResolvedThemeColor(theme, "warning", "#F2B86D");
160
+ assertResolvedThemeColor(theme, "error", "#FF718F");
161
+ assertResolvedThemeColor(theme, "muted", "#A78E9B");
162
+ assertResolvedThemeColor(theme, "dim", "#76616B");
163
+ assertResolvedThemeColor(theme, "toolPendingBg", "#100A0F");
164
+ assertResolvedThemeColor(theme, "toolSuccessBg", "#151316");
165
+ assertResolvedThemeColor(theme, "toolErrorBg", "#261019");
166
+ assertResolvedThemeColor(theme, "toolTitle", "#E0C27A");
167
+ assertResolvedThemeColor(theme, "customMessageLabel", "#E0C27A");
168
+ assert.equal(colors.mdCodeBlockBorder, "muted");
169
+ assertResolvedThemeColor(theme, "mdCodeBlockBorder", "#A78E9B");
170
+ assert.equal(colors.mdQuoteBorder, "deepPink");
171
+ assertResolvedThemeColor(theme, "mdQuoteBorder", "#BF0F50");
172
+ assert.equal(colors.mdLink, "activePink");
173
+ assertResolvedThemeColor(theme, "mdLink", "#FF4F9A");
174
+ assert.equal(colors.mdListBullet, "activePink");
175
+ assertResolvedThemeColor(theme, "mdListBullet", "#FF4F9A");
176
+ assertResolvedThemeColor(theme, "toolDiffAdded", "#A9C7EE");
177
+ assert.equal(colors.syntaxKeyword, "deepPink");
178
+ assertResolvedThemeColor(theme, "syntaxKeyword", "#BF0F50");
179
+ assertResolvedThemeColor(theme, "syntaxFunction", "#C49BFF");
180
+ assert.equal(colors.syntaxOperator, "activePink");
181
+ assertResolvedThemeColor(theme, "syntaxOperator", "#FF4F9A");
182
+ assertResolvedThemeColor(theme, "syntaxString", "#E0C27A");
183
+ assertResolvedThemeColor(theme, "syntaxNumber", "#D7A0B8");
184
+ assertResolvedThemeColor(theme, "syntaxType", "#A9C7EE");
185
+ assert.equal(colors.thinkingXhigh, "activePink");
186
+ assertResolvedThemeColor(theme, "thinkingXhigh", "#FF4F9A");
187
+ assert.equal(colors.thinkingMax, "accent");
188
+ assertResolvedThemeColor(theme, "thinkingMax", "#F43888");
189
+ assert.equal(theme.export?.pageBg, "bg");
190
+ assert.equal(theme.export?.cardBg, "bgElement");
191
+ assert.equal(theme.export?.infoBg, "infoBg");
192
+ });
193
+
194
+ test("bundled Gentleman-Cute Pi theme defines complete brand and restrained color mappings", () => {
195
+ const theme = readJson<GentleThemeJson>(
196
+ join(PACKAGE_ROOT, "themes", "Gentleman-Cute.json"),
197
+ );
198
+ const colors = theme.colors ?? {};
199
+
200
+ assert.deepEqual(
201
+ REQUIRED_THEME_COLOR_KEYS.filter((key) => !(key in colors)),
202
+ [],
203
+ );
204
+ assert.deepEqual(
205
+ ["scrollbarThumb", "searchMatchBg", "searchMatchText", "thinkingMax"].filter(
206
+ (key) => !(key in colors),
207
+ ),
208
+ [],
209
+ );
210
+
211
+ assertResolvedThemeColor(theme, "accent", "#F095C8");
212
+ assertResolvedThemeColor(theme, "searchMatchBg", "#C96AA2");
213
+ assert.equal(colors.borderAccent, "activePink");
214
+ assertResolvedThemeColor(theme, "borderAccent", "#FFB1DD");
215
+ assertResolvedThemeColor(theme, "selectedBg", "#28121E");
216
+ assertResolvedThemeColor(theme, "text", "#F6EFF3");
217
+ assertResolvedThemeColor(theme, "success", "#B4E7C7");
218
+ assertResolvedThemeColor(theme, "warning", "#F2B86D");
219
+ assertResolvedThemeColor(theme, "error", "#FF718F");
220
+ assertResolvedThemeColor(theme, "muted", "#A78E9B");
221
+ assertResolvedThemeColor(theme, "dim", "#76616B");
222
+ assertResolvedThemeColor(theme, "toolPendingBg", "#100A0F");
223
+ assertResolvedThemeColor(theme, "toolSuccessBg", "#151316");
224
+ assertResolvedThemeColor(theme, "toolErrorBg", "#261019");
225
+ assertResolvedThemeColor(theme, "toolTitle", "#E0C27A");
226
+ assertResolvedThemeColor(theme, "customMessageLabel", "#E0C27A");
227
+ assert.equal(colors.mdCodeBlockBorder, "muted");
228
+ assertResolvedThemeColor(theme, "mdCodeBlockBorder", "#A78E9B");
229
+ assert.equal(colors.mdQuoteBorder, "deepPink");
230
+ assertResolvedThemeColor(theme, "mdQuoteBorder", "#C96AA2");
231
+ assert.equal(colors.mdLink, "accent");
232
+ assertResolvedThemeColor(theme, "mdLink", "#F095C8");
233
+ assert.equal(colors.mdListBullet, "accent");
234
+ assertResolvedThemeColor(theme, "mdListBullet", "#F095C8");
235
+ assertResolvedThemeColor(theme, "toolDiffAdded", "#B4E7C7");
236
+ assert.equal(colors.syntaxKeyword, "accent");
237
+ assertResolvedThemeColor(theme, "syntaxKeyword", "#F095C8");
238
+ assertResolvedThemeColor(theme, "syntaxFunction", "#A9C7EE");
239
+ assert.equal(colors.syntaxOperator, "sky");
240
+ assertResolvedThemeColor(theme, "syntaxOperator", "#C4DAF6");
241
+ assertResolvedThemeColor(theme, "syntaxString", "#B4E7C7");
242
+ assertResolvedThemeColor(theme, "syntaxNumber", "#F2B86D");
243
+ assertResolvedThemeColor(theme, "syntaxType", "#E0C27A");
244
+ assert.equal(colors.thinkingXhigh, "accent");
245
+ assertResolvedThemeColor(theme, "thinkingXhigh", "#F095C8");
246
+ assert.equal(colors.thinkingMax, "activePink");
247
+ assertResolvedThemeColor(theme, "thinkingMax", "#FFB1DD");
248
+ assert.equal(theme.export?.pageBg, "bg");
249
+ assert.equal(theme.export?.cardBg, "bgElement");
250
+ assert.equal(theme.export?.infoBg, "infoBg");
251
+ });
252
+
120
253
  test("bundled Pi theme is named exactly Gentle and defines all required colors", () => {
121
254
  const theme = readJson<GentleThemeJson>(
122
255
  join(PACKAGE_ROOT, "themes", "Gentle.json"),
@@ -9,12 +9,12 @@
9
9
  // them via env. The baseline is described generically; external evidence
10
10
  // establishes whether it is the immutable RC8 runtime.
11
11
  import assert from "node:assert/strict";
12
- import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
12
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
- import { dirname, join } from "node:path";
14
+ import { delimiter, dirname, isAbsolute, join, relative } from "node:path";
15
15
  import test from "node:test";
16
16
  import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
17
- import { ARM_POSITIVE_ENV, CASE_KINDS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, loadDescriptor, resolveDeclaredExecutable, runMatrix, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs";
17
+ import { ARM_POSITIVE_ENV, CASE_KINDS, DEFAULT_ROLE_VECTOR_TIMEOUT_MS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, PROVIDER_ROLE_VECTOR_KINDS, PROVIDER_ROLE_VECTOR_ROLE, PROVIDER_ROLE_VECTOR_VERB, ProviderRoleVectorError, ROLE_STREAM_MAX_BYTES, ROLE_VECTOR_FAILURE, loadDescriptor, resolveDeclaredExecutable, runMatrix, runProviderRoleVector, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs";
18
18
  const BASELINE_ENV = "GENTLE_PI_MAINTAINER_BASELINE_BINARY";
19
19
  const CAPABLE_ENV = "GENTLE_PI_MAINTAINER_CAPABLE_BINARY";
20
20
  const REQUIRE_ENV = "GENTLE_PI_REQUIRE_MAINTAINER";
@@ -47,6 +47,15 @@ const SUBMISSION = Object.freeze({
47
47
  values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]),
48
48
  });
49
49
 
50
+ const ROLE_LINEAGE = "review-fixture-role-vector", ROLE_REVISION = `sha256:${"a".repeat(64)}`;
51
+ const ROLE_TARGET = `sha256:${"b".repeat(64)}`;
52
+ const ROLE_CONTEXT = `rctx1_${"c".repeat(64)}`;
53
+ const ROLE_REQUEST_HASH = `sha256:${"9".repeat(64)}`;
54
+ const REFUTER_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected-revision=${ROLE_REVISION}`, `--target=${ROLE_TARGET}`, `--repository-context=${ROLE_CONTEXT}`, "--agent=pi", "--execute=true"]);
55
+ const VALIDATOR_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected-revision=${ROLE_REVISION}`, `--target=${ROLE_TARGET}`, `--repository-context=${ROLE_CONTEXT}`, `--request-hash=${ROLE_REQUEST_HASH}`, "--agent=pi", "--execute=true"]);
56
+ const VALIDATION_REQUEST = Object.freeze({ schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: ROLE_REQUEST_HASH });
57
+ const ROLE_ARTIFACT = Object.freeze({ schema: "gentle-ai.review-provider-role-capture/v1", lineage_id: ROLE_LINEAGE, target_identity: ROLE_TARGET, role: "refuter", captured: true });
58
+
50
59
  function descriptor(overrides = {}) {
51
60
  return {
52
61
  schema: DESCRIPTOR_SCHEMA,
@@ -83,7 +92,7 @@ test("rejects malformed descriptor fields with exact-shape errors (no defaults,
83
92
  rejects({ ...descriptor(), gentleAiExecutable: undefined }, "absolute path");
84
93
  rejects({ ...descriptor(), extra: 1 }, "descriptor.extra");
85
94
  rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, extra: 1 }] }, "extra");
86
- assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens"]);
95
+ assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens", ...PROVIDER_ROLE_VECTOR_KINDS]);
87
96
  rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, kind: "bogus" }] }, "kind must be one of");
88
97
  const dup = { ...descriptor().cases[0]!, name: "dup" };
89
98
  rejects({ ...descriptor(), cases: [structuredClone(dup), structuredClone(dup)] }, "duplicated");
@@ -108,6 +117,214 @@ test("loadDescriptor reads and validates a descriptor file", (t) => {
108
117
  assert.throws(() => loadDescriptor(join(dirname(path), "bad.json")), /not valid JSON/);
109
118
  });
110
119
 
120
+ function roleDescriptor(kind: "provider-role-refuter" | "provider-role-validator", overrides: Record<string, unknown> = {}) {
121
+ const tokens = kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS;
122
+ const entry: Record<string, unknown> = { name: `${kind}-case`, kind, argumentTokens: [...tokens] };
123
+ if (kind === "provider-role-validator") entry.validationRequest = structuredClone(VALIDATION_REQUEST);
124
+ return { schema: DESCRIPTOR_SCHEMA, gentleAiExecutable: PLACEHOLDER_BINARY, piExecutable: "pi", cases: [entry], ...overrides };
125
+ }
126
+ const ROLE_STUB_BINARY = sandboxPath("gentle-ai-role-stub");
127
+ writeFileSync(ROLE_STUB_BINARY, "stub");
128
+ test("role vector descriptors validate and return a normalized copy with exact provider tokens", () => {
129
+ assert.deepEqual([...PROVIDER_ROLE_VECTOR_KINDS], ["provider-role-refuter", "provider-role-validator"]);
130
+ assert.deepEqual(PROVIDER_ROLE_VECTOR_VERB, { "provider-role-refuter": "capture-refuter", "provider-role-validator": "capture-validation" });
131
+ assert.deepEqual(PROVIDER_ROLE_VECTOR_ROLE, { "provider-role-refuter": "refuter", "provider-role-validator": "targeted-validator" });
132
+ const refuter = validateDescriptor(roleDescriptor("provider-role-refuter")).cases[0]!;
133
+ assert.equal(refuter.kind, "provider-role-refuter");
134
+ assert.deepEqual(refuter.argumentTokens, [...REFUTER_TOKENS]);
135
+ assert.equal("validationRequest" in refuter, false);
136
+ assert.equal("captureArgumentTokens" in refuter, false);
137
+ assert.equal("submission" in refuter, false);
138
+ const validator = validateDescriptor(roleDescriptor("provider-role-validator")).cases[0]!;
139
+ assert.deepEqual(validator.argumentTokens, [...VALIDATOR_TOKENS]);
140
+ assert.deepEqual(validator.validationRequest, VALIDATION_REQUEST);
141
+ });
142
+ test("role vector descriptors require exactly one --agent=pi and --execute=true, never a lens slot", () => {
143
+ for (const [name, argumentTokens, expected] of [
144
+ ["missing agent", REFUTER_TOKENS.filter((t) => t !== "--agent=pi"), "--agent=pi"], ["duplicate agent", [...REFUTER_TOKENS, "--agent=pi"], "--agent=pi"],
145
+ ["conflicting agent", [...REFUTER_TOKENS, "--agent=other"], "--agent=pi"], ["missing execute", REFUTER_TOKENS.filter((t) => t !== "--execute=true"), "--execute=true"],
146
+ ["duplicate execute", [...REFUTER_TOKENS, "--execute=true"], "--execute=true"], ["conflicting execute", [...REFUTER_TOKENS, "--execute=false"], "--execute=true"],
147
+ ] as const) {
148
+ rejects(roleDescriptor("provider-role-refuter", { cases: [{ name, kind: "provider-role-refuter", argumentTokens: [...argumentTokens] }] }), expected);
149
+ }
150
+ rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, "--materialize=true"] }] }), "--materialize=true");
151
+ });
152
+ test("targeted-validator descriptor requires exactly one request hash matching validationRequest", () => {
153
+ const mismatched = { ...structuredClone(VALIDATION_REQUEST), requestHash: `sha256:${"0".repeat(64)}` };
154
+ rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: mismatched }] }), "must include exactly one");
155
+ rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS, `--request-hash=${ROLE_REQUEST_HASH}`], validationRequest: structuredClone(VALIDATION_REQUEST) }] }), "must include exactly one");
156
+ rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS] }] }), "validationRequest");
157
+ rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS], validationRequest: structuredClone(VALIDATION_REQUEST) }] }), "validationRequest");
158
+ const badHash = { ...structuredClone(VALIDATION_REQUEST), requestHash: "not-a-sha256" };
159
+ rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: badHash }] }), "sha256");
160
+ });
161
+
162
+ test("refuter vector: runMatrix executes exactly once through review.capture-refuter with exact provider tokens and returns the typed artifact", async () => {
163
+ const calls: Array<{ kind: string; argumentTokens: readonly string[]; gentleAiExecutable: string; validationRequest?: unknown }> = [];
164
+ const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
165
+ armPositive: true,
166
+ roleRunner: async (request) => {
167
+ calls.push({ kind: request.kind, argumentTokens: request.argumentTokens, gentleAiExecutable: request.gentleAiExecutable, ...(request.validationRequest === undefined ? {} : { validationRequest: request.validationRequest }) });
168
+ return { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: "refuter", captured: true };
169
+ },
170
+ });
171
+ assert.equal(calls.length, 1, "the refuter vector must execute exactly once");
172
+ assert.equal(calls[0]!.kind, "provider-role-refuter");
173
+ assert.equal("validationRequest" in calls[0]!, false, "the refuter vector must not carry a validation_request");
174
+ assert.deepEqual(calls[0]!.argumentTokens, [...REFUTER_TOKENS]);
175
+ assert.ok(calls[0]!.argumentTokens.includes("--execute=true"));
176
+ assert.ok(!calls[0]!.argumentTokens.includes("--materialize=true"));
177
+ assert.equal(calls[0]!.gentleAiExecutable, ROLE_STUB_BINARY);
178
+ assert.equal(verdict!.verdict, "pass");
179
+ assert.equal(verdict!.role, "refuter");
180
+ assert.equal(verdict!.captured, true);
181
+ assert.equal(verdict!.lineageId, ROLE_LINEAGE);
182
+ assert.equal(verdict!.targetIdentity, ROLE_TARGET);
183
+ });
184
+ test("validator vector: runMatrix executes exactly once through review.capture-validation and preserves the request-hash binding", async () => {
185
+ const calls: Array<{ kind: string; argumentTokens: readonly string[]; validationRequest?: unknown }> = [];
186
+ const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-validator"), gentleAiExecutable: ROLE_STUB_BINARY }), {
187
+ armPositive: true,
188
+ roleRunner: async (request) => {
189
+ calls.push({ kind: request.kind, argumentTokens: request.argumentTokens, ...(request.validationRequest === undefined ? {} : { validationRequest: request.validationRequest }) });
190
+ return { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: "targeted-validator", captured: true };
191
+ },
192
+ });
193
+ assert.equal(calls.length, 1, "the validator vector must execute exactly once");
194
+ assert.equal(calls[0]!.kind, "provider-role-validator");
195
+ assert.deepEqual(calls[0]!.argumentTokens, [...VALIDATOR_TOKENS]);
196
+ assert.ok(calls[0]!.argumentTokens.includes(`--request-hash=${ROLE_REQUEST_HASH}`));
197
+ assert.deepEqual(calls[0]!.validationRequest, VALIDATION_REQUEST);
198
+ assert.equal(verdict!.verdict, "pass");
199
+ assert.equal(verdict!.role, "targeted-validator");
200
+ assert.equal(verdict!.captured, true);
201
+ });
202
+ test("an unarmed role vector blocks loudly and never fakes green (no model launch)", async () => {
203
+ const calls: unknown[] = [];
204
+ const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
205
+ roleRunner: async () => { calls.push("ran"); return ROLE_ARTIFACT; },
206
+ });
207
+ assert.equal(calls.length, 0, "an unarmed role vector must never reach the runner");
208
+ assert.equal(verdict!.verdict, "blocked");
209
+ assert.match(verdict!.reason, new RegExp(ARM_POSITIVE_ENV));
210
+ assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
211
+ });
212
+ test("handshake refusal reports relay-contract negotiation, distinct from a missing role surface", async () => {
213
+ for (const [kind, message] of [[ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "relay handshake refused"], [ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "missing role surface"]] as const) {
214
+ const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { armPositive: true, roleRunner: async () => { throw new ProviderRoleVectorError(kind, "execute", message); } });
215
+ assert.equal(verdict!.verdict, "blocked"); assert.match(verdict!.reason, kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED ? /relay-contract negotiation/ : /provider role capture surface/);
216
+ }
217
+ });
218
+ test("negative control: an unsupported role surface fails closed with zero role invocation and no mutation", async () => {
219
+ const calls: unknown[] = [];
220
+ const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
221
+ armPositive: true,
222
+ roleRunner: async (request) => {
223
+ calls.push(request);
224
+ throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface");
225
+ },
226
+ });
227
+ assert.equal(calls.length, 1, "the runner was probed exactly once to detect the missing surface");
228
+ assert.equal(verdict!.verdict, "blocked");
229
+ assert.equal(verdict!.mutationOutcome, "none");
230
+ assert.match(verdict!.reason, /provider role capture surface/);
231
+ assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
232
+ assert.notEqual(verdict!.verdict, "pass");
233
+ assert.notEqual(verdict!.verdict, "fail");
234
+ });
235
+ test("negative control: a role vector failure (not surface-unavailable) is reported as fail, never pass", async () => {
236
+ const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
237
+ armPositive: true,
238
+ roleRunner: async () => {
239
+ throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed");
240
+ },
241
+ });
242
+ assert.equal(verdict!.verdict, "fail");
243
+ assert.match(verdict!.reason, /role-failed/);
244
+ });
245
+ test("runProviderRoleVector treats a prelaunch abort as not-started with no mutation", async () => {
246
+ const controller = new AbortController(); controller.abort();
247
+ await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: process.execPath, signal: controller.signal }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_ABORTED && error.stage === "launch" && error.mutationOutcome === "none");
248
+ });
249
+ function roleChild(t: test.TestContext, name: string, source: string) {
250
+ const path = sandboxPath(`${name} preload.cjs`), savedNodeOptions = process.env.NODE_OPTIONS; writeFileSync(path, `const roleArgv = ["review", ...process.argv.slice(2)], Module = require("node:module"), resolveFilename = Module._resolveFilename; Module._resolveFilename = function (request, ...args) { return /[\\\\/]review$/.test(request) ? ${JSON.stringify(path)} : resolveFilename.call(this, request, ...args); }; ${source}`);
251
+ t.after(() => { if (savedNodeOptions === undefined) delete process.env.NODE_OPTIONS; else process.env.NODE_OPTIONS = savedNodeOptions; rmSync(path, { force: true }); }); return { executable: process.execPath, env: { NODE_OPTIONS: `--require=${JSON.stringify(path)}` } };
252
+ }
253
+ test("runProviderRoleVector executes both real stub-child verbs and decodes snake_case artifacts", async (t) => {
254
+ for (const kind of PROVIDER_ROLE_VECTOR_KINDS) {
255
+ const log = sandboxPath(`${kind}.json`), child = roleChild(t, `${kind}.cjs`, `const fs = require("node:fs"), refuter = roleArgv[1] === "capture-refuter"; fs.writeFileSync(${JSON.stringify(sandboxPath("ROLE_LOG_PLACEHOLDER"))}.replace("ROLE_LOG_PLACEHOLDER", ${JSON.stringify(`${kind}.json`)}), JSON.stringify(roleArgv)); process.stdout.write(JSON.stringify({ schema: ${JSON.stringify(PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA)}, lineage_id: ${JSON.stringify(ROLE_LINEAGE)}, target_identity: ${JSON.stringify(ROLE_TARGET)}, role: refuter ? "refuter" : "targeted-validator", captured: true })); process.exit(0);`);
256
+ const artifact = await runProviderRoleVector({ kind, argumentTokens: kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS, gentleAiExecutable: child.executable, env: child.env });
257
+ assert.deepEqual(JSON.parse(readFileSync(log, "utf8")), ["review", PROVIDER_ROLE_VECTOR_VERB[kind], ...(kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS)]);
258
+ assert.deepEqual(artifact, { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: PROVIDER_ROLE_VECTOR_ROLE[kind], captured: true });
259
+ }
260
+ });
261
+ test("runProviderRoleVector rejects malformed artifact binding shape before stale binding comparison", async (t) => {
262
+ for (const malformed of [{ lineage_id: "", target_identity: ROLE_TARGET }, { lineage_id: ROLE_LINEAGE, target_identity: "not-a-sha256" }]) {
263
+ const child = roleChild(t, `malformed-${malformed.lineage_id || "lineage"}.cjs`, `process.stdout.write(JSON.stringify({ schema: ${JSON.stringify(PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA)}, role: "refuter", captured: true, ...${JSON.stringify(malformed)} })); process.exit(0);`);
264
+ await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_FAILED && /typed shape/.test(error.message));
265
+ }
266
+ });
267
+ test("runProviderRoleVector classifies an empty successful artifact as unknown and requires STATUS re-query", async (t) => {
268
+ const child = roleChild(t, "empty-artifact.cjs", "process.exit(0);");
269
+ await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (caught) => {
270
+ assert.ok(caught instanceof ProviderRoleVectorError); assert.equal(caught.kind, ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT); assert.equal(caught.stage, "execute"); assert.equal(caught.mutationOutcome, "unknown"); assert.match(caught.message, /re-query negotiated STATUS before any retry/); return true;
271
+ });
272
+ });
273
+ test("runProviderRoleVector bounds each output stream before JSON or exit handling", async (t) => {
274
+ for (const stream of ["stdout", "stderr"] as const) {
275
+ const child = roleChild(t, `overflow-${stream}.cjs`, `process.${stream}.write("x".repeat(${ROLE_STREAM_MAX_BYTES + 1})); setInterval(() => {}, 1_000);`);
276
+ await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, timeoutMs: 1_000 }), (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW && error.mutationOutcome === "unknown");
277
+ }
278
+ });
279
+ async function descendantPid(path: string) {
280
+ const deadline = Date.now() + 500;
281
+ while (Date.now() < deadline) { if (existsSync(path)) { const pid = Number(readFileSync(path, "utf8")); if (pid > 0) return pid; } await new Promise((resolve) => setTimeout(resolve, 10)); }
282
+ assert.fail("fixture did not report a positive descendant PID");
283
+ }
284
+ async function assertReaped(pid: number) {
285
+ assert.ok(pid > 0, "only a positive descendant PID may be probed"); for (let attempt = 0; attempt < 50; attempt += 1) try { process.kill(pid, 0); await new Promise((resolve) => setTimeout(resolve, 10)); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return; throw error; }
286
+ assert.fail("role termination left a deterministic child-process descendant running");
287
+ }
288
+ test("role watchdog and abort reap descendants on every platform", async (t) => {
289
+ for (const mode of ["watchdog", "abort"] as const) await t.test(mode, async (t) => {
290
+ const grandchild = sandboxPath(`role-grandchild-${mode}.pid`), child = roleChild(t, `hang-role-child-${mode}.cjs`, `const fs = require("node:fs"), { spawn } = require("node:child_process"), grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", env: { ...process.env, NODE_OPTIONS: "" } }); fs.writeFileSync(${JSON.stringify(sandboxPath("ROLE_PID_PLACEHOLDER"))}.replace("ROLE_PID_PLACEHOLDER", ${JSON.stringify(`role-grandchild-${mode}.pid`)}), String(grandchild.pid)); setInterval(() => {}, 1000);`), controller = new AbortController();
291
+ const run = runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, timeoutMs: 1_000, signal: controller.signal }), pid = await descendantPid(grandchild);
292
+ t.after(() => { if (pid > 0) try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } });
293
+ assert.ok(DEFAULT_ROLE_VECTOR_TIMEOUT_MS > 600_000, "the outer 900s watchdog FIRES after the provider-owned 600s deadline with setup/cancellation margin");
294
+ if (mode === "abort") controller.abort();
295
+ await assert.rejects(run, (error) => error instanceof ProviderRoleVectorError && error.kind === (mode === "abort" ? ROLE_VECTOR_FAILURE.ROLE_ABORTED : ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT) && error.mutationOutcome === "unknown");
296
+ await assertReaped(pid);
297
+ });
298
+ });
299
+ test("failed tree termination settles despite a surviving descendant retaining inherited pipes", async (t) => {
300
+ const grandchild = sandboxPath("role-pipe-holder.pid"), controller = new AbortController();
301
+ const child = roleChild(t, "pipe-holder-role-child.cjs", `const fs = require("node:fs"), { spawn } = require("node:child_process"), grandchild = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "inherit", env: { ...process.env, NODE_OPTIONS: "" } }); fs.writeFileSync(${JSON.stringify(sandboxPath("ROLE_PID_PLACEHOLDER"))}.replace("ROLE_PID_PLACEHOLDER", ${JSON.stringify("role-pipe-holder.pid")}), String(grandchild.pid)); setInterval(() => {}, 1000);`);
302
+ const run = runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env, signal: controller.signal, terminateProcessTree: (process) => { process.kill("SIGKILL"); return false; } });
303
+ const pid = await descendantPid(grandchild); t.after(() => { try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } });
304
+ const started = Date.now(); controller.abort(); await assert.rejects(run, (error) => error instanceof ProviderRoleVectorError && error.kind === ROLE_VECTOR_FAILURE.ROLE_TERMINATION_FAILED && error.stage === "execute" && error.mutationOutcome === "unknown");
305
+ assert.ok(Date.now() - started < 500, "failed tree termination must not wait for descendant-held pipes to close"); assert.doesNotThrow(() => process.kill(pid, 0), "the fixture descendant must survive while retaining inherited pipes");
306
+ });
307
+ test("stale-target fail-closed: missing or duplicate required binding tokens are rejected before runner invocation", () => {
308
+ const drop = (tokens: readonly string[], prefix: string) => tokens.filter((t) => !t.startsWith(prefix));
309
+ for (const prefix of ["--lineage=", "--target=", "--expected-revision=", "--repository-context="]) {
310
+ rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: drop(REFUTER_TOKENS, prefix) }] }), `exactly one "${prefix}`);
311
+ }
312
+ rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, `--lineage=${ROLE_LINEAGE}`] }] }), "exactly one \"--lineage=\"");
313
+ const badTarget = REFUTER_TOKENS.map((t) => t.startsWith("--target=") ? "--target=not-a-sha256" : t);
314
+ rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: badTarget }] }), "malformed");
315
+ });
316
+ test("stale-target fail-closed: a returned artifact with mismatched lineage or target fails, never passes", async () => {
317
+ const stale = `sha256:${"f".repeat(64)}`;
318
+ for (const [lineageId, targetIdentity] of [[stale, ROLE_TARGET], [ROLE_LINEAGE, stale]] as const) {
319
+ const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
320
+ armPositive: true,
321
+ roleRunner: async () => ({ schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId, targetIdentity, role: "refuter", captured: true }),
322
+ });
323
+ assert.equal(verdict!.verdict, "fail");
324
+ assert.match(verdict!.reason, /stale artifact/);
325
+ }
326
+ });
327
+
111
328
  // ---------------------------------------------------------------------------
112
329
  // No-production-resolution — the runner uses only declared executables and
113
330
  // never falls through to the package-local production binary.
@@ -187,6 +404,125 @@ test("a relay-unavailable case against a CAPABLE binary fails closed at pi: zero
187
404
  // read-only (mutationOutcome stays "none" before submit).
188
405
  assert.equal(existsSync(stub.materializeLog), true);
189
406
  });
407
+ // ---------------------------------------------------------------------------
408
+ // Resolve-once (POSIX subprocess proof) — a bare Pi declaration is resolved
409
+ // exactly once at the precheck and never re-resolved between precheck and
410
+ // launch. Deterministic local stub scenario: the first PATH candidate is
411
+ // resolved at precheck, a capable gentle-ai removes it during materialization,
412
+ // and a second same-name PATH candidate remains. The fixed code must attempt
413
+ // only the first concrete path (now gone) and fail closed — never fall through
414
+ // to launch the second. This is a POSIX shebang/executable fixture: it spawns
415
+ // real subprocesses with `shell:false`, which cannot execute `.bat`/`.cmd`
416
+ // launchers on Windows. Windows native launcher execution is #311 P8 evidence,
417
+ // not silently claimed here.
418
+ // ---------------------------------------------------------------------------
419
+ test("a bare Pi declaration is resolved once: the relay never falls through to a second PATH candidate after materialization removes the first", { skip: process.platform === "win32" && "POSIX shebang/executable subprocess fixture; Windows .cmd launcher execution (shell:false) is #311 P8 evidence, not claimed here" }, async (t) => {
420
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-resolve-once-"));
421
+ chmodSync(root, 0o700);
422
+ t.after(() => rmSync(root, { recursive: true, force: true }));
423
+ const dirA = join(root, "path-a");
424
+ const dirB = join(root, "path-b");
425
+ mkdirSync(dirA, { recursive: true });
426
+ mkdirSync(dirB, { recursive: true });
427
+ chmodSync(dirA, 0o700);
428
+ chmodSync(dirB, 0o700);
429
+ const firstPiLog = join(root, "pi-first-launched");
430
+ const secondPiLog = join(root, "pi-second-launched");
431
+ const firstPi = join(dirA, "pi");
432
+ const secondPi = join(dirB, "pi");
433
+ // First PATH candidate: the one the precheck resolves.
434
+ writeFileSync(firstPi, `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(firstPiLog)}, "launched");\nprocess.exit(0);\n`);
435
+ chmodSync(firstPi, 0o755);
436
+ // Second PATH candidate: must NEVER launch under the fix.
437
+ writeFileSync(secondPi, `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(secondPiLog)}, "launched");\nprocess.exit(0);\n`);
438
+ chmodSync(secondPi, 0o755);
439
+ // A capable gentle-ai that removes the FIRST pi candidate during
440
+ // materialization, simulating a provider that cleans up its own runtime
441
+ // while a same-name second candidate remains on PATH.
442
+ const gentleAi = join(root, "gentle-ai");
443
+ writeFileSync(gentleAi, `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nif (argv.includes("--materialize=true")) {\n\ttry { fs.rmSync(${JSON.stringify(firstPi)}, { force: true }); } catch {}\n\tprocess.stdout.write("prompt-bytes");\n\tprocess.exit(0);\n}\nprocess.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));\nprocess.exit(0);\n`);
444
+ chmodSync(gentleAi, 0o755);
445
+ const savedPath = process.env.PATH;
446
+ process.env.PATH = [dirA, dirB, savedPath].join(delimiter);
447
+ t.after(() => { process.env.PATH = savedPath; });
448
+ const [verdict] = await runMatrix(validateDescriptor({
449
+ ...descriptor(),
450
+ gentleAiExecutable: gentleAi,
451
+ piExecutable: "pi",
452
+ cases: [{ name: "resolve-once", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
453
+ }), { armPositive: true });
454
+ // The first pi was removed during materialization, so the relay must
455
+ // fail closed at pi-launch — never fall through to the second candidate.
456
+ assert.equal(verdict!.verdict, "fail");
457
+ assert.match(verdict!.reason, /pi-launch-failed/);
458
+ // The load-bearing assertion: the second PATH candidate never launched.
459
+ assert.equal(existsSync(secondPiLog), false, "the relay must never fall through to a second PATH candidate; the bare declaration is resolved once at precheck");
460
+ assert.equal(existsSync(firstPiLog), false, "the first candidate was removed during materialization and must not launch");
461
+ });
462
+ // ---------------------------------------------------------------------------
463
+ // Resolve-once (platform-neutral boundary proof) — runs on Windows AND POSIX.
464
+ // Proves runMatrix passes the first resolved concrete Pi path into the relay
465
+ // boundary, not the bare declaration and not a second PATH candidate. Uses the
466
+ // smallest dependency-injection seam in the existing options object: an
467
+ // optional `relay` function defaulting to the real relay. The injected fake
468
+ // records request.piExecutable and never executes a real subprocess, so no
469
+ // shebang/chmod/.cmd execution is involved. No real model or network.
470
+ // ---------------------------------------------------------------------------
471
+ test("platform-neutral: runMatrix passes the first resolved concrete Pi path into the relay boundary (no second resolution)", async (t) => {
472
+ // Keep the fixture on the same Windows volume as process.cwd() so the
473
+ // first PATH component genuinely exercises relative-path normalization.
474
+ const root = mkdtempSync(join(process.cwd(), ".gentle-pi-maintainer-portable-"));
475
+ chmodSync(root, 0o700);
476
+ t.after(() => rmSync(root, { recursive: true, force: true }));
477
+ const dirA = join(root, "path-a");
478
+ const dirB = join(root, "path-b");
479
+ mkdirSync(dirA, { recursive: true });
480
+ mkdirSync(dirB, { recursive: true });
481
+ chmodSync(dirA, 0o700);
482
+ chmodSync(dirB, 0o700);
483
+ const firstPi = join(dirA, "pi");
484
+ const secondPi = join(dirB, "pi");
485
+ // Ordinary files (no shebang/chmod needed): the injected relay never
486
+ // executes them. Only the precheck's existsSync/statSync touches them.
487
+ writeFileSync(firstPi, "first");
488
+ writeFileSync(secondPi, "second");
489
+ const gentleAi = join(root, "gentle-ai");
490
+ writeFileSync(gentleAi, "gentle-ai");
491
+ // First PATH component is RELATIVE to process.cwd(): a relative component
492
+ // must still normalize to the same absolute firstPi the precheck resolved.
493
+ const dirARelative = relative(process.cwd(), dirA);
494
+ assert.equal(isAbsolute(dirARelative), false, "fixture must keep the first PATH component relative");
495
+ const savedPath = process.env.PATH;
496
+ process.env.PATH = [dirARelative, dirB, savedPath].join(delimiter);
497
+ t.after(() => { process.env.PATH = savedPath; });
498
+ let receivedPiExecutable: string | undefined;
499
+ const [verdict] = await runMatrix(validateDescriptor({
500
+ ...descriptor(),
501
+ gentleAiExecutable: gentleAi,
502
+ piExecutable: "pi",
503
+ cases: [{ name: "portable-resolve-once", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
504
+ }), {
505
+ armPositive: true,
506
+ relay: async (request) => {
507
+ receivedPiExecutable = request.piExecutable;
508
+ // Simulate materialization removing the first candidate; the
509
+ // path was captured at precheck and must not be re-resolved.
510
+ rmSync(firstPi, { force: true });
511
+ return { promptByteLength: 1, resultByteLength: 1, submission: "{}" };
512
+ },
513
+ });
514
+ // The relay received the EXACT first concrete resolved path — equality to
515
+ // the absolute fixture proves normalization held under a relative PATH.
516
+ assert.equal(receivedPiExecutable, firstPi);
517
+ assert.notEqual(receivedPiExecutable, secondPi);
518
+ assert.notEqual(receivedPiExecutable, "pi");
519
+ assert.equal(verdict!.verdict, "pass");
520
+ // The first candidate was removed during the relay call; the second is
521
+ // untouched, proving no fallthrough resolution occurred.
522
+ assert.equal(existsSync(firstPi), false);
523
+ assert.equal(existsSync(secondPi), true);
524
+ });
525
+
190
526
  // ---------------------------------------------------------------------------
191
527
  // Negative control — a baseline runtime without --materialize fails closed as
192
528
  // relay-unavailable with zero Pi launch and zero submission. The baseline is