gentle-pi 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +51 -80
  2. package/assets/agents/review-readability.md +0 -2
  3. package/assets/agents/review-reliability.md +0 -2
  4. package/assets/agents/review-resilience.md +0 -2
  5. package/assets/agents/review-risk.md +0 -2
  6. package/assets/agents/sdd-apply.md +5 -3
  7. package/assets/agents/sdd-proposal.md +2 -0
  8. package/assets/agents/sdd-research.md +54 -0
  9. package/assets/agents/sdd-status.md +4 -4
  10. package/assets/agents/sdd-tasks.md +2 -3
  11. package/assets/agents/sdd-verify.md +21 -1
  12. package/assets/chains/sdd-full.chain.md +1 -1
  13. package/assets/chains/sdd-verify.chain.md +1 -1
  14. package/assets/orchestrator-delegation.md +54 -248
  15. package/assets/orchestrator-memory.md +2 -0
  16. package/assets/orchestrator.md +22 -38
  17. package/assets/sdd-orchestrator-workflow.md +30 -37
  18. package/assets/support/sdd-status-contract.md +7 -7
  19. package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
  20. package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
  21. package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
  22. package/docs/native-authority-architecture.md +9 -11
  23. package/docs/review-integration.md +27 -373
  24. package/extensions/ask-user-choice.ts +151 -0
  25. package/extensions/gentle-ai.ts +1778 -3673
  26. package/extensions/quiet-tools.ts +515 -32
  27. package/extensions/sdd-init.ts +4 -8
  28. package/lib/gentle-ai-renderer.ts +70 -0
  29. package/lib/model-routing-authority.ts +133 -0
  30. package/lib/native-review-cli.ts +372 -940
  31. package/lib/opaque-pi-reviewer-adapter.ts +284 -0
  32. package/lib/review-candidate-view.ts +341 -132
  33. package/lib/review-host-relay.ts +210 -68
  34. package/lib/review-integration-v2.ts +839 -307
  35. package/lib/review-last-event-controller.ts +35 -0
  36. package/lib/sdd-preflight.ts +177 -66
  37. package/lib/sdd-status.ts +66 -111
  38. package/lib/terminal-theme.ts +1 -1
  39. package/package.json +83 -82
  40. package/runtime/gentle-ai-binary.mjs +1 -1
  41. package/runtime/native-review-cli.mjs +322 -890
  42. package/runtime/review-integration-v2.mjs +789 -257
  43. package/runtime/review-relay-contract.mjs +1 -1
  44. package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
  45. package/scripts/gentle-ai-installer.mjs +75 -22
  46. package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
  47. package/scripts/test-packed-runner.mjs +4 -7
  48. package/scripts/verify-package-files.mjs +12 -13
  49. package/skills/_shared/review-ledger-contract.md +8 -14
  50. package/skills/chained-pr/SKILL.md +3 -0
  51. package/skills/cognitive-doc-design/SKILL.md +1 -1
  52. package/skills/comment-writer/SKILL.md +1 -1
  53. package/skills/gentle-ai/SKILL.md +7 -74
  54. package/skills/judgment-day/SKILL.md +5 -7
  55. package/skills/rdd-defect-workflow/SKILL.md +3 -3
  56. package/skills/release/SKILL.md +3 -3
  57. package/skills/skill-registry/SKILL.md +1 -1
  58. package/skills/work-unit-commits/SKILL.md +3 -1
  59. package/tests/artifact-language.test.ts +24 -11
  60. package/tests/ask-user-choice.test.ts +264 -0
  61. package/tests/codegraph-tools.test.ts +3 -3
  62. package/tests/crosslane/cross-lane.mjs +15 -1168
  63. package/tests/delegated-key-learnings-contract.test.ts +8 -6
  64. package/tests/devbinary/native-review-parity.devtest.ts +167 -250
  65. package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
  66. package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
  67. package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
  68. package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
  69. package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
  70. package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
  71. package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
  72. package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
  73. package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
  74. package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
  75. package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
  76. package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
  77. package/tests/gentle-ai-binary.test.ts +83 -13
  78. package/tests/gentle-ai-dev-binary.test.ts +51 -228
  79. package/tests/gentle-ai-installer.test.ts +137 -47
  80. package/tests/gentle-ai.test.ts +820 -131
  81. package/tests/gentle-theme.test.ts +133 -0
  82. package/tests/maintainer/provider-relay.maintest.ts +340 -4
  83. package/tests/model-routing-authority.test.ts +257 -0
  84. package/tests/native-review-capability-contract.test.ts +28 -1
  85. package/tests/native-review-cli.test.ts +586 -962
  86. package/tests/native-review-consent.test.ts +150 -66
  87. package/tests/native-review-parity-runtime.test.ts +80 -359
  88. package/tests/native-review-parity.test.ts +674 -925
  89. package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
  90. package/tests/orchestrator-budget.test.ts +132 -88
  91. package/tests/orchestrator-rdd-ownership.test.ts +103 -0
  92. package/tests/package-manifest.test.ts +37 -35
  93. package/tests/provider-defect-handoff.test.ts +15 -118
  94. package/tests/quiet-tool-rendering.test.ts +1055 -28
  95. package/tests/review-authority-recovery-docs.test.ts +1 -2
  96. package/tests/review-candidate-view.test.ts +409 -3
  97. package/tests/review-compact-contract.test.ts +4 -30
  98. package/tests/review-controller-lock-status.test.ts +2 -2
  99. package/tests/review-controller-native-recovery.test.ts +363 -1450
  100. package/tests/review-controller-native-routing.test.ts +1251 -5642
  101. package/tests/review-controller-retired-ops.test.ts +1 -1
  102. package/tests/review-controller-workspace-root.test.ts +222 -71
  103. package/tests/review-controller.test.ts +26 -816
  104. package/tests/review-corrected-finalize-binding.test.ts +112 -153
  105. package/tests/review-dispatch-hydration-gap.test.ts +1 -53
  106. package/tests/review-gate.test.ts +0 -45
  107. package/tests/review-host-relay-restart-parity.test.ts +360 -0
  108. package/tests/review-host-relay-routing.test.ts +117 -82
  109. package/tests/review-host-relay.test.ts +242 -8
  110. package/tests/review-integration-v2-forward.test.ts +576 -553
  111. package/tests/review-integration-v2.test.ts +239 -165
  112. package/tests/review-last-event-closure.test.ts +408 -0
  113. package/tests/review-ledger-contract.test.ts +97 -35
  114. package/tests/review-recovered-lineage-routing.test.ts +0 -47
  115. package/tests/review-relay-transport-agent.test.ts +119 -46
  116. package/tests/review-snapshot.test.ts +3 -2
  117. package/tests/runtime-harness.mjs +361 -196
  118. package/tests/sdd-agent-tools.test.ts +36 -0
  119. package/tests/sdd-preflight.test.ts +81 -15
  120. package/tests/sdd-status.test.ts +109 -110
  121. package/tests/skill-collision-prefixes.test.ts +5 -8
  122. package/tests/writer-edit-surface-scope.test.ts +230 -0
  123. package/themes/Gentleman-Cute.json +94 -0
  124. package/themes/Gentleman-Sexy.json +92 -0
  125. package/lib/git-commit-transaction.ts +0 -861
  126. package/runtime/git-commit-transaction.mjs +0 -862
  127. package/scripts/run-git-commit-transaction.mjs +0 -35
  128. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
  129. package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
  130. package/tests/git-commit-transaction.test.ts +0 -530
