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.
Files changed (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. 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 +claude-sonnet-5 +│$/);
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
- assert.match(plain[1], /^│ \? sdd-explore asked: Delete\? +claude-sonnet-5 · 2s │$/);
86
- assert.match(plain[2], /^│ ✗ sdd-explore pi exited with code 1 +claude-sonnet-5 · 34k · \$0\.27 · 1s │$/);
87
- assert.match(plain[3], /^│ ○ sdd-explore map footer data sources +queued │$/);
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
- test("agent model and effort outrank usage at sidebar widths without inventing unknown values", () => {
95
- for (const width of [32, 44, 60, 100]) {
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.match(lines[1], /gpt-5 · high/);
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, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
4
5
  import { tmpdir } from "node:os";
5
- import { delimiter, join, resolve } from "node:path";
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 { OPAQUE_PI_REVIEWER_ARGV } from "../../lib/opaque-pi-reviewer-adapter.ts";
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
- 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);
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
- 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
- },
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
- 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");
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
- 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);
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({ ...request, gentleAiExecutable: RELAY_DEV_BINARY!, piExecutable: fakePi, environment, gentleAiTimeoutMs: 30_000, piTimeoutMs: 30_000 });
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
- // 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) => {
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
- 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
- },
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 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");
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
- 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");
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
- 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");
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
- 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");
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
- assert.equal(validatorInput.submission, undefined, "the Go-owned targeted-validator vector must not accept a caller-authored submission");
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("--execute=true"), "the public targeted-validator vector must retain Go-owned execution");
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
- environment.OPAQUE_PI_TARGETED_VALIDATOR_RESULT = JSON.stringify({
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
- 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");
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