gentle-pi 2.1.2 → 2.2.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 +70 -12
- 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 -1
- package/assets/agents/review-reliability.md +4 -1
- package/assets/agents/review-resilience.md +4 -1
- package/assets/agents/review-risk.md +4 -1
- package/assets/agents/sdd-apply.md +6 -1
- 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 +6 -1
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +6 -1
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +6 -1
- package/assets/agents/sdd-verify.md +6 -1
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +246 -67
- package/assets/orchestrator.md +7 -14
- package/assets/sdd-orchestrator-workflow.md +154 -9
- package/assets/support/sdd-status-contract.md +19 -1
- 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/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/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 +5 -5
- package/docs/review-integration.md +22 -2
- package/extensions/gentle-ai.ts +1595 -201
- package/extensions/sdd-init.ts +19 -6
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/git-commit-transaction.ts +77 -17
- package/lib/native-review-cli.ts +528 -65
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +527 -18
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +436 -0
- package/lib/review-integration-v2.ts +537 -36
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +53 -1
- package/package.json +5 -2
- package/runtime/gentle-ai-binary.mjs +173 -2
- package/runtime/git-commit-transaction.mjs +75 -15
- package/runtime/native-review-cli.mjs +524 -61
- package/runtime/review-integration-v2.mjs +536 -35
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/build-git-commit-transaction-runner.mjs +1 -0
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +23 -13
- package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/verify-package-files.mjs +110 -33
- package/skills/_shared/review-ledger-contract.md +4 -6
- package/skills/gentle-ai/SKILL.md +4 -4
- package/skills/issue-creation/SKILL.md +94 -168
- package/skills/judgment-day/SKILL.md +7 -1
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- package/tests/background-subagents.test.ts +771 -0
- package/tests/crosslane/cross-lane.mjs +1169 -0
- package/tests/delegated-key-learnings-contract.test.ts +238 -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/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -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/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/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 +1 -1
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +336 -0
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/git-commit-transaction.test.ts +229 -1
- package/tests/maintainer/provider-relay.maintest.ts +265 -0
- package/tests/native-review-capability-contract.test.ts +48 -2
- package/tests/native-review-cli.test.ts +56 -0
- package/tests/native-review-consent.test.ts +164 -3
- package/tests/native-review-parity-runtime.test.ts +37 -0
- package/tests/native-review-parity.test.ts +218 -15
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/orchestrator-budget.test.ts +30 -5
- package/tests/package-manifest.test.ts +98 -72
- 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 +355 -0
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-candidate-view.test.ts +489 -9
- package/tests/review-compact-contract.test.ts +52 -119
- package/tests/review-controller-native-recovery.test.ts +643 -47
- package/tests/review-controller-native-routing.test.ts +1667 -222
- package/tests/review-controller-workspace-root.test.ts +17 -2
- package/tests/review-corrected-finalize-binding.test.ts +175 -0
- package/tests/review-dispatch-hydration-gap.test.ts +197 -0
- package/tests/review-host-relay-routing.test.ts +317 -0
- package/tests/review-host-relay.test.ts +520 -0
- package/tests/review-integration-v2-forward.test.ts +631 -0
- package/tests/review-integration-v2.test.ts +114 -0
- package/tests/review-ledger-contract.test.ts +12 -28
- package/tests/review-recovered-lineage-routing.test.ts +246 -0
- package/tests/review-relay-transport-agent.test.ts +249 -0
- package/tests/runtime-harness.mjs +242 -14
- package/tests/sdd-agent-tools.test.ts +18 -33
- package/tests/skill-collision-prefixes.test.ts +1 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- package/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- 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/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/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// The thin Pi host relay (gentle-pi#311 P4; provider contract gentle-ai#3249).
|
|
2
|
+
//
|
|
3
|
+
// gentle-ai owns prompt materialization, role and schema selection, byte
|
|
4
|
+
// budgets, parsing, admission, immutable capture, retry and correction
|
|
5
|
+
// accounting, receipts, and delivery gates. This host boundary is
|
|
6
|
+
// intentionally narrow:
|
|
7
|
+
//
|
|
8
|
+
// 1. Run the exact provider-issued capture binding with `--agent pi
|
|
9
|
+
// --materialize` and take stdout as opaque prompt BYTES, verbatim.
|
|
10
|
+
// 2. Launch a brand-new locked-down print-mode `pi` subprocess in a fresh
|
|
11
|
+
// empty scratch directory, pipe the prompt through stdin, and take
|
|
12
|
+
// stdout as raw final bytes. Model/provider/profile selection stays
|
|
13
|
+
// user-owned: no --model, no --provider, environment untouched.
|
|
14
|
+
// 3. Submit those bytes untouched through the provider-owned `submission`
|
|
15
|
+
// form carried by the collect input: execute its exact operation and
|
|
16
|
+
// argument tokens with only the tempfile path substituted into the
|
|
17
|
+
// declared {{value}} slot (BOM-less: the buffer is written
|
|
18
|
+
// byte-for-byte). The host never synthesizes or filters the completing
|
|
19
|
+
// form; a materialize slot without a provider submission is a typed
|
|
20
|
+
// contract mismatch, never a rebuilt invocation.
|
|
21
|
+
//
|
|
22
|
+
// On any failure the relay returns a TYPED transport error and submits
|
|
23
|
+
// nothing further. After a transport failure the caller re-queries negotiated
|
|
24
|
+
// STATUS and relaunches only if the exact same bound slot is reoffered —
|
|
25
|
+
// never from transcript inference. The relay never parses or rebuilds
|
|
26
|
+
// binding, evidence, prompt, schema, budgets, or admission.
|
|
27
|
+
|
|
28
|
+
import { spawn } from "node:child_process";
|
|
29
|
+
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
30
|
+
import { tmpdir } from "node:os";
|
|
31
|
+
import { isAbsolute, join } from "node:path";
|
|
32
|
+
import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
|
|
33
|
+
import { REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION, REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS, type ReviewCaptureSubmissionV1, type ReviewCollectInputV3 } from "./review-integration-v2.ts";
|
|
34
|
+
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "./review-relay-contract.ts";
|
|
35
|
+
|
|
36
|
+
// The complete pinned lockdown argv for the reviewer `pi` subprocess: print
|
|
37
|
+
// mode, text output, and every discovery surface disabled. Nothing may be
|
|
38
|
+
// added or removed here without a new relay contract — in particular no
|
|
39
|
+
// --model/--provider/--profile, which remain user-owned.
|
|
40
|
+
export const REVIEW_HOST_RELAY_PI_ARGV = Object.freeze([
|
|
41
|
+
"--print",
|
|
42
|
+
"--mode", "text",
|
|
43
|
+
"--no-session",
|
|
44
|
+
"--no-tools",
|
|
45
|
+
"--no-extensions",
|
|
46
|
+
"--no-skills",
|
|
47
|
+
"--no-prompt-templates",
|
|
48
|
+
"--no-themes",
|
|
49
|
+
"--no-context-files",
|
|
50
|
+
"--no-approve",
|
|
51
|
+
] as const);
|
|
52
|
+
|
|
53
|
+
export const REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE =
|
|
54
|
+
"provider relay requires a gentle-ai build with the pi host relay surface";
|
|
55
|
+
|
|
56
|
+
export const REVIEW_HOST_RELAY_FAILURE = {
|
|
57
|
+
RELAY_UNAVAILABLE: "relay-unavailable",
|
|
58
|
+
HANDSHAKE_REFUSED: "handshake-refused",
|
|
59
|
+
SUBMISSION_CONTRACT_MISMATCH: "submission-contract-mismatch",
|
|
60
|
+
MATERIALIZE_FAILED: "materialize-failed",
|
|
61
|
+
EMPTY_PROMPT: "empty-prompt",
|
|
62
|
+
PI_LAUNCH_FAILED: "pi-launch-failed",
|
|
63
|
+
PI_FAILED: "pi-failed",
|
|
64
|
+
PI_EMPTY_OUTPUT: "pi-empty-output",
|
|
65
|
+
SUBMISSION_REFUSED: "submission-refused",
|
|
66
|
+
} as const;
|
|
67
|
+
export type ReviewHostRelayFailureKind = (typeof REVIEW_HOST_RELAY_FAILURE)[keyof typeof REVIEW_HOST_RELAY_FAILURE];
|
|
68
|
+
|
|
69
|
+
export type ReviewHostRelayStage = "binding" | "materialize" | "pi" | "submit";
|
|
70
|
+
|
|
71
|
+
export const REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT = "{{value}}";
|
|
72
|
+
|
|
73
|
+
export const REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE =
|
|
74
|
+
"provider contract mismatch: the materialize capture input carries no provider-owned submission form; the host never synthesizes the completing form";
|
|
75
|
+
|
|
76
|
+
export class ReviewHostRelayError extends Error {
|
|
77
|
+
readonly kind: ReviewHostRelayFailureKind;
|
|
78
|
+
readonly stage: ReviewHostRelayStage;
|
|
79
|
+
readonly exitCode: number | null;
|
|
80
|
+
readonly stderr: string;
|
|
81
|
+
readonly timedOut: boolean;
|
|
82
|
+
// "none" until the submission invocation launches; a launched submission
|
|
83
|
+
// whose outcome could not be read is "unknown" and the caller reconciles
|
|
84
|
+
// through negotiated STATUS, never through a blind retry.
|
|
85
|
+
readonly mutationOutcome: "none" | "unknown";
|
|
86
|
+
constructor(kind: ReviewHostRelayFailureKind, stage: ReviewHostRelayStage, message: string, details?: { exitCode?: number | null; stderr?: string; timedOut?: boolean }) {
|
|
87
|
+
super(message);
|
|
88
|
+
this.name = "ReviewHostRelayError";
|
|
89
|
+
this.kind = kind;
|
|
90
|
+
this.stage = stage;
|
|
91
|
+
this.exitCode = details?.exitCode ?? null;
|
|
92
|
+
this.stderr = details?.stderr ?? "";
|
|
93
|
+
this.timedOut = details?.timedOut ?? false;
|
|
94
|
+
this.mutationOutcome = stage === "submit" ? "unknown" : "none";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Refusal classification for the materialize invocation. The installed
|
|
99
|
+
// gentle-ai is the only authority on whether the materialize form exists; Pi
|
|
100
|
+
// never version-sniffs. Two typed refusal classes are distinguished:
|
|
101
|
+
//
|
|
102
|
+
// unknown-flag the Go flag package's exact refusal for a flag the binary
|
|
103
|
+
// does not define (any binary older than v2.4.0) —
|
|
104
|
+
// the relay is unavailable and existing behavior stays
|
|
105
|
+
// untouched.
|
|
106
|
+
// handshake the provider's pre-authority pi admission refusal — always
|
|
107
|
+
// surfaced verbatim, never worked around.
|
|
108
|
+
const UNKNOWN_FLAG_REFUSAL = /flag provided but not defined: -{1,2}(?:materialize|agent)\b/;
|
|
109
|
+
const HANDSHAKE_REFUSAL = new RegExp(
|
|
110
|
+
[
|
|
111
|
+
GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV,
|
|
112
|
+
GENTLE_PI_REVIEW_RELAY_CONTRACT.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"),
|
|
113
|
+
"not eligible for immutable receipt review",
|
|
114
|
+
].join("|"),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
export function classifyReviewHostRelayRefusal(stderr: string): "unknown-flag" | "handshake" | "other" {
|
|
118
|
+
if (UNKNOWN_FLAG_REFUSAL.test(stderr)) return "unknown-flag";
|
|
119
|
+
if (HANDSHAKE_REFUSAL.test(stderr)) return "handshake";
|
|
120
|
+
return "other";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Slot detection — the provider decides. A collect input routes through the
|
|
125
|
+
// host relay ONLY when the provider itself issued the `--materialize` token
|
|
126
|
+
// (with the pi runtime identity) on a `review.capture-result` collection
|
|
127
|
+
// input. Nothing is ever inferred from state prose, risk, or transcript.
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
export interface ReviewHostRelaySlot {
|
|
131
|
+
/** Every provider-issued argument token, verbatim, in provider order. */
|
|
132
|
+
readonly captureArgumentTokens: readonly string[];
|
|
133
|
+
/**
|
|
134
|
+
* The provider-owned completing form, verbatim. Absent only when the
|
|
135
|
+
* provider violated its own contract; the relay then fails closed with a
|
|
136
|
+
* typed submission-contract-mismatch error instead of synthesizing one.
|
|
137
|
+
*/
|
|
138
|
+
readonly submission?: ReviewCaptureSubmissionV1;
|
|
139
|
+
readonly lens?: string;
|
|
140
|
+
readonly order?: string;
|
|
141
|
+
readonly subjectHash?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function argumentValue(input: ReviewCollectInputV3, name: string): string | undefined {
|
|
145
|
+
const matches = input.arguments.filter((argument) => argument.name === name);
|
|
146
|
+
return matches.length === 1 ? matches[0]!.value : undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function renderToken(argument: ReviewCollectInputV3["arguments"][number]): string {
|
|
150
|
+
return argument.token ?? `--${argument.name}=${argument.value}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function isReviewHostRelayCollectInput(input: ReviewCollectInputV3): boolean {
|
|
154
|
+
return input.captureOperation === "review.capture-result"
|
|
155
|
+
&& argumentValue(input, "materialize") === "true"
|
|
156
|
+
&& argumentValue(input, "agent") === "pi";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function reviewHostRelaySlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewHostRelaySlot[] {
|
|
160
|
+
return inputs.filter((input) => isReviewHostRelayCollectInput(input)).map((input) => ({
|
|
161
|
+
captureArgumentTokens: input.arguments.map((argument) => renderToken(argument)),
|
|
162
|
+
...(input.submission === undefined ? {} : { submission: input.submission }),
|
|
163
|
+
...(argumentValue(input, "lens") === undefined ? {} : { lens: argumentValue(input, "lens") }),
|
|
164
|
+
...(argumentValue(input, "order") === undefined ? {} : { order: argumentValue(input, "order") }),
|
|
165
|
+
...(input.artifactSubject === undefined ? {} : { subjectHash: input.artifactSubject.subjectHash }),
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Provider role vectors (gentle-pi#311 P4-roles) — the two Go-owned non-lens
|
|
171
|
+
// adversarial role capture operations. Unlike the lens materialize slots
|
|
172
|
+
// above, these vectors are SELF-CONTAINED: the provider renders binding
|
|
173
|
+
// tokens plus `--agent=pi --execute=true`, and executing the exact rendered
|
|
174
|
+
// invocation makes Go materialize the role prompt, spawn its own locked-down
|
|
175
|
+
// pi subprocess, and admit the raw verdict into the compact slot. The host
|
|
176
|
+
// never materializes, launches pi, or submits anything for these slots — it
|
|
177
|
+
// runs one CLI invocation verbatim and re-queries negotiated STATUS.
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
export interface ReviewProviderRoleVectorSlot {
|
|
181
|
+
/** The provider-named capture operation, e.g. `review.capture-refuter`. */
|
|
182
|
+
readonly captureOperation: (typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION)[keyof typeof REVIEW_PROVIDER_ROLE_CAPTURE_OPERATION];
|
|
183
|
+
/** Every provider-issued argument token, verbatim, in provider order. */
|
|
184
|
+
readonly argumentTokens: readonly string[];
|
|
185
|
+
/** The provider-declared input name, e.g. `provider_refuter`. */
|
|
186
|
+
readonly name: string;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function isReviewProviderRoleVectorInput(input: ReviewCollectInputV3): boolean {
|
|
190
|
+
return (REVIEW_PROVIDER_ROLE_CAPTURE_OPERATIONS as readonly string[]).includes(input.captureOperation)
|
|
191
|
+
&& argumentValue(input, "execute") === "true"
|
|
192
|
+
&& argumentValue(input, "agent") === "pi";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function reviewProviderRoleVectorSlots(inputs: readonly ReviewCollectInputV3[]): readonly ReviewProviderRoleVectorSlot[] {
|
|
196
|
+
return inputs.filter((input) => isReviewProviderRoleVectorInput(input)).map((input) => ({
|
|
197
|
+
captureOperation: input.captureOperation as ReviewProviderRoleVectorSlot["captureOperation"],
|
|
198
|
+
argumentTokens: input.arguments.map((argument) => renderToken(argument)),
|
|
199
|
+
name: input.name,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Resolves the provider-owned submission form into an executable binding.
|
|
204
|
+
// Fails closed with a typed contract-mismatch error whenever the completing
|
|
205
|
+
// form is absent or cannot bind exactly one artifact value; the relay never
|
|
206
|
+
// repairs, filters, or synthesizes it.
|
|
207
|
+
export interface ReviewHostRelaySubmissionBinding {
|
|
208
|
+
readonly operationToken: string;
|
|
209
|
+
readonly argumentTokens: readonly string[];
|
|
210
|
+
readonly substitutionLocation: number;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveReviewHostRelaySubmission(submission: ReviewCaptureSubmissionV1 | undefined): ReviewHostRelaySubmissionBinding {
|
|
214
|
+
if (submission === undefined) {
|
|
215
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", REVIEW_HOST_RELAY_SUBMISSION_MISSING_MESSAGE);
|
|
216
|
+
}
|
|
217
|
+
if (submission.operationToken.length === 0 || submission.argumentTokens.length === 0 || submission.argumentTokens.some((token) => typeof token !== "string" || token.length === 0)) {
|
|
218
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission form carries an empty operation or argument token");
|
|
219
|
+
}
|
|
220
|
+
if (submission.values.length !== 1) {
|
|
221
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission form must bind exactly one artifact value, received ${submission.values.length}`);
|
|
222
|
+
}
|
|
223
|
+
const value = submission.values[0]!;
|
|
224
|
+
const location = value.substitutionLocation;
|
|
225
|
+
if (!Number.isSafeInteger(location) || location < 0 || location >= submission.argumentTokens.length) {
|
|
226
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", "provider contract mismatch: the submission substitution location is outside its argument tokens");
|
|
227
|
+
}
|
|
228
|
+
if (!submission.argumentTokens[location]!.includes(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT)) {
|
|
229
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_CONTRACT_MISMATCH, "binding", `provider contract mismatch: the submission token at location ${location} carries no ${REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT} slot`);
|
|
230
|
+
}
|
|
231
|
+
return { operationToken: submission.operationToken, argumentTokens: submission.argumentTokens, substitutionLocation: location };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Relay execution
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export interface ReviewHostRelayRequest {
|
|
239
|
+
readonly captureArgumentTokens: readonly string[];
|
|
240
|
+
/** The provider-owned completing form; absent means contract mismatch. */
|
|
241
|
+
readonly submission?: ReviewCaptureSubmissionV1;
|
|
242
|
+
/** Absolute path; defaults to the verified package-local binary. */
|
|
243
|
+
readonly gentleAiExecutable?: string;
|
|
244
|
+
/** User-owned pi launcher; defaults to `pi` on PATH. */
|
|
245
|
+
readonly piExecutable?: string;
|
|
246
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
247
|
+
readonly gentleAiTimeoutMs?: number;
|
|
248
|
+
readonly piTimeoutMs?: number;
|
|
249
|
+
readonly signal?: AbortSignal;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface ReviewHostRelayResult {
|
|
253
|
+
readonly promptByteLength: number;
|
|
254
|
+
readonly resultByteLength: number;
|
|
255
|
+
/** Raw submission stdout (the provider's admitted-manifest JSON), opaque. */
|
|
256
|
+
readonly submission: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type ReviewHostRelayRunner = (request: ReviewHostRelayRequest) => Promise<ReviewHostRelayResult>;
|
|
260
|
+
|
|
261
|
+
const DEFAULT_GENTLE_AI_TIMEOUT_MS = 120_000;
|
|
262
|
+
const DEFAULT_PI_TIMEOUT_MS = 600_000;
|
|
263
|
+
|
|
264
|
+
interface ProcessCapture {
|
|
265
|
+
stdout: Buffer;
|
|
266
|
+
stderr: Buffer;
|
|
267
|
+
exitCode: number | null;
|
|
268
|
+
timedOut: boolean;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function collectProcess(
|
|
272
|
+
file: string,
|
|
273
|
+
arguments_: readonly string[],
|
|
274
|
+
options: { cwd: string; env: NodeJS.ProcessEnv; stdin?: Buffer; timeoutMs: number; signal?: AbortSignal },
|
|
275
|
+
): Promise<ProcessCapture> {
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const child = spawn(file, [...arguments_], {
|
|
278
|
+
cwd: options.cwd,
|
|
279
|
+
env: options.env,
|
|
280
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
281
|
+
shell: false,
|
|
282
|
+
windowsHide: true,
|
|
283
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
284
|
+
});
|
|
285
|
+
const stdout: Buffer[] = [];
|
|
286
|
+
const stderr: Buffer[] = [];
|
|
287
|
+
let timedOut = false;
|
|
288
|
+
let settled = false;
|
|
289
|
+
const timer = options.timeoutMs > 0
|
|
290
|
+
? setTimeout(() => {
|
|
291
|
+
timedOut = true;
|
|
292
|
+
child.kill("SIGKILL");
|
|
293
|
+
}, options.timeoutMs)
|
|
294
|
+
: undefined;
|
|
295
|
+
timer?.unref();
|
|
296
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
297
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
298
|
+
child.on("error", (error) => {
|
|
299
|
+
if (settled) return;
|
|
300
|
+
settled = true;
|
|
301
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
302
|
+
reject(error);
|
|
303
|
+
});
|
|
304
|
+
child.on("close", (code) => {
|
|
305
|
+
if (settled) return;
|
|
306
|
+
settled = true;
|
|
307
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
308
|
+
resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), exitCode: code, timedOut });
|
|
309
|
+
});
|
|
310
|
+
if (options.stdin === undefined) {
|
|
311
|
+
child.stdin.end();
|
|
312
|
+
} else {
|
|
313
|
+
child.stdin.on("error", () => undefined);
|
|
314
|
+
child.stdin.end(options.stdin);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function assertTokens(name: string, tokens: readonly string[]): void {
|
|
320
|
+
if (tokens.length === 0) throw new TypeError(`Pi host relay requires the provider-issued ${name} tokens`);
|
|
321
|
+
if (tokens.some((token) => typeof token !== "string" || token.length === 0)) {
|
|
322
|
+
throw new TypeError(`Pi host relay ${name} tokens must all be non-empty strings`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Runs one complete host-relay capture for one provider-bound slot:
|
|
328
|
+
* materialize → fresh locked-down pi subprocess → submit. Throws a typed
|
|
329
|
+
* {@link ReviewHostRelayError} on every failure leg and submits nothing after
|
|
330
|
+
* a failure; the caller re-queries negotiated STATUS instead of retrying.
|
|
331
|
+
*/
|
|
332
|
+
export async function runReviewHostRelaySlot(request: ReviewHostRelayRequest): Promise<ReviewHostRelayResult> {
|
|
333
|
+
assertTokens("capture", request.captureArgumentTokens);
|
|
334
|
+
// The completing form is validated before any process launches: a
|
|
335
|
+
// materialize slot without a provider-owned submission is a typed
|
|
336
|
+
// contract mismatch, never a synthesized invocation.
|
|
337
|
+
const submissionBinding = resolveReviewHostRelaySubmission(request.submission);
|
|
338
|
+
const gentleAi = request.gentleAiExecutable ?? resolveGentleAiBinary();
|
|
339
|
+
if (!isAbsolute(gentleAi)) throw new TypeError("Pi host relay requires an absolute gentle-ai executable path");
|
|
340
|
+
const baseEnvironment = request.environment ?? process.env;
|
|
341
|
+
// Every gentle-ai invocation the relay makes carries the handshake; the
|
|
342
|
+
// pi subprocess environment stays exactly as the user configured it.
|
|
343
|
+
const gentleAiEnvironment = { ...baseEnvironment, [GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT };
|
|
344
|
+
const gentleAiTimeoutMs = request.gentleAiTimeoutMs ?? DEFAULT_GENTLE_AI_TIMEOUT_MS;
|
|
345
|
+
const piTimeoutMs = request.piTimeoutMs ?? DEFAULT_PI_TIMEOUT_MS;
|
|
346
|
+
|
|
347
|
+
// (a) Materialize the Go-issued opaque prompt. This invocation is also the
|
|
348
|
+
// capability detection: an old binary's unknown-flag refusal proves the
|
|
349
|
+
// relay surface is absent, and the provider's handshake refusal surfaces
|
|
350
|
+
// verbatim. No version sniffing.
|
|
351
|
+
let materialized: ProcessCapture;
|
|
352
|
+
try {
|
|
353
|
+
materialized = await collectProcess(gentleAi, ["review", "capture-result", ...request.captureArgumentTokens], {
|
|
354
|
+
cwd: process.cwd(),
|
|
355
|
+
env: gentleAiEnvironment,
|
|
356
|
+
timeoutMs: gentleAiTimeoutMs,
|
|
357
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
358
|
+
});
|
|
359
|
+
} catch (error) {
|
|
360
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", `gentle-ai prompt materialization could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
361
|
+
}
|
|
362
|
+
if (materialized.exitCode !== 0 || materialized.timedOut) {
|
|
363
|
+
const stderr = materialized.stderr.toString("utf8");
|
|
364
|
+
const refusal = classifyReviewHostRelayRefusal(stderr);
|
|
365
|
+
if (refusal === "unknown-flag") {
|
|
366
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE, "materialize", REVIEW_HOST_RELAY_UNAVAILABLE_MESSAGE, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
|
|
367
|
+
}
|
|
368
|
+
if (refusal === "handshake") {
|
|
369
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.HANDSHAKE_REFUSED, "materialize", stderr, { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
|
|
370
|
+
}
|
|
371
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.MATERIALIZE_FAILED, "materialize", "gentle-ai prompt materialization failed", { exitCode: materialized.exitCode, stderr, timedOut: materialized.timedOut });
|
|
372
|
+
}
|
|
373
|
+
const promptBytes = materialized.stdout;
|
|
374
|
+
if (promptBytes.length === 0) {
|
|
375
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.EMPTY_PROMPT, "materialize", "gentle-ai prompt materialization produced no bytes", { exitCode: 0, stderr: materialized.stderr.toString("utf8") });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// (b)/(c) Fresh locked-down pi subprocess in an empty scratch directory.
|
|
379
|
+
const scratchDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-scratch-"));
|
|
380
|
+
const stagingDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-host-relay-result-"));
|
|
381
|
+
try {
|
|
382
|
+
await chmod(scratchDirectory, 0o700);
|
|
383
|
+
await chmod(stagingDirectory, 0o700);
|
|
384
|
+
let piRun: ProcessCapture;
|
|
385
|
+
try {
|
|
386
|
+
piRun = await collectProcess(request.piExecutable ?? "pi", REVIEW_HOST_RELAY_PI_ARGV, {
|
|
387
|
+
cwd: scratchDirectory,
|
|
388
|
+
env: baseEnvironment,
|
|
389
|
+
stdin: promptBytes,
|
|
390
|
+
timeoutMs: piTimeoutMs,
|
|
391
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
392
|
+
});
|
|
393
|
+
} catch (error) {
|
|
394
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_LAUNCH_FAILED, "pi", `pi subprocess could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
395
|
+
}
|
|
396
|
+
if (piRun.exitCode !== 0 || piRun.timedOut) {
|
|
397
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_FAILED, "pi", "pi subprocess failed", { exitCode: piRun.exitCode, stderr: piRun.stderr.toString("utf8"), timedOut: piRun.timedOut });
|
|
398
|
+
}
|
|
399
|
+
const resultBytes = piRun.stdout;
|
|
400
|
+
if (resultBytes.length === 0) {
|
|
401
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.PI_EMPTY_OUTPUT, "pi", "pi subprocess produced no output bytes", { exitCode: 0, stderr: piRun.stderr.toString("utf8") });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// (d) Submit the raw final bytes untouched through the provider-owned
|
|
405
|
+
// completing form: its exact operation and argument tokens, with only
|
|
406
|
+
// the artifact path substituted into the declared {{value}} slot.
|
|
407
|
+
const resultFile = join(stagingDirectory, "result.raw");
|
|
408
|
+
await writeFile(resultFile, resultBytes, { mode: 0o600 });
|
|
409
|
+
await chmod(resultFile, 0o600);
|
|
410
|
+
const submitTokens = submissionBinding.argumentTokens.map((token, index) =>
|
|
411
|
+
index === submissionBinding.substitutionLocation ? token.split(REVIEW_HOST_RELAY_SUBMISSION_VALUE_SLOT).join(resultFile) : token,
|
|
412
|
+
);
|
|
413
|
+
let submission: ProcessCapture;
|
|
414
|
+
try {
|
|
415
|
+
submission = await collectProcess(gentleAi, ["review", submissionBinding.operationToken, ...submitTokens], {
|
|
416
|
+
cwd: process.cwd(),
|
|
417
|
+
env: gentleAiEnvironment,
|
|
418
|
+
timeoutMs: gentleAiTimeoutMs,
|
|
419
|
+
...(request.signal === undefined ? {} : { signal: request.signal }),
|
|
420
|
+
});
|
|
421
|
+
} catch (error) {
|
|
422
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", `gentle-ai capture submission could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
423
|
+
}
|
|
424
|
+
if (submission.exitCode !== 0 || submission.timedOut || submission.stdout.length === 0) {
|
|
425
|
+
throw new ReviewHostRelayError(REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED, "submit", "gentle-ai refused the relayed capture submission", { exitCode: submission.exitCode, stderr: submission.stderr.toString("utf8"), timedOut: submission.timedOut });
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
promptByteLength: promptBytes.length,
|
|
429
|
+
resultByteLength: resultBytes.length,
|
|
430
|
+
submission: submission.stdout.toString("utf8"),
|
|
431
|
+
};
|
|
432
|
+
} finally {
|
|
433
|
+
await rm(scratchDirectory, { recursive: true, force: true }).catch(() => undefined);
|
|
434
|
+
await rm(stagingDirectory, { recursive: true, force: true }).catch(() => undefined);
|
|
435
|
+
}
|
|
436
|
+
}
|