gentle-pi 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { delimiter, join, resolve } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { __testing, createGentleAiExtension } from "../../extensions/gentle-ai.ts";
|
|
9
|
+
import { resolveGentleAiBinary } from "../../lib/gentle-ai-binary.ts";
|
|
10
|
+
import { OPAQUE_PI_REVIEWER_ARGV } from "../../lib/opaque-pi-reviewer-adapter.ts";
|
|
11
|
+
import { NativeReviewCliV216, type ExecFileAdapter, type NativeReviewCli } from "../../lib/native-review-cli.ts";
|
|
12
|
+
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, reviewHostRelaySlots, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
|
|
13
|
+
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts";
|
|
14
|
+
import { decodeReviewStatusV3 } from "../../lib/review-integration-v2.ts";
|
|
15
|
+
import { requireDevBinary } from "../support/native-binary-gate.ts";
|
|
16
|
+
|
|
17
|
+
const DEV_BINARY = process.env.GENTLE_AI_DEV_BINARY;
|
|
18
|
+
const RELAY_DEV_BINARY = process.env.GENTLE_PI_GENTLE_AI_DEV_BINARY;
|
|
19
|
+
const POSIX = process.platform !== "win32";
|
|
20
|
+
const primaryDevBinaryGate = requireDevBinary({
|
|
21
|
+
devBinaryPath: DEV_BINARY,
|
|
22
|
+
exists: typeof DEV_BINARY === "string" && DEV_BINARY.length > 0 && DEV_BINARY.startsWith("/") && existsSync(DEV_BINARY),
|
|
23
|
+
env: process.env,
|
|
24
|
+
});
|
|
25
|
+
const relayDevBinaryGate = POSIX
|
|
26
|
+
? requireDevBinary({
|
|
27
|
+
devBinaryPath: RELAY_DEV_BINARY,
|
|
28
|
+
exists: typeof RELAY_DEV_BINARY === "string" && RELAY_DEV_BINARY.length > 0 && RELAY_DEV_BINARY.startsWith("/") && existsSync(RELAY_DEV_BINARY),
|
|
29
|
+
env: process.env,
|
|
30
|
+
})
|
|
31
|
+
: { run: false as const, reason: "Windows is explicitly skipped until a native fake-pi.exe exists; this test never enables a shell fallback." };
|
|
32
|
+
const RUNNABLE = POSIX && primaryDevBinaryGate.run && relayDevBinaryGate.run;
|
|
33
|
+
if (!POSIX) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${relayDevBinaryGate.reason}`);
|
|
34
|
+
if (!primaryDevBinaryGate.run) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${primaryDevBinaryGate.reason}`);
|
|
35
|
+
if (!relayDevBinaryGate.run && POSIX) console.log(`tests/devbinary/pi-host-relay.devtest.ts: ${relayDevBinaryGate.reason}`);
|
|
36
|
+
|
|
37
|
+
const ZERO_FINDING_PATHS = Object.freeze([".github/workflows/relay.yml"]);
|
|
38
|
+
|
|
39
|
+
interface RegisteredTool {
|
|
40
|
+
execute: (toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: undefined, context: ExtensionContext) => Promise<{ details?: unknown }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function git(cwd: string, ...arguments_: string[]): string {
|
|
44
|
+
return execFileSync("git", arguments_, { cwd, encoding: "utf8" }).trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeTreeWritable(path: string): void {
|
|
48
|
+
chmodSync(path, 0o700);
|
|
49
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
50
|
+
const entryPath = join(path, entry.name);
|
|
51
|
+
if (entry.isDirectory()) makeTreeWritable(entryPath);
|
|
52
|
+
else chmodSync(entryPath, 0o600);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function repository(t: test.TestContext, prefix: string): string {
|
|
57
|
+
const cwd = mkdtempSync(join(tmpdir(), prefix));
|
|
58
|
+
t.after(() => {
|
|
59
|
+
makeTreeWritable(cwd);
|
|
60
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
git(cwd, "init", "-b", "main");
|
|
63
|
+
git(cwd, "config", "user.email", "relay-devtest@example.invalid");
|
|
64
|
+
git(cwd, "config", "user.name", "Pi Host Relay Devtest");
|
|
65
|
+
writeFileSync(join(cwd, "app.ts"), "export const value = 1;\n");
|
|
66
|
+
git(cwd, "add", "app.ts");
|
|
67
|
+
git(cwd, "commit", "-qm", "initial");
|
|
68
|
+
return cwd;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function record(value: unknown, name: string): Record<string, unknown> {
|
|
72
|
+
assert.equal(typeof value, "object", `${name} must be an object`);
|
|
73
|
+
assert.notEqual(value, null, `${name} must not be null`);
|
|
74
|
+
assert.equal(Array.isArray(value), false, `${name} must not be an array`);
|
|
75
|
+
return value as Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function stringValue(value: unknown, name: string): string {
|
|
79
|
+
assert.equal(typeof value, "string", `${name} must be a string`);
|
|
80
|
+
return value as string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reviewEnvironment(home: string): NodeJS.ProcessEnv {
|
|
84
|
+
return {
|
|
85
|
+
...process.env,
|
|
86
|
+
HOME: home,
|
|
87
|
+
XDG_CONFIG_HOME: join(home, "config"),
|
|
88
|
+
[GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV]: GENTLE_PI_REVIEW_RELAY_CONTRACT,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function candidateJson(binary: string, cwd: string, arguments_: readonly string[], environment: NodeJS.ProcessEnv): unknown {
|
|
93
|
+
const stdout = execFileSync(binary, arguments_, { cwd, encoding: "utf8", env: environment });
|
|
94
|
+
return JSON.parse(stdout) as unknown;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function candidateStatus(binary: string, sessionCwd: string, requestedCwd: string, environment: NodeJS.ProcessEnv, lineage?: string, selectors: readonly string[] = []) {
|
|
98
|
+
return decodeReviewStatusV3(candidateJson(binary, sessionCwd, [
|
|
99
|
+
"review", "status", "--cwd", requestedCwd,
|
|
100
|
+
"--contract", "gentle-ai.review-integration/v2", "--agent", "pi", "--next-transition",
|
|
101
|
+
...selectors,
|
|
102
|
+
...(lineage === undefined ? [] : ["--lineage", lineage]),
|
|
103
|
+
], environment));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function enableGlobalReview(binary: string, sessionCwd: string, cwd: string, environment: NodeJS.ProcessEnv): void {
|
|
107
|
+
const enabled = record(candidateJson(binary, sessionCwd, [
|
|
108
|
+
"review", "mode", "enable", "--scope", "global", "--cwd", cwd, "--json",
|
|
109
|
+
], environment), "global mode enable");
|
|
110
|
+
assert.equal(record(enabled.status, "global mode enable status").effective, "on");
|
|
111
|
+
const status = record(candidateJson(binary, sessionCwd, [
|
|
112
|
+
"review", "mode", "status", "--cwd", cwd, "--json",
|
|
113
|
+
], environment), "global mode status");
|
|
114
|
+
assert.equal(record(status.status, "global mode status result").effective, "on");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function runRenderedInvocation(binary: string, sessionCwd: string, command: string, environment: NodeJS.ProcessEnv): unknown {
|
|
118
|
+
const words = command.split(" ");
|
|
119
|
+
assert.ok(words.length >= 3, `rendered invocation is incomplete: ${command}`);
|
|
120
|
+
assert.deepEqual(words.slice(0, 2), ["gentle-ai", "review"], `rendered invocation is not a native review command: ${command}`);
|
|
121
|
+
assert.equal(words.some((word) => word.includes("'") || word.includes('"')), false, `devtest fixture command must remain unquoted: ${command}`);
|
|
122
|
+
return candidateJson(binary, sessionCwd, words.slice(1), environment);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface RegisteredReviewTools {
|
|
126
|
+
controller: RegisteredTool;
|
|
127
|
+
capture: RegisteredTool;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function reviewToolsForNative(nativeReviewCli: NativeReviewCli): RegisteredReviewTools {
|
|
131
|
+
const tools = new Map<string, RegisteredTool>();
|
|
132
|
+
createGentleAiExtension({ nativeReviewCli } as unknown as Parameters<typeof createGentleAiExtension>[0])({
|
|
133
|
+
on() {},
|
|
134
|
+
registerTool(definition: RegisteredTool & { name: string }) { tools.set(definition.name, definition); },
|
|
135
|
+
registerCommand() {},
|
|
136
|
+
} as unknown as ExtensionAPI);
|
|
137
|
+
const controller = tools.get("gentle_review");
|
|
138
|
+
const capture = tools.get("gentle_review_capture");
|
|
139
|
+
assert.ok(controller, "gentle_review controller must be registered");
|
|
140
|
+
assert.ok(capture, "gentle_review_capture must be registered");
|
|
141
|
+
return { controller: controller!, capture: capture! };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function controllerForNative(nativeReviewCli: NativeReviewCli): RegisteredTool {
|
|
145
|
+
return reviewToolsForNative(nativeReviewCli).controller;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parsedCollectBinding(binding: string): Record<string, unknown> {
|
|
149
|
+
return record(JSON.parse(binding) as unknown, "public collectBinding");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function collectBindingsFor(details: unknown, captureOperation: string): readonly string[] {
|
|
153
|
+
const bindings = record(details, "public STATUS details").collectBindings;
|
|
154
|
+
assert.ok(Array.isArray(bindings), `public STATUS must publish collectBindings: ${JSON.stringify(details)}`);
|
|
155
|
+
return bindings
|
|
156
|
+
.map((value) => stringValue(record(value, "public collectBinding entry").collectBinding, "public collectBinding"))
|
|
157
|
+
.filter((binding) => parsedCollectBinding(binding).captureOperation === captureOperation);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function collectBindingFor(details: unknown, captureOperation: string): string {
|
|
161
|
+
const matches = collectBindingsFor(details, captureOperation);
|
|
162
|
+
assert.equal(matches.length, 1, `public STATUS must publish exactly one ${captureOperation} binding`);
|
|
163
|
+
return matches[0]!;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function collectBindingArgument(binding: string, name: string): string {
|
|
167
|
+
const arguments_ = parsedCollectBinding(binding).arguments;
|
|
168
|
+
assert.ok(Array.isArray(arguments_), "public collectBinding must carry arguments");
|
|
169
|
+
const matches = arguments_
|
|
170
|
+
.map((value) => record(value, "public collectBinding argument"))
|
|
171
|
+
.filter((argument) => argument.name === name)
|
|
172
|
+
.map((argument) => stringValue(argument.value, `public collectBinding ${name}`));
|
|
173
|
+
assert.equal(matches.length, 1, `public collectBinding must carry exactly one ${name} argument`);
|
|
174
|
+
return matches[0]!;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function collectBindingArgumentTokens(binding: string): readonly string[] {
|
|
178
|
+
const arguments_ = parsedCollectBinding(binding).arguments;
|
|
179
|
+
assert.ok(Array.isArray(arguments_), "public collectBinding must carry arguments");
|
|
180
|
+
return arguments_.map((value) => {
|
|
181
|
+
const argument = record(value, "public collectBinding argument");
|
|
182
|
+
const token = argument.token;
|
|
183
|
+
return typeof token === "string" ? token : `--${stringValue(argument.name, "public collectBinding argument name")}=${stringValue(argument.value, "public collectBinding argument value")}`;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function publicStatusProjectionPaths(details: unknown): readonly string[] {
|
|
188
|
+
const result = record(details, "public STATUS details").result;
|
|
189
|
+
const projection = record(record(result, "public STATUS result").projection, "public STATUS projection");
|
|
190
|
+
const paths = projection.paths;
|
|
191
|
+
assert.ok(Array.isArray(paths), "public STATUS projection must carry paths");
|
|
192
|
+
return paths.map((path) => stringValue(path, "public STATUS projection path"));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function crossRepositoryController(binary: string, sessionCwd: string, environment: NodeJS.ProcessEnv): RegisteredTool {
|
|
196
|
+
const native = {
|
|
197
|
+
targetStatus: async (request: { cwd: string; lineageId?: string }) => candidateStatus(binary, sessionCwd, request.cwd, environment, request.lineageId),
|
|
198
|
+
} as unknown as NativeReviewCli;
|
|
199
|
+
return controllerForNative(native);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface NativeProcessCall {
|
|
203
|
+
arguments: readonly string[];
|
|
204
|
+
cwd: string;
|
|
205
|
+
stdout?: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function processText(value: unknown): string {
|
|
209
|
+
if (typeof value === "string") return value;
|
|
210
|
+
return Buffer.isBuffer(value) ? value.toString("utf8") : "";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function devNativeCli(binary: string, environment: NodeJS.ProcessEnv, calls: NativeProcessCall[]): NativeReviewCliV216 {
|
|
214
|
+
const adapter: ExecFileAdapter = async (request) => {
|
|
215
|
+
try {
|
|
216
|
+
const stdout = execFileSync(request.file, request.arguments, {
|
|
217
|
+
cwd: request.cwd,
|
|
218
|
+
encoding: "utf8",
|
|
219
|
+
env: environment,
|
|
220
|
+
timeout: request.timeoutMs,
|
|
221
|
+
maxBuffer: request.maxBufferBytes,
|
|
222
|
+
});
|
|
223
|
+
calls.push({ arguments: [...request.arguments], cwd: request.cwd, stdout });
|
|
224
|
+
return {
|
|
225
|
+
stdout,
|
|
226
|
+
stderr: "",
|
|
227
|
+
exitCode: 0,
|
|
228
|
+
signal: null,
|
|
229
|
+
timedOut: false,
|
|
230
|
+
outputLimitExceeded: false,
|
|
231
|
+
};
|
|
232
|
+
} catch (error) {
|
|
233
|
+
calls.push({ arguments: [...request.arguments], cwd: request.cwd });
|
|
234
|
+
const failure = error as NodeJS.ErrnoException & {
|
|
235
|
+
stdout?: string | Buffer;
|
|
236
|
+
stderr?: string | Buffer;
|
|
237
|
+
status?: number;
|
|
238
|
+
signal?: NodeJS.Signals | null;
|
|
239
|
+
killed?: boolean;
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
stdout: processText(failure.stdout),
|
|
243
|
+
stderr: processText(failure.stderr),
|
|
244
|
+
exitCode: typeof failure.status === "number" ? failure.status : 1,
|
|
245
|
+
signal: failure.signal ?? null,
|
|
246
|
+
timedOut: failure.killed === true,
|
|
247
|
+
outputLimitExceeded: failure.code === "ENOBUFS" || failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
return new NativeReviewCliV216(adapter, binary);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function sessionContext(cwd: string): ExtensionContext {
|
|
255
|
+
return { cwd, hasUI: false, ui: { notify() {} } } as unknown as ExtensionContext;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function grantedConsentInvocation(value: unknown): string {
|
|
259
|
+
const consent = record(value, "consent response");
|
|
260
|
+
const choices = consent.choices;
|
|
261
|
+
assert.ok(Array.isArray(choices), "consent response must carry choices");
|
|
262
|
+
const granted = choices.map((choice) => record(choice, "consent choice")).find((choice) => choice.answer === "granted");
|
|
263
|
+
assert.ok(granted, "consent response must carry the granted choice");
|
|
264
|
+
return stringValue(granted!.invocation, "granted consent invocation");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const FAKE_POSIX_PI = `#!/usr/bin/env node
|
|
268
|
+
import fs from "node:fs";
|
|
269
|
+
const expectedArgv = JSON.parse(process.env.OPAQUE_PI_REVIEWER_ARGV);
|
|
270
|
+
const expectedPaths = JSON.parse(process.env.OPAQUE_PI_REVIEWER_PATHS);
|
|
271
|
+
const chunks = [];
|
|
272
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
273
|
+
process.stdin.on("end", () => {
|
|
274
|
+
const prompt = Buffer.concat(chunks);
|
|
275
|
+
const promptText = prompt.toString("utf8");
|
|
276
|
+
const targetedValidatorResult = process.env.OPAQUE_PI_TARGETED_VALIDATOR_RESULT;
|
|
277
|
+
let subjectHash;
|
|
278
|
+
if (targetedValidatorResult === undefined) {
|
|
279
|
+
const newline = prompt.indexOf(0x0a);
|
|
280
|
+
if (newline < 0) throw new Error("missing binding line");
|
|
281
|
+
const firstLine = prompt.subarray(0, newline).toString("utf8");
|
|
282
|
+
const prefix = "GENTLE_AI_REVIEW_BINDING ";
|
|
283
|
+
if (!firstLine.startsWith(prefix)) throw new Error("missing binding prefix");
|
|
284
|
+
const binding = JSON.parse(firstLine.slice(prefix.length));
|
|
285
|
+
if (typeof binding.subject_hash !== "string" || !/^sha256:[0-9a-f]{64}$/.test(binding.subject_hash)) throw new Error("invalid binding subject_hash");
|
|
286
|
+
if (JSON.stringify(process.argv.slice(2)) !== JSON.stringify(expectedArgv)) throw new Error("unexpected opaque Pi argv");
|
|
287
|
+
subjectHash = binding.subject_hash;
|
|
288
|
+
} else if (promptText.length === 0) {
|
|
289
|
+
throw new Error("missing targeted-validator prompt");
|
|
290
|
+
}
|
|
291
|
+
const prior = fs.existsSync(process.env.OPAQUE_PI_REVIEWER_LOG) ? JSON.parse(fs.readFileSync(process.env.OPAQUE_PI_REVIEWER_LOG, "utf8")) : { calls: [] };
|
|
292
|
+
const calls = Array.isArray(prior.calls) ? prior.calls : [];
|
|
293
|
+
calls.push({
|
|
294
|
+
argv: process.argv.slice(2), cwd: process.cwd(), entries: fs.readdirSync(process.cwd()), ...(subjectHash === undefined ? {} : { subject_hash: subjectHash }), prompt: promptText,
|
|
295
|
+
role: targetedValidatorResult === undefined ? "reviewer" : "targeted-validator",
|
|
296
|
+
});
|
|
297
|
+
fs.writeFileSync(process.env.OPAQUE_PI_REVIEWER_LOG, JSON.stringify({ calls }));
|
|
298
|
+
if (targetedValidatorResult !== undefined) {
|
|
299
|
+
process.stdout.write(targetedValidatorResult);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
process.stdout.write(JSON.stringify({
|
|
303
|
+
subject_hash: subjectHash,
|
|
304
|
+
inspection: { status: "completed", paths: expectedPaths },
|
|
305
|
+
findings: process.env.OPAQUE_PI_REVIEWER_FINDINGS === undefined ? [] : JSON.parse(process.env.OPAQUE_PI_REVIEWER_FINDINGS),
|
|
306
|
+
evidence: ["inspected every frozen candidate path"],
|
|
307
|
+
}));
|
|
308
|
+
});
|
|
309
|
+
`;
|
|
310
|
+
|
|
311
|
+
// This A -> B journey deliberately stops immediately after one Go-admitted
|
|
312
|
+
// reviewer capture. It proves real Pi relay transport and root continuity, but
|
|
313
|
+
// does not manufacture the remaining reviewer, refuter, validator, or approval
|
|
314
|
+
// transitions.
|
|
315
|
+
test("dev-binary: POSIX Pi host relay captures one real B-target slot from an A-session without reoffering it", { skip: !RUNNABLE }, async (t) => {
|
|
316
|
+
const sessionA = repository(t, "gentle-pi-relay-session-a-");
|
|
317
|
+
const targetB = repository(t, "gentle-pi-relay-target-b-");
|
|
318
|
+
const nestedTarget = join(targetB, "nested");
|
|
319
|
+
mkdirSync(nestedTarget);
|
|
320
|
+
const workflowDirectory = join(targetB, ".github", "workflows");
|
|
321
|
+
mkdirSync(workflowDirectory, { recursive: true });
|
|
322
|
+
const workflow = join(workflowDirectory, "relay.yml");
|
|
323
|
+
writeFileSync(workflow, "name: relay\non: push\n");
|
|
324
|
+
git(targetB, "add", ".github/workflows/relay.yml");
|
|
325
|
+
git(targetB, "commit", "-qm", "workflow baseline");
|
|
326
|
+
writeFileSync(workflow, "name: relay\non: push\njobs:\n relay:\n runs-on: ubuntu-latest\n");
|
|
327
|
+
writeFileSync(join(targetB, "selected.txt"), "selected relay input\n");
|
|
328
|
+
writeFileSync(join(targetB, "excluded.txt"), "excluded relay input\n");
|
|
329
|
+
|
|
330
|
+
const canonicalB = realpathSync(targetB);
|
|
331
|
+
assert.equal(realpathSync(git(nestedTarget, "rev-parse", "--show-toplevel")), canonicalB, "B/nested must canonicalize to B before native lifecycle routing");
|
|
332
|
+
const isolatedHome = join(sessionA, "home");
|
|
333
|
+
mkdirSync(isolatedHome);
|
|
334
|
+
const environment = reviewEnvironment(isolatedHome);
|
|
335
|
+
assert.ok(DEV_BINARY, "GENTLE_AI_DEV_BINARY is required for this devtest");
|
|
336
|
+
assert.ok(RELAY_DEV_BINARY, "GENTLE_PI_GENTLE_AI_DEV_BINARY is required for this devtest");
|
|
337
|
+
assert.equal(realpathSync(RELAY_DEV_BINARY!), realpathSync(DEV_BINARY!), "the devtest and production override must name the same candidate");
|
|
338
|
+
assert.equal(realpathSync(resolveGentleAiBinary()), realpathSync(RELAY_DEV_BINARY!), "production binary resolution must select the candidate realpath");
|
|
339
|
+
enableGlobalReview(RELAY_DEV_BINARY!, sessionA, canonicalB, environment);
|
|
340
|
+
const inspected = await crossRepositoryController(RELAY_DEV_BINARY!, sessionA, environment).execute(
|
|
341
|
+
"inspect-target-b-from-session-a",
|
|
342
|
+
{ operation: "inspect", workspaceRoot: nestedTarget },
|
|
343
|
+
undefined,
|
|
344
|
+
undefined,
|
|
345
|
+
sessionContext(sessionA),
|
|
346
|
+
);
|
|
347
|
+
assert.equal(record(inspected.details, "cross-repository controller result").workspace_root, canonicalB, "the controller must canonicalize B/nested to B while A remains the session cwd");
|
|
348
|
+
|
|
349
|
+
const initial = candidateStatus(RELAY_DEV_BINARY!, sessionA, canonicalB, environment);
|
|
350
|
+
assert.equal(initial.nextTransition?.kind, "collect", "selectorless Pi STATUS must require an intended-untracked declaration for B");
|
|
351
|
+
assert.equal(initial.nextTransition?.reasonCode, "intended_untracked_selection_required");
|
|
352
|
+
const selection = initial.nextTransition?.collect?.inputs.find((input) => input.name === "intended_untracked_selection");
|
|
353
|
+
assert.ok(selection, "selectorless Pi STATUS must publish the untracked selection input");
|
|
354
|
+
const inventory = selection!.arguments.find((argument) => argument.name === "expected_untracked_inventory")?.value;
|
|
355
|
+
const eligible = selection!.arguments.find((argument) => argument.name === "eligible_paths_json")?.value;
|
|
356
|
+
assert.equal(typeof inventory, "string");
|
|
357
|
+
assert.ok(typeof eligible === "string" && JSON.parse(eligible).includes("selected.txt") && JSON.parse(eligible).includes("excluded.txt"), "native inventory must name both B untracked controls");
|
|
358
|
+
const selectedStatus = candidateStatus(RELAY_DEV_BINARY!, sessionA, canonicalB, environment, undefined, ["--untracked-scope=select", `--expected-untracked-inventory=${inventory}`, "--intended-untracked=selected.txt"]);
|
|
359
|
+
assert.equal(selectedStatus.nextTransition?.kind, "execute", "selected Pi STATUS must offer native START for B");
|
|
360
|
+
const execute = selectedStatus.nextTransition?.execute;
|
|
361
|
+
assert.ok(execute, "selected Pi STATUS must render a START execution");
|
|
362
|
+
assert.equal(execute!.operation, "review.start");
|
|
363
|
+
assert.equal(execute!.command.startsWith("gentle-ai review start "), true);
|
|
364
|
+
assert.deepEqual(execute!.command.split(" ").slice(3), execute!.arguments.map((argument) => argument.token));
|
|
365
|
+
assert.ok(execute!.arguments.some((argument) => argument.token === `--cwd=${canonicalB}`), "rendered START must canonically target B, not A or B/nested");
|
|
366
|
+
assert.ok(execute!.arguments.some((argument) => argument.token === "--intended-untracked=selected.txt"), "rendered START must retain B's selected untracked path");
|
|
367
|
+
assert.equal(execute!.arguments.some((argument) => argument.token === "--intended-untracked=excluded.txt"), false, "rendered START must exclude B's unselected control");
|
|
368
|
+
|
|
369
|
+
const consent = runRenderedInvocation(RELAY_DEV_BINARY!, sessionA, execute!.command, environment);
|
|
370
|
+
const started = runRenderedInvocation(RELAY_DEV_BINARY!, sessionA, grantedConsentInvocation(consent), environment);
|
|
371
|
+
const startedRecord = record(started, "granted START response");
|
|
372
|
+
assert.equal(startedRecord.action, "created");
|
|
373
|
+
const lineage = stringValue(startedRecord.lineage_id, "granted START lineage_id");
|
|
374
|
+
|
|
375
|
+
const collecting = candidateStatus(RELAY_DEV_BINARY!, sessionA, canonicalB, environment, lineage);
|
|
376
|
+
const slots = reviewHostRelaySlots(collecting.nextTransition?.collect?.inputs ?? []);
|
|
377
|
+
assert.ok(slots.length > 0, `real Pi-bound STATUS must offer at least one materialize relay slot: ${JSON.stringify(collecting.raw)}`);
|
|
378
|
+
const slot = slots[0]!;
|
|
379
|
+
const slotInput = collecting.nextTransition?.collect?.inputs.find((input) => input.artifactSubject?.subjectHash === slot.subjectHash);
|
|
380
|
+
const expectedPaths = slotInput?.changedPathManifest?.map((entry) => entry.path) ?? ZERO_FINDING_PATHS;
|
|
381
|
+
assert.ok(expectedPaths.includes("selected.txt"), "the selected untracked file must reach the immutable reviewer manifest");
|
|
382
|
+
assert.equal(expectedPaths.includes("excluded.txt"), false, "the unselected B control must stay out of the reviewer manifest");
|
|
383
|
+
assert.ok(slot.submission, "the real Pi slot must include Go's provider-owned submission form");
|
|
384
|
+
assert.ok(slot.subjectHash, "the real Pi slot must include its artifact subject hash");
|
|
385
|
+
|
|
386
|
+
const fakePi = join(sessionA, "fake-pi");
|
|
387
|
+
const fakePiLog = join(sessionA, "fake-pi-log.json");
|
|
388
|
+
writeFileSync(fakePi, FAKE_POSIX_PI);
|
|
389
|
+
chmodSync(fakePi, 0o755);
|
|
390
|
+
const relay = await runReviewHostRelaySlot({
|
|
391
|
+
captureArgumentTokens: slot.captureArgumentTokens,
|
|
392
|
+
submission: slot.submission,
|
|
393
|
+
targetCwd: canonicalB,
|
|
394
|
+
piExecutable: fakePi,
|
|
395
|
+
environment: {
|
|
396
|
+
...environment,
|
|
397
|
+
OPAQUE_PI_REVIEWER_ARGV: JSON.stringify(OPAQUE_PI_REVIEWER_ARGV),
|
|
398
|
+
OPAQUE_PI_REVIEWER_LOG: fakePiLog,
|
|
399
|
+
OPAQUE_PI_REVIEWER_PATHS: JSON.stringify(expectedPaths),
|
|
400
|
+
},
|
|
401
|
+
gentleAiTimeoutMs: 30_000,
|
|
402
|
+
piTimeoutMs: 30_000,
|
|
403
|
+
});
|
|
404
|
+
assert.ok(relay.promptByteLength > 0);
|
|
405
|
+
assert.ok(relay.resultByteLength > 0);
|
|
406
|
+
assert.equal(record(JSON.parse(relay.submission) as unknown, "capture submission").admission_decision, "completed");
|
|
407
|
+
|
|
408
|
+
const fakePiLogRecord = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "fake Pi log");
|
|
409
|
+
assert.ok(Array.isArray(fakePiLogRecord.calls), "fake Pi log must record its subprocess calls");
|
|
410
|
+
assert.equal(fakePiLogRecord.calls.length, 1);
|
|
411
|
+
const fakePiResult = record(fakePiLogRecord.calls[0], "fake Pi result");
|
|
412
|
+
assert.deepEqual(fakePiResult.argv, OPAQUE_PI_REVIEWER_ARGV);
|
|
413
|
+
assert.deepEqual(fakePiResult.entries, []);
|
|
414
|
+
assert.equal(fakePiResult.subject_hash, slot.subjectHash);
|
|
415
|
+
const scratchCwd = stringValue(fakePiResult.cwd, "fake Pi scratch cwd");
|
|
416
|
+
assert.notEqual(scratchCwd, canonicalB);
|
|
417
|
+
assert.notEqual(scratchCwd, sessionA);
|
|
418
|
+
assert.equal(existsSync(scratchCwd), false, "opaque Pi scratch cwd must be removed after the subprocess exits");
|
|
419
|
+
|
|
420
|
+
const advanced = candidateStatus(RELAY_DEV_BINARY!, sessionA, canonicalB, environment, lineage);
|
|
421
|
+
const reoffered = reviewHostRelaySlots(advanced.nextTransition?.collect?.inputs ?? []).some((candidate) =>
|
|
422
|
+
candidate.subjectHash === slot.subjectHash
|
|
423
|
+
&& JSON.stringify(candidate.captureArgumentTokens) === JSON.stringify(slot.captureArgumentTokens),
|
|
424
|
+
);
|
|
425
|
+
assert.equal(reoffered, false, "the captured Pi slot must advance and never be reoffered");
|
|
426
|
+
assert.equal(advanced.authority?.state, "reviewing", "this devtest must not finalize, approve, or burn the review");
|
|
427
|
+
assert.equal("receipt" in advanced, false, "last-event STATUS no longer exposes receipt state");
|
|
428
|
+
t.diagnostic(`captured Pi slot: lineage=${lineage}; subject_hash=${slot.subjectHash}; admission=completed; reoffered=false; authority=${advanced.authority?.state}`);
|
|
429
|
+
|
|
430
|
+
const sessionStatus = candidateStatus(RELAY_DEV_BINARY!, sessionA, sessionA, environment);
|
|
431
|
+
assert.equal(sessionStatus.authority, undefined, "A must remain without B's review authority");
|
|
432
|
+
assert.notEqual(sessionStatus.targetIdentity, advanced.targetIdentity, "A must remain unrelated to B's candidate binding");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// gentle-pi#522 / #524: a reviewer whose bytes gentle-ai refuses at admission
|
|
436
|
+
// is a proven non-mutation. The real binary states that the lens slot was not
|
|
437
|
+
// consumed; the host must relay that refusal and its continuation instead of
|
|
438
|
+
// an unknown outcome the contract forbids replaying.
|
|
439
|
+
test("dev-binary: a garbage reviewer result is refused at admission as a proven non-mutation and the same slot is reoffered", { skip: !RUNNABLE }, async (t) => {
|
|
440
|
+
const cwd = repository(t, "gentle-pi-relay-refused-");
|
|
441
|
+
const workflowDirectory = join(cwd, ".github", "workflows");
|
|
442
|
+
mkdirSync(workflowDirectory, { recursive: true });
|
|
443
|
+
const workflow = join(workflowDirectory, "relay.yml");
|
|
444
|
+
writeFileSync(workflow, "name: relay\non: push\n");
|
|
445
|
+
git(cwd, "add", ".github/workflows/relay.yml");
|
|
446
|
+
git(cwd, "commit", "-qm", "workflow baseline");
|
|
447
|
+
writeFileSync(workflow, "name: relay\non: push\njobs:\n relay:\n runs-on: ubuntu-latest\n");
|
|
448
|
+
const canonical = realpathSync(cwd);
|
|
449
|
+
// The isolated home and the fake reviewer live outside the candidate so
|
|
450
|
+
// the repository stays free of untracked paths and START needs no
|
|
451
|
+
// intended-untracked selection.
|
|
452
|
+
const scratch = mkdtempSync(join(tmpdir(), "gentle-pi-relay-refused-scratch-"));
|
|
453
|
+
t.after(() => rmSync(scratch, { recursive: true, force: true }));
|
|
454
|
+
const isolatedHome = join(scratch, "home");
|
|
455
|
+
mkdirSync(isolatedHome);
|
|
456
|
+
const environment = reviewEnvironment(isolatedHome);
|
|
457
|
+
assert.ok(RELAY_DEV_BINARY, "GENTLE_PI_GENTLE_AI_DEV_BINARY is required for this devtest");
|
|
458
|
+
enableGlobalReview(RELAY_DEV_BINARY!, cwd, canonical, environment);
|
|
459
|
+
|
|
460
|
+
const nativeCalls: NativeProcessCall[] = [];
|
|
461
|
+
const native = devNativeCli(RELAY_DEV_BINARY!, environment, nativeCalls);
|
|
462
|
+
const { controller, capture } = reviewToolsForNative(native);
|
|
463
|
+
const prompted = record((await controller.execute("refused-start", { operation: "start", input: JSON.stringify({ mode: "ordinary" }) }, undefined, undefined, sessionContext(cwd))).details, "refused start consent");
|
|
464
|
+
assert.equal(prompted.outcome, "native-review-consent-required");
|
|
465
|
+
const consentBinding = stringValue(prompted.consent_binding, "refused consent binding");
|
|
466
|
+
const started = record((await controller.execute("refused-answer-consent", { operation: "answer-consent", input: JSON.stringify({ consentBinding, answer: "granted" }) }, undefined, undefined, sessionContext(cwd))).details, "refused granted start");
|
|
467
|
+
const lineage = stringValue(record(started.result, "refused start result").lineage_id, "refused lineage");
|
|
468
|
+
|
|
469
|
+
const status = record((await controller.execute("refused-status", { operation: "status", lineageId: lineage }, undefined, undefined, sessionContext(cwd))).details, "refused STATUS");
|
|
470
|
+
const reviewerBinding = collectBindingsFor(status, "review.capture-result")[0];
|
|
471
|
+
assert.ok(reviewerBinding, "current STATUS must publish a reviewer capture binding");
|
|
472
|
+
const subjectHash = collectBindingArgument(reviewerBinding!, "subject-hash");
|
|
473
|
+
const fakePi = join(scratch, "fake-pi");
|
|
474
|
+
writeFileSync(fakePi, "#!/usr/bin/env node\nprocess.stdin.resume();\nprocess.stdin.on('end', () => { process.stdout.write('not json at all'); });\n");
|
|
475
|
+
chmodSync(fakePi, 0o755);
|
|
476
|
+
let relayError: unknown;
|
|
477
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
478
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
479
|
+
try {
|
|
480
|
+
return await runReviewHostRelaySlot({ ...request, gentleAiExecutable: RELAY_DEV_BINARY!, piExecutable: fakePi, environment, gentleAiTimeoutMs: 30_000, piTimeoutMs: 30_000 });
|
|
481
|
+
} catch (error) {
|
|
482
|
+
relayError = error;
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
const forecast = record((await capture.execute("refused-forecast", { lineageId: lineage, collectBinding: reviewerBinding }, undefined, undefined, sessionContext(cwd))).details, "refused forecast");
|
|
487
|
+
const captured = forecast.outcome === "reviewer-model-run-forecast"
|
|
488
|
+
? record((await capture.execute("refused-capture", { lineageId: lineage, collectBinding: reviewerBinding, reviewerRunAcknowledged: true }, undefined, undefined, sessionContext(cwd))).details, "refused capture")
|
|
489
|
+
: forecast;
|
|
490
|
+
|
|
491
|
+
assert.ok(relayError instanceof ReviewHostRelayError, `the relay must fail with a typed error: ${String(relayError)}`);
|
|
492
|
+
assert.equal(relayError.kind, REVIEW_HOST_RELAY_FAILURE.SUBMISSION_REFUSED);
|
|
493
|
+
assert.equal(relayError.stage, "submit");
|
|
494
|
+
assert.equal(relayError.exitCode, 1);
|
|
495
|
+
assert.equal(relayError.mutationOutcome, "none", "gentle-ai's typed admission refusal proves the slot was not consumed");
|
|
496
|
+
assert.match(relayError.stderr, /reviewer payload contains no complete JSON object/);
|
|
497
|
+
assert.match(relayError.stderr, /\[invalid_request\]/);
|
|
498
|
+
|
|
499
|
+
assert.equal(captured.status, "blocked", JSON.stringify(captured));
|
|
500
|
+
assert.equal(captured.outcome, "pi-host-relay-transport-failure");
|
|
501
|
+
const failure = record(captured.failure, "refused capture failure");
|
|
502
|
+
assert.equal(failure.kind, "submission-refused");
|
|
503
|
+
assert.equal(failure.exit_code, 1);
|
|
504
|
+
assert.match(stringValue(failure.stderr, "refused capture stderr"), /\[invalid_request\]/);
|
|
505
|
+
assert.match(stringValue(captured.reason, "refused capture reason"), /no complete JSON object/);
|
|
506
|
+
assert.equal(captured.mutation_performed, false);
|
|
507
|
+
assert.equal(captured.mutation_outcome, "none");
|
|
508
|
+
assert.match(stringValue(captured.next_action, "refused capture next action"), /did not consume the lens slot/);
|
|
509
|
+
|
|
510
|
+
const after = await native.targetStatus({ cwd: canonical, agent: "pi", lineageId: lineage });
|
|
511
|
+
assert.equal(after.authority?.state, "reviewing", "the refused submission must leave the lineage reviewing");
|
|
512
|
+
const reoffered = reviewHostRelaySlots(after.nextTransition?.collect?.inputs ?? []).some((slot) => slot.subjectHash === subjectHash);
|
|
513
|
+
assert.equal(reoffered, true, "the unconsumed slot must be reoffered by fresh STATUS");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// This completes the same organic A -> B path through correction evidence,
|
|
517
|
+
// Go-owned targeted validation, and terminal approval. The only reviewer is the
|
|
518
|
+
// fixed fake Pi executable below; no model, provider, or profile is selected.
|
|
519
|
+
test("dev-binary: Pi controller keeps an explicit B root and selected-untracked binding through Go-owned validation approval", { skip: !RUNNABLE }, async (t) => {
|
|
520
|
+
const sessionA = repository(t, "gentle-pi-combined-session-a-");
|
|
521
|
+
const targetB = repository(t, "gentle-pi-combined-target-b-");
|
|
522
|
+
const nestedTarget = join(targetB, "nested", "target");
|
|
523
|
+
mkdirSync(nestedTarget, { recursive: true });
|
|
524
|
+
const workflowDirectory = join(targetB, ".github", "workflows");
|
|
525
|
+
mkdirSync(workflowDirectory, { recursive: true });
|
|
526
|
+
const workflow = join(workflowDirectory, "relay.yml");
|
|
527
|
+
writeFileSync(workflow, "name: relay\non: push\n");
|
|
528
|
+
git(targetB, "add", ".github/workflows/relay.yml");
|
|
529
|
+
git(targetB, "commit", "-qm", "workflow baseline");
|
|
530
|
+
writeFileSync(workflow, "name: relay\non: push\njobs:\n relay:\n runs-on: ubuntu-latest\n");
|
|
531
|
+
writeFileSync(join(targetB, "selected.txt"), "selected relay input\n");
|
|
532
|
+
writeFileSync(join(targetB, "excluded.txt"), "excluded relay input\n");
|
|
533
|
+
|
|
534
|
+
const canonicalB = realpathSync(targetB);
|
|
535
|
+
assert.equal(realpathSync(git(nestedTarget, "rev-parse", "--show-toplevel")), canonicalB, "B/nested must canonicalize to B before controller routing");
|
|
536
|
+
// --git-common-dir answers relative to the repository it was asked about, so
|
|
537
|
+
// resolving it against the process cwd compared the active project with
|
|
538
|
+
// itself: the assertion held in a linked worktree and failed in a primary
|
|
539
|
+
// checkout, and in neither case measured what it names. lib/review-candidate-view.ts
|
|
540
|
+
// resolves it against the repository root, which is the convention here too.
|
|
541
|
+
const activeProjectCommonDir = realpathSync(resolve(process.cwd(), git(process.cwd(), "rev-parse", "--git-common-dir")));
|
|
542
|
+
const sandboxCommonDir = realpathSync(resolve(canonicalB, git(canonicalB, "rev-parse", "--git-common-dir")));
|
|
543
|
+
assert.notEqual(sandboxCommonDir, activeProjectCommonDir, "the B sandbox must not share the active project's Git common directory");
|
|
544
|
+
const isolatedHome = join(sessionA, "home");
|
|
545
|
+
mkdirSync(isolatedHome);
|
|
546
|
+
const environment = reviewEnvironment(isolatedHome);
|
|
547
|
+
assert.ok(RELAY_DEV_BINARY, "GENTLE_PI_GENTLE_AI_DEV_BINARY is required for this devtest");
|
|
548
|
+
const isolatedModeBefore = record(candidateJson(RELAY_DEV_BINARY!, sessionA, ["review", "mode", "status", "--cwd", canonicalB, "--json"], environment), "isolated mode before setup");
|
|
549
|
+
assert.equal(record(isolatedModeBefore.status, "isolated mode before setup status").effective, "off", "the sandbox must start with its own RDD mode disabled");
|
|
550
|
+
enableGlobalReview(RELAY_DEV_BINARY!, sessionA, canonicalB, environment);
|
|
551
|
+
const isolatedModeAfterSetup = candidateJson(RELAY_DEV_BINARY!, sessionA, ["review", "mode", "status", "--cwd", canonicalB, "--json"], environment);
|
|
552
|
+
|
|
553
|
+
const initial = candidateStatus(RELAY_DEV_BINARY!, sessionA, canonicalB, environment);
|
|
554
|
+
const selectionInput = initial.nextTransition?.collect?.inputs.find((input) => input.name === "intended_untracked_selection");
|
|
555
|
+
assert.ok(selectionInput, "B STATUS must publish its explicit intended-untracked selection input");
|
|
556
|
+
const inventory = selectionInput!.arguments.find((argument) => argument.name === "expected_untracked_inventory")?.value;
|
|
557
|
+
assert.equal(typeof inventory, "string");
|
|
558
|
+
const selection = {
|
|
559
|
+
untrackedScope: "select" as const,
|
|
560
|
+
expectedUntrackedInventory: inventory!,
|
|
561
|
+
intendedUntracked: ["selected.txt"],
|
|
562
|
+
};
|
|
563
|
+
const selectionTokens = [
|
|
564
|
+
"--untracked-scope=select",
|
|
565
|
+
`--expected-untracked-inventory=${selection.expectedUntrackedInventory}`,
|
|
566
|
+
"--intended-untracked=selected.txt",
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
const nativeCalls: NativeProcessCall[] = [];
|
|
570
|
+
const native = devNativeCli(RELAY_DEV_BINARY!, environment, nativeCalls);
|
|
571
|
+
const { controller, capture } = reviewToolsForNative(native);
|
|
572
|
+
const inspected = await controller.execute(
|
|
573
|
+
"combined-inspect-target-b",
|
|
574
|
+
{ operation: "inspect", workspaceRoot: nestedTarget },
|
|
575
|
+
undefined,
|
|
576
|
+
undefined,
|
|
577
|
+
sessionContext(sessionA),
|
|
578
|
+
);
|
|
579
|
+
assert.equal(record(inspected.details, "combined inspect").workspace_root, canonicalB, "the A-session controller must expose B's canonical root");
|
|
580
|
+
|
|
581
|
+
const selectionBoundCallOffset = nativeCalls.length;
|
|
582
|
+
const startedPrompt = await controller.execute(
|
|
583
|
+
"combined-start-target-b",
|
|
584
|
+
{ operation: "start", workspaceRoot: nestedTarget, input: JSON.stringify({ mode: "ordinary", ...selection }) },
|
|
585
|
+
undefined,
|
|
586
|
+
undefined,
|
|
587
|
+
sessionContext(sessionA),
|
|
588
|
+
);
|
|
589
|
+
const prompted = record(startedPrompt.details, "combined start consent");
|
|
590
|
+
assert.equal(prompted.outcome, "native-review-consent-required");
|
|
591
|
+
const consentBinding = stringValue(prompted.consent_binding, "combined consent binding");
|
|
592
|
+
const started = await controller.execute(
|
|
593
|
+
"combined-answer-consent",
|
|
594
|
+
{ operation: "answer-consent", workspaceRoot: nestedTarget, input: JSON.stringify({ consentBinding, answer: "granted" }) },
|
|
595
|
+
undefined,
|
|
596
|
+
undefined,
|
|
597
|
+
sessionContext(sessionA),
|
|
598
|
+
);
|
|
599
|
+
const startedDetails = record(started.details, "combined granted start");
|
|
600
|
+
assert.equal(startedDetails.workspace_root, canonicalB);
|
|
601
|
+
const lineage = stringValue(record(startedDetails.result, "combined start result").lineage_id, "combined lineage");
|
|
602
|
+
|
|
603
|
+
const fakePiDirectory = join(sessionA, "fake-pi-bin");
|
|
604
|
+
mkdirSync(fakePiDirectory);
|
|
605
|
+
const fakePi = join(fakePiDirectory, "pi");
|
|
606
|
+
const fakePiLog = join(sessionA, "fake-pi-log.json");
|
|
607
|
+
writeFileSync(fakePi, FAKE_POSIX_PI);
|
|
608
|
+
chmodSync(fakePi, 0o755);
|
|
609
|
+
environment.PATH = [fakePiDirectory, process.env.PATH].filter((entry): entry is string => entry !== undefined && entry.length > 0).join(delimiter);
|
|
610
|
+
environment.OPAQUE_PI_REVIEWER_ARGV = JSON.stringify(OPAQUE_PI_REVIEWER_ARGV);
|
|
611
|
+
environment.OPAQUE_PI_REVIEWER_LOG = fakePiLog;
|
|
612
|
+
environment.OPAQUE_PI_REVIEWER_PATHS = JSON.stringify([".github/workflows/relay.yml", "selected.txt"]);
|
|
613
|
+
const reviewerFindings = [{
|
|
614
|
+
location: "selected.txt:1",
|
|
615
|
+
severity: "BLOCKER",
|
|
616
|
+
claim: "the selected relay input must be corrected before delivery",
|
|
617
|
+
proof_refs: ["selected.txt:1"],
|
|
618
|
+
evidence_class: "deterministic",
|
|
619
|
+
causal_disposition: "introduced",
|
|
620
|
+
}];
|
|
621
|
+
const relayTargetRoots: string[] = [];
|
|
622
|
+
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
623
|
+
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
624
|
+
assert.equal(request.targetCwd, canonicalB, "the host relay must materialize and submit against B");
|
|
625
|
+
relayTargetRoots.push(request.targetCwd!);
|
|
626
|
+
return await runReviewHostRelaySlot({
|
|
627
|
+
...request,
|
|
628
|
+
gentleAiExecutable: RELAY_DEV_BINARY!,
|
|
629
|
+
piExecutable: fakePi,
|
|
630
|
+
environment: {
|
|
631
|
+
...environment,
|
|
632
|
+
OPAQUE_PI_REVIEWER_ARGV: JSON.stringify(OPAQUE_PI_REVIEWER_ARGV),
|
|
633
|
+
OPAQUE_PI_REVIEWER_LOG: fakePiLog,
|
|
634
|
+
OPAQUE_PI_REVIEWER_PATHS: JSON.stringify([".github/workflows/relay.yml", "selected.txt"]),
|
|
635
|
+
OPAQUE_PI_REVIEWER_FINDINGS: JSON.stringify(reviewerFindings),
|
|
636
|
+
},
|
|
637
|
+
gentleAiTimeoutMs: 30_000,
|
|
638
|
+
piTimeoutMs: 30_000,
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const reviewerSubjects = new Set<string>();
|
|
643
|
+
let reviewerCaptureCount = 0;
|
|
644
|
+
let correctionOpened = false;
|
|
645
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
646
|
+
const reviewerStatus = await controller.execute(
|
|
647
|
+
`combined-reviewer-status-${attempt}`,
|
|
648
|
+
{ operation: "status", lineageId: lineage, workspaceRoot: nestedTarget, input: JSON.stringify(selection) },
|
|
649
|
+
undefined,
|
|
650
|
+
undefined,
|
|
651
|
+
sessionContext(sessionA),
|
|
652
|
+
);
|
|
653
|
+
const reviewerStatusDetails = record(reviewerStatus.details, "combined reviewer STATUS");
|
|
654
|
+
const reviewerBindings = collectBindingsFor(reviewerStatusDetails, "review.capture-result");
|
|
655
|
+
assert.ok(reviewerBindings.length > 0, "current public STATUS must publish a reviewer capture binding before correction opens");
|
|
656
|
+
const reviewerBinding = reviewerBindings[0]!;
|
|
657
|
+
assert.equal(reviewerSubjects.has(reviewerBinding), false, "each reviewer capture must use a fresh public STATUS binding");
|
|
658
|
+
reviewerSubjects.add(reviewerBinding);
|
|
659
|
+
const reviewerInput = parsedCollectBinding(reviewerBinding);
|
|
660
|
+
const reviewerPaths = reviewerInput.changedPathManifest;
|
|
661
|
+
assert.ok(Array.isArray(reviewerPaths), "the reviewer binding must carry its frozen changed-path manifest");
|
|
662
|
+
const manifestPaths = reviewerPaths.map((entry) => stringValue(record(entry, "reviewer manifest entry").path, "reviewer manifest path"));
|
|
663
|
+
assert.ok(manifestPaths.includes("selected.txt"), "the selected untracked file must remain in every reviewer binding");
|
|
664
|
+
assert.equal(manifestPaths.includes("excluded.txt"), false, "the excluded untracked file must remain outside every reviewer binding");
|
|
665
|
+
assert.ok(publicStatusProjectionPaths(reviewerStatusDetails).includes("selected.txt"), "public STATUS must retain the selected path in B's projection");
|
|
666
|
+
assert.equal(publicStatusProjectionPaths(reviewerStatusDetails).includes("excluded.txt"), false, "public STATUS must retain the excluded path outside B's projection");
|
|
667
|
+
|
|
668
|
+
const fakeCallsBeforeForecast = existsSync(fakePiLog)
|
|
669
|
+
? record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "reviewer fake Pi forecast log").calls
|
|
670
|
+
: [];
|
|
671
|
+
assert.ok(Array.isArray(fakeCallsBeforeForecast), "reviewer fake Pi forecast log must carry calls when present");
|
|
672
|
+
const reviewerForecast = await capture.execute(
|
|
673
|
+
`combined-reviewer-forecast-${attempt}`,
|
|
674
|
+
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: reviewerBinding },
|
|
675
|
+
undefined,
|
|
676
|
+
undefined,
|
|
677
|
+
sessionContext(sessionA),
|
|
678
|
+
);
|
|
679
|
+
const reviewerForecastDetails = record(reviewerForecast.details, "combined reviewer forecast");
|
|
680
|
+
assert.equal(reviewerForecastDetails.status, "blocked");
|
|
681
|
+
assert.equal(reviewerForecastDetails.outcome, "reviewer-model-run-forecast");
|
|
682
|
+
const fakeCallsAfterForecast = existsSync(fakePiLog)
|
|
683
|
+
? record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "reviewer fake Pi forecast result").calls
|
|
684
|
+
: [];
|
|
685
|
+
assert.ok(Array.isArray(fakeCallsAfterForecast), "reviewer fake Pi forecast result must carry calls when present");
|
|
686
|
+
assert.equal(fakeCallsAfterForecast.length, fakeCallsBeforeForecast.length, "forecast acknowledgement must not launch a reviewer capture");
|
|
687
|
+
|
|
688
|
+
const reviewerCapture = await capture.execute(
|
|
689
|
+
`combined-reviewer-capture-${attempt}`,
|
|
690
|
+
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: reviewerBinding, reviewerRunAcknowledged: true },
|
|
691
|
+
undefined,
|
|
692
|
+
undefined,
|
|
693
|
+
sessionContext(sessionA),
|
|
694
|
+
);
|
|
695
|
+
const reviewerCaptureDetails = record(reviewerCapture.details, "combined reviewer capture");
|
|
696
|
+
assert.ok(["captured", "closed"].includes(stringValue(reviewerCaptureDetails.status, "combined reviewer capture status")), "one acknowledged binding must perform exactly one native reviewer capture");
|
|
697
|
+
const fakeCallsAfterCapture = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "reviewer fake Pi capture result").calls;
|
|
698
|
+
assert.ok(Array.isArray(fakeCallsAfterCapture), "reviewer fake Pi capture result must carry calls");
|
|
699
|
+
assert.equal(fakeCallsAfterCapture.length, fakeCallsBeforeForecast.length + 1, "one acknowledged binding must launch exactly one reviewer capture");
|
|
700
|
+
reviewerCaptureCount += 1;
|
|
701
|
+
const closure = reviewerCaptureDetails.closure;
|
|
702
|
+
if (closure !== undefined) {
|
|
703
|
+
const reviewerClosure = record(closure, "reviewer last-event closure");
|
|
704
|
+
assert.equal(reviewerClosure.schema, "gentle-ai.review-last-event-closure/v1");
|
|
705
|
+
assert.equal(reviewerClosure.operation, "review/capture-result");
|
|
706
|
+
assert.equal(reviewerClosure.state, "correction_required", "the deterministic reviewer finding must open correction_required");
|
|
707
|
+
const statusContinuation = record(reviewerClosure.status_continuation, "reviewer status continuation");
|
|
708
|
+
assert.equal(statusContinuation.operation, "review.status", "correction-required closure must carry its provider-owned STATUS re-entry");
|
|
709
|
+
assert.ok(Array.isArray(statusContinuation.arguments), "reviewer status continuation must carry ordered arguments");
|
|
710
|
+
const statusContinuationTokens = statusContinuation.arguments.map((entry) => stringValue(record(entry, "reviewer status continuation argument").token, "reviewer status continuation token"));
|
|
711
|
+
assert.match(statusContinuationTokens[0]!, /^--cwd=/);
|
|
712
|
+
assert.deepEqual(statusContinuationTokens.slice(1), [
|
|
713
|
+
"--contract=gentle-ai.review-integration/v2",
|
|
714
|
+
"--next-transition=true",
|
|
715
|
+
`--lineage=${lineage}`,
|
|
716
|
+
"--agent=pi",
|
|
717
|
+
]);
|
|
718
|
+
correctionOpened = true;
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
assert.equal(correctionOpened, true, "the provider must close the final reviewer capture as correction_required");
|
|
723
|
+
assert.ok(reviewerCaptureCount > 0);
|
|
724
|
+
assert.ok(relayTargetRoots.length === reviewerCaptureCount);
|
|
725
|
+
assert.ok(relayTargetRoots.every((root) => root === canonicalB), "every reviewer relay leg must stay bound to B");
|
|
726
|
+
const combinedFakePiLog = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "combined fake Pi log");
|
|
727
|
+
assert.ok(Array.isArray(combinedFakePiLog.calls), "combined fake Pi log must record reviewer subprocess calls");
|
|
728
|
+
const reviewerCalls = combinedFakePiLog.calls.map((call) => record(call, "combined fake Pi reviewer call")).filter((call) => call.role === "reviewer");
|
|
729
|
+
assert.equal(reviewerCalls.length, reviewerCaptureCount);
|
|
730
|
+
for (const call of reviewerCalls) {
|
|
731
|
+
assert.equal(call.subject_hash === undefined, false, "the fake reviewer must receive one provider-bound subject");
|
|
732
|
+
assert.deepEqual(call.argv, OPAQUE_PI_REVIEWER_ARGV);
|
|
733
|
+
assert.deepEqual(call.entries, [], "the reviewer must run from an empty isolated sandbox");
|
|
734
|
+
assert.notEqual(call.cwd, canonicalB, "the reviewer subprocess must not run in B");
|
|
735
|
+
assert.notEqual(call.cwd, sessionA, "the reviewer subprocess must not run in A");
|
|
736
|
+
assert.equal(existsSync(stringValue(call.cwd, "reviewer fake Pi scratch cwd")), false, "the reviewer sandbox must be removed after the subprocess exits");
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const correctionStatus = await controller.execute(
|
|
740
|
+
"combined-correction-plan-status",
|
|
741
|
+
{ operation: "status", lineageId: lineage, workspaceRoot: nestedTarget, input: JSON.stringify(selection) },
|
|
742
|
+
undefined,
|
|
743
|
+
undefined,
|
|
744
|
+
sessionContext(sessionA),
|
|
745
|
+
);
|
|
746
|
+
const correctionBinding = collectBindingFor(correctionStatus.details, "review.capture-correction-plan");
|
|
747
|
+
const correctionInput = parsedCollectBinding(correctionBinding);
|
|
748
|
+
assert.equal(correctionInput.captureOperation, "review.capture-correction-plan");
|
|
749
|
+
assert.ok(publicStatusProjectionPaths(correctionStatus.details).includes("selected.txt"), "the correction plan STATUS must remain bound to selected.txt");
|
|
750
|
+
assert.equal(publicStatusProjectionPaths(correctionStatus.details).includes("excluded.txt"), false, "the correction plan STATUS must remain outside excluded.txt");
|
|
751
|
+
const correctionPlan = await capture.execute(
|
|
752
|
+
"combined-correction-plan",
|
|
753
|
+
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: correctionBinding, correctionLines: 1 },
|
|
754
|
+
undefined,
|
|
755
|
+
undefined,
|
|
756
|
+
sessionContext(sessionA),
|
|
757
|
+
);
|
|
758
|
+
const correctionPlanClosure = record(record(correctionPlan.details, "combined correction plan").closure, "correction plan closure");
|
|
759
|
+
assert.equal(correctionPlanClosure.schema, "gentle-ai.review-last-event-closure/v1");
|
|
760
|
+
assert.equal(correctionPlanClosure.operation, "review.capture-correction-plan");
|
|
761
|
+
assert.equal(correctionPlanClosure.state, "correction_required");
|
|
762
|
+
assert.equal(correctionPlanClosure.correction_lines, 1);
|
|
763
|
+
writeFileSync(join(targetB, "selected.txt"), "selected relay input corrected\n");
|
|
764
|
+
|
|
765
|
+
const validationStatus = await controller.execute(
|
|
766
|
+
"combined-targeted-validator-status",
|
|
767
|
+
{ operation: "status", lineageId: lineage, workspaceRoot: nestedTarget, input: JSON.stringify(selection) },
|
|
768
|
+
undefined,
|
|
769
|
+
undefined,
|
|
770
|
+
sessionContext(sessionA),
|
|
771
|
+
);
|
|
772
|
+
if (!("collectBindings" in record(validationStatus.details, "combined targeted-validator STATUS"))) {
|
|
773
|
+
t.diagnostic(`targeted-validator raw STATUS: ${nativeCalls.at(-1)?.stdout ?? "unavailable"}`);
|
|
774
|
+
}
|
|
775
|
+
const validatorBinding = collectBindingFor(validationStatus.details, "review.capture-validation");
|
|
776
|
+
const validatorInput = parsedCollectBinding(validatorBinding);
|
|
777
|
+
assert.equal(validatorInput.captureOperation, "review.capture-validation");
|
|
778
|
+
assert.equal(validatorInput.submission, undefined, "the Go-owned targeted-validator vector must not accept a caller-authored submission");
|
|
779
|
+
const validationRequest = record(validatorInput.validationRequest, "targeted-validator validation request");
|
|
780
|
+
const validatorArgumentTokens = collectBindingArgumentTokens(validatorBinding);
|
|
781
|
+
const validatorRequestHash = collectBindingArgument(validatorBinding, "request-hash");
|
|
782
|
+
const validatorTargetIdentity = collectBindingArgument(validatorBinding, "target");
|
|
783
|
+
assert.equal(validationRequest.schema, "gentle-ai.review-targeted-validation-request/v1");
|
|
784
|
+
assert.equal(validationRequest.requestHash, validatorRequestHash, "the public targeted-validator request must retain Go's exact request hash");
|
|
785
|
+
assert.equal(validationRequest.correctionTargetIdentity, validatorTargetIdentity, "the public targeted-validator request must retain Go's correction target");
|
|
786
|
+
assert.deepEqual(validationRequest.correctionPaths, ["selected.txt"], "the public targeted-validator request must retain Go's correction paths");
|
|
787
|
+
assert.equal(typeof validationRequest.policyContent, "string");
|
|
788
|
+
assert.ok(stringValue(validationRequest.policyContent, "targeted-validator policy content").length > 0, "the public targeted-validator request must retain Go's policy content");
|
|
789
|
+
assert.ok(Array.isArray(validationRequest.fixFindings) && validationRequest.fixFindings.length > 0, "the public targeted-validator request must retain Go's findings");
|
|
790
|
+
assert.ok(Array.isArray(validationRequest.fixClassifications) && validationRequest.fixClassifications.length > 0, "the public targeted-validator request must retain Go's classifications");
|
|
791
|
+
assert.ok(validatorArgumentTokens.includes(`--request-hash=${validatorRequestHash}`), "the public targeted-validator vector must retain its request hash");
|
|
792
|
+
assert.ok(validatorArgumentTokens.includes("--agent=pi"), "the public targeted-validator vector must retain the Pi binding");
|
|
793
|
+
assert.ok(validatorArgumentTokens.includes("--execute=true"), "the public targeted-validator vector must retain Go-owned execution");
|
|
794
|
+
assert.ok(publicStatusProjectionPaths(validationStatus.details).includes("selected.txt"), "the targeted-validator STATUS must remain bound to selected.txt");
|
|
795
|
+
assert.equal(publicStatusProjectionPaths(validationStatus.details).includes("excluded.txt"), false, "the targeted-validator STATUS must remain outside excluded.txt");
|
|
796
|
+
|
|
797
|
+
environment.OPAQUE_PI_TARGETED_VALIDATOR_RESULT = JSON.stringify({
|
|
798
|
+
targeted_validation_request_hash: validatorRequestHash,
|
|
799
|
+
correction_target_identity: validatorTargetIdentity,
|
|
800
|
+
original_criteria: { passed: true, evidence: ["focused acceptance proof passed"] },
|
|
801
|
+
correction_regression: { passed: true, evidence: ["focused regression proof passed"] },
|
|
802
|
+
follow_ups: [],
|
|
803
|
+
});
|
|
804
|
+
const providerValidation = await capture.execute(
|
|
805
|
+
"combined-provider-targeted-validation",
|
|
806
|
+
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: validatorBinding },
|
|
807
|
+
undefined,
|
|
808
|
+
undefined,
|
|
809
|
+
sessionContext(sessionA),
|
|
810
|
+
);
|
|
811
|
+
const providerValidationDetails = record(providerValidation.details, "combined provider targeted validation");
|
|
812
|
+
if (providerValidationDetails.status !== "closed") t.diagnostic(`targeted-validator capture result: ${JSON.stringify(providerValidationDetails)}`);
|
|
813
|
+
assert.equal(providerValidationDetails.status, "closed");
|
|
814
|
+
assert.equal(providerValidationDetails.outcome, "native-last-event-closure");
|
|
815
|
+
const validationClosure = record(providerValidationDetails.closure, "targeted-validator last-event closure");
|
|
816
|
+
assert.equal(validationClosure.schema, "gentle-ai.review-last-event-closure/v1");
|
|
817
|
+
assert.equal(validationClosure.operation, "review/capture-validation");
|
|
818
|
+
assert.equal(validationClosure.state, "approved");
|
|
819
|
+
const validationCaptureCalls = nativeCalls.filter((call) => call.arguments[0] === "review" && call.arguments[1] === "capture-validation");
|
|
820
|
+
assert.equal(validationCaptureCalls.length, 1, "the Go-owned targeted validator must capture exactly once");
|
|
821
|
+
assert.equal(validationCaptureCalls[0]!.cwd, canonicalB);
|
|
822
|
+
assert.deepEqual(validationCaptureCalls[0]!.arguments.slice(2), validatorArgumentTokens, "the Go-owned targeted validator must receive the exact public vector");
|
|
823
|
+
|
|
824
|
+
const validatorPiLog = record(JSON.parse(readFileSync(fakePiLog, "utf8")) as unknown, "validator fake Pi log");
|
|
825
|
+
assert.ok(Array.isArray(validatorPiLog.calls), "validator fake Pi log must record the Go-owned subprocess");
|
|
826
|
+
const validatorCall = validatorPiLog.calls.map((call) => record(call, "validator fake Pi call")).find((call) => call.role === "targeted-validator");
|
|
827
|
+
assert.ok(validatorCall, "the fake Pi log must contain the Go-owned targeted-validator subprocess");
|
|
828
|
+
assert.deepEqual(validatorCall!.entries, [], "the Go-owned validator must run from an empty isolated sandbox");
|
|
829
|
+
assert.notEqual(validatorCall!.cwd, canonicalB, "the Go-owned validator subprocess must not run in B");
|
|
830
|
+
assert.notEqual(validatorCall!.cwd, sessionA, "the Go-owned validator subprocess must not run in A");
|
|
831
|
+
assert.equal(existsSync(stringValue(validatorCall!.cwd, "validator fake Pi scratch cwd")), false, "the Go-owned validator sandbox must be removed after the subprocess exits");
|
|
832
|
+
const validatorPrompt = stringValue(validatorCall!.prompt, "validator fake Pi prompt");
|
|
833
|
+
assert.ok(validatorPrompt.length > 0, "the Go-owned validator must receive a provider-rendered prompt");
|
|
834
|
+
assert.ok(validatorPrompt.includes(validatorRequestHash), "the Go-owned validator prompt must retain the provider request hash");
|
|
835
|
+
|
|
836
|
+
// Approval no longer burns on its own: it commits one pending
|
|
837
|
+
// acknowledgement and waits for the host to run that exact invocation
|
|
838
|
+
// (gentle-ai #3851). The lineage is still live here on purpose, and running
|
|
839
|
+
// the provider's own tokens is what ends it.
|
|
840
|
+
const pendingAcknowledgement = record(validationClosure.acknowledgement, "approved acknowledgement continuation");
|
|
841
|
+
assert.equal(pendingAcknowledgement.operation, "review.acknowledge-approved");
|
|
842
|
+
const acknowledgementTokens = (pendingAcknowledgement.arguments as readonly Record<string, unknown>[])
|
|
843
|
+
.map((argument) => stringValue(argument.token, "acknowledgement argument token"));
|
|
844
|
+
const beforeAcknowledgement = await native.targetStatus!({ cwd: canonicalB, lineageId: lineage, agent: "pi", ...selection });
|
|
845
|
+
assert.equal(beforeAcknowledgement.authority?.state, "approved", "approved authority must survive until its exact acknowledgement runs");
|
|
846
|
+
await native.acknowledgeApproved!({ cwd: canonicalB, argumentTokens: acknowledgementTokens });
|
|
847
|
+
|
|
848
|
+
const terminal = await native.targetStatus!({ cwd: canonicalB, lineageId: lineage, agent: "pi", ...selection });
|
|
849
|
+
assert.equal(terminal.authority, undefined, "the exact acknowledgement must burn the sandbox review authority");
|
|
850
|
+
assert.equal("evidence" in terminal.raw, false, "terminal STATUS must not retain validation evidence");
|
|
851
|
+
assert.equal("staging" in terminal.raw, false, "terminal STATUS must not retain staging state");
|
|
852
|
+
assert.equal("receipt" in terminal.raw, false, "terminal STATUS must not retain a receipt after last-event approval");
|
|
853
|
+
assert.equal(git(canonicalB, "diff", "--cached", "--name-only"), "", "terminal approval must leave no sandbox staging entries");
|
|
854
|
+
assert.deepEqual(candidateJson(RELAY_DEV_BINARY!, sessionA, ["review", "mode", "status", "--cwd", canonicalB, "--json"], environment), isolatedModeAfterSetup, "approval must not change the isolated global or clone-local RDD mode");
|
|
855
|
+
|
|
856
|
+
const lifecycleCalls = nativeCalls.filter((call) => call.arguments[0] === "review");
|
|
857
|
+
assert.ok(lifecycleCalls.length > 0);
|
|
858
|
+
assert.ok(lifecycleCalls.every((call) => call.cwd === canonicalB), "every controller-native lifecycle operation must run from B's canonical worktree root");
|
|
859
|
+
const selectionBoundLifecycleCalls = nativeCalls.slice(selectionBoundCallOffset).filter((call) => call.arguments[0] === "review");
|
|
860
|
+
for (const call of selectionBoundLifecycleCalls.filter((call) => call.arguments[1] === "status")) {
|
|
861
|
+
assert.ok(selectionTokens.every((token) => call.arguments.includes(token)), `STATUS must preserve B's exact selected-untracked tokens: ${call.arguments.join(" ")}`);
|
|
862
|
+
}
|
|
863
|
+
const startCall = selectionBoundLifecycleCalls.find((call) => call.arguments[1] === "start");
|
|
864
|
+
assert.ok(startCall, "controller START must reach native");
|
|
865
|
+
assert.ok(selectionTokens.every((token) => startCall!.arguments.includes(token)), "START must preserve B's exact selected-untracked tokens");
|
|
866
|
+
assert.equal(lifecycleCalls.some((call) => call.arguments[1] === "mode" && call.arguments[2] === "enable"), false, "Pi must never enable RDD automatically");
|
|
867
|
+
});
|