gentle-pi 2.1.2 → 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 (228) hide show
  1. package/README.md +108 -79
  2. package/assets/agents/gentle-ai-worker.md +7 -3
  3. package/assets/agents/jd-fix-agent.md +1 -1
  4. package/assets/agents/jd-judge-a.md +3 -1
  5. package/assets/agents/jd-judge-b.md +3 -1
  6. package/assets/agents/review-readability.md +4 -3
  7. package/assets/agents/review-reliability.md +4 -3
  8. package/assets/agents/review-resilience.md +4 -3
  9. package/assets/agents/review-risk.md +4 -3
  10. package/assets/agents/sdd-apply.md +11 -4
  11. package/assets/agents/sdd-archive.md +6 -1
  12. package/assets/agents/sdd-design.md +6 -1
  13. package/assets/agents/sdd-explore.md +6 -2
  14. package/assets/agents/sdd-init.md +10 -2
  15. package/assets/agents/sdd-onboard.md +6 -1
  16. package/assets/agents/sdd-proposal.md +8 -1
  17. package/assets/agents/sdd-research.md +54 -0
  18. package/assets/agents/sdd-spec.md +6 -1
  19. package/assets/agents/sdd-status.md +10 -5
  20. package/assets/agents/sdd-sync.md +6 -1
  21. package/assets/agents/sdd-tasks.md +8 -4
  22. package/assets/agents/sdd-verify.md +27 -2
  23. package/assets/chains/4r-review.chain.md +2 -0
  24. package/assets/chains/sdd-full.chain.md +2 -2
  25. package/assets/chains/sdd-plan.chain.md +1 -1
  26. package/assets/chains/sdd-verify.chain.md +2 -2
  27. package/assets/orchestrator-delegation.md +145 -160
  28. package/assets/orchestrator-memory.md +2 -0
  29. package/assets/orchestrator.md +25 -48
  30. package/assets/sdd-orchestrator-workflow.md +163 -25
  31. package/assets/support/sdd-status-contract.md +24 -6
  32. package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
  33. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
  34. package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
  35. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
  36. package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
  37. package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
  38. package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
  39. package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
  40. package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
  41. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  42. package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
  43. package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
  44. package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
  45. package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
  46. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  47. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  48. package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
  49. package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
  50. package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
  51. package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
  52. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
  53. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
  54. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
  55. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
  56. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
  57. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
  58. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
  59. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
  60. package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
  61. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
  62. package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
  63. package/docs/native-authority-architecture.md +14 -16
  64. package/docs/review-integration.md +27 -353
  65. package/extensions/ask-user-choice.ts +151 -0
  66. package/extensions/gentle-ai.ts +2710 -3211
  67. package/extensions/quiet-tools.ts +515 -32
  68. package/extensions/sdd-init.ts +21 -12
  69. package/extensions/skill-registry.ts +10 -2
  70. package/extensions/startup-banner.ts +10 -4
  71. package/lib/gentle-ai-binary.ts +173 -2
  72. package/lib/gentle-ai-renderer.ts +70 -0
  73. package/lib/model-routing-authority.ts +133 -0
  74. package/lib/native-review-cli.ts +676 -781
  75. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  76. package/lib/provider-contract-bundle.ts +704 -0
  77. package/lib/review-candidate-view.ts +836 -118
  78. package/lib/review-compact-contract.ts +59 -248
  79. package/lib/review-host-relay.ts +578 -0
  80. package/lib/review-integration-v2.ts +1254 -221
  81. package/lib/review-last-event-controller.ts +35 -0
  82. package/lib/review-relay-contract.ts +16 -0
  83. package/lib/sdd-preflight.ts +230 -67
  84. package/lib/sdd-status.ts +66 -111
  85. package/lib/terminal-theme.ts +1 -1
  86. package/package.json +83 -79
  87. package/runtime/gentle-ai-binary.mjs +174 -3
  88. package/runtime/native-review-cli.mjs +610 -715
  89. package/runtime/review-integration-v2.mjs +1223 -190
  90. package/runtime/review-relay-contract.mjs +17 -0
  91. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
  92. package/scripts/check-provider-contract.mjs +138 -0
  93. package/scripts/gentle-ai-installer.mjs +85 -22
  94. package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
  95. package/scripts/mirror-provider-contract.mjs +143 -0
  96. package/scripts/test-packed-runner.mjs +20 -9
  97. package/scripts/verify-package-files.mjs +119 -43
  98. package/skills/_shared/review-ledger-contract.md +11 -19
  99. package/skills/chained-pr/SKILL.md +3 -0
  100. package/skills/cognitive-doc-design/SKILL.md +1 -1
  101. package/skills/comment-writer/SKILL.md +1 -1
  102. package/skills/gentle-ai/SKILL.md +7 -74
  103. package/skills/issue-creation/SKILL.md +94 -168
  104. package/skills/judgment-day/SKILL.md +9 -5
  105. package/skills/judgment-day/references/prompts-and-formats.md +2 -0
  106. package/skills/rdd-defect-workflow/SKILL.md +54 -0
  107. package/skills/release/SKILL.md +3 -3
  108. package/skills/skill-registry/SKILL.md +1 -1
  109. package/skills/work-unit-commits/SKILL.md +3 -1
  110. package/tests/artifact-language.test.ts +24 -11
  111. package/tests/ask-user-choice.test.ts +264 -0
  112. package/tests/background-subagents.test.ts +771 -0
  113. package/tests/codegraph-tools.test.ts +3 -3
  114. package/tests/crosslane/cross-lane.mjs +16 -0
  115. package/tests/delegated-key-learnings-contract.test.ts +240 -0
  116. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  117. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  118. package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
  119. package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
  120. package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
  121. package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
  122. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  123. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  124. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  125. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  126. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  127. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  128. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  129. package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
  130. package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
  131. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  132. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  133. package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
  134. package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
  135. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  136. package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
  137. package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
  138. package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
  139. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  140. package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
  141. package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
  142. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
  143. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
  144. package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
  145. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
  148. package/tests/gentle-ai-binary.test.ts +83 -13
  149. package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
  150. package/tests/gentle-ai-dev-binary.test.ts +159 -0
  151. package/tests/gentle-ai-installer.test.ts +138 -48
  152. package/tests/gentle-ai.test.ts +820 -131
  153. package/tests/gentle-theme.test.ts +133 -0
  154. package/tests/maintainer/provider-relay.maintest.ts +601 -0
  155. package/tests/model-routing-authority.test.ts +257 -0
  156. package/tests/native-review-capability-contract.test.ts +75 -2
  157. package/tests/native-review-cli.test.ts +588 -908
  158. package/tests/native-review-consent.test.ts +283 -38
  159. package/tests/native-review-parity-runtime.test.ts +108 -350
  160. package/tests/native-review-parity.test.ts +684 -732
  161. package/tests/native-sdd-attempt-authority.test.ts +235 -0
  162. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  163. package/tests/orchestrator-budget.test.ts +158 -89
  164. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  165. package/tests/package-manifest.test.ts +128 -100
  166. package/tests/provider-contract-bundle.test.ts +385 -0
  167. package/tests/provider-contract-mirror.test.ts +206 -0
  168. package/tests/provider-defect-handoff.test.ts +252 -0
  169. package/tests/quiet-tool-rendering.test.ts +1055 -28
  170. package/tests/review-actor-tool-deny.test.ts +12 -13
  171. package/tests/review-authority-recovery-docs.test.ts +1 -2
  172. package/tests/review-candidate-view.test.ts +898 -12
  173. package/tests/review-compact-contract.test.ts +29 -122
  174. package/tests/review-controller-lock-status.test.ts +2 -2
  175. package/tests/review-controller-native-recovery.test.ts +366 -857
  176. package/tests/review-controller-native-routing.test.ts +1253 -4199
  177. package/tests/review-controller-retired-ops.test.ts +1 -1
  178. package/tests/review-controller-workspace-root.test.ts +236 -70
  179. package/tests/review-controller.test.ts +26 -816
  180. package/tests/review-corrected-finalize-binding.test.ts +134 -0
  181. package/tests/review-dispatch-hydration-gap.test.ts +145 -0
  182. package/tests/review-gate.test.ts +0 -45
  183. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  184. package/tests/review-host-relay-routing.test.ts +352 -0
  185. package/tests/review-host-relay.test.ts +754 -0
  186. package/tests/review-integration-v2-forward.test.ts +654 -0
  187. package/tests/review-integration-v2.test.ts +317 -129
  188. package/tests/review-last-event-closure.test.ts +408 -0
  189. package/tests/review-ledger-contract.test.ts +106 -60
  190. package/tests/review-recovered-lineage-routing.test.ts +199 -0
  191. package/tests/review-relay-transport-agent.test.ts +322 -0
  192. package/tests/review-snapshot.test.ts +3 -2
  193. package/tests/runtime-harness.mjs +573 -180
  194. package/tests/sdd-agent-tools.test.ts +53 -32
  195. package/tests/sdd-preflight.test.ts +81 -15
  196. package/tests/sdd-status.test.ts +109 -110
  197. package/tests/skill-collision-prefixes.test.ts +6 -8
  198. package/tests/skill-registry.test.ts +50 -1
  199. package/tests/verify-package-files.test.ts +62 -0
  200. package/tests/writer-edit-surface-scope.test.ts +230 -0
  201. package/themes/Gentleman-Cute.json +94 -0
  202. package/themes/Gentleman-Sexy.json +92 -0
  203. package/assets/agents/review-refuter.md +0 -40
  204. package/assets/agents/review-validator.md +0 -23
  205. package/lib/git-commit-transaction.ts +0 -801
  206. package/lib/native-review-remediation.ts +0 -49
  207. package/lib/review-compact.ts +0 -947
  208. package/lib/review-refuter-adapter.ts +0 -129
  209. package/lib/review-runtime-contract.ts +0 -68
  210. package/prompts/gcl.md +0 -54
  211. package/prompts/gis.md +0 -25
  212. package/prompts/gpr.md +0 -41
  213. package/prompts/gwr.md +0 -31
  214. package/runtime/git-commit-transaction.mjs +0 -802
  215. package/scripts/run-git-commit-transaction.mjs +0 -35
  216. package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
  217. package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
  218. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
  219. package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
  220. package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
  221. package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
  222. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
  223. package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
  224. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  225. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  226. package/tests/git-commit-transaction.test.ts +0 -302
  227. package/tests/review-compact.test.ts +0 -243
  228. package/tests/review-refuter-adapter.test.ts +0 -89
