gentle-pi 3.2.0 → 3.2.1

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.
@@ -0,0 +1,293 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ import {
6
+ ODD_ROUTING_BLOCK_PREFIX,
7
+ ODD_ROUTING_FIXTURE_RELATIVE,
8
+ ODD_ROUTING_SOURCE_PATH,
9
+ ODD_ROUTING_SOURCE_REPO,
10
+ assertCleanGentleAiCheckout,
11
+ parseOddRoutingFixture,
12
+ renderOddRoutingFixture,
13
+ resolveOddRoutingProvenance,
14
+ sha256Hex,
15
+ } from "../scripts/mirror-odd-routing.mjs";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // ODD routing drift ratchet (gentle-pi#1147 follow-up)
19
+ //
20
+ // gentle-pi hand-mirrors the always-on ODD routing block rendered by gentle-ai
21
+ // `internal/components/agentguidance/routing.go` (RenderRouting). There is no
22
+ // automated sync, so a canonical change leaves the pi mirror stale with nothing
23
+ // catching it. This ratchet vendored-snapshots the canonical block into
24
+ // `fixtures/odd-routing-canonical.md` (regenerated via `npm run
25
+ // mirror:odd-routing`) and fails when a mandatory-delegation clause is dropped
26
+ // either from the canonical fixture or from a pi mirror surface.
27
+ //
28
+ // Regeneration writes ONLY the fixture; this ratchet never auto-rewrites mirror
29
+ // assets, so drift stays visible as a deliberate diff.
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const REPO_ROOT = join(import.meta.dirname, "..");
33
+ const FIXTURE_PATH = join(REPO_ROOT, ...ODD_ROUTING_FIXTURE_RELATIVE.split("/"));
34
+ const DELEGATION_PATH = join(REPO_ROOT, "assets", "orchestrator-delegation.md");
35
+ const CORE_PATH = join(REPO_ROOT, "assets", "orchestrator.md");
36
+ const EXTENSION_PATH = join(REPO_ROOT, "extensions", "gentle-ai.ts");
37
+
38
+ const readRepo = (absolutePath: string): string => readFileSync(absolutePath, "utf8");
39
+
40
+ // A mandatory-delegation semantic anchor: the clause the canonical block must
41
+ // still carry, plus the wording each pi mirror surface is expected to carry.
42
+ // A canonical change that drops `canonical` is caught here on regeneration
43
+ // because the fixture no longer carries it.
44
+ interface RoutingAnchor {
45
+ label: string;
46
+ canonical: string;
47
+ mirrors: ReadonlyArray<{ surface: string; includes: string }>;
48
+ }
49
+
50
+ const DELEGATION = "assets/orchestrator-delegation.md";
51
+ const CORE = "assets/orchestrator.md";
52
+ const EXTENSION = "extensions/gentle-ai.ts";
53
+
54
+ const ANCHORS: readonly RoutingAnchor[] = [
55
+ {
56
+ label: "mandatory delegation triggers heading",
57
+ canonical: "### Mandatory Delegation Triggers",
58
+ mirrors: [
59
+ { surface: DELEGATION, includes: "#### Mandatory Delegation Triggers" },
60
+ { surface: CORE, includes: "Mandatory Delegation Triggers" },
61
+ ],
62
+ },
63
+ {
64
+ label: "triggers are mandatory, not advisory",
65
+ canonical: "These triggers are mandatory, not advisory.",
66
+ mirrors: [
67
+ { surface: DELEGATION, includes: "These triggers are mandatory, not advisory." },
68
+ { surface: EXTENSION, includes: "These triggers are mandatory, not advisory" },
69
+ ],
70
+ },
71
+ {
72
+ label: "stop and delegate through the runtime's subagent mechanism",
73
+ canonical: "stop and delegate through the runtime's subagent mechanism before continuing",
74
+ mirrors: [
75
+ { surface: DELEGATION, includes: "stop and delegate through the runtime's subagent mechanism before continuing" },
76
+ ],
77
+ },
78
+ {
79
+ label: "executing past a fired trigger is a routing defect",
80
+ canonical: "executing past a fired trigger inline is a routing defect even if the work succeeds",
81
+ mirrors: [
82
+ { surface: DELEGATION, includes: "executing past a fired trigger inline is a routing defect even if the work succeeds" },
83
+ { surface: EXTENSION, includes: "executing past a fired trigger inline is a routing defect even if the work succeeds" },
84
+ ],
85
+ },
86
+ {
87
+ label: "mapping trigger at 4 or more files",
88
+ canonical: "**Mapping trigger:** when understanding the work requires 4 or more files",
89
+ mirrors: [
90
+ { surface: DELEGATION, includes: "**Mapping trigger (4-file rule):** when understanding the work requires 4 or more files" },
91
+ { surface: CORE, includes: "**4-file rule** — 4+ files to understand" },
92
+ ],
93
+ },
94
+ {
95
+ label: "writer trigger at 2 or more non-trivial files",
96
+ canonical: "**Writer trigger:** when implementation touches 2 or more non-trivial files",
97
+ mirrors: [
98
+ { surface: DELEGATION, includes: "**Writer trigger (Multi-file write rule):** when implementation touches 2 or more non-trivial files" },
99
+ { surface: CORE, includes: "**Multi-file write rule** — 2+ non-trivial files touched" },
100
+ ],
101
+ },
102
+ {
103
+ label: "preparation trigger",
104
+ canonical: "**Preparation trigger:**",
105
+ mirrors: [{ surface: DELEGATION, includes: "**Preparation trigger:**" }],
106
+ },
107
+ {
108
+ label: "long-session backstop",
109
+ canonical: "**Long-session backstop:**",
110
+ mirrors: [
111
+ { surface: DELEGATION, includes: "**Long-session backstop (Long-session rule):**" },
112
+ { surface: CORE, includes: "**Long-session rule** — ~20 tool calls, 5 exploratory reads, or 2 non-mechanical edits without delegation" },
113
+ ],
114
+ },
115
+ {
116
+ label: "route declaration records the chosen route per task",
117
+ canonical: "**Route declaration:**",
118
+ mirrors: [
119
+ { surface: DELEGATION, includes: "record the chosen route per task" },
120
+ ],
121
+ },
122
+ {
123
+ label: "triggers never select SDD",
124
+ canonical: "These triggers never select SDD and never create SDD artifacts",
125
+ mirrors: [
126
+ { surface: DELEGATION, includes: "These triggers never select SDD and never create SDD artifacts" },
127
+ ],
128
+ },
129
+ {
130
+ label: "ODD step 6 honors its mandatory delegation triggers",
131
+ canonical: "honoring its mandatory delegation triggers",
132
+ mirrors: [{ surface: EXTENSION, includes: "honoring its mandatory delegation triggers" }],
133
+ },
134
+ ];
135
+
136
+ // Condensed trigger rows that live only in the always-on core prompt; the
137
+ // canonical RenderRouting block does not carry the incident or verification
138
+ // rows, so they are mirror-only and not fixture-derived anchors.
139
+ const CORE_ONLY_TRIGGERS = [
140
+ "**Incident rule** — diagnose wrong cwd/worktree/git/tooling incidents separately",
141
+ "**Verification rule** — executing/delegating verification commands",
142
+ ] as const;
143
+
144
+ function fixtureBody(): string {
145
+ assert.ok(
146
+ existsSync(FIXTURE_PATH),
147
+ `missing canonical fixture ${ODD_ROUTING_FIXTURE_RELATIVE}; regenerate it with \`npm run mirror:odd-routing\``,
148
+ );
149
+ return parseOddRoutingFixture(readRepo(FIXTURE_PATH)).body;
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // 1 — Fixture provenance readback
154
+ // ---------------------------------------------------------------------------
155
+
156
+ test("the canonical routing fixture exists with managed provenance (source repo, commit, digest)", () => {
157
+ assert.ok(
158
+ existsSync(FIXTURE_PATH),
159
+ `missing canonical fixture ${ODD_ROUTING_FIXTURE_RELATIVE}; regenerate it with \`npm run mirror:odd-routing\``,
160
+ );
161
+ const { header } = parseOddRoutingFixture(readRepo(FIXTURE_PATH));
162
+ assert.equal(header.source_repo, ODD_ROUTING_SOURCE_REPO);
163
+ assert.equal(header.source_path, ODD_ROUTING_SOURCE_PATH);
164
+ assert.match(header.source_commit ?? "", /^[0-9a-f]{40}$/, "fixture must record the gentle-ai source commit");
165
+ assert.match(header.generated_at ?? "", /^\d{4}-\d{2}-\d{2}T/, "fixture must record a generation date");
166
+ assert.match(header.block_sha256 ?? "", /^[0-9a-f]{64}$/, "fixture must record the block body digest");
167
+ });
168
+
169
+ test("the fixture digest matches its block body byte-for-byte", () => {
170
+ const { header, body } = parseOddRoutingFixture(readRepo(FIXTURE_PATH));
171
+ assert.equal(header.block_sha256, sha256Hex(body), "the fixture body was hand-edited without regenerating its digest");
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // 2 — Canonical fixture carries every mandatory anchor
176
+ // ---------------------------------------------------------------------------
177
+
178
+ test("the canonical fixture carries every mandatory-delegation anchor", () => {
179
+ const body = fixtureBody();
180
+ for (const anchor of ANCHORS) {
181
+ assert.ok(body.includes(anchor.canonical), `canonical fixture dropped anchor: ${anchor.label}`);
182
+ }
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // 3 — Every pi mirror surface carries the mapped anchor
187
+ // ---------------------------------------------------------------------------
188
+
189
+ test("the pi mirror surfaces carry every mapped mandatory-delegation anchor", () => {
190
+ const surfaces: Record<string, string> = {
191
+ [DELEGATION]: readRepo(DELEGATION_PATH),
192
+ [CORE]: readRepo(CORE_PATH),
193
+ [EXTENSION]: readRepo(EXTENSION_PATH),
194
+ };
195
+ for (const anchor of ANCHORS) {
196
+ for (const mirror of anchor.mirrors) {
197
+ const text = surfaces[mirror.surface];
198
+ assert.ok(text !== undefined, `unknown mirror surface ${mirror.surface}`);
199
+ assert.ok(
200
+ text.includes(mirror.includes),
201
+ `${mirror.surface} is missing the mirror of "${anchor.label}": ${JSON.stringify(mirror.includes)}`,
202
+ );
203
+ }
204
+ }
205
+ });
206
+
207
+ test("the always-on core prompt carries the condensed incident and verification trigger rows", () => {
208
+ const core = readRepo(CORE_PATH);
209
+ for (const row of CORE_ONLY_TRIGGERS) {
210
+ assert.ok(core.includes(row), `assets/orchestrator.md is missing condensed trigger row: ${JSON.stringify(row)}`);
211
+ }
212
+ });
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // 4 — Regeneration provenance: idempotent and fail-closed
216
+ // ---------------------------------------------------------------------------
217
+
218
+ // The fixture header claims provenance from the source commit, so regeneration
219
+ // must be byte-reproducible: the same gentle-ai commit must not produce a
220
+ // date-only diff. `generated_at` therefore tracks the source commit's committer
221
+ // date instead of wall-clock time.
222
+ test("fixture rendering is idempotent and derives generated_at from the source commit", () => {
223
+ const sourceCommit = "e7729359fd9d6cb691ed2a88e8f72b1372f7c92e";
224
+ const committerDate = "2026-09-18T13:49:03+02:00";
225
+ const fakeGit = (args: readonly string[]): string => {
226
+ switch (args[0]) {
227
+ case "status":
228
+ return "";
229
+ case "rev-parse":
230
+ return `${sourceCommit}\n`;
231
+ case "show":
232
+ return `${committerDate}\n`;
233
+ default:
234
+ throw new Error(`unexpected git invocation: ${args.join(" ")}`);
235
+ }
236
+ };
237
+ const block = `${ODD_ROUTING_BLOCK_PREFIX}\n\n- a canonical clause\n`;
238
+ const first = renderOddRoutingFixture(block, resolveOddRoutingProvenance("/fake/gentle-ai", fakeGit));
239
+ const second = renderOddRoutingFixture(block, resolveOddRoutingProvenance("/fake/gentle-ai", fakeGit));
240
+ assert.equal(first, second, "rendering the same source commit twice must be byte-identical");
241
+ const { header } = parseOddRoutingFixture(first);
242
+ assert.equal(header.source_commit, sourceCommit);
243
+ assert.equal(
244
+ header.generated_at,
245
+ committerDate,
246
+ "generated_at must track the source commit's committer date, not wall-clock time",
247
+ );
248
+ });
249
+
250
+ // A dirty checkout would render uncommitted content under the committed
251
+ // provenance claim, so the mirror fails closed before rendering and names the
252
+ // tracked paths. Untracked files (the odd/tasks notes) must never block.
253
+ test("the mirror fails closed on a dirty source checkout and names the tracked paths", () => {
254
+ const recordedCalls: string[][] = [];
255
+ const cleanGit = (args: readonly string[]): string => {
256
+ recordedCalls.push([...args]);
257
+ switch (args[0]) {
258
+ case "status":
259
+ return "";
260
+ case "rev-parse":
261
+ return `${"a".repeat(40)}\n`;
262
+ case "show":
263
+ return "2026-09-18T13:49:03+02:00\n";
264
+ default:
265
+ throw new Error(`unexpected git invocation: ${args.join(" ")}`);
266
+ }
267
+ };
268
+ resolveOddRoutingProvenance("/fake/gentle-ai", cleanGit);
269
+ assert.deepEqual(
270
+ recordedCalls.find((args) => args[0] === "status"),
271
+ ["status", "--porcelain", "--untracked-files=no"],
272
+ "the dirty guard must ignore untracked files",
273
+ );
274
+
275
+ assert.doesNotThrow(() => assertCleanGentleAiCheckout(""));
276
+ const dirtyGit = (args: readonly string[]): string => {
277
+ if (args[0] === "status") {
278
+ return " M internal/components/agentguidance/routing.go\n M scripts/other.mjs\n";
279
+ }
280
+ throw new Error(`unexpected git invocation: ${args.join(" ")}`);
281
+ };
282
+ assert.throws(
283
+ () => resolveOddRoutingProvenance("/fake/gentle-ai", dirtyGit),
284
+ (error: unknown) => {
285
+ assert.ok(error instanceof Error);
286
+ assert.match(
287
+ error.message,
288
+ /gentle-ai checkout is dirty; provenance would be unverifiable: internal\/components\/agentguidance\/routing\.go, scripts\/other\.mjs\. Commit or stash first\./,
289
+ );
290
+ return true;
291
+ },
292
+ );
293
+ });
@@ -204,6 +204,63 @@ test("ODD forwards configured TDD without equating test presence with enablement
204
204
  assert.doesNotMatch(wrapper, /If tests exist, use strict TDD/);
205
205
  });
206
206
 
207
+ test("mandatory delegation triggers are behavioral in the lazy canonical port and the always-on ODD step", () => {
208
+ for (const clause of [
209
+ "These triggers are mandatory, not advisory.",
210
+ "stop and delegate through the runtime's subagent mechanism before continuing",
211
+ "executing past a fired trigger inline is a routing defect even if the work succeeds",
212
+ "**Mapping trigger",
213
+ "**Writer trigger",
214
+ "**Preparation trigger:**",
215
+ "**Long-session backstop",
216
+ "pause and delegate the next bounded unit of work",
217
+ "**Route declaration:**",
218
+ "record the chosen route per task",
219
+ "so skipped delegation is observable instead of silent",
220
+ "These triggers never select SDD and never create SDD artifacts",
221
+ ]) {
222
+ assert.ok(delegation.includes(clause), `lazy canonical port is missing mandatory delegation clause: ${clause}`);
223
+ }
224
+ assert.ok(
225
+ wrapper.includes("honoring its mandatory delegation triggers"),
226
+ "the always-on ODD step 6 must honor its mandatory delegation triggers",
227
+ );
228
+ assert.ok(
229
+ wrapper.includes("executing past a fired trigger inline is a routing defect"),
230
+ "the always-on ODD protocol must state that skipping a fired trigger is a routing defect",
231
+ );
232
+ });
233
+
234
+ test("core and lazy canonical trigger lists agree in numbering and semantics", () => {
235
+ for (const entry of [
236
+ "1. **4-file rule**",
237
+ "2. **Multi-file write rule**",
238
+ "3. **Incident rule**",
239
+ "4. **Long-session rule**",
240
+ "5. **Verification rule**",
241
+ ]) {
242
+ assert.ok(core.includes(entry), `always-on core trigger list is missing: ${entry}`);
243
+ }
244
+ for (const entry of [
245
+ "1. **Mapping trigger (4-file rule):**",
246
+ "2. **Writer trigger (Multi-file write rule):**",
247
+ "3. **Incident rule:**",
248
+ "4. **Long-session backstop (Long-session rule):**",
249
+ "5. **Verification rule**",
250
+ ]) {
251
+ assert.ok(delegation.includes(entry), `lazy canonical trigger list is missing: ${entry}`);
252
+ }
253
+ for (const stale of [
254
+ "**Bounded read rule**",
255
+ "**Write rule**",
256
+ "**Context rule**",
257
+ "**Per-action rule**",
258
+ "**Optional SDD rule**",
259
+ ]) {
260
+ assert.ok(!delegation.includes(stale), `reconciled canonical list retains stale trigger framing: ${stale}`);
261
+ }
262
+ });
263
+
207
264
  test("ODD protocol is always-on in the rendered system prompt and runs by default", () => {
208
265
  const orderedClauses = [
209
266
  "Default workflow: Organic Driven Development (MANDATORY)",
@@ -8,6 +8,7 @@ import {
8
8
  OPAQUE_PI_REVIEWER_ARGV,
9
9
  OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE,
10
10
  OpaquePiReviewerTransportError,
11
+ extractPiAssistantText,
11
12
  resolvePiLaunch,
12
13
  runOpaquePiReviewer,
13
14
  } from "../lib/opaque-pi-reviewer-adapter.ts";
@@ -43,10 +44,12 @@ const PROMPT_BYTES = Buffer.concat([
43
44
  Buffer.from("opaque prompt\r\n\u0000", "utf8"),
44
45
  Buffer.from([0x01, 0xff, 0xfe, 0x00]),
45
46
  ]);
46
- const OUTPUT_BYTES = Buffer.concat([
47
- Buffer.from("opaque output\r\n\u0000", "utf8"),
48
- Buffer.from([0x07, 0xff, 0xfe, 0x00]),
49
- ]);
47
+ const DEFAULT_OUTPUT_TEXT = "opaque output\r\n\u03b5 \u2014 unicode stays intact\n";
48
+
49
+ const OUTPUT_BYTES = piEventStream(
50
+ ...userTurn("opaque prompt"),
51
+ ...assistantTurn([{ type: "text", text: DEFAULT_OUTPUT_TEXT }]),
52
+ );
50
53
 
51
54
  interface OpaqueHarness {
52
55
  directory: string;
@@ -94,6 +97,34 @@ function readLog(path: string): OpaquePiLog[] {
94
97
  .map((line) => JSON.parse(line) as OpaquePiLog);
95
98
  }
96
99
 
100
+ // The reviewer child runs `pi --mode json`, so its stdout is a newline-
101
+ // delimited pi event stream. These helpers build the minimal stream shapes the
102
+ // extraction has to understand, including the shapes behind the empty-output
103
+ // field reports (#1140: a reviewer that answers with a tool call writes zero
104
+ // usable bytes in text mode and exits 0 in silence).
105
+ function piEventStream(...events: readonly unknown[]): Buffer {
106
+ return Buffer.from(events.map((event) => JSON.stringify(event)).join("\n") + "\n", "utf8");
107
+ }
108
+
109
+ function userTurn(text: string): unknown[] {
110
+ const message = { role: "user", content: [{ type: "text", text }] };
111
+ return [
112
+ { type: "message_start", message },
113
+ { type: "message_end", message },
114
+ ];
115
+ }
116
+
117
+ function assistantTurn(parts: readonly Record<string, unknown>[], reviewerModel?: string): unknown[] {
118
+ const content = parts.map((part) => ({ ...part }));
119
+ const message: Record<string, unknown> = { role: "assistant", content };
120
+ if (reviewerModel !== undefined) message.model = reviewerModel;
121
+ return [
122
+ { type: "message_start", message },
123
+ { type: "message_end", message },
124
+ { type: "agent_end", messages: [message] },
125
+ ];
126
+ }
127
+
97
128
  async function rejectsWithTransportError(
98
129
  promise: Promise<unknown>,
99
130
  kind: (typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE)[keyof typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE],
@@ -107,8 +138,22 @@ async function rejectsWithTransportError(
107
138
  return caught!;
108
139
  }
109
140
 
110
- test("the opaque adapter streams arbitrary prompt and stdout bytes verbatim through the fixed Pi subprocess", async (t) => {
111
- const fixture = harness(t);
141
+ // #1140/#1156: the child runs `pi --mode json` and the transport emits the
142
+ // final assistant text of that event stream. The prompt side stays byte-
143
+ // verbatim; the output side is pi's own envelope, so a silent or unintelligible
144
+ // run becomes a typed, evidenced failure instead of zero bytes and exit 0.
145
+ const OUTPUT_TEXT = "opaque output\r\n\u03b5 \u2014 unicode stays intact\nsecond assistant text part";
146
+
147
+ function eventStreamFor(text: string): Buffer {
148
+ return piEventStream(
149
+ ...userTurn("opaque prompt"),
150
+ ...assistantTurn([{ type: "text", text }], "pi-test-model"),
151
+ { type: "agent_settled" },
152
+ );
153
+ }
154
+
155
+ test("the opaque adapter streams the prompt verbatim and emits the pi event stream's final assistant text", async (t) => {
156
+ const fixture = harness(t, { OPAQUE_PI_OUTPUT_B64: eventStreamFor(OUTPUT_TEXT).toString("base64") });
112
157
  const result = await runOpaquePiReviewer(PROMPT_BYTES, {
113
158
  piExecutable: fixture.pi,
114
159
  environment: fixture.environment,
@@ -116,8 +161,8 @@ test("the opaque adapter streams arbitrary prompt and stdout bytes verbatim thro
116
161
  });
117
162
 
118
163
  assert.equal(result.promptByteLength, PROMPT_BYTES.length);
119
- assert.equal(result.stdoutByteLength, OUTPUT_BYTES.length);
120
- assert.deepEqual(result.stdout, OUTPUT_BYTES);
164
+ assert.equal(result.stdoutByteLength, Buffer.byteLength(OUTPUT_TEXT));
165
+ assert.deepEqual(result.stdout, Buffer.from(OUTPUT_TEXT, "utf8"));
121
166
  assert.deepEqual(readFileSync(fixture.stdinCapturePath), PROMPT_BYTES);
122
167
 
123
168
  const calls = readLog(fixture.logPath);
@@ -129,6 +174,100 @@ test("the opaque adapter streams arbitrary prompt and stdout bytes verbatim thro
129
174
  assert.equal(existsSync(calls[0]!.cwd), false, "the empty scratch directory is removed after success");
130
175
  });
131
176
 
177
+ test("the extraction mirrors the pi event stream: every assistant text part in order, nothing else", () => {
178
+ const streamed = piEventStream(
179
+ ...userTurn("prompt"),
180
+ ...assistantTurn([{ type: "thinking", thinking: "hidden" }, { type: "text", text: "first " }], "pi-a"),
181
+ ...assistantTurn([{ type: "text", text: "second" }], "pi-b"),
182
+ );
183
+ const extracted = extractPiAssistantText(streamed);
184
+ assert.equal(extracted.kind, "text");
185
+ if (extracted.kind === "text") assert.equal(extracted.text, "first second");
186
+
187
+ const single = extractPiAssistantText(eventStreamFor("{\"findings\": []}"));
188
+ assert.equal(single.kind, "text");
189
+ if (single.kind === "text") assert.equal(single.text, "{\"findings\": []}");
190
+
191
+ // A stream pi could not have produced is not silently passed through.
192
+ assert.equal(extractPiAssistantText(Buffer.from("garbage, not an event stream", "utf8")).kind, "none");
193
+ assert.equal(extractPiAssistantText(Buffer.alloc(0)).kind, "none");
194
+ });
195
+
196
+ test("a silent or unintelligible reviewer run fails typed with the evidence the event stream carries", async (t) => {
197
+ const fixture = harness(t);
198
+
199
+ const silent = await rejectsWithTransportError(
200
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_MODE: "empty" }, timeoutMs: 10_000 }),
201
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
202
+ );
203
+ assert.equal(silent.evidence?.stdoutKind, "no-output");
204
+
205
+ const unintelligible = await rejectsWithTransportError(
206
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_OUTPUT_B64: Buffer.from("not json at all", "utf8").toString("base64") }, timeoutMs: 10_000 }),
207
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
208
+ );
209
+ assert.equal(unintelligible.evidence?.stdoutKind, "not-a-pi-event-stream");
210
+ assert.match(unintelligible.message, /no assistant text|not a pi event stream|no output/i);
211
+
212
+ const textless = await rejectsWithTransportError(
213
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: { ...fixture.environment, OPAQUE_PI_OUTPUT_B64: piEventStream(...userTurn("prompt"), { type: "agent_settled" }).toString("base64") }, timeoutMs: 10_000 }),
214
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
215
+ );
216
+ assert.equal(textless.evidence?.stdoutKind, "no-assistant-text");
217
+ });
218
+
219
+ test("a reviewer run that spent its turn on a tool call reports that in the evidence instead of vanishing", async (t) => {
220
+ const fixture = harness(t, {
221
+ OPAQUE_PI_OUTPUT_B64: piEventStream(
222
+ ...userTurn("prompt"),
223
+ ...assistantTurn([{ type: "toolCall", id: "call_1", name: "bash" }], "nan/deepseek-v4-flash"),
224
+ ).toString("base64"),
225
+ });
226
+ const error = await rejectsWithTransportError(
227
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000 }),
228
+ OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
229
+ );
230
+ assert.equal(error.evidence?.stdoutKind, "no-assistant-text");
231
+ assert.equal(error.evidence?.toolCallAttempted, true);
232
+ assert.equal(error.evidence?.reviewerModel, "nan/deepseek-v4-flash");
233
+ assert.match(error.message, /tool call/i);
234
+ assert.match(error.message, /nan\/deepseek-v4-flash/);
235
+ });
236
+
237
+ test("a tool-call attempt followed by text still yields the text", async (t) => {
238
+ const fixture = harness(t, {
239
+ OPAQUE_PI_OUTPUT_B64: piEventStream(
240
+ ...userTurn("prompt"),
241
+ ...assistantTurn([{ type: "toolCall", id: "call_1", name: "bash" }]),
242
+ ...assistantTurn([{ type: "text", text: "{\"verdict\": \"pass\"}" }], "nan/glm5.3-flash"),
243
+ ).toString("base64"),
244
+ });
245
+ const result = await runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000 });
246
+ assert.deepEqual(result.stdout, Buffer.from("{\"verdict\": \"pass\"}", "utf8"));
247
+ });
248
+
249
+ test("caller-owned launch arguments ride the frozen argv verbatim and are validated before spawn", async (t) => {
250
+ const fixture = harness(t, { OPAQUE_PI_OUTPUT_B64: eventStreamFor("ok").toString("base64") });
251
+ await runOpaquePiReviewer(PROMPT_BYTES, {
252
+ piExecutable: fixture.pi,
253
+ environment: fixture.environment,
254
+ timeoutMs: 10_000,
255
+ extraArguments: ["--model", "nan/glm5.3-flash", "-e", "/abs/auth-adapter.ts"],
256
+ });
257
+ const calls = readLog(fixture.logPath);
258
+ assert.equal(calls.length, 1);
259
+ assert.deepEqual(calls[0]!.argv, [...OPAQUE_PI_REVIEWER_ARGV, "--model", "nan/glm5.3-flash", "-e", "/abs/auth-adapter.ts"]);
260
+
261
+ await assert.rejects(
262
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000, extraArguments: ["--model", ""] }),
263
+ /non-empty/,
264
+ );
265
+ await assert.rejects(
266
+ runOpaquePiReviewer(PROMPT_BYTES, { piExecutable: fixture.pi, environment: fixture.environment, timeoutMs: 10_000, extraArguments: [42 as unknown as string] }),
267
+ /non-empty/,
268
+ );
269
+ });
270
+
132
271
  test("the opaque adapter returns typed transport errors for launch, nonzero, empty, timeout, and cancellation", async (t) => {
133
272
  const fixture = harness(t);
134
273
  await rejectsWithTransportError(
@@ -228,8 +367,13 @@ test("the opaque adapter has no review lifecycle imports or identifiers", () =>
228
367
  const adapterPath = fileURLToPath(new URL("../lib/opaque-pi-reviewer-adapter.ts", import.meta.url));
229
368
  const source = readFileSync(adapterPath, "utf8");
230
369
  assert.doesNotMatch(source, /review-integration|gentle-ai|materialize|submit/i);
370
+ // The guard reads source, not strings: the one lifecycle-shaped word the
371
+ // transport may spell is pi's own quoted wire key for the selection the
372
+ // child reports in its events (#1140). It is data there, never an
373
+ // identifier, so it is stripped before the scan.
374
+ const withoutWireKeys = source.replaceAll('"model"', '""');
231
375
  for (const identifier of ["lineage", "target", "revision", "receipt", "lens", "order", "subject", "schema", "capture", "submission", "status", "model", "provider", "profile"]) {
232
- assert.doesNotMatch(source, new RegExp(`\\b${identifier}\\b`, "i"), `adapter must not contain lifecycle identifier ${identifier}`);
376
+ assert.doesNotMatch(withoutWireKeys, new RegExp(`\\b${identifier}\\b`, "i"), `adapter must not contain lifecycle identifier ${identifier}`);
233
377
  }
234
378
  });
235
379
 
@@ -309,20 +309,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
309
309
  );
310
310
  });
