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 +9 -0
- package/README.md +1 -1
- package/extensions/lib/tool-result-warnings.test.ts +105 -0
- package/extensions/lib/tool-result-warnings.ts +35 -0
- package/extensions/phase-tracker.test.ts +350 -20
- package/extensions/phase-tracker.ts +36 -38
- package/extensions/telemetry.test.ts +68 -3
- package/extensions/telemetry.ts +8 -4
- package/extensions/test-support/merge-tool-result.mjs +9 -0
- package/extensions/verify-before-ship.test.ts +72 -5
- package/extensions/verify-before-ship.ts +12 -20
- package/package.json +4 -4
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.
|
|
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
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
|
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
|
-
|
|
2223
|
-
|
|
2224
|
-
const
|
|
2225
|
-
|
|
2226
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
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"]);
|
package/extensions/telemetry.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
+
"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.
|
|
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.
|
|
71
|
+
"@anthropic-ai/claude-code": "2.1.319",
|
|
72
72
|
"esbuild": "0.28.2",
|
|
73
|
-
"typebox": "^1.
|
|
73
|
+
"typebox": "^1.4.6"
|
|
74
74
|
}
|
|
75
75
|
}
|