pi-gauntlet 8.4.3 → 8.4.5

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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v8.4.5 - 2026-11-08
4
+
5
+ - Guard, ship-verification, and post-ship spec warnings preserve tool `structuredContent`; nested-call warnings reach the parent codemode result once without changing the child's value or re-arming warn-once guards. (#133)
6
+
7
+ ## v8.4.4 - 2026-11-08
8
+
9
+ - Block scripted `subagent` dispatches in every entered gauntlet phase and exclude nested results from closure latch, fix-round counter, and cadence updates. Use direct tool calls instead; disable with `closureReview.enforce: false`. The configuration guide documents codemode `on` support with direct-only dispatch, why `only` is unsupported in an entered flow, and how to read literal skill dispatch examples (#132, #134).
10
+ - Dev dependencies move to current: `@anthropic-ai/claude-code` 2.1.319 (the Claude Code loading smoke runs against it) and `typebox` ^1.4.6 (the pi 1.5.0 host serves 1.3.27 at runtime; both validate the extension schemas identically).
11
+
3
12
  ## v8.4.3 - 2026-11-08
4
13
 
5
14
  - Telemetry publishes and reads `refs/telemetry/records` outside branch namespaces. The migration guide orders machine upgrades, repeatable record carry with `--migrate-branch`, verified remote retirement with `--retire-branch`, local cleanup and removal of the old CI exclusion. (#130)
package/README.md CHANGED
@@ -142,7 +142,7 @@ Run by a human against pi-cohort >= 7.1.0, before a pi-gauntlet release claims t
142
142
  ## Requirements
143
143
 
144
144
  - [pi-coding-agent](https://github.com/earendil-works/pi) >= 1.5.0 - supports model-only tracker exposure and sequential tracker execution, so updates persist as transcript tool results rather than nested calls.
145
- - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.6.7 - required peer package, including binary-safe worktree patches; it also lets a persona with `subagent` in `tools:` start with inherited extensions ([pi-cohort #26](https://github.com/jjuraszek/pi-cohort/issues/26)), which multi-PR `gatekeep-pr` needs. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
145
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.6.8 - required peer package, including binary-safe worktree patches; it also lets a persona with `subagent` in `tools:` start with inherited extensions ([pi-cohort #26](https://github.com/jjuraszek/pi-cohort/issues/26)), which multi-PR `gatekeep-pr` needs. Skills that dispatch agents (`requesting-code-review`, `subagent-driven-development`, `dispatching-parallel-agents`, `writing-plans`, `forge-skill`, `shape-ticket`, `roasting-the-spec`) call `subagent({})`, which pi-cohort provides. pi-gauntlet does not vendor the dispatch tool; without pi-cohort those skills have nothing to call. Gauntlet execution dispatches explicitly use `async: false`; do not enable pi-cohort's incompatible `forceTopLevelAsync` setting. See pi-cohort's [dispatch configuration](https://github.com/jjuraszek/pi-cohort/blob/main/doc/configuration.md) for that setting's owner and semantics. A wave of N >= 1 tasks dispatches its implementers and first spec reviews as one chain call. `subagent-driven-development` stops before dispatch on unsupported installs; there is no older-runtime fallback.
146
146
 
147
147
  Both packages must be listed in your `.pi/settings.json#packages` array (pi adds them automatically when you `pi install`). pi-gauntlet and pi-cohort are versioned independently but release together whenever dispatch semantics change - pin compatible versions of both. At each pi-gauntlet release, set the pi-cohort peer minimum and README floor together to the newest released pi-cohort version; `/skill:release` checks the registry before proceeding.
148
148
 
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { createPendingWarnings, prependWarning } from "./tool-result-warnings.ts";
4
+ import { mergeToolResult } from "../test-support/merge-tool-result.mjs";
5
+
6
+ for (const [name, toolName, structuredContent] of [
7
+ ["bash payload", "bash", { output: "ok", exit_code: 0 }],
8
+ ["write null", "write", null],
9
+ ["edit false", "edit", false],
10
+ ["zero", "codemode", 0],
11
+ ["empty string", "read", ""],
12
+ ["absent", "read", undefined],
13
+ ] as const) {
14
+ test(`prependWarning preserves the runner-merged result (${name})`, () => {
15
+ const event = {
16
+ toolName, toolCallId: "c1", input: {}, isError: true,
17
+ ...(structuredContent !== undefined ? { structuredContent } : {}),
18
+ content: [{ type: "text", text: "original" }, { type: "image", data: "AA==", mimeType: "image/png" }],
19
+ details: { sentinel: "details" }, usage: { sentinel: "usage" },
20
+ };
21
+ const original = structuredClone(event);
22
+ const ret = prependWarning(event as any, "warning");
23
+ assert.deepEqual(ret, { content: [{ type: "text", text: "warning" }, ...event.content], structuredContent });
24
+ assert.deepEqual(mergeToolResult(event, ret), { ...event, content: ret.content });
25
+ assert.deepEqual(event, original);
26
+ });
27
+ }
28
+
29
+ test("nested warning re-keys without changing the child or the fired latch, then delivers once", () => {
30
+ const store = createPendingWarnings();
31
+ const firedGuards = new Map([["guard", true]]);
32
+ const child = {
33
+ toolName: "bash", toolCallId: "c1/0", parentToolCallId: "c1", input: {}, isError: false,
34
+ content: [{ type: "text", text: "ok" }], structuredContent: { output: "ok", exit_code: 0 },
35
+ };
36
+ const original = structuredClone(child);
37
+ store.add(child.toolCallId, "warning");
38
+ const ret = store.deliver(child as any, store.take(child.toolCallId)!);
39
+ assert.equal(ret, undefined);
40
+ assert.deepEqual(mergeToolResult(child, ret), original);
41
+ assert.deepEqual(child, original);
42
+ assert.equal(firedGuards.get("guard"), true);
43
+ assert.equal(store.take(child.toolCallId), undefined);
44
+
45
+ const parent = { ...child, toolName: "codemode", toolCallId: "c1", parentToolCallId: undefined, structuredContent: { value: 0 } };
46
+ const text = store.take(parent.toolCallId)!;
47
+ assert.equal(text, "warning");
48
+ const delivered = store.deliver(parent as any, text)!;
49
+ assert.deepEqual(mergeToolResult(parent, delivered), { ...parent, content: [{ type: "text", text }, ...parent.content] });
50
+ assert.equal(store.take(parent.toolCallId), undefined);
51
+ });
52
+
53
+ test("two nested guards reach one parent once in arrival order", () => {
54
+ const store = createPendingWarnings();
55
+ for (const [id, text] of [["c1/0", "first guard"], ["c1/1", "second guard"]]) {
56
+ store.add(id, text);
57
+ assert.equal(store.deliver({ toolCallId: id, parentToolCallId: "c1" } as any, store.take(id)!), undefined);
58
+ assert.equal(store.take(id), undefined);
59
+ }
60
+ const parent = { toolName: "codemode", toolCallId: "c1", isError: false, content: [], structuredContent: null };
61
+ const ret = store.deliver(parent as any, store.take("c1")!)!;
62
+ const merged = mergeToolResult(parent, ret);
63
+ assert.deepEqual(merged.content, [{ type: "text", text: "first guard\n\nsecond guard" }]);
64
+ assert.deepEqual(merged.structuredContent, parent.structuredContent);
65
+ assert.equal(store.take("c1"), undefined);
66
+ });
67
+
68
+ test("depth-two nesting re-keys c1/0/2 -> c1/0 -> c1", () => {
69
+ const store = createPendingWarnings();
70
+ store.add("c1/0/2", "warning");
71
+ for (const [toolCallId, parentToolCallId] of [["c1/0/2", "c1/0"], ["c1/0", "c1"]]) {
72
+ const event = { toolName: "codemode", toolCallId, parentToolCallId, content: [], structuredContent: { value: 0 } };
73
+ const ret = store.deliver(event as any, store.take(toolCallId)!);
74
+ assert.equal(ret, undefined);
75
+ assert.deepEqual(mergeToolResult(event, ret), event);
76
+ assert.equal(store.take(toolCallId), undefined);
77
+ }
78
+ const parent = { toolCallId: "c1", content: [], structuredContent: { value: 0 } };
79
+ const ret = store.deliver(parent as any, store.take("c1")!);
80
+ const merged = mergeToolResult(parent, ret);
81
+ assert.deepEqual(merged.content, [{ type: "text", text: "warning" }]);
82
+ assert.deepEqual(merged.structuredContent, parent.structuredContent);
83
+ assert.equal(store.take("c1"), undefined);
84
+ });
85
+
86
+ test("warning delivery tolerates missing content and preserves structuredContent", () => {
87
+ const store = createPendingWarnings();
88
+ const event = { toolName: "bash", toolCallId: "c1", isError: false, structuredContent: { output: "ok", exit_code: 0 } };
89
+ store.add(event.toolCallId, "warning");
90
+ const ret = store.deliver(event as any, store.take(event.toolCallId)!);
91
+ assert.deepEqual(mergeToolResult(event, ret), { ...event, content: [{ type: "text", text: "warning" }] });
92
+ assert.equal(store.take(event.toolCallId), undefined);
93
+ });
94
+
95
+ test("add concatenates warnings and clear discards all entries", () => {
96
+ const store = createPendingWarnings();
97
+ store.add("c1", "first");
98
+ store.add("c1", "second");
99
+ assert.equal(store.take("c1"), "first\n\nsecond");
100
+ store.add("c1", "pending");
101
+ store.add("c2", "another");
102
+ store.clear();
103
+ assert.equal(store.take("c1"), undefined);
104
+ assert.equal(store.take("c2"), undefined);
105
+ });
@@ -0,0 +1,35 @@
1
+ import type { ToolResultEvent, ToolResultEventResult } from "@earendil-works/pi-coding-agent";
2
+
3
+ export function prependWarning(event: ToolResultEvent, text: string): ToolResultEventResult {
4
+ return {
5
+ content: [{ type: "text", text }, ...(event.content ?? [])],
6
+ // Pi drops structuredContent when a content replacement omits it.
7
+ structuredContent: event.structuredContent,
8
+ };
9
+ }
10
+
11
+ export function createPendingWarnings() {
12
+ const pending = new Map<string, string>();
13
+ const add = (id: string, text: string) => {
14
+ const prior = pending.get(id);
15
+ pending.set(id, prior ? `${prior}\n\n${text}` : text);
16
+ };
17
+ return {
18
+ add,
19
+ take(id: string): string | undefined {
20
+ const text = pending.get(id);
21
+ pending.delete(id);
22
+ return text;
23
+ },
24
+ deliver(event: ToolResultEvent, text: string): ToolResultEventResult | undefined {
25
+ if (event.parentToolCallId) {
26
+ add(event.parentToolCallId, text);
27
+ return undefined;
28
+ }
29
+ return prependWarning(event, text);
30
+ },
31
+ clear() {
32
+ pending.clear();
33
+ },
34
+ };
35
+ }
@@ -9,6 +9,7 @@ import { tmpdir } from "node:os";
9
9
  import { isAbsolute, join } from "node:path";
10
10
  import registerPhaseTracker from "./phase-tracker.ts";
11
11
  import registerPlanTracker from "./plan-tracker.ts";
12
+ import { mergeToolResult } from "./test-support/merge-tool-result.mjs";
12
13
 
13
14
  const originalSubagentDepth = process.env.PI_SUBAGENT_DEPTH;
14
15
  process.env.PI_SUBAGENT_DEPTH = "0";
@@ -391,19 +392,105 @@ test("implement-write guard: silent when unarmed, when enforce is false, and in
391
392
  }
392
393
  });
393
394
 
394
- test("brainstorm write guard is unchanged by the implement guard", async () => {
395
- const priorDepth = process.env.PI_SUBAGENT_DEPTH;
396
- delete process.env.PI_SUBAGENT_DEPTH;
397
- try {
398
- const h = harness({ cwd: tempCwd(), branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
399
- await h.emit("session_start");
400
- await h.emitEvent("tool_call", writeCall("t1", "src/x.ts"));
401
- const warned = (await h.emitEvent("tool_result", writeResult("t1")))[0] as { content: { text: string }[] };
402
- assert.match(warned.content[0].text, /brainstorm/);
403
- } finally {
404
- if (priorDepth !== undefined) process.env.PI_SUBAGENT_DEPTH = priorDepth;
395
+ for (const toolName of ["bash", "write", "edit"]) {
396
+ for (const nested of [false, true]) {
397
+ test(`brainstorm ${toolName} warning preserves results (${nested ? "nested" : "direct"}) and stays fired`, async () => {
398
+ const h = harness({ branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
399
+ await h.emit("session_start");
400
+ const toolCallId = nested ? "parent/0" : "direct";
401
+ const nesting = nested ? { parentToolCallId: "parent" } : {};
402
+ const call = {
403
+ toolName, toolCallId, ...nesting,
404
+ input: toolName === "bash" ? { command: "echo x > src/a.ts" } : { path: "src/a.ts" },
405
+ };
406
+ await h.emitEvent("tool_call", call);
407
+ const event = {
408
+ ...writeResult(toolCallId), toolName, ...nesting, isError: true,
409
+ details: { sentinel: "guard details" }, usage: { sentinel: "guard usage" },
410
+ structuredContent: toolName === "bash" ? { output: "ok", exit_code: 0 } : toolName === "write" ? null : { edited: true },
411
+ };
412
+ const original = structuredClone(event);
413
+ const [ret] = await h.emitEvent("tool_result", event);
414
+ let delivered = event;
415
+ let merged = mergeToolResult(event, ret);
416
+ if (nested) {
417
+ assert.equal(ret, undefined);
418
+ assert.deepEqual(merged, original);
419
+ const parent = {
420
+ ...event, toolName: "codemode", toolCallId: "parent", parentToolCallId: undefined,
421
+ content: [{ type: "text", text: "script value: 0" }], structuredContent: { value: 0 },
422
+ };
423
+ delivered = parent;
424
+ const [parentRet] = await h.emitEvent("tool_result", parent);
425
+ merged = mergeToolResult(parent, parentRet);
426
+ }
427
+ assert.deepEqual(event, original);
428
+ assert.match(merged.content[0].text, /Writing outside the spec directory/);
429
+ assert.equal(merged.content[0].text.split("Writing outside the spec directory").length - 1, 1);
430
+ assert.deepEqual(merged, { ...delivered, content: [merged.content[0], ...delivered.content] });
431
+ assert.equal((await h.emitEvent("tool_result", delivered))[0], undefined);
432
+ await h.emitEvent("tool_call", writeCall("later", "src/b.ts"));
433
+ assert.equal((await h.emitEvent("tool_result", writeResult("later")))[0], undefined);
434
+ });
405
435
  }
406
- });
436
+ }
437
+
438
+ for (const toolName of ["bash", "write", "edit"]) {
439
+ for (const nested of [false, true]) {
440
+ test(`implement-write and cadence warnings preserve ${toolName} results (${nested ? "nested" : "direct"})`, async () => {
441
+ const h = harness({ branch: implementBranch([subagentResult(["implementer"])]) });
442
+ await h.emit("session_start");
443
+ const nesting = nested ? { parentToolCallId: "parent" } : {};
444
+ const write = { ...writeCall("write", "src/a.ts"), toolName: toolName === "edit" ? "edit" : "write" };
445
+ for (const [index, call] of [write, commitCall("commit")].entries()) {
446
+ const toolCallId = nested ? `parent/${index}` : call.toolCallId;
447
+ await h.emitEvent("tool_call", { ...call, toolCallId, ...nesting });
448
+ // Deferred cadence delivery is keyed by call ID, not the result's tool name.
449
+ const event = { ...writeResult(toolCallId), toolName: index === 0 ? write.toolName : toolName, ...nesting, structuredContent: { exit_code: 0 } };
450
+ const [ret] = await h.emitEvent("tool_result", event);
451
+ if (nested) {
452
+ assert.equal(ret, undefined);
453
+ assert.deepEqual(mergeToolResult(event, ret), event);
454
+ } else {
455
+ const merged = mergeToolResult(event, ret);
456
+ assert.match(merged.content[0].text, index === 0 ? /Parent write/ : /no wave-reviewer observed/);
457
+ assert.deepEqual(merged, { ...event, content: [merged.content[0], ...event.content] });
458
+ }
459
+ }
460
+ if (nested) {
461
+ const parent = { ...commitResult("parent"), toolName: "codemode", structuredContent: { value: "done" } };
462
+ const [ret] = await h.emitEvent("tool_result", parent);
463
+ const merged = mergeToolResult(parent, ret);
464
+ assert.match(merged.content[0].text, /Parent write[\s\S]*no wave-reviewer observed/);
465
+ assert.equal(merged.content[0].text.split("Parent write").length - 1, 1);
466
+ assert.equal(merged.content[0].text.split("no wave-reviewer observed").length - 1, 1);
467
+ assert.deepEqual(merged, { ...parent, content: [merged.content[0], ...parent.content] });
468
+ assert.equal((await h.emitEvent("tool_result", parent))[0], undefined);
469
+ }
470
+ await h.emitEvent("tool_call", writeCall("later", "src/b.ts"));
471
+ assert.equal((await h.emitEvent("tool_result", writeResult("later")))[0], undefined);
472
+ });
473
+ }
474
+ }
475
+
476
+ for (const transition of ["reset", "session_start"]) {
477
+ test(`${transition} clears a deferred nested guard warning before the parent result`, async () => {
478
+ const h = harness({ branch: [phaseResult("start", phases({ brainstorm: "in_progress" }))] });
479
+ await h.emit("session_start");
480
+ await h.emitEvent("tool_call", { ...writeCall("parent/0", "src/a.ts"), parentToolCallId: "parent" });
481
+ assert.equal((await h.emitEvent("tool_result", { ...writeResult("parent/0"), parentToolCallId: "parent" }))[0], undefined);
482
+ if (transition === "reset") {
483
+ const tool = h.tools.find((t) => t.name === "phase_tracker")!;
484
+ await tool.execute("reset", { action: "reset" }, undefined, undefined, h.ctx);
485
+ } else {
486
+ await h.emit("session_start");
487
+ }
488
+ const parent = { ...writeResult("parent"), toolName: "codemode", structuredContent: { value: 0 } };
489
+ const [ret] = await h.emitEvent("tool_result", parent);
490
+ assert.equal(ret, undefined);
491
+ assert.deepEqual(mergeToolResult(parent, ret), parent);
492
+ });
493
+ }
407
494
 
408
495
  test("brainstorm write confinement: a docs/specs write under the default dirs does not warn", async () => {
409
496
  const priorDepth = process.env.PI_SUBAGENT_DEPTH;
@@ -1551,6 +1638,153 @@ const implementerWave = (n = 1, agent = "implementer") => ({
1551
1638
  const firstCallResult = async (h: ReturnType<typeof harness>, id: string, input: unknown) =>
1552
1639
  (await h.emitEvent("tool_call", subagentCall(id, input)))[0] as { block?: boolean; reason?: string } | undefined;
1553
1640
 
1641
+ const nestedCall = (id: string, input: unknown) => ({ ...subagentCall(id, input), parentToolCallId: "script" });
1642
+
1643
+ for (const phase of PHASES) {
1644
+ test(`nested dispatch: blocked in ${phase}; reason names direct call, only-mode, and the switches`, async () => {
1645
+ const h = harness({ hasUI: true, branch: enteredBranch(phases({ [phase]: "in_progress" })) });
1646
+ await h.emit("session_start");
1647
+ const widget = h.widgets.at(-1);
1648
+ for (const [i, input] of [loneImplementer(), implementerWave(), { agent: "conformance-reviewer" }].entries()) {
1649
+ const [blocked] = await h.emitEvent("tool_call", nestedCall(`script/${i}`, input)) as { block: boolean; reason: string }[];
1650
+ assert.equal(blocked?.block, true);
1651
+ assert.match(blocked.reason, /call subagent as a direct tool call/i);
1652
+ assert.match(blocked.reason, /codemode only mode is unsupported while a gauntlet flow is entered/);
1653
+ assert.match(blocked.reason, /codemode\.mode: on/);
1654
+ assert.match(blocked.reason, /piGauntlet\.closureReview\.enforce: false/);
1655
+ assert.equal(h.widgets.at(-1), widget);
1656
+ }
1657
+ assert.equal(await firstCallResult(h, "direct", { agent: "conformance-reviewer" }), undefined);
1658
+ assert.ok(h.widgets.at(-1)!.includes(`${phase}(conformance)`));
1659
+ assert.doesNotMatch(h.widgets.at(-1)!, /#2/);
1660
+ });
1661
+ }
1662
+
1663
+ const blockedNestedDispatchFixture = async (malformed = false) => {
1664
+ const cwd = tempCwd({ piGauntlet: { closureReview: { maxFixRounds: 5 } } });
1665
+ const h = harness({ hasUI: true, cwd, branch: verifyBranch([
1666
+ subagentResult(["conformance-reviewer"]),
1667
+ subagentResult(["wave-reviewer"]),
1668
+ ...Array.from({ length: 5 }, () => subagentResult(["implementer"])),
1669
+ ]) });
1670
+ await h.emit("session_start");
1671
+ const before = await firstCallResult(h, "cap-before", implementerWave());
1672
+ assert.equal(before?.block, true);
1673
+ assert.match(before?.reason ?? "", /5 fix round\(s\) used against a cap of 5/);
1674
+ if (malformed) writeFileSync(join(cwd, ".pi/settings.json"), "{");
1675
+ const widget = h.widgets.at(-1);
1676
+ const [blocked] = await h.emitEvent("tool_call", nestedCall("script/1", implementerWave())) as { block: boolean; reason: string }[];
1677
+ assert.equal(blocked?.block, true);
1678
+ assert.match(blocked.reason, /call subagent as a direct tool call/i);
1679
+ assert.equal(h.widgets.at(-1), widget);
1680
+ return { cwd, h, before, widget };
1681
+ };
1682
+
1683
+ const assertBlockedNestedStateUntouched = async ({ cwd, h, before, widget }: Awaited<ReturnType<typeof blockedNestedDispatchFixture>>) => {
1684
+ assert.equal(h.widgets.at(-1), widget);
1685
+ assert.deepEqual(await firstCallResult(h, "cap-after", implementerWave()), before);
1686
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
1687
+ const status = await tool.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any;
1688
+ assert.equal(status.details.phases.verify.substep, undefined);
1689
+ assert.equal((await completePhase(h, "verify")).details.error, undefined);
1690
+
1691
+ // Re-enter implement without replay so the commit guard observes the live cadence.
1692
+ writeFileSync(join(cwd, ".pi/settings.json"), JSON.stringify({ piGauntlet: { flowGuards: { enforce: false } } }));
1693
+ const started = await tool.execute("restart", { action: "start", phase: "implement", force: true }, undefined, undefined, h.ctx) as any;
1694
+ assert.equal(started.details.error, undefined);
1695
+ writeFileSync(join(cwd, ".pi/settings.json"), "{}");
1696
+ await h.emitEvent("tool_call", commitCall("commit"));
1697
+ const [warning] = await h.emitEvent("tool_result", commitResult("commit")) as { content: { text: string }[] }[];
1698
+ assert.match(warning.content[0].text, /no wave-reviewer observed since the last implementer/);
1699
+ assert.equal(await firstCallResult(h, "direct", { agent: "wave-reviewer" }), undefined);
1700
+ assert.match(h.widgets.at(-1)!, /implement\(wave-review\)/);
1701
+ assert.deepEqual(await h.emitEvent("tool_result", waveResult("direct", [{ agent: "wave-reviewer", exitCode: 0 }])), [undefined]);
1702
+ assert.doesNotMatch(h.widgets.at(-1)!, /implement\(/);
1703
+ };
1704
+
1705
+ for (const malformed of [false, true]) {
1706
+ test(`nested dispatch: latch, counter, cadence, and liveDispatch unchanged after a block; malformed=${malformed}`, async () => {
1707
+ await assertBlockedNestedStateUntouched(await blockedNestedDispatchFixture(malformed));
1708
+ });
1709
+ }
1710
+
1711
+ for (const isError of [true, false]) {
1712
+ test(`nested result: replay-mirrored state untouched; isError=${isError}`, async () => {
1713
+ const fixture = await blockedNestedDispatchFixture();
1714
+ await fixture.h.emitEvent("tool_result", {
1715
+ ...waveResult("script/1", [
1716
+ { agent: "conformance-reviewer", exitCode: 0 },
1717
+ { agent: "implementer", exitCode: 0 },
1718
+ { agent: "wave-reviewer", exitCode: 0 },
1719
+ ], isError),
1720
+ parentToolCallId: "script",
1721
+ });
1722
+ await assertBlockedNestedStateUntouched(fixture);
1723
+ });
1724
+ }
1725
+
1726
+ for (const entered of [false, true]) {
1727
+ test(`nested dispatch: child follows replayed flow entry=${entered}`, async () => {
1728
+ const priorDepth = process.env.PI_SUBAGENT_DEPTH;
1729
+ let h: ReturnType<typeof harness>;
1730
+ try {
1731
+ process.env.PI_SUBAGENT_DEPTH = "1";
1732
+ h = harness({ branch: entered ? verifyBranch() : [] });
1733
+ } finally {
1734
+ if (priorDepth === undefined) delete process.env.PI_SUBAGENT_DEPTH;
1735
+ else process.env.PI_SUBAGENT_DEPTH = priorDepth;
1736
+ }
1737
+ await h.emit("session_fork");
1738
+ const [result] = await h.emitEvent("tool_call", nestedCall("script/1", loneImplementer())) as ({ block?: boolean } | undefined)[];
1739
+ assert.equal(result?.block, entered ? true : undefined);
1740
+ });
1741
+ }
1742
+
1743
+ test("nested dispatch: allowed without flow entry", async () => {
1744
+ const h = harness({ cwd: tempCwd() });
1745
+ await h.emit("session_start");
1746
+ assert.deepEqual(await h.emitEvent("tool_call", nestedCall("script/1", implementerWave())), [undefined]);
1747
+ });
1748
+
1749
+ for (const malformed of [false, true]) {
1750
+ test(`nested dispatch: management passes and its parent consumes queued warnings; malformed=${malformed}`, async () => {
1751
+ const cwd = tempCwd({});
1752
+ if (malformed) writeFileSync(join(cwd, ".pi/settings.json"), "{");
1753
+ const h = harness({ cwd, branch: verifyBranch() });
1754
+ await h.emit("session_start");
1755
+ assert.deepEqual(await h.emitEvent("tool_call", nestedCall("script/status", { action: "status" })), [undefined]);
1756
+ const content = [{ type: "text", text: "status result" }];
1757
+ const result = { ...waveResult("script/status", []), parentToolCallId: "script", content };
1758
+ const original = structuredClone(result);
1759
+ const [ret] = await h.emitEvent("tool_result", result);
1760
+ assert.equal(ret, undefined);
1761
+ assert.deepEqual(result, original);
1762
+ assert.deepEqual(mergeToolResult(result, ret), original);
1763
+ assert.deepEqual(await h.emitEvent("tool_result", result), [undefined]);
1764
+ const parent = {
1765
+ ...result, toolName: "codemode", toolCallId: "script", parentToolCallId: undefined,
1766
+ content: [{ type: "text", text: "script status result" }], structuredContent: { value: "status" },
1767
+ };
1768
+ const [warning] = await h.emitEvent("tool_result", parent);
1769
+ assert.equal(warning !== undefined, malformed);
1770
+ const merged = mergeToolResult(parent, warning);
1771
+ if (malformed) {
1772
+ assert.match(merged.content[0].text, /gauntlet settings load error \(using defaults\)/);
1773
+ assert.equal(merged.content[0].text.split("gauntlet settings load error").length - 1, 1);
1774
+ assert.deepEqual(merged, { ...parent, content: [merged.content[0], ...parent.content] });
1775
+ } else {
1776
+ assert.deepEqual(merged, parent);
1777
+ }
1778
+ assert.deepEqual(await h.emitEvent("tool_result", parent), [undefined]);
1779
+ });
1780
+ }
1781
+
1782
+ test("nested dispatch: dormant when closureReview.enforce is false", async () => {
1783
+ const h = harness({ cwd: tempCwd({ piGauntlet: { closureReview: { enforce: false } } }), branch: verifyBranch() });
1784
+ await h.emit("session_start");
1785
+ assert.deepEqual(await h.emitEvent("tool_call", nestedCall("script/1", loneImplementer())), [undefined]);
1786
+ });
1787
+
1554
1788
  for (const agent of ["implementer", "loop-implementer"]) {
1555
1789
  test(`shape guard: lone ${agent} blocked in verify after the audit, with and without closureReview.model, async or not`, async () => {
1556
1790
  for (const settings of [
@@ -1685,6 +1919,94 @@ for (const delivery of ["live", "session_start", "session_switch", "session_fork
1685
1919
  });
1686
1920
  }
1687
1921
 
1922
+ for (const isError of [false, true]) {
1923
+ for (const enforce of [true, false]) {
1924
+ test(`nested results: closure latch stays replay-equivalent with enforce=${enforce}, isError=${isError}`, async () => {
1925
+ const cwd = tempCwd({ piGauntlet: { closureReview: { enforce } } });
1926
+ const h = harness({ hasUI: true, cwd, branch: verifyBranch() });
1927
+ await h.emit("session_start");
1928
+ const widget = h.widgets.at(-1);
1929
+ await h.emitEvent("tool_result", {
1930
+ ...waveResult("nested", [{ agent: "conformance-reviewer", exitCode: 0 }], isError),
1931
+ parentToolCallId: "script",
1932
+ });
1933
+ assert.equal(h.widgets.at(-1), widget);
1934
+ const status = await h.tools.find(t => t.name === "phase_tracker")!.execute("status", { action: "status" }, undefined, undefined, h.ctx) as any;
1935
+ assert.equal(status.details.phases.verify.substep, undefined);
1936
+ writeFileSync(join(cwd, ".pi/settings.json"), "{}");
1937
+ assert.equal((await completePhase(h, "verify")).details.error, "no conformance-reviewer dispatch observed");
1938
+ await h.emit("session_switch");
1939
+ assert.equal((await completePhase(h, "verify")).details.error, "no conformance-reviewer dispatch observed");
1940
+ await h.emitEvent("tool_result", waveResult("direct", [{ agent: "conformance-reviewer", exitCode: 0 }]));
1941
+ assert.equal((await completePhase(h, "verify")).details.error, undefined);
1942
+ });
1943
+ }
1944
+
1945
+ for (const agent of ["implementer", "loop-implementer"]) {
1946
+ test(`nested results: ${agent} preserves rounds and granted credits; isError=${isError}`, async () => {
1947
+ const h = guardedHarness({ piGauntlet: { closureReview: { maxFixRounds: 1 } } });
1948
+ await h.emit("session_start");
1949
+ const result = waveResult("wave", [{ agent, exitCode: 0 }]);
1950
+ await h.emitEvent("tool_result", { ...result, isError, parentToolCallId: "script" });
1951
+ assert.equal(await firstCallResult(h, "first", implementerWave(1, agent)), undefined);
1952
+ await h.emitEvent("tool_result", result);
1953
+ assert.equal((await firstCallResult(h, "capped", implementerWave(1, agent)))?.block, true);
1954
+ assert.equal((await grant(h, "grant", { rounds: 1, reason: "one more repair" })).details.error, undefined);
1955
+ await h.emitEvent("tool_result", { ...result, isError, parentToolCallId: "script" });
1956
+ assert.equal(await firstCallResult(h, "funded", implementerWave(1, agent)), undefined);
1957
+ await h.emitEvent("tool_result", result);
1958
+ const spent = await firstCallResult(h, "spent", implementerWave(1, agent));
1959
+ assert.equal(spent?.block, true);
1960
+ assert.match(spent?.reason ?? "", /2 fix round\(s\) used against a cap of 1/);
1961
+ });
1962
+ }
1963
+
1964
+ for (const row of [
1965
+ { agent: "implementer", prior: [], warns: false },
1966
+ { agent: "wave-reviewer", prior: [subagentResult(["implementer"])], warns: true },
1967
+ ]) {
1968
+ test(`nested results: ${row.agent} leaves cadence unchanged but finishes the registered nested dispatch; isError=${isError}`, async () => {
1969
+ const h = harness({ hasUI: true, cwd: tempCwd({ piGauntlet: { closureReview: { enforce: false } } }), branch: implementBranch(row.prior) });
1970
+ await h.emit("session_start");
1971
+ assert.deepEqual(await h.emitEvent("tool_call", nestedCall("work", { agent: row.agent })), [undefined]);
1972
+ assert.match(h.widgets.at(-1)!, /implement\(/);
1973
+ const tool = h.tools.find(t => t.name === "phase_tracker")!;
1974
+ const status = () => tool.execute("status", { action: "status" }, undefined, undefined, h.ctx) as Promise<any>;
1975
+ assert.equal((await status()).details.phases.implement.substep, row.agent === "implementer" ? "implement" : "wave-review");
1976
+ await h.emitEvent("tool_result", {
1977
+ ...waveResult("work", [{ agent: row.agent, exitCode: 0 }], isError),
1978
+ parentToolCallId: "script",
1979
+ });
1980
+ assert.doesNotMatch(h.widgets.at(-1)!, /implement\(/);
1981
+ assert.equal((await status()).details.phases.implement.substep, undefined);
1982
+ for (const delivery of ["live", "session_switch"]) {
1983
+ if (delivery !== "live") await h.emit(delivery);
1984
+ await h.emitEvent("tool_call", commitCall("commit"));
1985
+ const [warning] = await h.emitEvent("tool_result", commitResult("commit"));
1986
+ assert.equal(warning !== undefined, row.warns, delivery);
1987
+ }
1988
+ await h.emitEvent("tool_result", waveResult("direct", [{ agent: row.agent, exitCode: 0 }]));
1989
+ await h.emitEvent("tool_call", commitCall("direct-commit"));
1990
+ const [warning] = await h.emitEvent("tool_result", commitResult("direct-commit"));
1991
+ assert.equal(warning !== undefined, !row.warns);
1992
+ });
1993
+ }
1994
+
1995
+ }
1996
+
1997
+ test("nested result: open-task nudge is skipped while the same direct result nudges", async () => {
1998
+ const h = harness({ branch: verifyBranch([
1999
+ subagentResult(["conformance-reviewer"]),
2000
+ taskSnapshot([{ name: "repair", status: "in_progress" }]),
2001
+ ]) });
2002
+ await h.emit("session_start");
2003
+ const result = okWave("repair");
2004
+ const [nested] = await h.emitEvent("tool_result", { ...result, parentToolCallId: "script" });
2005
+ assert.equal(nested, undefined);
2006
+ const [direct] = await h.emitEvent("tool_result", result) as { content: { text: string }[] }[];
2007
+ assert.match(direct.content[0].text, /0: repair \(in_progress\)/);
2008
+ });
2009
+
1688
2010
  test("interrupted implementer waves still spend rounds and granted credits and nudge open tasks live and on replay", async () => {
1689
2011
  const cwd = tempCwd({ piGauntlet: { closureReview: { maxFixRounds: 1 } } });
1690
2012
  const interrupted = waveResult("paused", [{ agent: "implementer", exitCode: 0, interrupted: true }]);
@@ -2212,18 +2534,26 @@ test("open-task nudge: fires on every qualifying wave and leaves fix-round count
2212
2534
  });
2213
2535
 
2214
2536
  // The closure-model mismatch warning is stashed at tool_call; it must render first.
2215
- test("open-task nudge: a stashed tool_call warning stays ahead of the nudge on the same result", async () => {
2537
+ test("open-task nudge and mismatch preserve results and warning order (direct)", async () => {
2216
2538
  const h = harness({
2217
- cwd: tempCwd({ piGauntlet: { closureReview: { enforce: true, model: "p/configured" } } }),
2218
- branch: verifyBranch([taskSnapshot(eightTasks([3]))]),
2539
+ cwd: tempCwd({ piGauntlet: { closureReview: { enforce: true, model: "p/configured", maxFixRounds: 1 } } }),
2540
+ branch: verifyBranch([taskSnapshot(eightTasks([3])), subagentResult(["conformance-reviewer"])]),
2219
2541
  });
2220
2542
  await h.emit("session_start");
2221
2543
  const call = { tasks: [{ agent: "conformance-reviewer", model: "p/other", task: "audit" }, { agent: "implementer", task: "fix", worktree: true }] };
2222
- assert.equal(await firstCallResult(h, "m1", call), undefined);
2223
- const text = (await resultText(h, "m1")) ?? "";
2224
- const warningAt = text.indexOf("p/other");
2225
- const nudgeAt = text.indexOf("Plan tasks still open after this repair wave:");
2226
- assert.ok(warningAt >= 0 && nudgeAt > warningAt, `warning first, nudge second: ${text}`);
2544
+ const toolCallId = "m1";
2545
+ assert.equal((await h.emitEvent("tool_call", { toolName: "subagent", toolCallId, input: call }))[0], undefined);
2546
+ const event = {
2547
+ ...waveResult(toolCallId, [{ agent: "implementer", exitCode: 0 }]),
2548
+ structuredContent: { results: [{ value: "repair" }] }, usage: { sentinel: "subagent usage" },
2549
+ };
2550
+ const original = structuredClone(event);
2551
+ const [ret] = await h.emitEvent("tool_result", event);
2552
+ const merged = mergeToolResult(event, ret);
2553
+ assert.deepEqual(event, original);
2554
+ assert.match(merged.content[0].text, /p\/other[\s\S]*Plan tasks still open after this repair wave:/);
2555
+ assert.deepEqual(merged, { ...event, content: [merged.content[0], ...event.content] });
2556
+ assert.equal((await firstCallResult(h, "next", implementerWave(1)))?.block, true, "repair still spends one fix round");
2227
2557
  });
2228
2558
 
2229
2559
  test("open-task nudge: silent outside the verify window and without open rows", async () => {
@@ -28,6 +28,7 @@ import {
28
28
  settingsErrorWarning,
29
29
  } from "./lib/gauntlet-settings.ts";
30
30
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
31
+ import { createPendingWarnings } from "./lib/tool-result-warnings.ts";
31
32
  import { checkoutOf, gitSync } from "./lib/checkout.ts";
32
33
  import { checkPlan, renderManifest, legOwnership, sha256, type FsPort, type PlanCheckFinding } from "./lib/plan-check.ts";
33
34
  import {
@@ -259,6 +260,13 @@ const fixRoundCapBlockReason = (used: number, cap: number, settingsPath: string)
259
260
  "a repo closureReview block replaces the preset's whole block, so restate model and maxFixRounds alongside). " +
260
261
  "Start the escalation message with the line: gauntlet: gate=verification-before-completion/step-6";
261
262
 
263
+ const nestedDispatchBlockReason = (): string =>
264
+ "Call subagent as a direct tool call, not from inside a codemode script; " +
265
+ "codemode only mode is unsupported while a gauntlet flow is entered, because a scripted dispatch's result " +
266
+ "is not replayed on reload or fork and the fix-round counter, closure-review latch, and cadence would reset. " +
267
+ "Switch codemode.mode: on and issue the call directly. " +
268
+ "Last resort: set piGauntlet.closureReview.enforce: false (disables all closure guards).";
269
+
262
270
  const closureModelBlockReason = (model: string, missing: number, total: number): string =>
263
271
  `Blocked: ${missing} of ${total} conformance-reviewer ${total === 1 ? "dispatch" : "entries"} ` +
264
272
  `omitted a model while piGauntlet.closureReview.model is set to "${model}".\n` +
@@ -490,11 +498,7 @@ export default function (pi: ExtensionAPI) {
490
498
  const firedGuards = new Map<string, boolean>();
491
499
  // Warnings stashed at tool_call, prepended at tool_result (verify-before-ship pattern).
492
500
  // Relies on tool_result firing for every tool_call; reconstructState clears any stragglers.
493
- const pendingGuardWarnings = new Map<string, string>();
494
- const addGuardWarning = (id: string, text: string) => {
495
- const prior = pendingGuardWarnings.get(id);
496
- pendingGuardWarnings.set(id, prior ? prior + "\n\n" + text : text);
497
- };
501
+ const pendingWarnings = createPendingWarnings();
498
502
 
499
503
  const activeGuardPhase = (): Phase | undefined =>
500
504
  GUARD_PHASES.find((p) => phases[p].status === "in_progress");
@@ -547,7 +551,7 @@ export default function (pi: ExtensionAPI) {
547
551
  planCheckStamp = undefined;
548
552
  attemptedRecoveryEdges.clear();
549
553
  firedGuards.clear();
550
- pendingGuardWarnings.clear();
554
+ pendingWarnings.clear();
551
555
  cadenceSeq = 0;
552
556
  cadenceLastSeen.implementer = 0;
553
557
  cadenceLastSeen["wave-reviewer"] = 0;
@@ -665,7 +669,7 @@ export default function (pi: ExtensionAPI) {
665
669
  const g = () => {
666
670
  if (!gauntletCache) {
667
671
  gauntletCache = loadGauntletSettings(ctx.cwd);
668
- if (gauntletCache.errors.length > 0) addGuardWarning(event.toolCallId, settingsErrorWarning(gauntletCache.errors));
672
+ if (gauntletCache.errors.length > 0) pendingWarnings.add(event.toolCallId, settingsErrorWarning(gauntletCache.errors));
669
673
  }
670
674
  return gauntletCache.gauntlet;
671
675
  };
@@ -683,6 +687,9 @@ export default function (pi: ExtensionAPI) {
683
687
  // Only execution-mode dispatches carry a model or run agents; management/control
684
688
  // modes (action: list/get/create/update/delete/status/...) execute nothing, so skip them.
685
689
  const hasAction = !!(event.input as { action?: unknown })?.action;
690
+ if (event.parentToolCallId && !hasAction) {
691
+ return { block: true, reason: nestedDispatchBlockReason() };
692
+ }
686
693
  // Fix-loop window: verify in progress and the R0 audit observed. Read directly,
687
694
  // not via activeGuardPhase() - verify is not a GUARD_PHASES member.
688
695
  const fixLoopWindow = !isSubagentChild && !hasAction && phases.verify.status === "in_progress" && conformanceDispatched;
@@ -711,7 +718,7 @@ export default function (pi: ExtensionAPI) {
711
718
  }
712
719
  const mismatched = [...new Set((models as string[]).filter((m) => m !== configured))];
713
720
  if (mismatched.length > 0) {
714
- addGuardWarning(event.toolCallId, closureModelMismatchWarning(configured, mismatched));
721
+ pendingWarnings.add(event.toolCallId, closureModelMismatchWarning(configured, mismatched));
715
722
  }
716
723
  }
717
724
  }
@@ -743,13 +750,13 @@ export default function (pi: ExtensionAPI) {
743
750
  if (firedGuards.get("brainstorm-write")) return undefined;
744
751
  if (typeof p !== "string" || pathInSpecDirs(p, specDirs())) return undefined;
745
752
  firedGuards.set("brainstorm-write", true);
746
- addGuardWarning(event.toolCallId, brainstormWriteWarning(specDirs()));
753
+ pendingWarnings.add(event.toolCallId, brainstormWriteWarning(specDirs()));
747
754
  return undefined;
748
755
  }
749
756
  if (firedGuards.get("implement-write")) return undefined;
750
757
  if (typeof p !== "string" || pathInSpecDirs(p, implementExemptDirs(specDirs()))) return undefined;
751
758
  firedGuards.set("implement-write", true);
752
- addGuardWarning(event.toolCallId, implementWriteWarning());
759
+ pendingWarnings.add(event.toolCallId, implementWriteWarning());
753
760
  return undefined;
754
761
  }
755
762
 
@@ -838,44 +845,34 @@ export default function (pi: ExtensionAPI) {
838
845
  }
839
846
  }
840
847
 
841
- if (warnings.length > 0) addGuardWarning(event.toolCallId, warnings.join("\n\n"));
848
+ if (warnings.length > 0) pendingWarnings.add(event.toolCallId, warnings.join("\n\n"));
842
849
  return undefined;
843
850
  });
844
851
 
845
852
  pi.on("tool_result", async (event, ctx) => {
846
853
  if (finishDispatch(event.toolName, event.toolCallId, event.isError, event.details)) updateWidget(ctx);
847
854
  if (event.toolName === "subagent") {
848
- observeFixWave(event.details, event.isError, ctx);
849
- if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
850
- observeCadence(event.details);
851
- if (
852
- !isSubagentChild &&
853
- gauntletEntered &&
854
- phases.verify.status === "in_progress" &&
855
- isImplementerWave(event.details, event.isError)
856
- ) {
857
- const loaded = loadGauntletSettings(ctx.cwd);
858
- if (loaded.errors.length === 0 && resolveFlowGuards(loaded.gauntlet).enforce) {
859
- const open = unfinishedTaskLines(latestPlanSnapshot(ctx));
860
- if (open.length) addGuardWarning(event.toolCallId, openTaskNudge(open));
855
+ if (!event.parentToolCallId) {
856
+ observeFixWave(event.details, event.isError, ctx);
857
+ if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
858
+ observeCadence(event.details);
859
+ if (
860
+ !isSubagentChild &&
861
+ gauntletEntered &&
862
+ phases.verify.status === "in_progress" &&
863
+ isImplementerWave(event.details, event.isError)
864
+ ) {
865
+ const loaded = loadGauntletSettings(ctx.cwd);
866
+ if (loaded.errors.length === 0 && resolveFlowGuards(loaded.gauntlet).enforce) {
867
+ const open = unfinishedTaskLines(latestPlanSnapshot(ctx));
868
+ if (open.length) pendingWarnings.add(event.toolCallId, openTaskNudge(open));
869
+ }
861
870
  }
862
871
  }
863
- const warning = pendingGuardWarnings.get(event.toolCallId);
864
- if (warning) {
865
- pendingGuardWarnings.delete(event.toolCallId);
866
- return { content: [{ type: "text" as const, text: warning }, ...event.content] };
867
- }
868
- return undefined;
869
- }
870
-
871
- // Guards 2/3 — prepend the stashed advisory warning to the bash/write/edit result.
872
- if (event.toolName === "bash" || event.toolName === "write" || event.toolName === "edit") {
873
- const warning = pendingGuardWarnings.get(event.toolCallId);
874
- if (!warning) return undefined;
875
- pendingGuardWarnings.delete(event.toolCallId);
876
- return { content: [{ type: "text" as const, text: warning }, ...event.content] };
877
872
  }
878
873
 
874
+ const text = pendingWarnings.take(event.toolCallId);
875
+ if (text) return pendingWarnings.deliver(event, text);
879
876
  return undefined;
880
877
  });
881
878
 
@@ -1337,6 +1334,7 @@ export default function (pi: ExtensionAPI) {
1337
1334
  fixRoundCredits = 0;
1338
1335
  gauntletEntered = nextGauntletEntered(gauntletEntered, "reset", phases.brainstorm.status);
1339
1336
  firedGuards.clear();
1337
+ pendingWarnings.clear();
1340
1338
  updateWidget(ctx);
1341
1339
  return {
1342
1340
  content: [{ type: "text", text: "Phase tracker reset. All phases pending." }],
@@ -7,6 +7,7 @@
7
7
  import { DEFAULT_TELEMETRY_BUCKETS, planDirsFor, resolveFlowGuards } from "./lib/gauntlet-settings.ts";
8
8
  import { emptyAccumulators, parseRecord, serializeRecord } from "./lib/telemetry-record.ts";
9
9
  import { guardReason } from "./lib/telemetry-ship.ts";
10
+ import { mergeToolResult } from "./test-support/merge-tool-result.mjs";
10
11
 
11
12
 
12
13
  const originalDepth = process.env.PI_SUBAGENT_DEPTH;
@@ -35,6 +36,7 @@
35
36
  const specDirs = resolveFlowGuards({}).specDirs;
36
37
  const fetchedRecords = new Map<string, string>();
37
38
  let clock = Date.parse("2026-09-17T10:00:00Z");
39
+ let enabled = o.enabled ?? true;
38
40
  const deps: Deps = {
39
41
  telemetryRepo: () => "test/repo",
40
42
  readTelemetryBranch: () => ({ records: [...fetchedRecords].map(([path, bytes]) => ({ path, bytes })) }),
@@ -61,7 +63,7 @@
61
63
  if (args[0] === "ls-files") return { code: 0, stdout: "", stderr: "" };
62
64
  return { code: 0, stdout: "", stderr: "" };
63
65
  },
64
- settings: () => ({ specDirs, planDirs: planDirsFor(specDirs), telemetry: { enabled: o.enabled ?? true, buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
66
+ settings: () => ({ specDirs, planDirs: planDirsFor(specDirs), telemetry: { enabled, buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
65
67
  // A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
66
68
  jj: async (args, cwd) => {
67
69
  jjCalls.push(args);
@@ -100,7 +102,7 @@
100
102
  await emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: rel, content: body } });
101
103
  return emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: rel, content: body }, content: [], isError: false, details: undefined });
102
104
  };
103
- return { fetchedRecords, root, handlers, emit, gitCalls, gitCwds, jjCalls, readFileCalls, gitWrites, excludeFile, bash, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
105
+ return { setEnabled: (value: boolean) => (enabled = value), fetchedRecords, root, handlers, emit, gitCalls, gitCwds, jjCalls, readFileCalls, gitWrites, excludeFile, bash, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
104
106
  }
105
107
 
106
108
  const P = (over: Record<string, string> = {}) => Object.fromEntries(["brainstorm", "plan", "implement", "verify", "ship"].map((p) => [p, { status: over[p] ?? "pending" }]));
@@ -1222,6 +1224,69 @@
1222
1224
  return h;
1223
1225
  }
1224
1226
 
1227
+ for (const toolName of ["write", "edit"]) {
1228
+ for (const nested of [false, true]) {
1229
+ test(`post-ship ${toolName} warning preserves results (${nested ? "nested" : "direct"})`, async () => {
1230
+ const h = await boundInJjShip();
1231
+ const before = readFileSync(h.recordPath(), "utf8");
1232
+ const event = {
1233
+ toolName, toolCallId: nested ? "parent/0" : "direct",
1234
+ ...(nested ? { parentToolCallId: "parent" } : {}),
1235
+ input: { path: "doc/specs/a.md", content: "# Spec\n", newText: "updated" },
1236
+ content: [{ type: "text", text: "spec updated" }], isError: false,
1237
+ structuredContent: toolName === "write" ? { written: true } : null,
1238
+ details: { sentinel: "telemetry details" }, usage: { sentinel: "telemetry usage" },
1239
+ };
1240
+ const original = structuredClone(event);
1241
+ const [ret] = await h.emit("tool_result", event);
1242
+ let delivered = event;
1243
+ let merged = mergeToolResult(event, ret);
1244
+ if (nested) {
1245
+ assert.equal(ret, undefined);
1246
+ assert.deepEqual(merged, original);
1247
+ const parent = {
1248
+ ...event, toolName: "codemode", toolCallId: "parent", parentToolCallId: undefined,
1249
+ content: [{ type: "text", text: "script complete" }], structuredContent: { value: true },
1250
+ };
1251
+ delivered = parent;
1252
+ const [parentRet] = await h.emit("tool_result", parent);
1253
+ merged = mergeToolResult(parent, parentRet);
1254
+ assert.equal((await h.emit("tool_result", parent))[0], undefined);
1255
+ }
1256
+ assert.deepEqual(event, original);
1257
+ assert.match(merged.content[0].text, /spec shipped at .*write a follow-up spec/);
1258
+ assert.equal(merged.content[0].text.split("spec shipped at").length - 1, 1);
1259
+ assert.deepEqual(merged, { ...delivered, content: [merged.content[0], ...delivered.content] });
1260
+ assert.equal(readFileSync(h.recordPath(), "utf8"), before, "sealed record stays frozen");
1261
+ });
1262
+ }
1263
+ }
1264
+
1265
+ for (const transition of ["disabled", "session_tree"] as const) {
1266
+ test(`deferred shipped-spec warning survives ${transition} before its parent result`, async () => {
1267
+ const h = await boundInJjShip();
1268
+ const before = readFileSync(h.recordPath(), "utf8");
1269
+ const child = {
1270
+ toolName: "edit", toolCallId: "parent/0", parentToolCallId: "parent",
1271
+ input: { path: "doc/specs/a.md", newText: "updated" },
1272
+ content: [{ type: "text", text: "updated" }], structuredContent: { edited: true }, isError: false,
1273
+ };
1274
+ assert.equal((await h.emit("tool_result", child))[0], undefined);
1275
+ if (transition === "disabled") h.setEnabled(false);
1276
+ else await h.emit("session_tree", { type: "session_tree" });
1277
+ const parent = {
1278
+ toolName: "codemode", toolCallId: "parent", input: {}, isError: false,
1279
+ content: [{ type: "text", text: "done" }], structuredContent: { value: 0 },
1280
+ };
1281
+ const [ret] = await h.emit("tool_result", parent);
1282
+ const merged = mergeToolResult(parent, ret);
1283
+ assert.match(merged.content[0].text, /spec shipped at .*write a follow-up spec/);
1284
+ assert.deepEqual(merged, { ...parent, content: [merged.content[0], ...parent.content] });
1285
+ assert.equal((await h.emit("tool_result", parent))[0], undefined);
1286
+ assert.equal(readFileSync(h.recordPath(), "utf8"), before, "sealed record stays frozen");
1287
+ });
1288
+ }
1289
+
1225
1290
  const diffWarnings = (h: { readRecord: () => ReturnType<typeof parseRecord> }) =>
1226
1291
  h.readRecord()!.events.filter((e) => e.kind === "warning").map((e: any) => e.message as string).filter((m) => m.startsWith("diff omitted"));
1227
1292
 
@@ -1234,7 +1299,7 @@
1234
1299
  assert.equal(rec.derived.gates.ship_option, "keep");
1235
1300
  const beforeWrite = readFileSync(h.recordPath(), "utf8");
1236
1301
  const out = await h.writeSpec("doc/specs/a.md");
1237
- assert.deepEqual(out.filter(Boolean), [{ content: [{ type: "text", text: `⚠️ spec shipped at ${rec.shipped_at}; write a follow-up spec that supersedes it` }] }]);
1302
+ assert.deepEqual(out.filter(Boolean), [{ content: [{ type: "text", text: `⚠️ spec shipped at ${rec.shipped_at}; write a follow-up spec that supersedes it` }], structuredContent: undefined }]);
1238
1303
  assert.equal(readFileSync(h.recordPath(), "utf8"), beforeWrite);
1239
1304
  assert.equal(h.readRecord().derived.spec_edits_after_ship, 0);
1240
1305
  assert.deepEqual(rec.derived.modified_files, ["src/a.ts", "test/a.test.ts"]);
@@ -14,9 +14,10 @@
14
14
  import { dirname, isAbsolute, join, posix, resolve } from "node:path";
15
15
  import * as piRuntime from "@earendil-works/pi-coding-agent";
16
16
  import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
17
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent";
18
18
  import { DEFAULT_TELEMETRY_DIR, DEFAULT_TEST_COMMANDS, planDirsFor, resolveFlowGuards, resolveTelemetry, settingsErrorWarning, type TelemetryResolved } from "./lib/gauntlet-settings.ts";
19
19
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
20
+ import { createPendingWarnings } from "./lib/tool-result-warnings.ts";
20
21
  import { checkoutOf } from "./lib/checkout.ts";
21
22
  import { CONTEXT_DRAFT_MARKER, nextGauntletEntered } from "./lib/phase-tracker-helpers.ts";
22
23
  import { sha256 } from "./lib/plan-check.ts";
@@ -544,6 +545,8 @@
544
545
  });
545
546
 
546
547
  pi.on("tool_result", async (event, ctx) => {
548
+ const text = pendingWarnings.take(event.toolCallId);
549
+ if (text) return pendingWarnings.deliver(event, text);
547
550
  lastCtx = ctx;
548
551
  const snap = enabledSettings(ctx);
549
552
  if (!snap) return undefined;
@@ -605,7 +608,7 @@
605
608
  });
606
609
 
607
610
  // Binding + spec-write bookkeeping; the bound-spec hooks are defined in block 2.
608
- const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string; via: "git" | "jj" }, event: { input: unknown; content: unknown[] }, snap: SettingsSnapshot): Promise<unknown> => {
611
+ const onSpecInteraction = async (tool: "write" | "edit" | "read", loc: { toplevel: string; rel: string; via: "git" | "jj" }, event: ToolResultEvent, snap: SettingsSnapshot): Promise<unknown> => {
609
612
  const rel = loc.rel;
610
613
  if (!boundSpec) {
611
614
  if ((tool === "write" || tool === "edit") && isSpecPath(rel, snap.specDirs)) {
@@ -655,12 +658,13 @@
655
658
  };
656
659
 
657
660
  // Defined in block 2.
658
- let onBoundSpecWrite: (tool: "write" | "edit", rel: string, event: { input: unknown; content: unknown[] }) => unknown;
661
+ let onBoundSpecWrite: (tool: "write" | "edit", rel: string, event: ToolResultEvent) => unknown;
659
662
  let onOtherSpecEdit: (rel: string, event: { input: unknown }, specDirs: string[]) => void;
660
663
 
661
664
  let amendmentOpen = false;
662
665
  let lastPlanTasks: { name?: string; status: string }[] | undefined;
663
666
  const pendingTest = new Map<string, string>(); // toolCallId -> matched statement
667
+ const pendingWarnings = createPendingWarnings();
664
668
 
665
669
  const phaseAcc = (k: PhaseKey = phaseNow()): PhaseAcc => (live().phases[k] ??= {});
666
670
 
@@ -877,7 +881,7 @@
877
881
  refreshLinks();
878
882
  if (record.shipped_at) {
879
883
  const warning = `⚠️ spec shipped at ${record.shipped_at}; write a follow-up spec that supersedes it`;
880
- return { content: [{ type: "text" as const, text: warning }, ...(event.content as { type: string }[])] };
884
+ return pendingWarnings.deliver(event, warning);
881
885
  }
882
886
  if (record.approved_at && !amendmentOpen) {
883
887
  acc.amendments += 1;
@@ -0,0 +1,9 @@
1
+ // Mirror the runner's replacement rule so a raw hook return cannot hide data loss.
2
+ export function mergeToolResult(event, ret) {
3
+ if (ret === undefined) return event;
4
+ const merged = { ...event, ...ret };
5
+ if (ret.content !== undefined && ret.structuredContent === undefined) {
6
+ delete merged.structuredContent;
7
+ }
8
+ return merged;
9
+ }
@@ -4,6 +4,7 @@ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import registerVerifyBeforeShip from "./verify-before-ship.ts";
7
+ import { mergeToolResult } from "./test-support/merge-tool-result.mjs";
7
8
  import { execFileSync } from "node:child_process";
8
9
 
9
10
  const tempDirs: string[] = [];
@@ -57,12 +58,78 @@ test("git commit is not watched: no warning even when unverified", async () => {
57
58
  assert.equal((await h.emitEvent("tool_result", bashResult("c1")))[0], undefined);
58
59
  });
59
60
 
60
- test("git push warns when unverified", async () => {
61
+ for (const { name, toolName, resultFields } of [
62
+ { name: "bash", toolName: "bash", resultFields: { structuredContent: { output: "ok", exit_code: 0 } } },
63
+ { name: "write", toolName: "write", resultFields: { structuredContent: null } },
64
+ { name: "edit", toolName: "edit", resultFields: { structuredContent: { sentinel: "edit payload" } } },
65
+ { name: "edit without structuredContent", toolName: "edit", resultFields: {} },
66
+ ]) {
67
+ test(`direct ${name} ship warning preserves the result fields`, async () => {
68
+ const h = harness();
69
+ await editSource(h);
70
+ await h.emitEvent("tool_call", bashCall("p1", "git push origin main"));
71
+ // Synthetic write/edit results exercise the tool-independent pending drain.
72
+ const event = {
73
+ ...bashResult("p1", true), toolName,
74
+ ...resultFields,
75
+ details: { sentinel: "ship details" }, usage: { sentinel: "ship usage" },
76
+ };
77
+ const [ret] = await h.emitEvent("tool_result", event);
78
+ const merged = mergeToolResult(event, ret);
79
+ assert.match(merged.content[0].text, /ran without verification/);
80
+ assert.deepEqual(merged.content.slice(1), event.content);
81
+ assert.deepEqual(merged, { ...event, content: [merged.content[0], ...event.content] });
82
+ assert.equal((await h.emitEvent("tool_result", event))[0], undefined);
83
+ });
84
+ }
85
+
86
+ for (const toolName of ["bash", "write", "edit"]) {
87
+ test(`nested ${toolName} ship warning leaves the child clean and reaches its parent once`, async () => {
88
+ const h = harness();
89
+ await editSource(h);
90
+ await h.emitEvent("tool_call", { ...bashCall("parent/0", "git push origin main"), parentToolCallId: "parent" });
91
+ const child = {
92
+ ...bashResult("parent/0"), toolName, parentToolCallId: "parent",
93
+ structuredContent: { output: "ok", exit_code: 0 },
94
+ };
95
+ const [childRet] = await h.emitEvent("tool_result", child);
96
+ assert.deepEqual(mergeToolResult(child, childRet), child);
97
+ assert.equal(childRet, undefined);
98
+ const parent = {
99
+ ...bashResult("parent"), toolName: "codemode",
100
+ content: [{ type: "text", text: "script value: 0" }], structuredContent: { value: 0 },
101
+ };
102
+ const [parentRet] = await h.emitEvent("tool_result", parent);
103
+ const merged = mergeToolResult(parent, parentRet);
104
+ assert.match(merged.content[0].text, /ran without verification/);
105
+ assert.equal(merged.content[0].text.split("ran without verification").length - 1, 1);
106
+ assert.deepEqual(merged.content.slice(1), parent.content);
107
+ assert.deepEqual(merged, { ...parent, content: [merged.content[0], ...parent.content] });
108
+ assert.equal((await h.emitEvent("tool_result", parent))[0], undefined);
109
+ });
110
+ }
111
+
112
+ test("nested ship warnings aggregate in arrival order without crediting the parent as a test", async () => {
61
113
  const h = harness();
62
- await editSource(h);
63
- await h.emitEvent("tool_call", bashCall("p1", "git push origin main"));
64
- const warning = warningOf(await h.emitEvent("tool_result", bashResult("p1")));
65
- assert.match(warning ?? "", /ran without verification/);
114
+ for (const [index, command] of ["git push origin first", "git push origin second"].entries()) {
115
+ const toolCallId = `parent/${index}`;
116
+ await h.emitEvent("tool_call", { ...bashCall(toolCallId, command), parentToolCallId: "parent" });
117
+ const child = { ...bashResult(toolCallId), parentToolCallId: "parent", structuredContent: { exit_code: 0 } };
118
+ assert.equal((await h.emitEvent("tool_result", child))[0], undefined);
119
+ }
120
+ const parent = { ...bashResult("parent", true), toolName: "codemode", structuredContent: null };
121
+ const [ret] = await h.emitEvent("tool_result", parent);
122
+ const merged = mergeToolResult(parent, ret);
123
+ assert.match(merged.content[0].text, /origin first[\s\S]*origin second/);
124
+ assert.equal(merged.content[0].text.split("ran without verification").length - 1, 2);
125
+ assert.deepEqual(merged, { ...parent, content: [merged.content[0], ...parent.content] });
126
+ assert.equal((await h.emitEvent("tool_result", parent))[0], undefined);
127
+
128
+ await h.emitEvent("tool_call", { ...bashCall("test-parent/0", "npm test"), parentToolCallId: "test-parent" });
129
+ await h.emitEvent("tool_result", { ...bashResult("test-parent/0"), parentToolCallId: "test-parent" });
130
+ await h.emitEvent("tool_result", { ...bashResult("test-parent", true), toolName: "codemode" });
131
+ await h.emitEvent("tool_call", bashCall("verified", "git push"));
132
+ assert.equal((await h.emitEvent("tool_result", bashResult("verified")))[0], undefined);
66
133
  });
67
134
 
68
135
  test("gh pr create warns when unverified", async () => {
@@ -36,6 +36,7 @@ import {
36
36
  settingsErrorWarning,
37
37
  } from "./lib/gauntlet-settings.ts";
38
38
  import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
39
+ import { createPendingWarnings } from "./lib/tool-result-warnings.ts";
39
40
  import { matchShipStatement } from "./lib/telemetry-paths.ts";
40
41
  import { checkoutOfSync, gitSync } from "./lib/checkout.ts";
41
42
  import { parseLegs } from "./lib/plan-check.ts";
@@ -214,7 +215,7 @@ export default function (pi: ExtensionAPI) {
214
215
  });
215
216
  };
216
217
  const pendingTests = new Map<string, Set<string>>();
217
- const pendingShipWarnings = new Map<string, string>();
218
+ const pendingWarnings = createPendingWarnings();
218
219
 
219
220
  const getCommand = (event: { input: unknown }): string => {
220
221
  const input = event.input as { command?: unknown } | undefined;
@@ -236,11 +237,6 @@ export default function (pi: ExtensionAPI) {
236
237
  const command = getCommand(event);
237
238
  if (!command) return undefined;
238
239
 
239
- const addShipWarning = (id: string, text: string) => {
240
- const prior = pendingShipWarnings.get(id);
241
- pendingShipWarnings.set(id, prior ? prior + "\n\n" + text : text);
242
- };
243
-
244
240
  const targets = new Set<string>();
245
241
  let settingsErrShown = false;
246
242
  for (const statement of statements(command, ctx.cwd)) {
@@ -255,11 +251,11 @@ export default function (pi: ExtensionAPI) {
255
251
  const isTest = buildTestCmdRegex(testCommands).test(statement.testCommand) && !/^(?:echo|printf|rg|grep)\b/.test(statement.command);
256
252
  if (isTest && statement.trusted) targets.add(statement.target);
257
253
  if ((isTest || matchShipStatement(statement.command)) && errors.length && !settingsErrShown) {
258
- addShipWarning(event.toolCallId, settingsErrorWarning(errors));
254
+ pendingWarnings.add(event.toolCallId, settingsErrorWarning(errors));
259
255
  settingsErrShown = true;
260
256
  }
261
257
  if (matchShipStatement(statement.command) && (!statement.trusted || !verified.has(statement.target))) {
262
- addShipWarning(event.toolCallId, formatWarning(command, testCommands, warningReference));
258
+ pendingWarnings.add(event.toolCallId, formatWarning(command, testCommands, warningReference));
263
259
  }
264
260
  }
265
261
  if (targets.size) pendingTests.set(event.toolCallId, targets);
@@ -268,18 +264,14 @@ export default function (pi: ExtensionAPI) {
268
264
 
269
265
  pi.on("tool_result", async (event, ctx) => {
270
266
  rememberPlan(event, ctx.cwd);
271
- if (event.toolName !== "bash") return undefined;
272
-
273
- const targets = pendingTests.get(event.toolCallId);
274
- pendingTests.delete(event.toolCallId);
275
- if (!event.isError) for (const target of targets ?? []) verified.add(target);
276
-
277
- const warning = pendingShipWarnings.get(event.toolCallId);
278
- if (!warning) return undefined;
279
- pendingShipWarnings.delete(event.toolCallId);
267
+ if (event.toolName === "bash") {
268
+ const targets = pendingTests.get(event.toolCallId);
269
+ pendingTests.delete(event.toolCallId);
270
+ if (!event.isError) for (const target of targets ?? []) verified.add(target);
271
+ }
280
272
 
281
- return {
282
- content: [{ type: "text" as const, text: warning }, ...event.content],
283
- };
273
+ const text = pendingWarnings.take(event.toolCallId);
274
+ if (text) return pendingWarnings.deliver(event, text);
275
+ return undefined;
284
276
  });
285
277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "8.4.3",
3
+ "version": "8.4.5",
4
4
  "description": "Opinionated, gated workflow skills, subagent personas, and runtime extensions for the pi coding agent.",
5
5
  "author": "Jacek Juraszek",
6
6
  "type": "module",
@@ -62,14 +62,14 @@
62
62
  "test": "node scripts/ci.mjs"
63
63
  },
64
64
  "peerDependencies": {
65
- "pi-cohort": ">=7.6.7"
65
+ "pi-cohort": ">=7.6.8"
66
66
  },
67
67
  "dependencies": {
68
68
  "yaml": "^2.9.0"
69
69
  },
70
70
  "devDependencies": {
71
- "@anthropic-ai/claude-code": "2.1.309",
71
+ "@anthropic-ai/claude-code": "2.1.319",
72
72
  "esbuild": "0.28.2",
73
- "typebox": "^1.3.11"
73
+ "typebox": "^1.4.6"
74
74
  }
75
75
  }