@@ -1,4 +1,4 @@
1
- // Generated by scripts/build-git-commit-transaction-runner.mjs. Do not edit.
1
+ // Generated by scripts/build-runtime-modules.mjs. Do not edit.
2
2
  // gentle-pi.review-relay/v1 — the Pi host relay handshake (gentle-pi#311 P4,
3
3
  // provider side gentle-ai#3249/#3254).
4
4
  //
@@ -11,9 +11,8 @@ const sources = [
11
11
  "review-relay-contract",
12
12
  "review-integration-v2",
13
13
  "native-review-cli",
14
- "git-commit-transaction",
15
14
  ];
16
- const header = "// Generated by scripts/build-git-commit-transaction-runner.mjs. Do not edit.\n";
15
+ const header = "// Generated by scripts/build-runtime-modules.mjs. Do not edit.\n";
17
16
 
18
17
  function assertNoTrailingWhitespace(content, label) {
19
18
  const line = content.split("\n").findIndex((value) => /[ \t]+$/.test(value));
@@ -35,7 +34,7 @@ async function generatedBytes(name) {
35
34
  async function main() {
36
35
  const mode = process.argv[2];
37
36
  if ((mode !== "--write" && mode !== "--check") || process.argv.length !== 3) {
38
- throw new Error("usage: build-git-commit-transaction-runner.mjs --write|--check");
37
+ throw new Error("usage: build-runtime-modules.mjs --write|--check");
39
38
  }
40
39
  const runtime = join(root, "runtime");
41
40
  if (mode === "--write") await mkdir(runtime, { recursive: true });
@@ -56,9 +55,9 @@ async function main() {
56
55
  if (actual !== expected) drift.push(destination);
57
56
  }
58
57
  if (drift.length > 0) {
59
- throw new Error(`generated commit transaction runtime is stale: ${drift.join(", ")}`);
58
+ throw new Error(`generated runtime is stale: ${drift.join(", ")}`);
60
59
  }
61
- process.stdout.write(`commit transaction runtime ${mode === "--write" ? "generated" : "matches TypeScript sources"} (${sources.length} modules)\n`);
60
+ process.stdout.write(`runtime ${mode === "--write" ? "generated" : "matches TypeScript sources"} (${sources.length} modules)\n`);
62
61
  }
63
62
 
64
63
  main().catch((error) => {
@@ -23,6 +23,12 @@ const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
23
23
  const MAX_REDIRECTS = 3;
24
24
  const DOWNLOAD_TIMEOUTS = { headers: 10_000, body: 30_000, attempts: 2, retryDelay: 100 };
25
25
  const GO_COMMAND_TIMEOUT_MS = 120_000;
26
+ // `go install` compiles the pinned module from source on Windows. A clean
27
+ // build on a cold module cache measured about 232 s (#400), so the 120 s
28
+ // probe bound killed a healthy build; fifteen minutes leaves close to a 4x
29
+ // margin for slower disks and real-time scanners while still bounding a hung
30
+ // toolchain. The `go version` and `where.exe` probes keep the 120 s bound.
31
+ const GO_INSTALL_TIMEOUT_MS = 15 * 60_000;
26
32
  const GO_COMMAND_MAX_BUFFER = 1024 * 1024;
27
33
  const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
28
34
  // The one authoritative pinned Gentle AI version. Every other location in this
@@ -30,7 +36,7 @@ const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
30
36
  // version check below) derives from this constant instead of repeating the
31
37
  // literal, so a pin bump cannot leave a stale copy behind. See
32
38
  // scripts/install-gentle-ai.mjs for the incident that motivated this.
33
- export const INSTALLER_VERSION = "2.4.0";
39
+ export const INSTALLER_VERSION = "2.5.0";
34
40
  export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
35
41
  export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
36
42
  SIGNED_RELEASE_ASSET: "signed-release-asset",
@@ -39,12 +45,10 @@ export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
39
45
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai";
40
46
  export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v2";
41
47
  export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
42
- // `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.4.0`
43
- // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum. The
44
- // same response resolves the tag to commit 301fb2ad, which is also the
45
- // `vcs_revision` the published v2.4.0 binary self-reports in its capabilities
46
- // envelope -- the two independent provenance paths agree.
47
- export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:XKkcy+t76cyUKQnRwL1UnqHbhh8o416I8HQ1UsdvWSI=";
48
+ // `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.5.0`
49
+ // with GOSUMDB=sum.golang.org reports this exact module SumDB checksum, and the
50
+ // tag resolves to commit f5dd1a6c, the published v2.5.0 release head.
51
+ export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:0nvTLJFAf9ruBDwTvi+aDrWt/dt/GKvUP2cFVB6c2DI=";
48
52
  export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
49
53
  export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
50
54
  export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
@@ -63,28 +67,73 @@ export class GentleAiInstallerError extends Error {
63
67
  // Sentinel used while a re-pinned gentle-ai release is not yet published. A
64
68
  // sentinel digest can never match a real SHA-256, so installation fails closed,
65
69
  // and verify-package-files.mjs refuses to pack/publish while any digest below
66
- // still holds it. The v2.4.0 digests are pinned from the published release:
70
+ // still holds it. The v2.5.0 digests are pinned from the published release:
67
71
  // archive sha256 values verified against the minisign-signed checksums.txt and
68
72
  // freshly computed hashes; binary sha256 values computed from the extracted
69
73
  // executables.
70
74
  export const GENTLE_AI_PENDING_DIGEST = "PENDING-GENTLE-AI-RELEASE-DIGEST";
71
75
 
76
+ // A raw prerelease asset is less durable than a signed stable archive, and the
77
+ // install path has no other Darwin/Linux source, so the gentle-pi repository
78
+ // hosts a byte-identical mirror of the pinned prerelease assets under its own
79
+ // release tag. The mirror is only a second download source: every byte from
80
+ // either source must still match the same pinned SHA-256 digests, and a
81
+ // checksum mismatch never falls through to the other source.
82
+ export const GENTLE_AI_MIRROR_RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-pi/releases/download/gentle-ai-mirror-v${INSTALLER_VERSION}/`;
83
+ export const GENTLE_AI_ASSET_UNAVAILABLE_CODE = "GENTLE_AI_ASSET_UNAVAILABLE";
84
+
72
85
  function asset(name, sha256, binarySha256, executable) {
73
- return Object.freeze({ name, sha256, binarySha256, executable, url: `${RELEASE_BASE_URL}${name}` });
86
+ const mirrorUrl = INSTALLER_VERSION.includes("-") ? `${GENTLE_AI_MIRROR_RELEASE_BASE_URL}${name}` : undefined;
87
+ return Object.freeze({ name, sha256, binarySha256, executable, url: `${RELEASE_BASE_URL}${name}`, ...(mirrorUrl === undefined ? {} : { mirrorUrl }) });
88
+ }
89
+
90
+ // Download failure (not integrity failure) is the only condition that falls
91
+ // through to the mirror; when both sources are unavailable the error names the
92
+ // documented recovery instead of leaving a dead end until the next repin.
93
+ async function downloadPinnedGentleAiAsset(asset, destination, options) {
94
+ const download = options.download ?? downloadGentleAiAsset;
95
+ try {
96
+ await download(asset.url, destination);
97
+ } catch (primaryError) {
98
+ if (asset.mirrorUrl === undefined) throw primaryError;
99
+ try {
100
+ await download(asset.mirrorUrl, destination);
101
+ } catch (mirrorError) {
102
+ throw new GentleAiInstallerError(
103
+ GENTLE_AI_ASSET_UNAVAILABLE_CODE,
104
+ `Gentle AI ${asset.name} is unavailable from the pinned release and its gentle-pi mirror. Retry later, or set GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1 and register a locally built v${INSTALLER_VERSION} binary through ~/.pi/gentle-ai/dev-binary.json.`,
105
+ mirrorError,
106
+ );
107
+ }
108
+ }
74
109
  }
75
110
 
76
111
  // Windows is absent from signed release archives on purpose. gentle-ai stopped
77
- // distributing unsigned Windows builds in c4b764d0, so v2.4.0 publishes signed
112
+ // distributing unsigned Windows builds in c4b764d0, so v2.5.0 publishes signed
78
113
  // Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
79
114
  // exact-tag Go SumDB source-build path below; restore archive rows only when
80
115
  // upstream ships signed Windows assets.
81
116
  export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
82
- "darwin/amd64": asset("gentle-ai_2.4.0_darwin_amd64.tar.gz", "c3f5a4251e927c41864e7a9a5670ac3fce97c6282755ec8e9b2e7e064067f750", "c4d25b98bae0d06b33baa23ba997cb64ab63c2e93e97983bcb9c239139bceaba", "gentle-ai"),
83
- "darwin/arm64": asset("gentle-ai_2.4.0_darwin_arm64.tar.gz", "2240ae797119dd1dc5a0dd2fd2711077ce6712e66ab0b67461344c983478f7f4", "54a8a5957ce4f10f36b46ef818c888f484a3e213a71faca6b7783af6c029eb77", "gentle-ai"),
84
- "linux/amd64": asset("gentle-ai_2.4.0_linux_amd64.tar.gz", "517eb8420cf966b74be1413a7ea1036119a5ccbb376e0778aa9f4d4d283e2e9a", "0be446737f8f1a9458fd2891d488beb2d72786578afead9bbf2d6ddddc9ec113", "gentle-ai"),
85
- "linux/arm64": asset("gentle-ai_2.4.0_linux_arm64.tar.gz", "1badec9cad1f2bbeb22b5c6804c3a88c601d844abae231264e416e8095f2a15f", "cdd8fa43c3417e844cc11d2e436d4667edb0a54f086e0eb1d6cd3f7493bfee5a", "gentle-ai"),
117
+ "darwin/amd64": asset("gentle-ai_2.5.0_darwin_amd64.tar.gz", "8204ed692d7ff16fae2eb8bd7b3132b5e4e4dd7b9ea40e93bfced57394da27c0", "929817d320e94ad62be7ed546ad8746e62bd63ab97e3f38c94456f221f163eca", "gentle-ai"),
118
+ "darwin/arm64": asset("gentle-ai_2.5.0_darwin_arm64.tar.gz", "ce06831cea9fd2ac7b2b36d9e98e63988246e0fbafbbd2cc7c003ec2f1463a23", "c859cd88138eb41e83ef9b75309697aaff2acb9f720c9a029b432095f66aea02", "gentle-ai"),
119
+ "linux/amd64": asset("gentle-ai_2.5.0_linux_amd64.tar.gz", "2ba84a3a7ba2b1193019bde2acb05b02cbf222b667568c919686352b3caab113", "5acedd14b7927a7665636bf728c14a614597ce85dd91313bc50cbc96881c6cab", "gentle-ai"),
120
+ "linux/arm64": asset("gentle-ai_2.5.0_linux_arm64.tar.gz", "16a6243d17c146e3fc0024f6f005c5bd141fbb2dda4b56e269318e499ddc170b", "ce75ba1174955813ef462f4e7a50dd59a8e8167575033f6e17aa8d8488630cb8", "gentle-ai"),
86
121
  });
87
122
 
123
+ // A pinned asset is either a signed archive or, for a prerelease pin only,
124
+ // the raw executable itself: prereleases are hand-built and ship raw binaries,
125
+ // while every stable release publishes signed archives, so a raw asset under a
126
+ // stable pin means the pin itself is wrong. Anything else fails closed before
127
+ // a byte is downloaded.
128
+ export function gentleAiAssetForm(name, installerVersion = INSTALLER_VERSION) {
129
+ if (name.endsWith(".tar.gz") || name.endsWith(".zip")) return "archive";
130
+ if (/^gentle-ai_[0-9A-Za-z.+~-]+_(darwin|linux|windows)_(amd64|arm64)(\.exe)?$/.test(name)) {
131
+ if (!installerVersion.includes("-")) throw new Error(`raw Gentle AI release asset ${name} is only admitted for a prerelease pin; stable pin ${installerVersion} requires a signed archive`);
132
+ return "raw-binary";
133
+ }
134
+ throw new Error(`unsupported Gentle AI release asset form: ${name}`);
135
+ }
136
+
88
137
  function upstreamArchitecture(architecture) {
89
138
  return architecture === "x64" ? "amd64" : architecture;
90
139
  }
@@ -104,7 +153,7 @@ export function resolveGentleAiReleaseAsset(platform = process.platform, archite
104
153
  const windowsSource = isWindowsGoSumdbSourceTarget(platform, architecture)
105
154
  ? "; Windows x64/arm64 use Go SumDB source installation because no signed Windows archive is published"
106
155
  : "";
107
- throw new Error(`unsupported Gentle AI platform/architecture: ${platform}/${architecture}; signed archive pairs are darwin/x64, darwin/arm64, linux/x64, and linux/arm64${windowsSource}`);
156
+ throw new Error(`unsupported Gentle AI platform/architecture: ${platform}/${architecture}; pinned release asset pairs are darwin/x64, darwin/arm64, linux/x64, and linux/arm64${windowsSource}`);
108
157
  }
109
158
  return resolved;
110
159
  }
@@ -249,8 +298,8 @@ function isCanonicalManifest(contents, parsed, expected) {
249
298
 
250
299
  function commandOutput(result) { return typeof result?.stdout === "string" ? result.stdout : ""; }
251
300
 
252
- function commandOptions(env, cwd) {
253
- return { cwd, env, shell: false, windowsHide: true, timeout: GO_COMMAND_TIMEOUT_MS, maxBuffer: GO_COMMAND_MAX_BUFFER };
301
+ function commandOptions(env, cwd, timeout = GO_COMMAND_TIMEOUT_MS) {
302
+ return { cwd, env, shell: false, windowsHide: true, timeout, maxBuffer: GO_COMMAND_MAX_BUFFER };
254
303
  }
255
304
 
256
305
  function outputVersion(output) {
@@ -584,7 +633,7 @@ async function installWindowsGentleAiFromGoSumdb(options, packageRoot, architect
584
633
  const existing = versionBundlePath(runtimeRoot);
585
634
  if (await existingWindowsSourceBundleMatches(existing, execute, goPath, environment, architecture)) return { installed: false, binaryPath: join(existing, "gentle-ai.exe"), method: GENTLE_AI_INSTALL_METHOD.GO_SUMDB_SOURCE_BUILD };
586
635
  await assertGoToolchain(execute, goPath, environment, stagingDirectory);
587
- try { await runCommand(execute, goPath, ["install", GENTLE_AI_WINDOWS_SOURCE_PACKAGE], commandOptions(environment, stagingDirectory)); }
636
+ try { await runCommand(execute, goPath, ["install", GENTLE_AI_WINDOWS_SOURCE_PACKAGE], commandOptions(environment, stagingDirectory, GO_INSTALL_TIMEOUT_MS)); }
588
637
  catch (error) { throw new GentleAiInstallerError(GENTLE_AI_GO_INSTALL_FAILED_CODE, `Gentle AI Go SumDB source installation failed for ${GENTLE_AI_WINDOWS_SOURCE_PACKAGE}.`, error); }
589
638
  const builtBinary = join(environment.GOBIN, "gentle-ai.exe"), binaryPath = join(stagingDirectory, "gentle-ai.exe");
590
639
  const details = await lstat(builtBinary);
@@ -609,18 +658,22 @@ async function installSignedRelease(options, packageRoot, platform, arch, asset)
609
658
  if (await existingSignedBundleMatches(existing, asset, platform)) return { installed: false, binaryPath: join(existing, asset.executable), asset };
610
659
  const stagingDirectory = await mkdtemp(join(runtimeRoot, `.v${INSTALLER_VERSION}.staging-`));
611
660
  try {
661
+ const form = gentleAiAssetForm(asset.name);
612
662
  const archive = join(stagingDirectory, asset.name);
613
- await (options.download ?? downloadGentleAiAsset)(asset.url, archive);
663
+ await downloadPinnedGentleAiAsset(asset, archive, options);
614
664
  if ((await sha256File(archive)) !== asset.sha256) throw new Error(`Gentle AI archive checksum mismatch for ${asset.name}`);
665
+ let source = archive;
615
666
  const extracted = join(stagingDirectory, "extracted");
616
- await (options.extractArchive ?? extractGentleAiArchive)(archive, extracted);
617
- const source = await expectedRegularFile(extracted, asset.executable);
667
+ if (form === "archive") {
668
+ await (options.extractArchive ?? extractGentleAiArchive)(archive, extracted);
669
+ source = await expectedRegularFile(extracted, asset.executable);
670
+ }
618
671
  if (asset.binarySha256 && (await sha256File(source)) !== asset.binarySha256) throw new Error(`Gentle AI binary checksum mismatch for ${asset.name}`);
619
672
  const binaryPath = join(stagingDirectory, asset.executable);
620
673
  await copyFile(source, binaryPath);
621
674
  if (platform !== "win32") await chmod(binaryPath, 0o700);
622
675
  await writeFile(join(stagingDirectory, "integrity.json"), canonicalManifest(signedReleaseManifest(asset, await sha256File(binaryPath))), { mode: 0o600 });
623
- await safeRemoveDirectory(extracted);
676
+ if (form === "archive") await safeRemoveDirectory(extracted);
624
677
  await rm(archive, { force: true });
625
678
  const published = await publishBundle(runtimeRoot, stagingDirectory, options);
626
679
  return { installed: true, binaryPath: join(published, asset.executable), asset };
@@ -9,12 +9,55 @@
9
9
  // `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests;
10
10
  // the CLI is the entry point the separate verifier uses for the real organic
11
11
  // positive journey after a user-visible forecast.
12
+ import { spawn, spawnSync } from "node:child_process";
12
13
  import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
13
14
  import { tmpdir } from "node:os";
14
- import { isAbsolute, join } from "node:path";
15
- import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
15
+ import { delimiter, isAbsolute, join, resolve } from "node:path";
16
+ import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, classifyReviewHostRelayRefusal, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
17
+ import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts";
16
18
  export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1";
17
- export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens"]);
19
+ export const PROVIDER_ROLE_VECTOR_KINDS = Object.freeze(["provider-role-refuter", "provider-role-validator"]);
20
+ export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens", ...PROVIDER_ROLE_VECTOR_KINDS]);
21
+ export const PROVIDER_ROLE_VECTOR_VERB = Object.freeze({ "provider-role-refuter": "capture-refuter", "provider-role-validator": "capture-validation" });
22
+ export const PROVIDER_ROLE_VECTOR_ROLE = Object.freeze({ "provider-role-refuter": "refuter", "provider-role-validator": "targeted-validator" });
23
+ export const PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA = "gentle-ai.review-provider-role-capture/v1";
24
+ export const ROLE_STREAM_MAX_BYTES = 1024 * 1024;
25
+ export const ROLE_VECTOR_FAILURE = Object.freeze({
26
+ ROLE_SURFACE_UNAVAILABLE: "role-surface-unavailable",
27
+ ROLE_LAUNCH_FAILED: "role-launch-failed",
28
+ ROLE_FAILED: "role-failed",
29
+ ROLE_TIMED_OUT: "role-timed-out",
30
+ ROLE_OUTPUT_OVERFLOW: "role-output-overflow",
31
+ ROLE_TERMINATION_FAILED: "role-termination-failed",
32
+ ROLE_ABORTED: "role-aborted",
33
+ EMPTY_ARTIFACT: "empty-artifact",
34
+ HANDSHAKE_REFUSED: "handshake-refused",
35
+ });
36
+ const REQUEST_HASH_RE = /^sha256:[0-9a-f]{64}$/;
37
+ const RCTX_RE = /^rctx1_[0-9a-f]{64}$/;
38
+ // Provider-returned lineage and target bindings are the sole authority and must be unique.
39
+ const ROLE_BINDING_RE = Object.freeze({ "--lineage=": /^.+$/, "--expected-revision=": REQUEST_HASH_RE, "--target=": REQUEST_HASH_RE, "--repository-context=": RCTX_RE });
40
+ // The 900s watchdog FIRES after the provider-owned 600s deadline and leaves setup/cancellation margin.
41
+ export const DEFAULT_ROLE_VECTOR_TIMEOUT_MS = 900_000;
42
+ export class ProviderRoleVectorError extends Error {
43
+ kind;
44
+ stage;
45
+ exitCode;
46
+ stderr;
47
+ timedOut;
48
+ mutationOutcome;
49
+ // Unreadable outcomes after launch are unknown and require STATUS re-query.
50
+ constructor(kind, stage, message, details) {
51
+ super(message);
52
+ this.name = "ProviderRoleVectorError";
53
+ this.kind = kind;
54
+ this.stage = stage;
55
+ this.exitCode = details?.exitCode ?? null;
56
+ this.stderr = details?.stderr ?? "";
57
+ this.timedOut = details?.timedOut ?? false;
58
+ this.mutationOutcome = details?.mutationOutcome ?? (kind === ROLE_VECTOR_FAILURE.ROLE_FAILED || kind === ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT || kind === ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW ? "unknown" : "none");
59
+ }
60
+ }
18
61
  // The positive lens runs only when explicitly armed: the organic journey
19
62
  // needs a real capable binary, a real pi, and a real review session. Without
20
63
  // the arm the runner blocks the positive leg before materialize.
@@ -53,11 +96,14 @@ export function validateDescriptor(value) {
53
96
  const seen = new Set();
54
97
  return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, piExecutable: value.piExecutable, cases: value.cases.map((entry, index) => {
55
98
  if (!isObj(entry)) fail(`descriptor.cases[${index}] must be an object`);
56
- exactKeys(entry, new Set(["name", "kind", "captureArgumentTokens", "submission"]), `descriptor.cases[${index}]`);
57
99
  if (!isStr(entry.name)) fail(`descriptor.cases[${index}].name must be a non-empty string`);
58
100
  if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`);
59
101
  seen.add(entry.name);
60
102
  if (!CASE_KINDS.includes(entry.kind)) fail(`descriptor.cases[${index}].kind must be one of ${JSON.stringify([...CASE_KINDS])}`);
103
+ if (PROVIDER_ROLE_VECTOR_KINDS.includes(entry.kind)) {
104
+ return validateRoleCaseEntry(entry, index);
105
+ }
106
+ exactKeys(entry, new Set(["name", "kind", "captureArgumentTokens", "submission"]), `descriptor.cases[${index}]`);
61
107
  if (!isStrArr(entry.captureArgumentTokens)) fail(`descriptor.cases[${index}].captureArgumentTokens must be a non-empty array of non-empty strings`);
62
108
  // A real host-relay materialize slot is the provider-issued
63
109
  // --agent=pi --materialize=true pair; the descriptor must declare
@@ -93,6 +139,43 @@ export function validateDescriptor(value) {
93
139
  }),
94
140
  };
95
141
  }
142
+ // Validates provider-issued role tokens and the validator's minimal request-hash binding.
143
+ function validateRoleCaseEntry(entry, index) {
144
+ const label = `descriptor.cases[${index}]`;
145
+ const allowedKeys = new Set(["name", "kind", "argumentTokens", ...(entry.kind === "provider-role-validator" ? ["validationRequest"] : [])]);
146
+ exactKeys(entry, allowedKeys, label);
147
+ if (!isStrArr(entry.argumentTokens)) fail(`${label}.argumentTokens must be a non-empty array of non-empty strings`);
148
+ // Each self-contained execute control must appear exactly once at its exact value.
149
+ for (const required of ["--agent=pi", "--execute=true"]) {
150
+ const prefix = `${required.slice(0, required.indexOf("=") + 1)}`;
151
+ const matches = entry.argumentTokens.filter((token) => token.startsWith(prefix));
152
+ if (matches.length !== 1 || matches[0] !== required) fail(`${label}.argumentTokens must include exactly one provider-issued "${required}" token`);
153
+ }
154
+ if (entry.argumentTokens.includes("--materialize=true")) {
155
+ fail(`${label}.argumentTokens must not include --materialize=true; a role vector is self-contained, never a lens materialize slot`);
156
+ }
157
+ const bindings = {};
158
+ for (const [prefix, pattern] of Object.entries(ROLE_BINDING_RE)) {
159
+ const matches = entry.argumentTokens.filter((t) => t.startsWith(prefix));
160
+ if (matches.length !== 1) fail(`${label}.argumentTokens must include exactly one "${prefix}" token, found ${matches.length}`);
161
+ const value = matches[0].slice(prefix.length);
162
+ if (!pattern.test(value)) fail(`${label}.argumentTokens "${prefix}" value is malformed`);
163
+ bindings[prefix] = value;
164
+ }
165
+ const result = { name: entry.name, kind: entry.kind, argumentTokens: [...entry.argumentTokens], lineage: bindings["--lineage="], targetIdentity: bindings["--target="] };
166
+ if (entry.kind === "provider-role-validator") {
167
+ const vr = entry.validationRequest;
168
+ if (!isObj(vr)) fail(`${label}.validationRequest must be an object; the provider-embedded validation_request binding is required on the targeted-validator vector`);
169
+ exactKeys(vr, new Set(["schema", "requestHash"]), `${label}.validationRequest`);
170
+ if (vr.schema !== "gentle-ai.review-targeted-validation-request/v1") fail(`${label}.validationRequest.schema must be exactly "gentle-ai.review-targeted-validation-request/v1"`);
171
+ if (!isStr(vr.requestHash) || !REQUEST_HASH_RE.test(vr.requestHash)) fail(`${label}.validationRequest.requestHash must be a sha256 digest`);
172
+ const expectedToken = `--request-hash=${vr.requestHash}`;
173
+ const requestHashTokens = entry.argumentTokens.filter((token) => token.startsWith("--request-hash="));
174
+ if (requestHashTokens.length !== 1 || requestHashTokens[0] !== expectedToken) fail(`${label}.argumentTokens must include exactly one provider-issued "${expectedToken}" token that binds the embedded validation_request`);
175
+ result.validationRequest = { schema: vr.schema, requestHash: vr.requestHash };
176
+ }
177
+ return Object.freeze(result);
178
+ }
96
179
  export function loadDescriptor(path) {
97
180
  if (!isStr(path)) fail("a descriptor path is required");
98
181
  let raw, parsed;
@@ -109,12 +192,14 @@ export function loadDescriptor(path) {
109
192
  return validateDescriptor(parsed);
110
193
  }
111
194
  // Resolves a declared executable without a shell: absolute path checked
112
- // verbatim, bare name searched on PATH. Never re-resolves production.
195
+ // verbatim, bare name searched on PATH. Never re-resolves production. PATH
196
+ // matches are normalized to absolute paths (a relative component like `.`
197
+ // must not yield a relative candidate; the relay launches from scratch).
113
198
  export function resolveDeclaredExecutable(executable) {
114
199
  if (isAbsolute(executable)) return existsSync(executable) && statSync(executable).isFile() ? executable : null;
115
- for (const directory of (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":")) {
200
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
116
201
  if (!directory) continue;
117
- const candidate = join(directory, executable);
202
+ const candidate = resolve(directory, executable);
118
203
  if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
119
204
  }
120
205
  return null;
@@ -128,10 +213,76 @@ function unlaunchablePi() {
128
213
  chmodSync(directory, 0o700);
129
214
  return { directory, executable: join(directory, "pi-must-never-launch") };
130
215
  }
216
+ function terminateRoleProcessTree(child) {
217
+ if (child.pid === undefined) return false;
218
+ if (process.platform === "win32") {
219
+ try { const result = spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: false, windowsHide: true, timeout: 5_000 }); if (result.error === undefined && result.status === 0) return true; } catch {}
220
+ child.kill("SIGKILL"); return false;
221
+ }
222
+ try { process.kill(-child.pid, "SIGKILL"); return true; } catch (error) { if (error?.code === "ESRCH") return true; child.kill("SIGKILL"); return false; }
223
+ }
224
+ export async function runProviderRoleVector(request) {
225
+ const verb = PROVIDER_ROLE_VECTOR_VERB[request.kind];
226
+ if (verb === undefined) throw new TypeError(`runProviderRoleVector received an unknown role vector kind: ${request.kind}`);
227
+ if (request.signal?.aborted) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_ABORTED, "launch", "gentle-ai role vector was aborted before launch", { mutationOutcome: "none" });
228
+ const argv = ["review", verb, ...request.argumentTokens], env = { ...process.env, ...request.env, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
229
+ let capture, launched = false;
230
+ try {
231
+ capture = await new Promise((resolve, reject) => {
232
+ const child = spawn(request.gentleAiExecutable, argv, { cwd: process.cwd(), env, stdio: ["ignore", "pipe", "pipe"], shell: false, windowsHide: true, ...(process.platform === "win32" ? {} : { detached: true }) });
233
+ launched = child.pid !== undefined;
234
+ const stdout = [], stderr = []; let stdoutBytes = 0, stderrBytes = 0, terminationCause = null, treeTerminationFailed = false, settled = false;
235
+ const captured = (exitCode) => ({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode, terminationCause, treeTerminationFailed });
236
+ const collect = (stream, chunks) => (chunk) => { if (terminationCause !== null) return; const bytes = chunk.length; if ((stream === "stdout" ? stdoutBytes : stderrBytes) + bytes > ROLE_STREAM_MAX_BYTES) { terminate(stream); return; } if (stream === "stdout") stdoutBytes += bytes; else stderrBytes += bytes; chunks.push(chunk); };
237
+ const onStdout = collect("stdout", stdout), onStderr = collect("stderr", stderr);
238
+ const abort = () => terminate("abort");
239
+ const cleanup = () => { clearTimeout(timer); request.signal?.removeEventListener("abort", abort); child.stdout.off("data", onStdout); child.stderr.off("data", onStderr); child.off("error", onError); child.off("close", onClose); };
240
+ const settle = (result) => { if (settled) return; settled = true; cleanup(); resolve(result); };
241
+ const onError = (error) => { if (terminationCause === null) { if (settled) return; settled = true; cleanup(); reject(error); } else settle(captured(null)); };
242
+ const onClose = (code) => settle(captured(code));
243
+ const terminate = (cause) => {
244
+ if (terminationCause !== null) return;
245
+ terminationCause = cause;
246
+ treeTerminationFailed = !(request.terminateProcessTree ?? terminateRoleProcessTree)(child);
247
+ if (treeTerminationFailed) { child.stdout.destroy(); child.stderr.destroy(); settle(captured(null)); }
248
+ };
249
+ child.stdout.on("data", onStdout); child.stderr.on("data", onStderr); child.on("error", onError); child.on("close", onClose);
250
+ const timer = setTimeout(() => terminate("timeout"), request.timeoutMs ?? DEFAULT_ROLE_VECTOR_TIMEOUT_MS); timer.unref();
251
+ if (request.signal?.aborted) abort(); else request.signal?.addEventListener("abort", abort, { once: true });
252
+ });
253
+ } catch (error) {
254
+ if (request.signal?.aborted && launched) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_ABORTED, "execute", "gentle-ai role vector was aborted after launch", { mutationOutcome: "unknown" });
255
+ throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_LAUNCH_FAILED, "launch", `gentle-ai role vector could not start: ${error instanceof Error ? error.message : String(error)}`);
256
+ }
257
+ const stderrText = capture.stderr.toString("utf8");
258
+ if (capture.treeTerminationFailed) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_TERMINATION_FAILED, "execute", "gentle-ai role vector tree termination could not be confirmed; re-query negotiated STATUS before any retry", { exitCode: capture.exitCode, stderr: stderrText, mutationOutcome: "unknown" });
259
+ if (capture.terminationCause === "abort") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_ABORTED, "execute", "gentle-ai role vector was aborted after launch", { exitCode: capture.exitCode, stderr: stderrText, mutationOutcome: "unknown" });
260
+ if (capture.terminationCause === "stdout" || capture.terminationCause === "stderr") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_OUTPUT_OVERFLOW, "execute", `gentle-ai role vector ${capture.terminationCause} exceeded the ${ROLE_STREAM_MAX_BYTES}-byte limit; re-query negotiated STATUS before any retry`, { exitCode: capture.exitCode, stderr: stderrText });
261
+ if (capture.terminationCause === "timeout") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_TIMED_OUT, "execute", "gentle-ai role vector exceeded its outer watchdog; re-query negotiated STATUS before any retry, and the same transcript must not relaunch blindly", { exitCode: capture.exitCode, stderr: stderrText, timedOut: true });
262
+ if (capture.exitCode !== 0) {
263
+ const refusal = classifyReviewHostRelayRefusal(stderrText);
264
+ if (refusal === "handshake") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "execute", stderrText, { exitCode: capture.exitCode, stderr: stderrText });
265
+ if (refusal === "unknown-flag") throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface", { exitCode: capture.exitCode, stderr: stderrText });
266
+ throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed", { exitCode: capture.exitCode, stderr: stderrText });
267
+ }
268
+ if (capture.stdout.length === 0) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.EMPTY_ARTIFACT, "execute", "gentle-ai role vector produced no artifact bytes; re-query negotiated STATUS before any retry", { exitCode: 0, stderr: stderrText, mutationOutcome: "unknown" });
269
+ let artifact;
270
+ try { artifact = JSON.parse(capture.stdout.toString("utf8")); } catch (error) { throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", `gentle-ai role vector returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { exitCode: 0, stderr: stderrText }); }
271
+ const expectedRole = PROVIDER_ROLE_VECTOR_ROLE[request.kind];
272
+ if (!isObj(artifact) || artifact.schema !== PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA || artifact.role !== expectedRole || artifact.captured !== true || !isStr(artifact.lineage_id) || !REQUEST_HASH_RE.test(artifact.target_identity)) throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector returned an artifact that does not match the expected typed shape", { exitCode: 0, stderr: stderrText });
273
+ return { schema: artifact.schema, lineageId: artifact.lineage_id, targetIdentity: artifact.target_identity, role: artifact.role, captured: true };
274
+ }
131
275
  // Runs the matrix against the REAL relay. A case that cannot run honestly is
132
276
  // `blocked` (never `pass`); `fail` is an armed case whose outcome mismatched.
133
277
  export async function runMatrix(descriptor, options = {}) {
134
278
  const positiveArmed = options.armPositive ?? (process.env[ARM_POSITIVE_ENV] === "1");
279
+ // Smallest test seam: an optional injected relay function. Defaults to
280
+ // the real relay so production CLI behavior is identical when unset; tests
281
+ // inject a fake to assert the exact resolved path reaches the boundary
282
+ // without executing a real subprocess (platform-neutral #324 evidence).
283
+ const relay = options.relay ?? runReviewHostRelaySlot;
284
+ // Test seam only; production defaults to the real role runner.
285
+ const roleRunner = options.roleRunner ?? runProviderRoleVector;
135
286
  const verdicts = [];
136
287
  for (const caseEntry of descriptor.cases) {
137
288
  const gentleAiPath = resolveDeclaredExecutable(descriptor.gentleAiExecutable);
@@ -139,10 +290,43 @@ export async function runMatrix(descriptor, options = {}) {
139
290
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.gentleAiExecutable, "gentleAiExecutable") });
140
291
  continue;
141
292
  }
293
+ // Self-contained role vectors require explicit arming and a real provider result.
294
+ if (PROVIDER_ROLE_VECTOR_KINDS.includes(caseEntry.kind)) {
295
+ if (!positiveArmed) {
296
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `${caseEntry.kind} case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic role vector. The runner never synthesizes a provider verdict.`, command: POSITIVE_JOURNEY_COMMAND });
297
+ continue;
298
+ }
299
+ try {
300
+ const artifact = await roleRunner({ kind: caseEntry.kind, argumentTokens: caseEntry.argumentTokens, gentleAiExecutable: gentleAiPath, ...(caseEntry.validationRequest === undefined ? {} : { validationRequest: caseEntry.validationRequest }), ...(options.signal === undefined ? {} : { signal: options.signal }) });
301
+ if (artifact.lineageId !== caseEntry.lineage || artifact.targetIdentity !== caseEntry.targetIdentity) {
302
+ throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", `role vector returned a stale artifact: expected lineage=${caseEntry.lineage} target=${caseEntry.targetIdentity}, got lineage=${artifact.lineageId} target=${artifact.targetIdentity}`);
303
+ }
304
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", role: artifact.role, lineageId: artifact.lineageId, targetIdentity: artifact.targetIdentity, captured: artifact.captured });
305
+ } catch (error) {
306
+ if (error instanceof ProviderRoleVectorError) {
307
+ if (error.kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED) {
308
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: `the declared gentle-ai binary refused relay-contract negotiation: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND });
309
+ } else if (error.kind === ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE) {
310
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: `the declared gentle-ai binary lacks the provider role capture surface: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND });
311
+ } else {
312
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `role vector error kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}: ${error.message}`, stderr: error.stderr, timedOut: error.timedOut });
313
+ }
314
+ } else {
315
+ verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-role error: ${error instanceof Error ? error.message : String(error)}` });
316
+ }
317
+ }
318
+ continue;
319
+ }
142
320
  // The positive lens needs a real pi AND an explicit arm; the negative
143
- // control never launches pi (fails closed at materialize).
321
+ // control never launches pi (fails closed at materialize). The precheck
322
+ // resolves the declared Pi executable ONCE and reuses that exact
323
+ // concrete path for launch; a bare declaration is never re-resolved
324
+ // between precheck and spawn, so the relay launches exactly the
325
+ // executable the harness checked (issue #324).
326
+ let piPath = null;
144
327
  if (caseEntry.kind === "positive-lens") {
145
- if (resolveDeclaredExecutable(descriptor.piExecutable) === null) {
328
+ piPath = resolveDeclaredExecutable(descriptor.piExecutable);
329
+ if (piPath === null) {
146
330
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.piExecutable, "piExecutable"), command: POSITIVE_JOURNEY_COMMAND });
147
331
  continue;
148
332
  }
@@ -164,9 +348,9 @@ export async function runMatrix(descriptor, options = {}) {
164
348
  // stage as kind=pi-launch-failed stage=pi mutationOutcome=none, with
165
349
  // zero pi launch and zero submission.
166
350
  const negativeControl = caseEntry.kind === "relay-unavailable" ? unlaunchablePi() : null;
167
- const request = { captureArgumentTokens: caseEntry.captureArgumentTokens, submission: caseEntry.submission, gentleAiExecutable: gentleAiPath, piExecutable: negativeControl === null ? descriptor.piExecutable : negativeControl.executable, ...(options.signal === undefined ? {} : { signal: options.signal }) };
351
+ const request = { captureArgumentTokens: caseEntry.captureArgumentTokens, submission: caseEntry.submission, gentleAiExecutable: gentleAiPath, piExecutable: negativeControl === null ? piPath : negativeControl.executable, ...(options.signal === undefined ? {} : { signal: options.signal }) };
168
352
  try {
169
- const result = await runReviewHostRelaySlot(request);
353
+ const result = await relay(request);
170
354
  if (caseEntry.kind === "relay-unavailable") {
171
355
  verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: "expected relay-unavailable (runtime without --materialize) but the relay succeeded; the descriptor's binary is unexpectedly capable" });
172
356
  } else {
@@ -50,12 +50,9 @@ try {
50
50
  stdio: "inherit",
51
51
  });
52
52
  const packageRoot = join(installDirectory, "node_modules", "gentle-pi");
53
- const runner = join(packageRoot, "scripts", "run-git-commit-transaction.mjs");
54
- const result = JSON.parse(execFileSync(process.execPath, [runner, "self-test"], { cwd: installDirectory, encoding: "utf8" }));
55
- if (result.schema !== "gentle-pi.git-commit-transaction-runner-self-test/v1" || !Array.isArray(result.states) || !result.states.includes("prepared") || !result.states.includes("committed")) {
56
- throw new Error("installed transaction runner self-test returned an incompatible result");
57
- }
58
- const versions = readdirSync(join(packageRoot, ".gentle-ai"), { withFileTypes: true }).filter((entry) => entry.isDirectory() && /^v\d+\.\d+\.\d+$/.test(entry.name));
53
+ // Accept prerelease pins too: a stable-only pattern here was a second,
54
+ // silent pin that refused the first prerelease version directory.
55
+ const versions = readdirSync(join(packageRoot, ".gentle-ai"), { withFileTypes: true }).filter((entry) => entry.isDirectory() && /^v\d+\.\d+\.\d+(?:-[0-9A-Za-z][0-9A-Za-z.]*)?$/.test(entry.name));
59
56
  if (versions.length !== 1) throw new Error("packed install did not contain exactly one package-local Gentle AI version");
60
57
  const executable = join(packageRoot, ".gentle-ai", versions[0].name, process.platform === "win32" ? "gentle-ai.exe" : "gentle-ai");
61
58
  const capabilities = JSON.parse(execFileSync(executable, ["review", "capabilities", "--contract", "gentle-ai.review-integration/v2"], { cwd: installDirectory, encoding: "utf8" }));
@@ -74,7 +71,7 @@ try {
74
71
  const decoded = decodeReviewCapabilitiesV2(capabilities, executableDigest);
75
72
  if (decoded.contract !== "gentle-ai.review-integration/v2" || decoded.packageVersion !== versions[0].name.slice(1)) throw new Error("package-local Gentle AI returned incompatible capabilities");
76
73
  const packageManifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
77
- process.stdout.write(`packed runner E2E passed (gentle-pi ${packageManifest.version ?? "unknown"}; Gentle AI ${capabilities.package?.version ?? "unknown"}; ${result.states.length} states)\n`);
74
+ process.stdout.write(`packed package E2E passed (gentle-pi ${packageManifest.version ?? "unknown"}; Gentle AI ${decoded.packageVersion ?? "unknown"})\n`);
78
75
  } finally {
79
76
  rmSync(temporary, { recursive: true, force: true });
80
77
  }