311
311
 
312
- test("package verification binds the published Gentle AI v3.1.0 runtime pin", () => {
312
+ test("package verification binds the published Gentle AI v3.2.1 runtime pin", () => {
313
313
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
314
314
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
315
315
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
316
316
 
317
- assert.match(installer, /INSTALLER_VERSION = "3\.1\.0"/);
317
+ assert.match(installer, /INSTALLER_VERSION = "3\.2\.1"/);
318
318
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
319
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:CrZlui5N8\/RSmxvn\/7usKe6q1EvRt7y6dyfEwjstmJw="/);
319
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:0QFo0ERv8\/3lgTepEG0E\/P7yD9qXXCx2M7ppQviVHMo="/);
320
320
  assert.match(installer, /GOTOOLCHAIN: "local"/);
321
321
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
322
322
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
323
323
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
324
324
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
325
- assert.match(verifier, /v3\.1\.0/);
325
+ assert.match(verifier, /v3\.2\.1/);
326
326
  });
327
327
 
328
328
 
@@ -1574,9 +1574,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
1574
1574
  assert.match(wrapper, /quietToolsEnabled/);
1575
1575
  });
1576
1576
 
1577
- test("Gentle Shell v3.2.0 package and runtime stop before publication", () => {
1577
+ test("Gentle Shell v3.2.1 package and runtime stop before publication", () => {
1578
1578
  const packageJson = readPackageJson();
1579
- assert.equal(packageJson.version, "3.2.0", "the release manifest must remain explicitly pinned to v3.2.0");
1579
+ assert.equal(packageJson.version, "3.2.1", "the release manifest must remain explicitly pinned to v3.2.1");
1580
1580
  assert.equal(
1581
1581
  packageJson.scripts?.test,
1582
1582
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",