gentle-pi 2.1.2 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -79
- package/assets/agents/gentle-ai-worker.md +7 -3
- package/assets/agents/jd-fix-agent.md +1 -1
- package/assets/agents/jd-judge-a.md +3 -1
- package/assets/agents/jd-judge-b.md +3 -1
- package/assets/agents/review-readability.md +4 -3
- package/assets/agents/review-reliability.md +4 -3
- package/assets/agents/review-resilience.md +4 -3
- package/assets/agents/review-risk.md +4 -3
- package/assets/agents/sdd-apply.md +11 -4
- package/assets/agents/sdd-archive.md +6 -1
- package/assets/agents/sdd-design.md +6 -1
- package/assets/agents/sdd-explore.md +6 -2
- package/assets/agents/sdd-init.md +10 -2
- package/assets/agents/sdd-onboard.md +6 -1
- package/assets/agents/sdd-proposal.md +8 -1
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +10 -5
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +8 -4
- package/assets/agents/sdd-verify.md +27 -2
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +2 -2
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +2 -2
- package/assets/orchestrator-delegation.md +145 -160
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +25 -48
- package/assets/sdd-orchestrator-workflow.md +163 -25
- package/assets/support/sdd-status-contract.md +24 -6
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
- package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
- package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
- package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
- package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
- package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
- 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/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
- package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
- package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
- package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
- package/docs/native-authority-architecture.md +14 -16
- package/docs/review-integration.md +27 -353
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +2710 -3211
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +21 -12
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +676 -781
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +836 -118
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +578 -0
- package/lib/review-integration-v2.ts +1254 -221
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +230 -67
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -79
- package/runtime/gentle-ai-binary.mjs +174 -3
- package/runtime/native-review-cli.mjs +610 -715
- package/runtime/review-integration-v2.mjs +1223 -190
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +5 -5
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +85 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +403 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +20 -9
- package/scripts/verify-package-files.mjs +119 -43
- package/skills/_shared/review-ledger-contract.md +11 -19
- 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/issue-creation/SKILL.md +94 -168
- package/skills/judgment-day/SKILL.md +9 -5
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- 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/background-subagents.test.ts +771 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +16 -0
- package/tests/delegated-key-learnings-contract.test.ts +240 -0
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
- package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
- package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
- package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -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/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -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-consent-declined.captured.json +19 -0
- package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
- package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
- package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +159 -0
- package/tests/gentle-ai-installer.test.ts +138 -48
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +601 -0
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +75 -2
- package/tests/native-review-cli.test.ts +588 -908
- package/tests/native-review-consent.test.ts +283 -38
- package/tests/native-review-parity-runtime.test.ts +108 -350
- package/tests/native-review-parity.test.ts +684 -732
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +158 -89
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +128 -100
- package/tests/provider-contract-bundle.test.ts +385 -0
- package/tests/provider-contract-mirror.test.ts +206 -0
- package/tests/provider-defect-handoff.test.ts +252 -0
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +898 -12
- package/tests/review-compact-contract.test.ts +29 -122
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +366 -857
- package/tests/review-controller-native-routing.test.ts +1253 -4199
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +236 -70
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +134 -0
- package/tests/review-dispatch-hydration-gap.test.ts +145 -0
- 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 +352 -0
- package/tests/review-host-relay.test.ts +754 -0
- package/tests/review-integration-v2-forward.test.ts +654 -0
- package/tests/review-integration-v2.test.ts +317 -129
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +106 -60
- package/tests/review-recovered-lineage-routing.test.ts +199 -0
- package/tests/review-relay-transport-agent.test.ts +322 -0
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +573 -180
- package/tests/sdd-agent-tools.test.ts +53 -32
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +6 -8
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- 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/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- package/lib/git-commit-transaction.ts +0 -801
- package/lib/native-review-remediation.ts +0 -49
- package/lib/review-compact.ts +0 -947
- package/lib/review-refuter-adapter.ts +0 -129
- package/lib/review-runtime-contract.ts +0 -68
- package/prompts/gcl.md +0 -54
- package/prompts/gis.md +0 -25
- package/prompts/gpr.md +0 -41
- package/prompts/gwr.md +0 -31
- package/runtime/git-commit-transaction.mjs +0 -802
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
- package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
- package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
- package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
- 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 -302
- package/tests/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Maintainer provider-relay matrix (gentle-pi#311, first direct work unit).
|
|
3
|
+
//
|
|
4
|
+
// Read-only maintainer harness driving the REAL Pi host relay
|
|
5
|
+
// (lib/review-host-relay.ts#runReviewHostRelaySlot) through explicit
|
|
6
|
+
// maintainer runtime descriptors. Validates an EXACT descriptor shape, uses
|
|
7
|
+
// ONLY declared executables (never re-resolves the production binary), and
|
|
8
|
+
// emits one machine-readable NDJSON verdict per case. Not a production path:
|
|
9
|
+
// `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests;
|
|
10
|
+
// the CLI is the entry point the separate verifier uses for the real organic
|
|
11
|
+
// positive journey after a user-visible forecast.
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
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";
|
|
18
|
+
export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1";
|
|
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
|
+
}
|
|
61
|
+
// The positive lens runs only when explicitly armed: the organic journey
|
|
62
|
+
// needs a real capable binary, a real pi, and a real review session. Without
|
|
63
|
+
// the arm the runner blocks the positive leg before materialize.
|
|
64
|
+
export const ARM_POSITIVE_ENV = "GENTLE_PI_MAINTAINER_ARM_POSITIVE";
|
|
65
|
+
export const POSITIVE_JOURNEY_COMMAND =
|
|
66
|
+
"GENTLE_PI_MAINTAINER_ARM_POSITIVE=1 node --experimental-strip-types scripts/maintainer/provider-relay-matrix.mjs --descriptor <descriptor.json> # needs a real capable gentle-ai binary + real pi on PATH + real binding tokens from a live review session (gentle-ai review status --next-transition)";
|
|
67
|
+
export class DescriptorValidationError extends Error {
|
|
68
|
+
constructor(message) {
|
|
69
|
+
super(message);
|
|
70
|
+
this.name = "DescriptorValidationError";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
74
|
+
const isStr = (value) => typeof value === "string" && value.length > 0;
|
|
75
|
+
const isStrArr = (value) => Array.isArray(value) && value.length > 0 && value.every(isStr);
|
|
76
|
+
const fail = (message) => {
|
|
77
|
+
throw new DescriptorValidationError(message);
|
|
78
|
+
};
|
|
79
|
+
// Reject any key outside an exact allowed set; keeps the descriptor shape
|
|
80
|
+
// strict so a maintainer cannot smuggle production overrides through it.
|
|
81
|
+
const exactKeys = (obj, allowed, label) => {
|
|
82
|
+
for (const key of Object.keys(obj)) {
|
|
83
|
+
if (!allowed.has(key)) fail(`${label}.${key} is not allowed; exact shape is {${[...allowed].join(",")}}`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
// Strict, exact-shape descriptor validation: no defaults, no production
|
|
87
|
+
// resolution. The submission is validated through the REAL relay resolver so
|
|
88
|
+
// the completing form is provably bindable before any process launches.
|
|
89
|
+
export function validateDescriptor(value) {
|
|
90
|
+
if (!isObj(value)) fail("descriptor must be a JSON object");
|
|
91
|
+
exactKeys(value, new Set(["schema", "gentleAiExecutable", "piExecutable", "cases"]), "descriptor");
|
|
92
|
+
if (value.schema !== DESCRIPTOR_SCHEMA) fail(`descriptor.schema must be exactly "${DESCRIPTOR_SCHEMA}"`);
|
|
93
|
+
if (!isStr(value.gentleAiExecutable) || !isAbsolute(value.gentleAiExecutable)) fail("descriptor.gentleAiExecutable must be a non-empty absolute path; the runner never re-resolves the production binary");
|
|
94
|
+
if (!isStr(value.piExecutable)) fail("descriptor.piExecutable must be a non-empty string");
|
|
95
|
+
if (!Array.isArray(value.cases) || value.cases.length === 0) fail("descriptor.cases must be a non-empty array");
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, piExecutable: value.piExecutable, cases: value.cases.map((entry, index) => {
|
|
98
|
+
if (!isObj(entry)) fail(`descriptor.cases[${index}] must be an object`);
|
|
99
|
+
if (!isStr(entry.name)) fail(`descriptor.cases[${index}].name must be a non-empty string`);
|
|
100
|
+
if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`);
|
|
101
|
+
seen.add(entry.name);
|
|
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}]`);
|
|
107
|
+
if (!isStrArr(entry.captureArgumentTokens)) fail(`descriptor.cases[${index}].captureArgumentTokens must be a non-empty array of non-empty strings`);
|
|
108
|
+
// A real host-relay materialize slot is the provider-issued
|
|
109
|
+
// --agent=pi --materialize=true pair; the descriptor must declare
|
|
110
|
+
// exactly that so the negative control exercises the unknown-flag
|
|
111
|
+
// refusal and the positive leg exercises the real surface.
|
|
112
|
+
for (const required of ["--agent=pi", "--materialize=true"]) {
|
|
113
|
+
if (!entry.captureArgumentTokens.includes(required)) fail(`descriptor.cases[${index}].captureArgumentTokens must include the provider-issued "${required}" token`);
|
|
114
|
+
}
|
|
115
|
+
const submission = entry.submission;
|
|
116
|
+
if (!isObj(submission)) fail(`descriptor.cases[${index}].submission must be an object; a materialize slot without a provider submission is a contract mismatch, never synthesized`);
|
|
117
|
+
exactKeys(submission, new Set(["operationToken", "argumentTokens", "values"]), `descriptor.cases[${index}].submission`);
|
|
118
|
+
if (!isStr(submission.operationToken)) fail(`descriptor.cases[${index}].submission.operationToken must be a non-empty string`);
|
|
119
|
+
if (!isStrArr(submission.argumentTokens)) fail(`descriptor.cases[${index}].submission.argumentTokens must be a non-empty array of non-empty strings`);
|
|
120
|
+
if (!Array.isArray(submission.values) || submission.values.length === 0 || !submission.values.every(isObj)) {
|
|
121
|
+
fail(`descriptor.cases[${index}].submission.values must be a non-empty array of objects`);
|
|
122
|
+
}
|
|
123
|
+
for (const [vi, ve] of submission.values.entries()) {
|
|
124
|
+
exactKeys(ve, new Set(["slot", "domain", "substitutionLocation"]), `descriptor.cases[${index}].submission.values[${vi}]`);
|
|
125
|
+
if (!isStr(ve.slot) || !isStr(ve.domain)) fail(`descriptor.cases[${index}].submission.values[${vi}] must have non-empty string slot and domain`);
|
|
126
|
+
}
|
|
127
|
+
// Delegate binding semantics (count, substitution location, the
|
|
128
|
+
// {{value}} slot) to the REAL relay resolver so they never drift.
|
|
129
|
+
try {
|
|
130
|
+
resolveReviewHostRelaySubmission({
|
|
131
|
+
operationToken: submission.operationToken,
|
|
132
|
+
argumentTokens: submission.argumentTokens,
|
|
133
|
+
values: submission.values.map((ve) => ({ slot: ve.slot, domain: ve.domain, substitutionLocation: ve.substitutionLocation })),
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
fail(`descriptor.cases[${index}].submission is not a bindable provider form: ${error instanceof Error ? error.message : String(error)}`);
|
|
137
|
+
}
|
|
138
|
+
return { name: entry.name, kind: entry.kind, captureArgumentTokens: [...entry.captureArgumentTokens], submission: { operationToken: submission.operationToken, argumentTokens: [...submission.argumentTokens], values: submission.values.map((ve) => ({ ...ve })) } };
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
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
|
+
}
|
|
179
|
+
export function loadDescriptor(path) {
|
|
180
|
+
if (!isStr(path)) fail("a descriptor path is required");
|
|
181
|
+
let raw, parsed;
|
|
182
|
+
try {
|
|
183
|
+
raw = readFileSync(path, "utf8");
|
|
184
|
+
} catch (error) {
|
|
185
|
+
fail(`could not read descriptor at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
parsed = JSON.parse(raw);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
fail(`descriptor at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
191
|
+
}
|
|
192
|
+
return validateDescriptor(parsed);
|
|
193
|
+
}
|
|
194
|
+
// Resolves a declared executable without a shell: absolute path checked
|
|
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).
|
|
198
|
+
export function resolveDeclaredExecutable(executable) {
|
|
199
|
+
if (isAbsolute(executable)) return existsSync(executable) && statSync(executable).isFile() ? executable : null;
|
|
200
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
201
|
+
if (!directory) continue;
|
|
202
|
+
const candidate = resolve(directory, executable);
|
|
203
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const missingReason = (executable, label) => `${label} "${executable}" is not an existing executable; arm the descriptor with a real binary, or set GENTLE_PI_REQUIRE_MAINTAINER=1 to fail instead of block.`;
|
|
208
|
+
// A guaranteed-nonexistent pi path inside a fresh private directory. mkdtemp
|
|
209
|
+
// picks an unpredictable name and 0700 keeps it ours, so nothing can race a
|
|
210
|
+
// file into the slot between the mkdtemp and the spawn.
|
|
211
|
+
function unlaunchablePi() {
|
|
212
|
+
const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-no-pi-"));
|
|
213
|
+
chmodSync(directory, 0o700);
|
|
214
|
+
return { directory, executable: join(directory, "pi-must-never-launch") };
|
|
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
|
+
}
|
|
275
|
+
// Runs the matrix against the REAL relay. A case that cannot run honestly is
|
|
276
|
+
// `blocked` (never `pass`); `fail` is an armed case whose outcome mismatched.
|
|
277
|
+
export async function runMatrix(descriptor, options = {}) {
|
|
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;
|
|
286
|
+
const verdicts = [];
|
|
287
|
+
for (const caseEntry of descriptor.cases) {
|
|
288
|
+
const gentleAiPath = resolveDeclaredExecutable(descriptor.gentleAiExecutable);
|
|
289
|
+
if (gentleAiPath === null) {
|
|
290
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.gentleAiExecutable, "gentleAiExecutable") });
|
|
291
|
+
continue;
|
|
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
|
+
}
|
|
320
|
+
// The positive lens needs a real pi AND an explicit arm; the negative
|
|
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;
|
|
327
|
+
if (caseEntry.kind === "positive-lens") {
|
|
328
|
+
piPath = resolveDeclaredExecutable(descriptor.piExecutable);
|
|
329
|
+
if (piPath === null) {
|
|
330
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.piExecutable, "piExecutable"), command: POSITIVE_JOURNEY_COMMAND });
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (!positiveArmed) {
|
|
334
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens 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 journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// `relay-unavailable` is a NEGATIVE CONTROL: it must never launch pi and
|
|
339
|
+
// never submit. The maintainer-declared `kind` is not evidence that the
|
|
340
|
+
// declared binary is actually incapable, so the arm gate cannot key off
|
|
341
|
+
// the declaration alone. Pointed at a mis-declared CAPABLE binary, a
|
|
342
|
+
// declaration-only gate materializes, runs a REAL pi model, and executes
|
|
343
|
+
// a REAL `capture-result --input=...` submission, and only then reports
|
|
344
|
+
// `fail` — the mutation already happened. So give the negative control a
|
|
345
|
+
// pi that cannot exist: an incapable binary still classifies
|
|
346
|
+
// relay-unavailable at materialize (the control stays genuine, since it
|
|
347
|
+
// never reached pi anyway), while a capable one fails closed at the pi
|
|
348
|
+
// stage as kind=pi-launch-failed stage=pi mutationOutcome=none, with
|
|
349
|
+
// zero pi launch and zero submission.
|
|
350
|
+
const negativeControl = caseEntry.kind === "relay-unavailable" ? unlaunchablePi() : null;
|
|
351
|
+
const request = { captureArgumentTokens: caseEntry.captureArgumentTokens, submission: caseEntry.submission, gentleAiExecutable: gentleAiPath, piExecutable: negativeControl === null ? piPath : negativeControl.executable, ...(options.signal === undefined ? {} : { signal: options.signal }) };
|
|
352
|
+
try {
|
|
353
|
+
const result = await relay(request);
|
|
354
|
+
if (caseEntry.kind === "relay-unavailable") {
|
|
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" });
|
|
356
|
+
} else {
|
|
357
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submissionByteLength: result.submission.length });
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error instanceof ReviewHostRelayError) {
|
|
361
|
+
if (caseEntry.kind === "relay-unavailable") {
|
|
362
|
+
if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE && error.stage === "materialize" && error.mutationOutcome === "none") {
|
|
363
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE });
|
|
364
|
+
} else {
|
|
365
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `expected relay-unavailable at materialize with zero mutation, got kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}`, stderr: error.stderr });
|
|
366
|
+
}
|
|
367
|
+
} else if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE) {
|
|
368
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, reason: `the declared gentle-ai binary lacks the pi host relay surface: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND });
|
|
369
|
+
} else {
|
|
370
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `relay error kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}: ${error.message}`, stderr: error.stderr });
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-relay error: ${error instanceof Error ? error.message : String(error)}` });
|
|
374
|
+
}
|
|
375
|
+
} finally {
|
|
376
|
+
if (negativeControl !== null) rmSync(negativeControl.directory, { recursive: true, force: true });
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return verdicts;
|
|
380
|
+
}
|
|
381
|
+
async function main() {
|
|
382
|
+
const argv = process.argv.slice(2);
|
|
383
|
+
const index = argv.indexOf("--descriptor");
|
|
384
|
+
if (index === -1 || argv[index + 1] === undefined) {
|
|
385
|
+
process.stderr.write("usage: provider-relay-matrix.mjs --descriptor <path.json>\n");
|
|
386
|
+
process.exitCode = 2;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
let descriptor;
|
|
390
|
+
try {
|
|
391
|
+
descriptor = loadDescriptor(argv[index + 1]);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
process.stderr.write(`descriptor validation failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
394
|
+
process.exitCode = 2;
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const verdicts = await runMatrix(descriptor, { armPositive: process.env[ARM_POSITIVE_ENV] === "1" });
|
|
398
|
+
for (const verdict of verdicts) {
|
|
399
|
+
process.stdout.write(`${JSON.stringify(verdict)}\n`);
|
|
400
|
+
if (verdict.verdict !== "pass") process.exitCode = 1;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (process.argv[1]?.endsWith("provider-relay-matrix.mjs")) await main();
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Field-test acquisition of the gentle-ai provider contract bundle
|
|
3
|
+
// (gentle-pi#311 P1/P2).
|
|
4
|
+
//
|
|
5
|
+
// This script NEVER fetches anything from the network and NEVER touches the
|
|
6
|
+
// Gentle AI release pin (scripts/gentle-ai-installer.mjs INSTALLER_VERSION).
|
|
7
|
+
// RC and main-built bundles are field-test input only: you pass an explicit
|
|
8
|
+
// LOCAL path to a `gentle-ai-review-provider-contract-<semver>.tar.gz` archive
|
|
9
|
+
// or to an already-extracted bundle tree, the bundle is strictly verified,
|
|
10
|
+
// and only then is the verified mirror written under
|
|
11
|
+
// `contracts/review-provider-contract-mirror/` together with a lock record
|
|
12
|
+
// (contract semver, archive digest when mirroring an archive, tree digest,
|
|
13
|
+
// per-entry digests, generated baseline digests, runtime registry).
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// node scripts/mirror-provider-contract.mjs --bundle <local archive or tree> [--root <package root>]
|
|
17
|
+
|
|
18
|
+
import { lstatSync, mkdirSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
import {
|
|
23
|
+
generateProviderContractBaselines,
|
|
24
|
+
piRuntimeRegistration,
|
|
25
|
+
verifyProviderContractBundleArchive,
|
|
26
|
+
verifyProviderContractBundleTree,
|
|
27
|
+
} from "../lib/provider-contract-bundle.ts";
|
|
28
|
+
|
|
29
|
+
export const PROVIDER_CONTRACT_MIRROR_ROOT = "contracts/review-provider-contract-mirror";
|
|
30
|
+
export const PROVIDER_CONTRACT_LOCK_FILE = "provider-contract.lock.json";
|
|
31
|
+
export const PROVIDER_CONTRACT_LOCK_SCHEMA = "gentle-pi.review-provider-contract-mirror-lock/v1";
|
|
32
|
+
// This script exists for field-test consumption only; the lock says so
|
|
33
|
+
// explicitly so nothing downstream mistakes it for a release pin.
|
|
34
|
+
export const PROVIDER_CONTRACT_ACQUISITION = "field-test-local";
|
|
35
|
+
|
|
36
|
+
function sha256Hex(bytes) {
|
|
37
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseArguments(argv) {
|
|
41
|
+
const values = { bundle: undefined, root: undefined };
|
|
42
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
43
|
+
const flag = argv[index];
|
|
44
|
+
const value = argv[index + 1];
|
|
45
|
+
if ((flag !== "--bundle" && flag !== "--root") || value === undefined) {
|
|
46
|
+
throw new Error("usage: node scripts/mirror-provider-contract.mjs --bundle <local archive or tree> [--root <package root>]");
|
|
47
|
+
}
|
|
48
|
+
if (values[flag.slice(2)] !== undefined) throw new Error(`duplicate ${flag} argument`);
|
|
49
|
+
values[flag.slice(2)] = value;
|
|
50
|
+
}
|
|
51
|
+
if (values.bundle === undefined) {
|
|
52
|
+
throw new Error("usage: node scripts/mirror-provider-contract.mjs --bundle <local archive or tree> [--root <package root>]");
|
|
53
|
+
}
|
|
54
|
+
return values;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function verifyLocalBundle(bundlePath) {
|
|
58
|
+
const details = lstatSync(bundlePath);
|
|
59
|
+
if (details.isDirectory()) {
|
|
60
|
+
return { bundle: verifyProviderContractBundleTree(bundlePath), source: { kind: "tree" } };
|
|
61
|
+
}
|
|
62
|
+
const { bundle, archiveSha256 } = verifyProviderContractBundleArchive(bundlePath);
|
|
63
|
+
return { bundle, source: { kind: "archive", archive_sha256: archiveSha256 } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sortedRecord(map) {
|
|
67
|
+
return Object.fromEntries([...map.entries()].sort(([left], [right]) => (left < right ? -1 : 1)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildLockRecord(bundle, source, generatedSha256) {
|
|
71
|
+
return {
|
|
72
|
+
schema: PROVIDER_CONTRACT_LOCK_SCHEMA,
|
|
73
|
+
acquisition: PROVIDER_CONTRACT_ACQUISITION,
|
|
74
|
+
contract_semver: bundle.contractSemver,
|
|
75
|
+
source,
|
|
76
|
+
tree_sha256: bundle.treeSha256,
|
|
77
|
+
entries: sortedRecord(bundle.entrySha256),
|
|
78
|
+
generated: sortedRecord(generatedSha256),
|
|
79
|
+
runtimes: [...(bundle.runtimes ?? [])],
|
|
80
|
+
pi_registered: piRuntimeRegistration(bundle).registered,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function mirrorProviderContractBundle(bundlePath, packageRoot) {
|
|
85
|
+
const { bundle, source } = verifyLocalBundle(bundlePath);
|
|
86
|
+
const registration = piRuntimeRegistration(bundle);
|
|
87
|
+
if (!registration.registered) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`refusing to mirror provider contract ${bundle.contractSemver}: ${registration.reason ?? "the pi runtime identity is not registered"}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const mirrorRoot = join(resolve(packageRoot), ...PROVIDER_CONTRACT_MIRROR_ROOT.split("/"));
|
|
94
|
+
const versionRoot = join(mirrorRoot, `v${bundle.contractSemver}`);
|
|
95
|
+
const bundleRoot = join(versionRoot, "bundle");
|
|
96
|
+
const generatedRoot = join(versionRoot, "generated");
|
|
97
|
+
|
|
98
|
+
// Single locked version policy: the mirror directory holds exactly one
|
|
99
|
+
// verified bundle plus its lock, so rebuild it from scratch every time.
|
|
100
|
+
rmSync(mirrorRoot, { recursive: true, force: true });
|
|
101
|
+
|
|
102
|
+
// Confined materialization: entries come exclusively from the verified
|
|
103
|
+
// in-memory inventory whose paths already passed canonical-path checks, so
|
|
104
|
+
// no archive metadata ever steers a write location or a file mode.
|
|
105
|
+
for (const [name, payload] of bundle.entries) {
|
|
106
|
+
const destination = join(bundleRoot, ...name.split("/"));
|
|
107
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
108
|
+
writeFileSync(destination, payload, { flag: "wx" });
|
|
109
|
+
chmodSync(destination, 0o644);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const baselines = generateProviderContractBaselines(bundle);
|
|
113
|
+
const generatedSha256 = new Map();
|
|
114
|
+
for (const [name, payload] of baselines) {
|
|
115
|
+
const destination = join(generatedRoot, name);
|
|
116
|
+
mkdirSync(generatedRoot, { recursive: true });
|
|
117
|
+
writeFileSync(destination, payload, { flag: "wx" });
|
|
118
|
+
chmodSync(destination, 0o644);
|
|
119
|
+
generatedSha256.set(`generated/${name}`, sha256Hex(Buffer.from(payload, "utf8")));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lock = buildLockRecord(bundle, source, generatedSha256);
|
|
123
|
+
const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE);
|
|
124
|
+
writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`, { flag: "wx" });
|
|
125
|
+
chmodSync(lockPath, 0o644);
|
|
126
|
+
|
|
127
|
+
return { bundle, source, lock, mirrorRoot };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function main() {
|
|
131
|
+
const packageRoot = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
132
|
+
const { bundle: bundlePath, root } = parseArguments(process.argv.slice(2));
|
|
133
|
+
const { bundle, source, mirrorRoot } = mirrorProviderContractBundle(resolve(bundlePath), root ?? packageRoot);
|
|
134
|
+
console.log(
|
|
135
|
+
`Mirrored provider contract ${bundle.contractSemver} (${source.kind}${source.kind === "archive" ? ` ${source.archive_sha256}` : ""}) into ${mirrorRoot}`,
|
|
136
|
+
);
|
|
137
|
+
console.log(`tree ${bundle.treeSha256}; runtimes ${(bundle.runtimes ?? []).join(", ")}; acquisition ${PROVIDER_CONTRACT_ACQUISITION}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
141
|
+
if (isMainModule) {
|
|
142
|
+
await main();
|
|
143
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
4
5
|
import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { basename, dirname, join } from "node:path";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
9
|
|
|
9
10
|
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
10
11
|
const temporary = mkdtempSync(join(tmpdir(), "gentle-pi-packed-runner-"));
|
|
@@ -49,18 +50,28 @@ try {
|
|
|
49
50
|
stdio: "inherit",
|
|
50
51
|
});
|
|
51
52
|
const packageRoot = join(installDirectory, "node_modules", "gentle-pi");
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
throw new Error("installed transaction runner self-test returned an incompatible result");
|
|
56
|
-
}
|
|
57
|
-
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));
|
|
58
56
|
if (versions.length !== 1) throw new Error("packed install did not contain exactly one package-local Gentle AI version");
|
|
59
57
|
const executable = join(packageRoot, ".gentle-ai", versions[0].name, process.platform === "win32" ? "gentle-ai.exe" : "gentle-ai");
|
|
60
58
|
const capabilities = JSON.parse(execFileSync(executable, ["review", "capabilities", "--contract", "gentle-ai.review-integration/v2"], { cwd: installDirectory, encoding: "utf8" }));
|
|
61
|
-
|
|
59
|
+
// Decode with the PACKED consumer's own decoder rather than comparing the
|
|
60
|
+
// schema string against a list hand-copied into this script. The copy was a
|
|
61
|
+
// second, silent pin: it accepted only `capabilities/v2`, so the moment the
|
|
62
|
+
// pinned provider advertised an additive minor this E2E rejected a pairing
|
|
63
|
+
// that gentle-pi reads correctly, and it would have done so again on the
|
|
64
|
+
// next minor. Using the shipped decoder makes the assertion what it always
|
|
65
|
+
// meant to be — the packed consumer can read the packed provider — and it
|
|
66
|
+
// checks the whole envelope (protocol major/minor, required operations,
|
|
67
|
+
// gates, projections, advertised schemas, mandatory features, and the
|
|
68
|
+
// self-reported executable digest) instead of one string.
|
|
69
|
+
const { decodeReviewCapabilitiesV2 } = await import(pathToFileURL(join(packageRoot, "runtime", "review-integration-v2.mjs")).href);
|
|
70
|
+
const executableDigest = `sha256:${createHash("sha256").update(readFileSync(executable)).digest("hex")}`;
|
|
71
|
+
const decoded = decodeReviewCapabilitiesV2(capabilities, executableDigest);
|
|
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");
|
|
62
73
|
const packageManifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
63
|
-
process.stdout.write(`packed
|
|
74
|
+
process.stdout.write(`packed package E2E passed (gentle-pi ${packageManifest.version ?? "unknown"}; Gentle AI ${decoded.packageVersion ?? "unknown"})\n`);
|
|
64
75
|
} finally {
|
|
65
76
|
rmSync(temporary, { recursive: true, force: true });
|
|
66
77
|
}
|