@@ -0,0 +1,601 @@
1
+ // Maintainer provider-relay matrix — behavior-first tests (gentle-pi#311).
2
+ //
3
+ // `pnpm test` never picks this up: it globs `tests/*.test.ts` only, and this
4
+ // file lives one directory deeper and ends in `.maintest.ts`. It runs only
5
+ // via `pnpm run test:maintainer`. Strict validation + no-production-resolution
6
+ // always run; real-binary tests run only when env-supplied maintainer binaries
7
+ // exist (self-skip; fail-loud under GENTLE_PI_REQUIRE_MAINTAINER=1). Never fakes
8
+ // green. Tests never hardcode machine-specific paths; the verifier supplies
9
+ // them via env. The baseline is described generically; external evidence
10
+ // establishes whether it is the immutable RC8 runtime.
11
+ import assert from "node:assert/strict";
12
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { delimiter, dirname, isAbsolute, join, relative } from "node:path";
15
+ import test from "node:test";
16
+ import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
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
+ const BASELINE_ENV = "GENTLE_PI_MAINTAINER_BASELINE_BINARY";
19
+ const CAPABLE_ENV = "GENTLE_PI_MAINTAINER_CAPABLE_BINARY";
20
+ const REQUIRE_ENV = "GENTLE_PI_REQUIRE_MAINTAINER";
21
+ // Every declared-but-nonexistent executable path these tests use lives inside
22
+ // one private sandbox. A predictable /tmp path is squattable: another user can
23
+ // pre-create it between runs, and a test that resolves it would then spawn a
24
+ // file it never wrote. mkdtemp's unpredictable name plus 0700 removes that.
25
+ const SANDBOX = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-sandbox-"));
26
+ chmodSync(SANDBOX, 0o700);
27
+ process.on("exit", () => rmSync(SANDBOX, { recursive: true, force: true }));
28
+ const sandboxPath = (name: string) => join(SANDBOX, name);
29
+ const PLACEHOLDER_BINARY = sandboxPath("not-a-real-gentle-ai-binary");
30
+ const baselineBinary = process.env[BASELINE_ENV];
31
+ const capableBinary = process.env[CAPABLE_ENV];
32
+ const armed = process.env[REQUIRE_ENV] === "1";
33
+
34
+ const BINDING_TOKENS = Object.freeze([
35
+ "--lineage=review-1d5aadacc600e167",
36
+ `--expected-revision=sha256:${"c".repeat(64)}`,
37
+ `--target=sha256:${"d".repeat(64)}`,
38
+ `--repository-context=rctx1_${"e".repeat(64)}`,
39
+ "--lens=review-reliability",
40
+ "--order=0",
41
+ `--subject-hash=sha256:${"a".repeat(64)}`,
42
+ ]);
43
+ const CAPTURE_TOKENS = Object.freeze([...BINDING_TOKENS, "--agent=pi", "--materialize=true"]);
44
+ const SUBMISSION = Object.freeze({
45
+ operationToken: "capture-result",
46
+ argumentTokens: Object.freeze([...BINDING_TOKENS, "--input={{value}}"]),
47
+ values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]),
48
+ });
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
+
59
+ function descriptor(overrides = {}) {
60
+ return {
61
+ schema: DESCRIPTOR_SCHEMA,
62
+ gentleAiExecutable: PLACEHOLDER_BINARY,
63
+ piExecutable: "pi",
64
+ cases: [{ name: "baseline-negative-control", kind: "relay-unavailable", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
65
+ ...overrides,
66
+ };
67
+ }
68
+ function rejects(obj, fragment) {
69
+ assert.throws(() => validateDescriptor(obj), (error) => error instanceof DescriptorValidationError && error.message.includes(fragment));
70
+ }
71
+ function tempDescriptor(t, obj) {
72
+ const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-"));
73
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
74
+ const path = join(directory, "d.json");
75
+ writeFileSync(path, JSON.stringify(obj));
76
+ return path;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Strict descriptor validation — exact shape, no defaults, no production
81
+ // resolution. Pure/deterministic; always runs.
82
+ // ---------------------------------------------------------------------------
83
+ test("valid descriptor validates and returns a normalized copy", () => {
84
+ const d = validateDescriptor(descriptor());
85
+ assert.equal(d.schema, DESCRIPTOR_SCHEMA);
86
+ assert.deepEqual(d.cases[0]!.captureArgumentTokens, [...CAPTURE_TOKENS]);
87
+ assert.deepEqual(d.cases[0]!.submission.values, SUBMISSION.values);
88
+ });
89
+ test("rejects malformed descriptor fields with exact-shape errors (no defaults, no production resolution)", () => {
90
+ rejects({ ...descriptor(), schema: "gentle-pi.maintainer.provider-relay-descriptor/v2" }, "descriptor.schema must be exactly");
91
+ rejects({ ...descriptor(), gentleAiExecutable: "gentle-ai" }, "absolute path");
92
+ rejects({ ...descriptor(), gentleAiExecutable: undefined }, "absolute path");
93
+ rejects({ ...descriptor(), extra: 1 }, "descriptor.extra");
94
+ rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, extra: 1 }] }, "extra");
95
+ assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens", ...PROVIDER_ROLE_VECTOR_KINDS]);
96
+ rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, kind: "bogus" }] }, "kind must be one of");
97
+ const dup = { ...descriptor().cases[0]!, name: "dup" };
98
+ rejects({ ...descriptor(), cases: [structuredClone(dup), structuredClone(dup)] }, "duplicated");
99
+ // Every case must declare the real provider-issued materialize slot.
100
+ rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, captureArgumentTokens: [...CAPTURE_TOKENS].filter((t) => t !== "--agent=pi") }] }, "--agent=pi");
101
+ rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, captureArgumentTokens: [...CAPTURE_TOKENS].filter((t) => t !== "--materialize=true") }] }, "--materialize=true");
102
+ });
103
+ test("submission is validated through the real relay resolver before any process launches (exact shape)", () => {
104
+ const twoValues = structuredClone(SUBMISSION);
105
+ twoValues.values = [...SUBMISSION.values, { slot: "extra", domain: "artifact_path_or_stdin", substitutionLocation: 0 }];
106
+ rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: twoValues }] }, "not a bindable provider form");
107
+ const noSlot = structuredClone(SUBMISSION);
108
+ noSlot.argumentTokens = [...BINDING_TOKENS, "--input=/no/value/slot"];
109
+ rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: noSlot }] }, "not a bindable provider form");
110
+ rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: { ...structuredClone(SUBMISSION), extra: 1 } }] }, "extra");
111
+ });
112
+ test("loadDescriptor reads and validates a descriptor file", (t) => {
113
+ const path = tempDescriptor(t, descriptor());
114
+ assert.equal(loadDescriptor(path).schema, DESCRIPTOR_SCHEMA);
115
+ assert.throws(() => loadDescriptor(join(dirname(path), "missing.json")), /could not read descriptor/);
116
+ writeFileSync(join(dirname(path), "bad.json"), "{not json");
117
+ assert.throws(() => loadDescriptor(join(dirname(path), "bad.json")), /not valid JSON/);
118
+ });
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
+
328
+ // ---------------------------------------------------------------------------
329
+ // No-production-resolution — the runner uses only declared executables and
330
+ // never falls through to the package-local production binary.
331
+ // ---------------------------------------------------------------------------
332
+ test("runMatrix uses only the declared executable, never the production resolver", async () => {
333
+ const [verdict] = await runMatrix(validateDescriptor({ ...descriptor(), gentleAiExecutable: sandboxPath("not-a-gentle-ai-binary") }));
334
+ assert.equal(verdict!.verdict, "blocked");
335
+ assert.match(verdict!.reason, /gentleAiExecutable/);
336
+ assert.notEqual(verdict!.verdict, "pass");
337
+ });
338
+ test("resolveDeclaredExecutable checks absolute paths verbatim and bare names on PATH only", () => {
339
+ assert.equal(resolveDeclaredExecutable(sandboxPath("nonexistent-absolute-123")), null);
340
+ assert.equal(resolveDeclaredExecutable(""), null);
341
+ });
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Mis-declared capable binary — a `relay-unavailable` case is a negative
345
+ // control, so the arm gate must not trust the maintainer-declared `kind`. A
346
+ // declaration-only gate pointed at a CAPABLE binary would materialize, launch
347
+ // a REAL pi model, and run a REAL `capture-result --input=...` submission
348
+ // before reporting `fail`: the mutation would already have happened. Driven by
349
+ // local stub executables (the repo's fake-executable idiom), so it is
350
+ // deterministic and needs no arming.
351
+ // ---------------------------------------------------------------------------
352
+ function capableStubHarness(t: test.TestContext) {
353
+ const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-capable-stub-"));
354
+ chmodSync(directory, 0o700);
355
+ t.after(() => rmSync(directory, { recursive: true, force: true }));
356
+ const materializeLog = join(directory, "materialized");
357
+ const submitLog = join(directory, "submitted");
358
+ const piLog = join(directory, "pi-launched");
359
+ const gentleAi = join(directory, "gentle-ai");
360
+ // Deliberately CAPABLE: it honours --materialize=true and would accept a
361
+ // submission, exactly like a binary a maintainer mis-declared as incapable.
362
+ writeFileSync(
363
+ gentleAi,
364
+ `#!/usr/bin/env node
365
+ const fs = require("node:fs");
366
+ const argv = process.argv.slice(2);
367
+ if (argv.includes("--materialize=true")) {
368
+ fs.writeFileSync(${JSON.stringify(materializeLog)}, JSON.stringify(argv));
369
+ process.stdout.write("prompt-bytes");
370
+ process.exit(0);
371
+ }
372
+ fs.writeFileSync(${JSON.stringify(submitLog)}, JSON.stringify(argv));
373
+ process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
374
+ process.exit(0);
375
+ `,
376
+ );
377
+ chmodSync(gentleAi, 0o755);
378
+ const pi = join(directory, "pi");
379
+ writeFileSync(
380
+ pi,
381
+ `#!/usr/bin/env node
382
+ const fs = require("node:fs");
383
+ fs.writeFileSync(${JSON.stringify(piLog)}, "launched");
384
+ const chunks = [];
385
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
386
+ process.stdin.on("end", () => { process.stdout.write("pi-result-bytes"); process.exit(0); });
387
+ `,
388
+ );
389
+ chmodSync(pi, 0o755);
390
+ return { gentleAi, pi, materializeLog, submitLog, piLog };
391
+ }
392
+ test("a relay-unavailable case against a CAPABLE binary fails closed at pi: zero pi launch, zero submission", async (t) => {
393
+ const stub = capableStubHarness(t);
394
+ const [verdict] = await runMatrix(validateDescriptor({ ...descriptor(), gentleAiExecutable: stub.gentleAi, piExecutable: stub.pi }));
395
+ // The declared binary really is capable, so the negative control cannot
396
+ // pass — but it must fail WITHOUT mutating anything.
397
+ assert.equal(verdict!.verdict, "fail");
398
+ assert.match(verdict!.reason, /kind=pi-launch-failed stage=pi mutationOutcome=none/);
399
+ // The load-bearing assertions: the real pi never ran and the real
400
+ // submission never fired.
401
+ assert.equal(existsSync(stub.piLog), false, "pi must never launch for a relay-unavailable case");
402
+ assert.equal(existsSync(stub.submitLog), false, "a relay-unavailable case must never reach submit");
403
+ // Materialize still happens: it is the honest capability probe, and it is
404
+ // read-only (mutationOutcome stays "none" before submit).
405
+ assert.equal(existsSync(stub.materializeLog), true);
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
+
526
+ // ---------------------------------------------------------------------------
527
+ // Negative control — a baseline runtime without --materialize fails closed as
528
+ // relay-unavailable with zero Pi launch and zero submission. The baseline is
529
+ // env-supplied (GENTLE_PI_MAINTAINER_BASELINE_BINARY); external evidence
530
+ // establishes whether it is the immutable RC8 runtime. Self-skip / fail-loud.
531
+ // ---------------------------------------------------------------------------
532
+ const baselineArmed = typeof baselineBinary === "string" && baselineBinary.length > 0 && baselineBinary.startsWith("/") && existsSync(baselineBinary);
533
+ const baselineReason = () => `${BASELINE_ENV} is unset or not an existing absolute path; supply the baseline gentle-ai binary (external evidence establishes whether it is RC8), or set ${REQUIRE_ENV}=1 to fail instead of skip.`;
534
+ if (!baselineArmed && armed) throw new Error(baselineReason());
535
+ if (!baselineArmed) console.log(`tests/maintainer/provider-relay.maintest.ts: ${baselineReason()}`);
536
+ function baselineDescriptor(kind = "relay-unavailable" as const) {
537
+ return validateDescriptor({ ...descriptor(), gentleAiExecutable: baselineBinary, cases: [{ name: "baseline-negative-control", kind, captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }] });
538
+ }
539
+ test("negative control: runMatrix proves the baseline fails closed as relay-unavailable with zero mutation", { skip: !baselineArmed }, async () => {
540
+ const [verdict] = await runMatrix(baselineDescriptor());
541
+ assert.equal(verdict!.kind, "relay-unavailable");
542
+ assert.equal(verdict!.verdict, "pass");
543
+ assert.equal(verdict!.stage, "materialize");
544
+ assert.equal(verdict!.mutationOutcome, "none");
545
+ assert.equal(verdict!.reason, REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE);
546
+ });
547
+ test("negative control: the real relay returns a typed relay-unavailable error before Pi launches", { skip: !baselineArmed }, async () => {
548
+ // The baseline lacks --materialize, so it fails at materialize before any
549
+ // pi launch; RELAY_UNAVAILABLE at materialize proves Pi never launched.
550
+ let caught: unknown;
551
+ try {
552
+ await runReviewHostRelaySlot({ captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION), gentleAiExecutable: baselineBinary!, piExecutable: "pi" });
553
+ } catch (error) {
554
+ caught = error;
555
+ }
556
+ assert.ok(caught instanceof ReviewHostRelayError);
557
+ const e = caught as ReviewHostRelayError;
558
+ assert.equal(e.kind, REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE);
559
+ assert.equal(e.stage, "materialize");
560
+ assert.equal(e.mutationOutcome, "none");
561
+ assert.match(e.stderr, /flag provided but not defined: -(?:materialize|agent)/);
562
+ });
563
+ test("negative control: an armed positive-lens against the baseline blocks because it lacks the surface", { skip: !baselineArmed }, async () => {
564
+ const [verdict] = await runMatrix(baselineDescriptor("positive-lens"), { armPositive: true });
565
+ assert.equal(verdict!.verdict, "blocked");
566
+ assert.match(verdict!.reason, /lacks the pi host relay surface/);
567
+ assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
568
+ });
569
+ // ---------------------------------------------------------------------------
570
+ // Honest positive arming/skip — the organic journey needs a real capable
571
+ // binary, a real pi, and a real review session (lifecycle machinery beyond
572
+ // this work unit). The runner blocks the positive leg before materialize
573
+ // unless armed. These tests run WITHOUT the arm (the verifier arms it).
574
+ // ---------------------------------------------------------------------------
575
+ const capableArmed = typeof capableBinary === "string" && capableBinary.length > 0 && capableBinary.startsWith("/") && existsSync(capableBinary);
576
+ const capableReason = () => `${CAPABLE_ENV} is unset or not an existing absolute path; supply a capable gentle-ai binary (local build of origin/main with the pi host-relay surface), or set ${REQUIRE_ENV}=1 to fail instead of skip.`;
577
+ if (!capableArmed && armed) throw new Error(capableReason());
578
+ if (!capableArmed) console.log(`tests/maintainer/provider-relay.maintest.ts: ${capableReason()}`);
579
+ function positiveDescriptor(gentleAi = capableBinary, pi = "pi") {
580
+ return validateDescriptor({ ...descriptor(), gentleAiExecutable: gentleAi!, piExecutable: pi, cases: [{ name: "positive-lens-relay", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }] });
581
+ }
582
+ test("positive arming/skip: an unarmed positive leg blocks loudly and never fakes green", { skip: !capableArmed }, async () => {
583
+ const [verdict] = await runMatrix(positiveDescriptor());
584
+ assert.equal(verdict!.kind, "positive-lens");
585
+ assert.equal(verdict!.verdict, "blocked");
586
+ assert.match(verdict!.reason, new RegExp(ARM_POSITIVE_ENV));
587
+ assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
588
+ assert.notEqual(verdict!.verdict, "pass");
589
+ assert.notEqual(verdict!.verdict, "fail");
590
+ });
591
+ test("positive arming/skip: a missing pi blocks at the pi pre-arm before materialize", { skip: !capableArmed }, async () => {
592
+ const [verdict] = await runMatrix(positiveDescriptor(capableBinary, sandboxPath("not-a-real-pi")));
593
+ assert.equal(verdict!.verdict, "blocked");
594
+ assert.match(verdict!.reason, /piExecutable/);
595
+ assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
596
+ });
597
+ test("positive arming/skip: the exact next evidence command is surfaced for the separate verifier", { skip: !capableArmed }, async () => {
598
+ const [verdict] = await runMatrix(positiveDescriptor());
599
+ assert.match(verdict!.command!, /node --experimental-strip-types scripts\/maintainer\/provider-relay-matrix.mjs --descriptor/);
600
+ assert.match(verdict!.command!, /GENTLE_PI_MAINTAINER_ARM_POSITIVE=1/);
601
+ });