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.
- package/README.md +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
|
@@ -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-
|
|
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-
|
|
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
|
|
58
|
+
throw new Error(`generated runtime is stale: ${drift.join(", ")}`);
|
|
60
59
|
}
|
|
61
|
-
process.stdout.write(`
|
|
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.
|
|
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.
|
|
43
|
-
// with GOSUMDB=sum.golang.org reports this exact module SumDB checksum
|
|
44
|
-
//
|
|
45
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
83
|
-
"darwin/arm64": asset("gentle-ai_2.
|
|
84
|
-
"linux/amd64": asset("gentle-ai_2.
|
|
85
|
-
"linux/arm64": asset("gentle-ai_2.
|
|
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};
|
|
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
|
|
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 (
|
|
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
|
-
|
|
617
|
-
|
|
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
|
|
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(
|
|
200
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
116
201
|
if (!directory) continue;
|
|
117
|
-
const candidate =
|
|
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
|
-
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
}
|