gentle-pi 3.2.0 → 3.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/assets/orchestrator-delegation.md +13 -8
- package/assets/orchestrator.md +2 -2
- package/docs/gentle-shell.md +40 -17
- package/docs/readme-reference.md +41 -7
- package/docs/review-integration.md +25 -11
- package/extensions/gentle-agents.ts +85 -17
- package/extensions/gentle-ai.ts +179 -12
- package/extensions/gentle-shell.ts +408 -38
- package/extensions/gentle-todo.ts +19 -1
- package/lib/agents-view.ts +41 -14
- package/lib/agents-widget.ts +84 -13
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/double-esc-cancel-policy.ts +138 -0
- package/lib/inprocess-reviewer.ts +260 -0
- package/lib/model-routing-authority.ts +1 -1
- package/lib/native-review-cli.ts +23 -0
- package/lib/odd-runtime-delegation-gate.ts +88 -0
- package/lib/review-host-relay.ts +262 -94
- package/lib/review-integration-v2.ts +110 -26
- package/lib/shell-bar.ts +158 -29
- package/lib/shell-card.ts +19 -9
- package/lib/shell-changes-view.ts +43 -5
- package/lib/shell-changes.ts +92 -5
- package/lib/shell-hover.ts +39 -0
- package/lib/shell-prompt.ts +10 -1
- package/lib/shell-sidebar-layout.ts +111 -15
- package/lib/shell-sidebar.ts +16 -0
- package/lib/shell-todo.ts +7 -1
- package/lib/shell-usage-view.ts +98 -10
- package/lib/shell-usage.ts +226 -10
- package/package.json +2 -1
- package/runtime/native-review-cli.mjs +23 -0
- package/runtime/review-integration-v2.mjs +110 -26
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
- package/scripts/mirror-odd-routing.mjs +242 -0
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/agents-grouping.test.ts +75 -18
- package/tests/agents-view.test.ts +28 -18
- package/tests/agents-widget.test.ts +100 -12
- package/tests/command-palette.test.ts +1 -0
- package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
- package/tests/double-esc-cancel-policy.test.ts +194 -0
- package/tests/gentle-agents.test.ts +528 -5
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +69 -5
- package/tests/gentle-shell.test.ts +903 -25
- package/tests/gentle-todo.test.ts +17 -4
- package/tests/inprocess-reviewer.test.ts +368 -0
- package/tests/maintainer/provider-relay.maintest.ts +101 -143
- package/tests/native-review-capability-contract.test.ts +32 -1
- package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
- package/tests/odd-routing-contract.test.ts +57 -0
- package/tests/odd-runtime-delegation-gate.test.ts +212 -0
- package/tests/orchestrator-rdd-ownership.test.ts +3 -3
- package/tests/package-manifest.test.ts +6 -6
- package/tests/review-controller-native-routing.test.ts +60 -1
- package/tests/review-host-relay-routing.test.ts +77 -0
- package/tests/review-host-relay.test.ts +285 -239
- package/tests/review-integration-v2-forward.test.ts +61 -0
- package/tests/review-integration-v2.test.ts +116 -1
- package/tests/review-relay-transport-agent.test.ts +83 -0
- package/tests/runtime-harness.mjs +11 -0
- package/tests/session-changes-shell.test.ts +27 -0
- package/tests/session-worktree-registry.test.ts +41 -0
- package/tests/shell-bar.test.ts +224 -6
- package/tests/shell-card.test.ts +5 -3
- package/tests/shell-changes-view.test.ts +47 -0
- package/tests/shell-changes.test.ts +177 -0
- package/tests/shell-hover.test.ts +19 -0
- package/tests/shell-prompt.test.ts +20 -0
- package/tests/shell-sidebar-fullscreen.test.ts +59 -0
- package/tests/shell-sidebar-layout.test.ts +243 -5
- package/tests/shell-sidebar.test.ts +25 -1
- package/tests/shell-todo.test.ts +36 -0
- package/tests/shell-usage-view.test.ts +123 -3
- package/tests/shell-usage.test.ts +254 -6
- package/lib/opaque-pi-reviewer-adapter.ts +0 -284
- package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
|
@@ -43,6 +43,13 @@ test("widgetExpiryMs says how long until the next finished row leaves the card",
|
|
|
43
43
|
assert.equal(widgetExpiryMs([], 100_000), undefined);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
test("renderAgentsCard paints the quiet-state INFO card with the rose frame (border) and title (accent)", () => {
|
|
47
|
+
const taggedTheme = { fg: (color: string, text: string) => `<${color}>${text}</${color}>` };
|
|
48
|
+
const lines = renderAgentsCard([task({ status: TASK_STATUS.RUNNING })], taggedTheme, 60, 5000, { collapsed: false });
|
|
49
|
+
assert.match(lines[0]!, /^<border>╭<\/border>/);
|
|
50
|
+
assert.match(lines[0]!, /<accent>❀ Agents<\/accent>/);
|
|
51
|
+
});
|
|
52
|
+
|
|
46
53
|
test("renderAgentsCard draws columns for agent, task, and model · tokens · cost · time, with the batch time in the rule", () => {
|
|
47
54
|
const tasks = [
|
|
48
55
|
task({ id: "a", status: TASK_STATUS.COMPLETED, startedAt: 1000, endedAt: 26_000 }),
|
|
@@ -58,6 +65,49 @@ test("renderAgentsCard draws columns for agent, task, and model · tokens · cos
|
|
|
58
65
|
assert.deepEqual(renderAgentsCard([], plainTheme, 60, 0, { collapsed: false }), []);
|
|
59
66
|
});
|
|
60
67
|
|
|
68
|
+
test("renderAgentsCard right-aligns model·effort, tokens, cost, and elapsed in fixed columns that line up across rows", () => {
|
|
69
|
+
const tasks = [
|
|
70
|
+
task({ id: "a", agent: "a", model: "claude-sonnet-5", thinking: "medium", startedAt: 1000, endedAt: 26_000, tokens: 34_000, cost: 0.27 }),
|
|
71
|
+
task({ id: "b", agent: "b", model: "openai/gpt-5", thinking: "high", startedAt: 2000, endedAt: null, tokens: 1_200_000, cost: 12.5 }),
|
|
72
|
+
];
|
|
73
|
+
const lines = renderAgentsCard(tasks, plainTheme, 100, 30_000, { collapsed: false }).map(stripAnsi);
|
|
74
|
+
const [, rowA, rowB] = lines as [string, string, string];
|
|
75
|
+
assert.match(rowA, /34k/);
|
|
76
|
+
assert.match(rowB, /1\.2M/);
|
|
77
|
+
assert.match(rowA, /\$0\.27/);
|
|
78
|
+
assert.match(rowB, /\$12\.50/);
|
|
79
|
+
// Each column has a fixed width, so a shorter value in one row (e.g. "34k"
|
|
80
|
+
// next to "1.2M") still ends at the exact same offset as the wider one.
|
|
81
|
+
const tokensEndA = rowA.indexOf("34k") + "34k".length;
|
|
82
|
+
const tokensEndB = rowB.indexOf("1.2M") + "1.2M".length;
|
|
83
|
+
assert.equal(tokensEndA, tokensEndB, "the tokens column ends at the same offset on every row");
|
|
84
|
+
const costEndA = rowA.indexOf("$0.27") + "$0.27".length;
|
|
85
|
+
const costEndB = rowB.indexOf("$12.50") + "$12.50".length;
|
|
86
|
+
assert.equal(costEndA, costEndB, "the cost column ends at the same offset on every row");
|
|
87
|
+
assert.equal(visibleWidth(rowA), visibleWidth(rowB));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("renderAgentsCard fills only the elapsed column with 'queued', leaving model, tokens, and cost blank", () => {
|
|
91
|
+
const tasks = [
|
|
92
|
+
task({ id: "running", status: TASK_STATUS.RUNNING, startedAt: 1000, endedAt: null }),
|
|
93
|
+
task({ id: "queued", status: TASK_STATUS.QUEUED, createdAt: 1500, startedAt: null, tokens: 0, cost: 0 }),
|
|
94
|
+
];
|
|
95
|
+
const lines = renderAgentsCard(tasks, plainTheme, 90, 5000, { collapsed: false }).map(stripAnsi);
|
|
96
|
+
const [, running, queued] = lines as [string, string, string];
|
|
97
|
+
assert.match(running, /claude-sonnet-5/);
|
|
98
|
+
assert.doesNotMatch(queued, /claude-sonnet-5|34k|\$0\.27/, "queued leaves the model, tokens, and cost columns blank");
|
|
99
|
+
assert.match(queued, /queued\s*│$/, "'queued' lands in the elapsed column, against the right border");
|
|
100
|
+
assert.equal(visibleWidth(running), visibleWidth(queued));
|
|
101
|
+
const queuedStart = queued.indexOf("queued");
|
|
102
|
+
const runningElapsedMatch = running.match(/\ds\s*│$/);
|
|
103
|
+
assert.ok(runningElapsedMatch, "the running row's elapsed value sits in the same trailing column");
|
|
104
|
+
// "queued" (6 chars) is wider than a short elapsed value like "4s" (2
|
|
105
|
+
// chars), so the elapsed column itself must have grown to fit it: the
|
|
106
|
+
// running row's elapsed value should now start at or before that offset,
|
|
107
|
+
// both ending flush against the same right border.
|
|
108
|
+
assert.ok(queuedStart <= runningElapsedMatch.index!);
|
|
109
|
+
});
|
|
110
|
+
|
|
61
111
|
test("renderAgentsCard renders singleton elapsed time only on its task row", () => {
|
|
62
112
|
const lines = renderAgentsCard([task({})], plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
|
|
63
113
|
assert.equal(lines.join("\n").match(/4s/g)?.length, 1);
|
|
@@ -69,9 +119,36 @@ test("renderAgentsCard keeps every task on one line, clipping long labels, and d
|
|
|
69
119
|
const wide = renderAgentsCard(tasks, plainTheme, 84, 5_000, { collapsed: false }).map(stripAnsi);
|
|
70
120
|
assert.equal(wide.length, 3);
|
|
71
121
|
assert.match(wide[1], /^│ ◐ sdd-explore write the gentle shell foot… +claude-sonnet-5 · 34k · \$0\.27 · 4s │$/);
|
|
122
|
+
// Narrow cards degrade per column: task text first, then the model name,
|
|
123
|
+
// then tokens and cost. Elapsed is the one value the reader cannot rebuild
|
|
124
|
+
// from anything else on screen, so it is the last to go.
|
|
72
125
|
const narrow = renderAgentsCard(tasks, plainTheme, 44, 5_000, { collapsed: false }).map(stripAnsi);
|
|
73
126
|
assert.equal(narrow.length, 3);
|
|
74
|
-
assert.match(narrow[1], /^│ ◐ sdd-explore +
|
|
127
|
+
assert.match(narrow[1], /^│ ◐ sdd-explore +34k · \$0\.27 · 4s │$/, "the model name goes before tokens, cost and elapsed");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// gentle-shell#1143: one long model id used to flip the whole card to the
|
|
131
|
+
// model-only fallback, silently dropping tokens, cost and the running time.
|
|
132
|
+
test("renderAgentsCard keeps tokens, cost and elapsed when a long model name no longer fits", () => {
|
|
133
|
+
const tasks = [task({ id: "a", model: "anthropic/claude-sonnet-4-5-20250929", tokens: 12_345, cost: 0.42, startedAt: 5_000 - 184_000 })];
|
|
134
|
+
const lines = renderAgentsCard(tasks, plainTheme, 46, 5_000, { collapsed: false }).map(stripAnsi);
|
|
135
|
+
assert.equal(lines.length, 3);
|
|
136
|
+
assert.match(lines[1], /^│ ◐ sdd-explore +12k · \$0\.42 · 3m04s │$/, "tokens, cost and elapsed survive; the model name is what gives way");
|
|
137
|
+
const tighter = renderAgentsCard(tasks, plainTheme, 34, 5_000, { collapsed: false }).map(stripAnsi);
|
|
138
|
+
assert.match(tighter[1], /^│ ◐ sdd-explore +\$0\.42 · 3m04s │$/, "then tokens go, then cost, elapsed last");
|
|
139
|
+
const tightest = renderAgentsCard(tasks, plainTheme, 28, 5_000, { collapsed: false }).map(stripAnsi);
|
|
140
|
+
assert.match(tightest[1], /^│ ◐ sdd-explore +3m04s │$/, "elapsed is the last column standing");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("renderAgentsCard degrades every row of a mixed card together so columns still align", () => {
|
|
144
|
+
const tasks = [
|
|
145
|
+
task({ id: "a", model: "anthropic/claude-sonnet-4-5-20250929", tokens: 12_345, cost: 0.42 }),
|
|
146
|
+
task({ id: "b", agent: "writer", model: "openai/gpt-5", tokens: 900, cost: 0.01 }),
|
|
147
|
+
];
|
|
148
|
+
const lines = renderAgentsCard(tasks, plainTheme, 46, 5_000, { collapsed: false }).map(stripAnsi);
|
|
149
|
+
assert.match(lines[1], /12k · \$0\.42 · 4s │$/);
|
|
150
|
+
assert.match(lines[2], /900 · \$0\.01 · 4s │$/);
|
|
151
|
+
assert.equal(lines[1].indexOf("· 4s"), lines[2].indexOf("· 4s"), "elapsed stays in one column across rows");
|
|
75
152
|
});
|
|
76
153
|
|
|
77
154
|
test("renderAgentsCard shows questions and failures in place of the task, and collapses to the first row", () => {
|
|
@@ -82,33 +159,44 @@ test("renderAgentsCard shows questions and failures in place of the task, and co
|
|
|
82
159
|
];
|
|
83
160
|
const plain = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: false }).map(stripAnsi);
|
|
84
161
|
assert.match(plain[0], /^╭─ ❀ Agents · 1 waiting · 1 queued · 1 failed ─+ 2s ╮$/);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
162
|
+
// The waiting row carries no tokens/cost of its own, but the failed row
|
|
163
|
+
// below it does, so those columns stay reserved (blank) rather than
|
|
164
|
+
// collapsing — the whole point of fixed columns over the old per-row join.
|
|
165
|
+
assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · {5}· {7}· {5}2s │$/);
|
|
166
|
+
assert.match(plain[2], /^│ ✗ sdd-explore pi exited with code… +claude-sonnet-5 · 34k · \$0\.27 · {5}1s │$/);
|
|
167
|
+
// Queued fills only the elapsed column with the literal word; model,
|
|
168
|
+
// tokens, and cost stay blank rather than the row's text spilling past them.
|
|
169
|
+
assert.match(plain[3], /^│ ○ sdd-explore map footer data sou… +· {5}· {7}· queued │$/);
|
|
88
170
|
const collapsed = renderAgentsCard(tasks, plainTheme, 80, 3000, { collapsed: true, collapseKey: "ctrl+shift+a" }).map(stripAnsi);
|
|
89
171
|
assert.equal(collapsed.length, 3);
|
|
90
172
|
assert.match(collapsed[0], /ctrl\+shift\+a expand ╮$/);
|
|
91
173
|
assert.match(collapsed[1], /^│ \? sdd-explore asked: Delete\?/);
|
|
92
174
|
});
|
|
93
175
|
|
|
94
|
-
|
|
95
|
-
|
|
176
|
+
// gentle-shell#1143 reversed the old priority: usage (tokens, cost and above
|
|
177
|
+
// all elapsed) outranks the model·effort label when the row is narrow, since
|
|
178
|
+
// the label is the one value the user already chose and can look up.
|
|
179
|
+
test("usage outranks the model label at narrow widths without inventing unknown values", () => {
|
|
180
|
+
for (const width of [60, 100]) {
|
|
181
|
+
const lines = renderAgentsCard([task({ agent: "worker", model: "openai/gpt-5", thinking: "high" })], plainTheme, width, 5000, { collapsed: false });
|
|
182
|
+
assert.equal(lines.length, 3);
|
|
183
|
+
assert.match(lines[1], /worker/);
|
|
184
|
+
assert.match(lines[1], /gpt-5 · high · 34k · \$0\.27 · 4s/, "wide enough for every column");
|
|
185
|
+
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
186
|
+
}
|
|
187
|
+
for (const width of [32, 44]) {
|
|
96
188
|
const lines = renderAgentsCard([task({ agent: "worker", model: "openai/gpt-5", thinking: "high" })], plainTheme, width, 5000, { collapsed: false });
|
|
97
189
|
assert.equal(lines.length, 3);
|
|
98
190
|
assert.match(lines[1], /worker/);
|
|
99
|
-
assert.
|
|
191
|
+
assert.doesNotMatch(lines[1], /gpt-5/, "the model label is the first column to go");
|
|
192
|
+
assert.match(lines[1], /4s/, "elapsed is the last column standing");
|
|
100
193
|
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
101
|
-
if (width <= 44) assert.doesNotMatch(lines[1], /34k|\$0\.27|4s/);
|
|
102
194
|
}
|
|
103
195
|
for (const width of [0, 1, 2, 3, 4, 8, 16, 24]) {
|
|
104
196
|
const lines = renderAgentsCard([task({ agent: "界worker", thinking: "xhigh" })], plainTheme, width, 5000, { collapsed: false });
|
|
105
197
|
assert.ok(lines.length <= 4, "narrow metadata gets at most one dedicated row");
|
|
106
198
|
for (const line of lines) assert.equal(visibleWidth(line), width);
|
|
107
199
|
}
|
|
108
|
-
const sidebar = renderAgentsCard([task({ agent: "gentle-ai-worker", model: "openai/gpt-5.6", thinking: "high" })], plainTheme, 32, 5000, { collapsed: false });
|
|
109
|
-
assert.match(sidebar.join("\n"), /gentle-ai-worker/);
|
|
110
|
-
assert.match(sidebar.join("\n"), /gpt-5\.6 · high/);
|
|
111
|
-
for (const line of sidebar) assert.equal(visibleWidth(line), 32);
|
|
112
200
|
const unknown = renderAgentsCard([task({ model: "default", thinking: undefined })], plainTheme, 80, 5000, { collapsed: false }).join("\n");
|
|
113
201
|
assert.doesNotMatch(unknown, /default|undefined|high|off/);
|
|
114
202
|
const off = renderAgentsCard([task({ thinking: "off" })], plainTheme, 80, 5000, { collapsed: false }).join("\n");
|
|
@@ -336,6 +336,7 @@ test("COMMAND_PALETTE_CATALOG matches the curated command set, in order", () =>
|
|
|
336
336
|
"gentle:persona",
|
|
337
337
|
"gentle:review-mode",
|
|
338
338
|
"gentle:background-subagents",
|
|
339
|
+
"gentle:double-esc-cancel",
|
|
339
340
|
"gentle:telemetry",
|
|
340
341
|
"gentle:banner",
|
|
341
342
|
"gentle:banner-color",
|
|
@@ -1,19 +1,74 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { chmodSync, existsSync, mkdirSync, mkdtempSync,
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
|
-
import {
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
6
7
|
import test from "node:test";
|
|
8
|
+
import type { Api, AssistantMessage, Context, Model } from "@earendil-works/pi-ai";
|
|
9
|
+
import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai/compat";
|
|
7
10
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
11
|
import { __testing, createGentleAiExtension } from "../../extensions/gentle-ai.ts";
|
|
9
12
|
import { resolveGentleAiBinary } from "../../lib/gentle-ai-binary.ts";
|
|
10
|
-
import {
|
|
13
|
+
import type { InProcessReviewerRegistry } from "../../lib/inprocess-reviewer.ts";
|
|
11
14
|
import { NativeReviewCliV216, type ExecFileAdapter, type NativeReviewCli } from "../../lib/native-review-cli.ts";
|
|
12
15
|
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, reviewHostRelaySlots, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
|
|
13
16
|
import { GENTLE_PI_REVIEW_RELAY_CONTRACT, GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV } from "../../lib/review-relay-contract.ts";
|
|
14
17
|
import { decodeReviewStatusV3 } from "../../lib/review-integration-v2.ts";
|
|
15
18
|
import { requireDevBinary } from "../support/native-binary-gate.ts";
|
|
16
19
|
|
|
20
|
+
// gentle-pi#311 P4 / P3: lens captures run one in-process reviewer completion
|
|
21
|
+
// through the live model registry (lib/inprocess-reviewer.ts), with no child
|
|
22
|
+
// process, no `piExecutable`/`-e` forwarding, and no extension allowlist.
|
|
23
|
+
// gentle-ai's v9 contract makes the refuter and targeted-validator role
|
|
24
|
+
// captures host-mediated the same way (P3): each carries a submission
|
|
25
|
+
// descriptor alongside `--materialize=true`, runs through the SAME relay
|
|
26
|
+
// seam, and reaches the SAME `fauxReviewerFor` fixture — there is no
|
|
27
|
+
// Go-owned `pi` subprocess left to fake on PATH for either role. The devtests
|
|
28
|
+
// below inject a fake reviewer registry instead of a fake `pi` binary for
|
|
29
|
+
// every one of these legs.
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Fake reviewer registry (gentle-pi#311 P4) — registers pi-ai's own faux
|
|
33
|
+
// provider, the SAME api-registry the real `completeSimple` dispatches
|
|
34
|
+
// through in production, so the real `runInProcessReviewer` -> real
|
|
35
|
+
// `completeSimple` path runs end-to-end with no network and no child
|
|
36
|
+
// process. The scripted response reads the frozen prompt's
|
|
37
|
+
// `GENTLE_AI_REVIEW_BINDING` line for the subject_hash the real Go admission
|
|
38
|
+
// requires, exactly like the fake pi child this replaced. `enqueue` must be
|
|
39
|
+
// called once per expected completion (the faux provider's response queue is
|
|
40
|
+
// FIFO and errors once exhausted).
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
interface FauxReviewerCall {
|
|
43
|
+
promptText: string;
|
|
44
|
+
subjectHash: string | undefined;
|
|
45
|
+
}
|
|
46
|
+
function fauxReviewerFor(buildResponseText: (subjectHash: string | undefined, promptText: string) => string) {
|
|
47
|
+
const id = randomUUID();
|
|
48
|
+
const provider = `dev-binary-faux-reviewer-${id}`;
|
|
49
|
+
const faux = registerFauxProvider({ api: provider, provider, models: [{ id: "dev-fixture" }] });
|
|
50
|
+
const calls: FauxReviewerCall[] = [];
|
|
51
|
+
const enqueue = () => {
|
|
52
|
+
faux.appendResponses([(context: Context): AssistantMessage => {
|
|
53
|
+
const content = context.messages[0]?.content;
|
|
54
|
+
const textPart = Array.isArray(content) ? content.find((part) => part.type === "text") : undefined;
|
|
55
|
+
const promptText = (textPart as { text?: string } | undefined)?.text ?? "";
|
|
56
|
+
const newline = promptText.indexOf("\n");
|
|
57
|
+
const firstLine = newline === -1 ? promptText : promptText.slice(0, newline);
|
|
58
|
+
const prefix = "GENTLE_AI_REVIEW_BINDING ";
|
|
59
|
+
const subjectHash = firstLine.startsWith(prefix) ? (JSON.parse(firstLine.slice(prefix.length)) as { subject_hash?: string }).subject_hash : undefined;
|
|
60
|
+
calls.push({ promptText, subjectHash });
|
|
61
|
+
return fauxAssistantMessage(buildResponseText(subjectHash, promptText));
|
|
62
|
+
}]);
|
|
63
|
+
};
|
|
64
|
+
const model = faux.getModel() as unknown as Model<Api>;
|
|
65
|
+
const registry: InProcessReviewerRegistry = {
|
|
66
|
+
find: () => model,
|
|
67
|
+
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "faux-key" }),
|
|
68
|
+
};
|
|
69
|
+
return { registry, selection: `${model.provider}/${model.id}`, routingKey: "review-risk", enqueue, calls };
|
|
70
|
+
}
|
|
71
|
+
|
|
17
72
|
const DEV_BINARY = process.env.GENTLE_AI_DEV_BINARY;
|
|
18
73
|
const RELAY_DEV_BINARY = process.env.GENTLE_PI_GENTLE_AI_DEV_BINARY;
|
|
19
74
|
const POSIX = process.platform !== "win32";
|
|
@@ -264,50 +319,6 @@ function grantedConsentInvocation(value: unknown): string {
|
|
|
264
319
|
return stringValue(granted!.invocation, "granted consent invocation");
|
|
265
320
|
}
|
|
266
321
|
|
|
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
322
|
// This A -> B journey deliberately stops immediately after one Go-admitted
|
|
312
323
|
// reviewer capture. It proves real Pi relay transport and root continuity, but
|
|
313
324
|
// does not manufacture the remaining reviewer, refuter, validator, or approval
|
|
@@ -383,21 +394,25 @@ test("dev-binary: POSIX Pi host relay captures one real B-target slot from an A-
|
|
|
383
394
|
assert.ok(slot.submission, "the real Pi slot must include Go's provider-owned submission form");
|
|
384
395
|
assert.ok(slot.subjectHash, "the real Pi slot must include its artifact subject hash");
|
|
385
396
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
397
|
+
// gentle-pi#311 P4: the reviewer completion runs in-process — a fake
|
|
398
|
+
// registry replaces the fake `pi` binary this devtest used to spawn for
|
|
399
|
+
// the lens capture. There is no child process and no scratch sandbox to
|
|
400
|
+
// isolate or clean up for this leg any more.
|
|
401
|
+
const reviewer = fauxReviewerFor((subjectHash) => JSON.stringify({
|
|
402
|
+
subject_hash: subjectHash,
|
|
403
|
+
inspection: { status: "completed", paths: expectedPaths },
|
|
404
|
+
findings: [],
|
|
405
|
+
evidence: ["inspected every frozen candidate path"],
|
|
406
|
+
}));
|
|
407
|
+
reviewer.enqueue();
|
|
390
408
|
const relay = await runReviewHostRelaySlot({
|
|
391
409
|
captureArgumentTokens: slot.captureArgumentTokens,
|
|
392
410
|
submission: slot.submission,
|
|
393
411
|
targetCwd: canonicalB,
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
OPAQUE_PI_REVIEWER_LOG: fakePiLog,
|
|
399
|
-
OPAQUE_PI_REVIEWER_PATHS: JSON.stringify(expectedPaths),
|
|
400
|
-
},
|
|
412
|
+
environment,
|
|
413
|
+
reviewerRegistry: reviewer.registry,
|
|
414
|
+
selection: reviewer.selection,
|
|
415
|
+
routingKey: reviewer.routingKey,
|
|
401
416
|
gentleAiTimeoutMs: 30_000,
|
|
402
417
|
piTimeoutMs: 30_000,
|
|
403
418
|
});
|
|
@@ -405,17 +420,8 @@ test("dev-binary: POSIX Pi host relay captures one real B-target slot from an A-
|
|
|
405
420
|
assert.ok(relay.resultByteLength > 0);
|
|
406
421
|
assert.equal(record(JSON.parse(relay.submission) as unknown, "capture submission").admission_decision, "completed");
|
|
407
422
|
|
|
408
|
-
|
|
409
|
-
assert.
|
|
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");
|
|
423
|
+
assert.equal(reviewer.calls.length, 1, "the in-process reviewer must complete exactly once");
|
|
424
|
+
assert.equal(reviewer.calls[0]!.subjectHash, slot.subjectHash, "the reviewer completion must receive the real frozen binding's subject hash");
|
|
419
425
|
|
|
420
426
|
const advanced = candidateStatus(RELAY_DEV_BINARY!, sessionA, canonicalB, environment, lineage);
|
|
421
427
|
const reoffered = reviewHostRelaySlots(advanced.nextTransition?.collect?.inputs ?? []).some((candidate) =>
|
|
@@ -470,14 +476,25 @@ test("dev-binary: a garbage reviewer result is refused at admission as a proven
|
|
|
470
476
|
const reviewerBinding = collectBindingsFor(status, "review.capture-result")[0];
|
|
471
477
|
assert.ok(reviewerBinding, "current STATUS must publish a reviewer capture binding");
|
|
472
478
|
const subjectHash = collectBindingArgument(reviewerBinding!, "subject-hash");
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
479
|
+
// gentle-pi#311 P4: the in-process reviewer completion produces the
|
|
480
|
+
// garbage text directly (no fake `pi` binary); Go's real admission still
|
|
481
|
+
// refuses it exactly as before.
|
|
482
|
+
const reviewer = fauxReviewerFor(() => "not json at all");
|
|
476
483
|
let relayError: unknown;
|
|
477
484
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
478
485
|
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
486
|
+
reviewer.enqueue();
|
|
479
487
|
try {
|
|
480
|
-
return await runReviewHostRelaySlot({
|
|
488
|
+
return await runReviewHostRelaySlot({
|
|
489
|
+
...request,
|
|
490
|
+
gentleAiExecutable: RELAY_DEV_BINARY!,
|
|
491
|
+
environment,
|
|
492
|
+
reviewerRegistry: reviewer.registry,
|
|
493
|
+
selection: reviewer.selection,
|
|
494
|
+
routingKey: reviewer.routingKey,
|
|
495
|
+
gentleAiTimeoutMs: 30_000,
|
|
496
|
+
piTimeoutMs: 30_000,
|
|
497
|
+
});
|
|
481
498
|
} catch (error) {
|
|
482
499
|
relayError = error;
|
|
483
500
|
throw error;
|
|
@@ -514,9 +531,10 @@ test("dev-binary: a garbage reviewer result is refused at admission as a proven
|
|
|
514
531
|
});
|
|
515
532
|
|
|
516
533
|
// This completes the same organic A -> B path through correction evidence,
|
|
517
|
-
//
|
|
518
|
-
// fixed
|
|
519
|
-
|
|
534
|
+
// host-mediated targeted validation, and terminal approval. The only
|
|
535
|
+
// reviewers are the fixed faux registries below; no real model, provider, or
|
|
536
|
+
// profile is selected.
|
|
537
|
+
test("dev-binary: Pi controller keeps an explicit B root and selected-untracked binding through host-mediated validation approval", { skip: !RUNNABLE }, async (t) => {
|
|
520
538
|
const sessionA = repository(t, "gentle-pi-combined-session-a-");
|
|
521
539
|
const targetB = repository(t, "gentle-pi-combined-target-b-");
|
|
522
540
|
const nestedTarget = join(targetB, "nested", "target");
|
|
@@ -600,16 +618,6 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
600
618
|
assert.equal(startedDetails.workspace_root, canonicalB);
|
|
601
619
|
const lineage = stringValue(record(startedDetails.result, "combined start result").lineage_id, "combined lineage");
|
|
602
620
|
|
|
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
621
|
const reviewerFindings = [{
|
|
614
622
|
location: "selected.txt:1",
|
|
615
623
|
severity: "BLOCKER",
|
|
@@ -618,22 +626,46 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
618
626
|
evidence_class: "deterministic",
|
|
619
627
|
causal_disposition: "introduced",
|
|
620
628
|
}];
|
|
629
|
+
// gentle-pi#311 P4 / P3: the lens reviewer capture runs in-process through
|
|
630
|
+
// a fake registry. gentle-ai's v9 contract makes the targeted-validator
|
|
631
|
+
// role host-mediated too (there is no Go-owned pi subprocess left to fake
|
|
632
|
+
// on PATH for it), so it reaches the SAME relay seam below through its own
|
|
633
|
+
// faux reviewer, armed once its request-hash and target identity are
|
|
634
|
+
// known further down.
|
|
635
|
+
const reviewer = fauxReviewerFor((subjectHash) => JSON.stringify({
|
|
636
|
+
subject_hash: subjectHash,
|
|
637
|
+
inspection: { status: "completed", paths: [".github/workflows/relay.yml", "selected.txt"] },
|
|
638
|
+
findings: reviewerFindings,
|
|
639
|
+
evidence: ["inspected every frozen candidate path"],
|
|
640
|
+
}));
|
|
641
|
+
let validatorReviewer: ReturnType<typeof fauxReviewerFor> | undefined;
|
|
621
642
|
const relayTargetRoots: string[] = [];
|
|
622
643
|
t.after(() => __testing.setReviewHostRelayRunnerForTesting());
|
|
623
644
|
__testing.setReviewHostRelayRunnerForTesting(async (request) => {
|
|
624
645
|
assert.equal(request.targetCwd, canonicalB, "the host relay must materialize and submit against B");
|
|
625
646
|
relayTargetRoots.push(request.targetCwd!);
|
|
647
|
+
if (request.routingKey === "review-validator") {
|
|
648
|
+
assert.ok(validatorReviewer, "the targeted-validator reviewer fixture must be armed before its slot reaches the relay");
|
|
649
|
+
validatorReviewer.enqueue();
|
|
650
|
+
return await runReviewHostRelaySlot({
|
|
651
|
+
...request,
|
|
652
|
+
gentleAiExecutable: RELAY_DEV_BINARY!,
|
|
653
|
+
environment,
|
|
654
|
+
reviewerRegistry: validatorReviewer.registry,
|
|
655
|
+
selection: validatorReviewer.selection,
|
|
656
|
+
routingKey: request.routingKey,
|
|
657
|
+
gentleAiTimeoutMs: 30_000,
|
|
658
|
+
piTimeoutMs: 30_000,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
reviewer.enqueue();
|
|
626
662
|
return await runReviewHostRelaySlot({
|
|
627
663
|
...request,
|
|
628
664
|
gentleAiExecutable: RELAY_DEV_BINARY!,
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
},
|
|
665
|
+
environment,
|
|
666
|
+
reviewerRegistry: reviewer.registry,
|
|
667
|
+
selection: reviewer.selection,
|
|
668
|
+
routingKey: reviewer.routingKey,
|
|
637
669
|
gentleAiTimeoutMs: 30_000,
|
|
638
670
|
piTimeoutMs: 30_000,
|
|
639
671
|
});
|
|
@@ -665,10 +697,7 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
665
697
|
assert.ok(publicStatusProjectionPaths(reviewerStatusDetails).includes("selected.txt"), "public STATUS must retain the selected path in B's projection");
|
|
666
698
|
assert.equal(publicStatusProjectionPaths(reviewerStatusDetails).includes("excluded.txt"), false, "public STATUS must retain the excluded path outside B's projection");
|
|
667
699
|
|
|
668
|
-
const
|
|
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");
|
|
700
|
+
const reviewerCallsBeforeForecast = reviewer.calls.length;
|
|
672
701
|
const reviewerForecast = await capture.execute(
|
|
673
702
|
`combined-reviewer-forecast-${attempt}`,
|
|
674
703
|
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: reviewerBinding },
|
|
@@ -679,11 +708,7 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
679
708
|
const reviewerForecastDetails = record(reviewerForecast.details, "combined reviewer forecast");
|
|
680
709
|
assert.equal(reviewerForecastDetails.status, "blocked");
|
|
681
710
|
assert.equal(reviewerForecastDetails.outcome, "reviewer-model-run-forecast");
|
|
682
|
-
|
|
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");
|
|
711
|
+
assert.equal(reviewer.calls.length, reviewerCallsBeforeForecast, "forecast acknowledgement must not launch a reviewer completion");
|
|
687
712
|
|
|
688
713
|
const reviewerCapture = await capture.execute(
|
|
689
714
|
`combined-reviewer-capture-${attempt}`,
|
|
@@ -694,9 +719,7 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
694
719
|
);
|
|
695
720
|
const reviewerCaptureDetails = record(reviewerCapture.details, "combined reviewer capture");
|
|
696
721
|
assert.ok(["captured", "closed"].includes(stringValue(reviewerCaptureDetails.status, "combined reviewer capture status")), "one acknowledged binding must perform exactly one native reviewer capture");
|
|
697
|
-
|
|
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");
|
|
722
|
+
assert.equal(reviewer.calls.length, reviewerCallsBeforeForecast + 1, "one acknowledged binding must launch exactly one reviewer completion");
|
|
700
723
|
reviewerCaptureCount += 1;
|
|
701
724
|
const closure = reviewerCaptureDetails.closure;
|
|
702
725
|
if (closure !== undefined) {
|
|
@@ -723,17 +746,12 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
723
746
|
assert.ok(reviewerCaptureCount > 0);
|
|
724
747
|
assert.ok(relayTargetRoots.length === reviewerCaptureCount);
|
|
725
748
|
assert.ok(relayTargetRoots.every((root) => root === canonicalB), "every reviewer relay leg must stay bound to B");
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
assert.equal(
|
|
730
|
-
for (const call of
|
|
731
|
-
assert.
|
|
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");
|
|
749
|
+
// gentle-pi#311 P4: the reviewer completion runs in-process — there is no
|
|
750
|
+
// subprocess log, argv, or scratch sandbox to assert for this leg any
|
|
751
|
+
// more; `reviewer.calls` is the in-process equivalent record.
|
|
752
|
+
assert.equal(reviewer.calls.length, reviewerCaptureCount);
|
|
753
|
+
for (const call of reviewer.calls) {
|
|
754
|
+
assert.notEqual(call.subjectHash, undefined, "the in-process reviewer must receive one provider-bound subject");
|
|
737
755
|
}
|
|
738
756
|
|
|
739
757
|
const correctionStatus = await controller.execute(
|
|
@@ -775,7 +793,17 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
775
793
|
const validatorBinding = collectBindingFor(validationStatus.details, "review.capture-validation");
|
|
776
794
|
const validatorInput = parsedCollectBinding(validatorBinding);
|
|
777
795
|
assert.equal(validatorInput.captureOperation, "review.capture-validation");
|
|
778
|
-
|
|
796
|
+
// gentle-pi#311 P3: gentle-ai's v9 contract renders the targeted-validator
|
|
797
|
+
// role host-mediated too, carrying a submission descriptor alongside
|
|
798
|
+
// --materialize=true exactly like a lens capture-result materialize slot.
|
|
799
|
+
const validatorSubmission = record(validatorInput.submission, "host-mediated targeted-validator submission");
|
|
800
|
+
assert.equal(validatorSubmission.operationToken, "capture-validation");
|
|
801
|
+
const validatorSubmissionValues = validatorSubmission.values;
|
|
802
|
+
assert.ok(Array.isArray(validatorSubmissionValues) && validatorSubmissionValues.length === 1, "the targeted-validator submission must bind exactly one artifact value");
|
|
803
|
+
const validatorSubmissionValue = record(validatorSubmissionValues[0], "targeted-validator submission value");
|
|
804
|
+
assert.equal(validatorSubmissionValue.slot, "provider_targeted_validator");
|
|
805
|
+
assert.equal(validatorSubmissionValue.domain, "artifact_path_or_stdin");
|
|
806
|
+
assert.equal(validatorSubmissionValue.schema, "https://gentle-ai.dev/schema/review/validator/v1");
|
|
779
807
|
const validationRequest = record(validatorInput.validationRequest, "targeted-validator validation request");
|
|
780
808
|
const validatorArgumentTokens = collectBindingArgumentTokens(validatorBinding);
|
|
781
809
|
const validatorRequestHash = collectBindingArgument(validatorBinding, "request-hash");
|
|
@@ -790,20 +818,36 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
790
818
|
assert.ok(Array.isArray(validationRequest.fixClassifications) && validationRequest.fixClassifications.length > 0, "the public targeted-validator request must retain Go's classifications");
|
|
791
819
|
assert.ok(validatorArgumentTokens.includes(`--request-hash=${validatorRequestHash}`), "the public targeted-validator vector must retain its request hash");
|
|
792
820
|
assert.ok(validatorArgumentTokens.includes("--agent=pi"), "the public targeted-validator vector must retain the Pi binding");
|
|
793
|
-
assert.ok(validatorArgumentTokens.includes("--
|
|
821
|
+
assert.ok(validatorArgumentTokens.includes("--materialize=true"), "the public targeted-validator vector must retain the host-mediated materialize binding");
|
|
822
|
+
assert.equal(validatorArgumentTokens.includes("--execute=true"), false, "the v9 host-mediated form must never carry --execute alongside its submission");
|
|
794
823
|
assert.ok(publicStatusProjectionPaths(validationStatus.details).includes("selected.txt"), "the targeted-validator STATUS must remain bound to selected.txt");
|
|
795
824
|
assert.equal(publicStatusProjectionPaths(validationStatus.details).includes("excluded.txt"), false, "the targeted-validator STATUS must remain outside excluded.txt");
|
|
796
825
|
|
|
797
|
-
|
|
826
|
+
// Arm the targeted-validator faux reviewer now that its request hash and
|
|
827
|
+
// target identity are known; the relay override above dispatches to it by
|
|
828
|
+
// routing key.
|
|
829
|
+
validatorReviewer = fauxReviewerFor(() => JSON.stringify({
|
|
798
830
|
targeted_validation_request_hash: validatorRequestHash,
|
|
799
831
|
correction_target_identity: validatorTargetIdentity,
|
|
800
832
|
original_criteria: { passed: true, evidence: ["focused acceptance proof passed"] },
|
|
801
833
|
correction_regression: { passed: true, evidence: ["focused regression proof passed"] },
|
|
802
834
|
follow_ups: [],
|
|
803
|
-
});
|
|
835
|
+
}));
|
|
836
|
+
const validatorForecast = await capture.execute(
|
|
837
|
+
"combined-targeted-validator-forecast",
|
|
838
|
+
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: validatorBinding },
|
|
839
|
+
undefined,
|
|
840
|
+
undefined,
|
|
841
|
+
sessionContext(sessionA),
|
|
842
|
+
);
|
|
843
|
+
const validatorForecastDetails = record(validatorForecast.details, "combined targeted-validator forecast");
|
|
844
|
+
assert.equal(validatorForecastDetails.status, "blocked");
|
|
845
|
+
assert.equal(validatorForecastDetails.outcome, "reviewer-model-run-forecast");
|
|
846
|
+
assert.equal(validatorReviewer.calls.length, 0, "forecast acknowledgement must not launch a reviewer completion");
|
|
847
|
+
|
|
804
848
|
const providerValidation = await capture.execute(
|
|
805
849
|
"combined-provider-targeted-validation",
|
|
806
|
-
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: validatorBinding },
|
|
850
|
+
{ lineageId: lineage, workspaceRoot: nestedTarget, collectBinding: validatorBinding, reviewerRunAcknowledged: true },
|
|
807
851
|
undefined,
|
|
808
852
|
undefined,
|
|
809
853
|
sessionContext(sessionA),
|
|
@@ -816,22 +860,16 @@ test("dev-binary: Pi controller keeps an explicit B root and selected-untracked
|
|
|
816
860
|
assert.equal(validationClosure.schema, "gentle-ai.review-last-event-closure/v1");
|
|
817
861
|
assert.equal(validationClosure.operation, "review/capture-validation");
|
|
818
862
|
assert.equal(validationClosure.state, "approved");
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
assert.ok(
|
|
828
|
-
assert.
|
|
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");
|
|
863
|
+
|
|
864
|
+
// gentle-pi#311 P3: the targeted validator now completes in-process
|
|
865
|
+
// through the same host relay seam as the lens reviewer above — there is
|
|
866
|
+
// no Go-owned pi subprocess, no PATH fixture, and no scratch sandbox to
|
|
867
|
+
// assert for this leg any more; it never reaches the native adapter's
|
|
868
|
+
// own `review capture-validation` invocation, since the relay spawns
|
|
869
|
+
// gentle-ai itself for both materialize and submit.
|
|
870
|
+
assert.equal(validatorReviewer.calls.length, 1, "the in-process targeted validator must complete exactly once");
|
|
871
|
+
assert.ok(validatorReviewer.calls[0]!.promptText.includes(validatorRequestHash), "the targeted-validator prompt must retain the provider request hash");
|
|
872
|
+
assert.equal(nativeCalls.some((call) => call.arguments[0] === "review" && call.arguments[1] === "capture-validation"), false, "the targeted validator must not reach the native adapter directly");
|
|
835
873
|
|
|
836
874
|
// Approval no longer burns on its own: it commits one pending
|
|
837
875
|
// acknowledgement and waits for the host to run that exact invocation
|