pi-gauntlet 8.4.5 → 8.4.6

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## v8.4.6 - 2026-11-08
4
+
5
+ - Require pi-cohort >=7.7.0 for model-only `subagent` registrations; remove the now-unreachable scripted-dispatch guard and nested-result exclusion. The codemode guide documents direct-only dispatch under both `on` and `only` and clarifies that skill examples illustrate direct tool calls, not scripts to execute. (#135)
6
+
3
7
  ## v8.4.5 - 2026-11-08
4
8
 
5
9
  - 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)
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.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.
145
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) >= 7.7.0 - 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
 
@@ -1638,153 +1638,6 @@ const implementerWave = (n = 1, agent = "implementer") => ({
1638
1638
  const firstCallResult = async (h: ReturnType<typeof harness>, id: string, input: unknown) =>
1639
1639
  (await h.emitEvent("tool_call", subagentCall(id, input)))[0] as { block?: boolean; reason?: string } | undefined;
1640
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
-
1788
1641
  for (const agent of ["implementer", "loop-implementer"]) {
1789
1642
  test(`shape guard: lone ${agent} blocked in verify after the audit, with and without closureReview.model, async or not`, async () => {
1790
1643
  for (const settings of [
@@ -1919,94 +1772,6 @@ for (const delivery of ["live", "session_start", "session_switch", "session_fork
1919
1772
  });
1920
1773
  }
1921
1774
 
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
-
2010
1775
  test("interrupted implementer waves still spend rounds and granted credits and nudge open tasks live and on replay", async () => {
2011
1776
  const cwd = tempCwd({ piGauntlet: { closureReview: { maxFixRounds: 1 } } });
2012
1777
  const interrupted = waveResult("paused", [{ agent: "implementer", exitCode: 0, interrupted: true }]);
@@ -260,13 +260,6 @@ const fixRoundCapBlockReason = (used: number, cap: number, settingsPath: string)
260
260
  "a repo closureReview block replaces the preset's whole block, so restate model and maxFixRounds alongside). " +
261
261
  "Start the escalation message with the line: gauntlet: gate=verification-before-completion/step-6";
262
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
-
270
263
  const closureModelBlockReason = (model: string, missing: number, total: number): string =>
271
264
  `Blocked: ${missing} of ${total} conformance-reviewer ${total === 1 ? "dispatch" : "entries"} ` +
272
265
  `omitted a model while piGauntlet.closureReview.model is set to "${model}".\n` +
@@ -687,9 +680,6 @@ export default function (pi: ExtensionAPI) {
687
680
  // Only execution-mode dispatches carry a model or run agents; management/control
688
681
  // modes (action: list/get/create/update/delete/status/...) execute nothing, so skip them.
689
682
  const hasAction = !!(event.input as { action?: unknown })?.action;
690
- if (event.parentToolCallId && !hasAction) {
691
- return { block: true, reason: nestedDispatchBlockReason() };
692
- }
693
683
  // Fix-loop window: verify in progress and the R0 audit observed. Read directly,
694
684
  // not via activeGuardPhase() - verify is not a GUARD_PHASES member.
695
685
  const fixLoopWindow = !isSubagentChild && !hasAction && phases.verify.status === "in_progress" && conformanceDispatched;
@@ -852,21 +842,19 @@ export default function (pi: ExtensionAPI) {
852
842
  pi.on("tool_result", async (event, ctx) => {
853
843
  if (finishDispatch(event.toolName, event.toolCallId, event.isError, event.details)) updateWidget(ctx);
854
844
  if (event.toolName === "subagent") {
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
- }
845
+ observeFixWave(event.details, event.isError, ctx);
846
+ if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
847
+ observeCadence(event.details);
848
+ if (
849
+ !isSubagentChild &&
850
+ gauntletEntered &&
851
+ phases.verify.status === "in_progress" &&
852
+ isImplementerWave(event.details, event.isError)
853
+ ) {
854
+ const loaded = loadGauntletSettings(ctx.cwd);
855
+ if (loaded.errors.length === 0 && resolveFlowGuards(loaded.gauntlet).enforce) {
856
+ const open = unfinishedTaskLines(latestPlanSnapshot(ctx));
857
+ if (open.length) pendingWarnings.add(event.toolCallId, openTaskNudge(open));
870
858
  }
871
859
  }
872
860
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "8.4.5",
3
+ "version": "8.4.6",
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,7 +62,7 @@
62
62
  "test": "node scripts/ci.mjs"
63
63
  },
64
64
  "peerDependencies": {
65
- "pi-cohort": ">=7.6.8"
65
+ "pi-cohort": ">=7.7.0"
66
66
  },
67
67
  "dependencies": {
68
68
  "yaml": "^2.9.0"