pi-gauntlet 7.17.1 → 7.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## v7.18.1 - 2026-10-19
4
+
5
+ ### Removed
6
+
7
+ - **`start implement` no longer warns about a missing `promptCache` lifetime.** The warning fired on every non-Anthropic model (pi's catalog declares `promptCache` only for direct `anthropic` Claude entries) and read as an instruction to edit `models.json`, which stalled auto-chaining into implement until the user typed "continue"; the per-call cache telemetry from #67 stays and is the real signal for cache hits.
8
+
9
+ ## v7.18.0 - 2026-10-19
10
+
11
+ ### Changed
12
+
13
+ - **Brainstorming questions list one option per line.** Each `A)`/`B)` option and the `Recommendation:` line sit on their own physical line instead of one paragraph; wording and the framing rules are unchanged. (#70)
14
+
15
+ ### Fixed
16
+
17
+ - **Telemetry counts the agents of a failed dispatch.** A `subagent` call that ended in error (a failed chain step, a sibling that exited non-zero) still records its retained per-persona results; spec-round and conformance bookkeeping stay on the success path. `package.json` now declares pi-cohort >= 7.3.1 as a peer dependency, matching the README requirement. (#70)
18
+
3
19
  ## v7.17.1 - 2026-10-18
4
20
 
5
21
  ### Fixed
@@ -69,7 +69,7 @@ const resumedBranch = (rest: Partial<Record<Phase, Status>>) => [
69
69
  phaseResult("complete", phases({ brainstorm: "skipped", ...rest })),
70
70
  ];
71
71
 
72
- type TestModel = { provider: string; id: string; promptCache?: { short?: number; long?: number } };
72
+ type TestModel = { provider: string; id: string };
73
73
 
74
74
  function harness(options: { cwd?: string; branch?: unknown[]; idle?: boolean; beforeSettled?: (setIdle: (idle: boolean) => void) => void; sendThrows?: boolean; model?: TestModel; thinkingLevel?: string; registry?: Record<string, TestModel>; setModelResult?: boolean | Error } = {}) {
75
75
  const handlers = new Map<string, ((event: unknown, ctx: unknown) => unknown)[]>();
@@ -136,7 +136,7 @@ const startPhase = async (h: ReturnType<typeof harness>, phase: string) => {
136
136
  return { text: r.content.map((c) => c.text).join("\n"), status: r.details.phases[phase].status };
137
137
  };
138
138
 
139
- const M = (id: string, promptCache?: { short?: number; long?: number }) => ({ provider: "p", id, promptCache });
139
+ const M = (id: string) => ({ provider: "p", id });
140
140
 
141
141
  const preparePhase = async (h: ReturnType<typeof harness>, phase = "plan") => {
142
142
  await settle(h);
@@ -223,46 +223,6 @@ test("phaseModels: resolver warnings appear only for their phase", async () => {
223
223
  assert.deepEqual(h.pi.thinkingCalls, []);
224
224
  });
225
225
 
226
- for (const row of [
227
- { name: "missing short", phase: "implement", cache: undefined, retention: undefined, tier: "short" },
228
- { name: "declared short", phase: "implement", cache: { short: 300 }, retention: undefined, tier: undefined },
229
- { name: "missing long", phase: "implement", cache: { short: 300 }, retention: "long", tier: "long" },
230
- { name: "plan ignores cache", phase: "plan", cache: undefined, retention: undefined, tier: undefined },
231
- ]) {
232
- test(`phaseModels: cache warning ${row.name}`, async () => {
233
- const retention = process.env.PI_CACHE_RETENTION;
234
- if (row.retention === undefined) delete process.env.PI_CACHE_RETENTION;
235
- else process.env.PI_CACHE_RETENTION = row.retention;
236
- try {
237
- const h = harness({ cwd: tempCwd({ piGauntlet: { flowGuards: { enforce: false } } }), model: M("m", row.cache) });
238
- await preparePhase(h, row.phase);
239
- const r = await startPhase(h, row.phase);
240
- if (row.tier) {
241
- assert.ok(r.text.includes(`p/m declares no promptCache.${row.tier} lifetime, so pi's cache warmer skips it`));
242
- assert.ok(r.text.includes("providers.p.modelOverrides.m.promptCache"));
243
- assert.ok(r.text.includes("providers.p.models[].promptCache"));
244
- } else assert.doesNotMatch(r.text, /cache warmer/);
245
- assert.deepEqual(h.pi.setModelCalls, []);
246
- assert.deepEqual(h.pi.thinkingCalls, []);
247
- assert.equal(r.status, "in_progress");
248
- } finally {
249
- if (retention === undefined) delete process.env.PI_CACHE_RETENTION;
250
- else process.env.PI_CACHE_RETENTION = retention;
251
- }
252
- });
253
- }
254
-
255
- test("phaseModels: cache warning checks the switched model", async () => {
256
- const h = harness({ cwd: tempCwd({ piGauntlet: { flowGuards: { enforce: false }, phaseModels: { implement: "p/warm" } } }), model: M("old"), registry: { "p/warm": M("warm", { short: 300, long: 3600 }) } });
257
- await preparePhase(h, "implement");
258
- const r = await startPhase(h, "implement");
259
- assert.equal(h.pi.setModelCalls.length, 1);
260
- assert.deepEqual(h.pi.thinkingCalls, []);
261
- assert.equal(h.ctx.model?.id, "warm");
262
- assert.doesNotMatch(r.text, /cache warmer/);
263
- assert.equal(r.status, "in_progress");
264
- });
265
-
266
226
  test("agent_settled nudges each exact edge with persisted details", async () => {
267
227
  for (const [state, edge, skill] of [
268
228
  [phases({ brainstorm: "complete", plan: "complete" }), "plan-implement", "/skill:subagent-driven-development"],
@@ -1031,17 +1031,6 @@ export default function (pi: ExtensionAPI) {
1031
1031
  for (const w of resolved.warning?.split("; ") ?? []) if (w.includes(`phaseModels.${phase} `)) startLines.push(`\u26a0\ufe0f ${w}`);
1032
1032
  const target = resolved.phaseModels[phase];
1033
1033
  if (target) startLines.push(...(await applyPhaseModel(pi, ctx, phase, target)));
1034
- if (phase === "implement") {
1035
- const tier = process.env.PI_CACHE_RETENTION === "long" ? "long" : "short";
1036
- const m = ctx.model;
1037
- if (m && m.promptCache?.[tier] === undefined) {
1038
- startLines.push(
1039
- `\u26a0\ufe0f ${m.provider}/${m.id} declares no promptCache.${tier} lifetime, so pi's cache warmer skips it. ` +
1040
- `Declare it in <agent-dir>/models.json (default ~/.pi/agent): providers.${m.provider}.modelOverrides.${m.id}.promptCache for a built-in model, ` +
1041
- `or providers.${m.provider}.models[].promptCache for a custom one; the lifetime is required by the warmer but not sufficient - see doc/configuration.md#phase-models.`,
1042
- );
1043
- }
1044
- }
1045
1034
  }
1046
1035
  phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState };
1047
1036
  // A rewind must not inherit the prior verify's conformance latch.
@@ -588,7 +588,7 @@
588
588
  assert.deepEqual(h.readRecord().derived.council, EXPECTED_COUNCIL);
589
589
  await h.phaseResult("complete", P({ brainstorm: "complete" }));
590
590
  assert.deepEqual(h.readRecord().derived.council, EXPECTED_COUNCIL);
591
- assert.equal(h.readRecord().derived.personas["spec-council-member"], undefined);
591
+ assert.deepEqual(h.readRecord().derived.personas["spec-council-member"], { dispatches: 3, models: ["p/alpha:xhigh", "p/beta:high"], tokens: { input: 3, output: 0, cache_read: 0, cache_write: 0, cost: 0 } });
592
592
  });
593
593
  test("council defaults missing chair model and survives unrelated result before audit", async () => {
594
594
  const h = await boundInBrainstorm();
@@ -727,6 +727,66 @@
727
727
  assert.deepEqual(rec.derived.phases.plan?.tokens, { input: 152, output: 15, cache_read: 0, cache_write: 0, cost: 1.5 });
728
728
  });
729
729
 
730
+ for (const [name, agents] of [
731
+ ["chain", ["implementer", "spec-reviewer", "spec-reviewer"]],
732
+ ["plain tasks", ["implementer", "implementer"]],
733
+ ["empty results", []],
734
+ ["missing results", undefined],
735
+ ] as const) {
736
+ test(`failed subagent ${name}: count terminal retained results once, not progress`, async () => {
737
+ const h = harness();
738
+ await h.phaseResult("start", P({ brainstorm: "in_progress" }));
739
+ await h.writeSpec("doc/specs/a.md");
740
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "in_progress" }));
741
+ await h.emit("tool_result", { toolName: "plan_tracker", toolCallId: "pt", input: {}, content: [], isError: false, details: { action: "init", tasks: [{ name: "repair", status: "in_progress" }] } });
742
+ const before = h.readRecord();
743
+ const results = agents?.map((agent, i) => ({ agent, exitCode: i === agents.length - 1 ? 1 : 0, model: `p/m${i}`, usage: usage(10, 2, 0.5) }));
744
+ const terminal = { ...subagentResult(results ?? []), isError: true, details: results === undefined ? {} : { results } };
745
+ for (const partial of [results?.slice(0, 1), results]) {
746
+ await h.emit("tool_execution_update", { type: "tool_execution_update", toolName: "subagent", toolCallId: "sa", args: {}, partialResult: { content: [], details: { results: partial ?? [] } } });
747
+ }
748
+ assert.deepEqual(h.readRecord(), before);
749
+ await h.emit("tool_result", terminal);
750
+ const rec = h.readRecord();
751
+ const dispatches = rec.events.filter((e) => e.kind === "dispatch");
752
+ assert.equal(dispatches.length, agents?.length ?? 0);
753
+ assert.deepEqual(dispatches.map((e) => ({ agent: e.agent, model: e.model, exit: e.exit, phase: e.phase })), results?.map((r) => ({ agent: r.agent, model: r.model, exit: r.exitCode || undefined, phase: "implement" })) ?? []);
754
+ for (const agent of new Set(agents)) {
755
+ const retained = results!.filter((r) => r.agent === agent);
756
+ assert.deepEqual(rec.derived.personas[agent], { dispatches: retained.length, models: retained.map((r) => r.model), tokens: { input: 10 * retained.length, output: 2 * retained.length, cache_read: 0, cache_write: 0, cost: 0.5 * retained.length } });
757
+ }
758
+ if (name === "chain") assert.deepEqual(rec.derived.reviews?.["spec-reviewer"], { dispatches: 2, nonzero_exit: 1 });
759
+ if (results?.length) assert.deepEqual(rec.derived.phases.implement?.tokens, { input: 10 * results.length, output: 2 * results.length, cache_read: 0, cache_write: 0, cost: 0.5 * results.length });
760
+ else assert.deepEqual(rec, before);
761
+ assert.deepEqual(rec.events.filter((e) => e.kind === "phase" || e.kind === "gate"), before.events.filter((e) => e.kind === "phase" || e.kind === "gate"));
762
+ assert.deepEqual(rec.derived.plan, before.derived.plan);
763
+ assert.deepEqual(rec.derived.gates, before.derived.gates);
764
+ });
765
+ }
766
+
767
+ test("failed spec and conformance dispatches count usage and failures without advancing successful gates", async () => {
768
+ const h = await boundInPlan();
769
+ await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "in_progress" }));
770
+ const results = ["spec-summarizer", "conformance-reviewer"].map((agent) => ({ agent, exitCode: 1, model: "p/r", usage: usage(3) }));
771
+ const before = h.readRecord();
772
+ await h.emit("tool_result", { ...subagentResult(results, "Conformance verdict: GAPS\nG1:\n verdict: MISSING"), isError: true });
773
+ let rec = h.readRecord();
774
+ assert.deepEqual(rec.events.filter((e) => e.kind === "dispatch").map((e) => ({ agent: e.agent, exit: e.exit, phase: e.phase })), results.map((r) => ({ agent: r.agent, exit: 1, phase: "verify" })));
775
+ for (const { agent } of results) assert.deepEqual(rec.derived.personas[agent], { dispatches: 1, models: ["p/r"], tokens: { input: 3, output: 0, cache_read: 0, cache_write: 0, cost: 0 } });
776
+ assert.equal(rec.derived.gates.spec_rounds, before.derived.gates.spec_rounds);
777
+ assert.equal(rec.derived.conformance_loops, before.derived.conformance_loops);
778
+ assert.equal(rec.derived.conformance_open_gaps, before.derived.conformance_open_gaps);
779
+ assert.deepEqual(rec.derived.reviews?.["conformance-reviewer"], { dispatches: 1, nonzero_exit: 1 });
780
+ assert.deepEqual(rec.derived.phases.verify?.tokens, { input: 6, output: 0, cache_read: 0, cache_write: 0, cost: 0 });
781
+ assert.deepEqual(rec.events.filter((e) => e.kind === "phase" || e.kind === "gate"), before.events.filter((e) => e.kind === "phase" || e.kind === "gate"));
782
+ await h.emit("tool_result", subagentResult(results.map((r) => ({ ...r, exitCode: 0 })), "Conformance verdict: GAPS\nG1:\n verdict: MISSING"));
783
+ rec = h.readRecord();
784
+ assert.equal(rec.derived.gates.spec_rounds, before.derived.gates.spec_rounds + 1);
785
+ assert.equal(rec.derived.conformance_loops, 1);
786
+ assert.equal(rec.derived.conformance_open_gaps, 1);
787
+ assert.deepEqual(rec.derived.reviews?.["conformance-reviewer"], { dispatches: 2, nonzero_exit: 1 });
788
+ });
789
+
730
790
  test("conformance-reviewer during verify counts loops and retains the latest open gap count", async () => {
731
791
  const h = await boundInPlan();
732
792
  await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete" }));
@@ -548,7 +548,7 @@
548
548
  }
549
549
  if (event.toolName === "subagent") {
550
550
  if (phaseNow() === "brainstorm") onCouncilResults(event.details, event.content);
551
- if (!event.isError) await onSubagentResult(event.details, event.content);
551
+ await onSubagentResult(event.details, event.content, event.isError);
552
552
  return undefined;
553
553
  }
554
554
  if (event.toolName === "plan_tracker" && !event.isError) {
@@ -670,7 +670,7 @@
670
670
  flush();
671
671
  };
672
672
 
673
- const onSubagentResult = async (details: unknown, content: unknown) => {
673
+ const onSubagentResult = async (details: unknown, content: unknown, isError: boolean) => {
674
674
  const results = (details as { results?: unknown[] } | undefined)?.results;
675
675
  if (!Array.isArray(results) || results.length === 0) return;
676
676
  const acc = live();
@@ -687,7 +687,7 @@
687
687
  persona.dispatches += 1;
688
688
  if (model && !persona.models.includes(model)) persona.models.push(model);
689
689
  if (tokens) persona.tokens = addTokens(persona.tokens, tokens);
690
- if (agent === "spec-summarizer") acc.gates.spec_rounds += 1;
690
+ if (!isError && agent === "spec-summarizer") acc.gates.spec_rounds += 1;
691
691
  if (REVIEWER_AGENTS.has(agent)) {
692
692
  const review = (acc.reviews[agent] ??= { dispatches: 0, nonzero_exit: 0 });
693
693
  review.dispatches += 1;
@@ -699,7 +699,7 @@
699
699
  review.findings.major += findings.major;
700
700
  review.findings.minor += findings.minor;
701
701
  }
702
- if (agent === "conformance-reviewer") {
702
+ if (!isError && agent === "conformance-reviewer") {
703
703
  if (phases.verify.status === "in_progress") acc.conformance_loops += 1;
704
704
  const openGaps = countOpenGaps(text);
705
705
  if (openGaps !== undefined) acc.conformance_open_gaps = openGaps;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "7.17.1",
3
+ "version": "7.18.1",
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",
@@ -61,6 +61,9 @@
61
61
  "build:bins": "node scripts/build-bins.mjs",
62
62
  "test": "node scripts/ci.mjs"
63
63
  },
64
+ "peerDependencies": {
65
+ "pi-cohort": ">=7.3.1"
66
+ },
64
67
  "dependencies": {
65
68
  "yaml": "^2.9.0"
66
69
  },
@@ -91,23 +91,46 @@ Clarify freely.
91
91
 
92
92
  `Read` the gathered draft unconditionally before question one. Treat it as a helper, not a fence: verify load-bearing claims from primary code, and confirm whether the codebase or ecosystem already solves the problem.
93
93
 
94
- Ask one question per message, about purpose, constraints, success, and affected actors. Every questionary question offers 2-4 labeled options (`A)`, `B)`, ...), each a different outcome, and ends `Recommendation: <letter> - <why>`; when more outcomes are plausible, the last option is `other - name it` or the question splits in two. This format overrides chat-style rules in `AGENTS.md` (one paragraph, no Options/Recommendation layout). A decision that would be a yes/no or "may I" question is not asked: make it, state it as an assumption in the message that carries the next question or the premise note, and record it in the draft. Before asking, check code, docs, and tracker: look up current-state facts (dispatch a subagent when costly); adopt a ticket-recorded decision and cite the ticket, asking it only when a cited code or API contradiction, or conflicting recorded outcomes, prevents adopting it. Other approvals retain their wording.
94
+ Ask one question per message, about purpose, constraints, success, and affected actors. Offer 2-4 labeled options (`A)`, `B)`, ...) for every questionary question, each a different outcome. Put the lead-in before the first option, each labeled option on its own physical line, and `Recommendation: <letter> - <why>` on its own final line; when more outcomes are plausible, the last option is `other - name it` or the question splits in two. Use this one-option-per-line layout even when chat-style rules in `AGENTS.md` require one paragraph or no Options/Recommendation layout. A decision that would be a yes/no or "may I" question is not asked: make it, state it as an assumption in the message that carries the next question or the premise note, and record it in the draft. Before asking, check code, docs, and tracker: look up current-state facts (dispatch a subagent when costly); adopt a ticket-recorded decision and cite the ticket, asking it only when a cited code or API contradiction, or conflicting recorded outcomes, prevents adopting it. Other approvals retain their wording.
95
95
 
96
96
  Bad: `May I treat those two acceptance criteria as location-only deviations? Recommendation: yes`
97
- Good: `The two ACs name a path that moved; the spec keeps them in-scope with the new path as a Design clause. Which surface owns the refresh? A) the extension B) a new bin C) the existing skill step. Recommendation: A - the extension already holds the poll loop, so no new entrypoint.`
97
+ Good:
98
+ ```text
99
+ The two ACs name a path that moved; the spec keeps them in-scope with the new path as a Design clause. Which surface owns the refresh?
100
+ A) the extension
101
+ B) a new bin
102
+ C) the existing skill step.
103
+ Recommendation: A - the extension already holds the poll loop, so no new entrypoint.
104
+ ```
98
105
 
99
106
  Append only citable findings - schemas, hard constraints, contradictions, and scope-changing answers - to `## Appended during questionary` using `edit`.
100
107
 
101
108
  Before approaches, state in chat the supported, disproved, corrected, and unverified premises with sources and attempted lookups, in full sentences - no status-keyword lists, no template; if the design depends on no claims, one sentence says so. An unverified claim is not a stop: put it in Open Questions or a stated assumption. If a load-bearing claim is contradicted, the premise note is your next message - even as question one - and states the corrected fact with its source; the design continues on it and the user overrides in reply. Record the corrected fact, and any override, in the draft for `## Problem` or the relevant design decision.
102
109
 
103
- Before question one, verify the draft's `Framing:` line against primary code and add your own recon. This check adds to the premise verification above and never replaces it: trace every consumer, caller, and data path the ask's constraints name before writing the holds-statement or the question, and name those traces in the holds-statement, not only the files read. A framing concern exists when you can state a cited case that the ask is misdiagnosed, wrong-sized, already solved, a band-aid where the root cause is reachable, or cuts across a sound pattern - including a repo convention that is itself the debt - and can name the alternative it implies; an objection without an alternative is a lead to investigate, not a concern. With a concern, question one is the framing question in the questionary format: `A) as framed`, `B) pivot: <named alternative> - <cited reason>`, `C) smaller or none: <what ships, or what happens if nothing ships>`, `Recommendation: <letter> - <why>`; for a band-aid concern, B carries the root-cause alternative. Without a concern, open the first message with one sentence naming what was checked - `The framing holds: checked <x>, <y>` - and continue to the first ordinary question; a scout objection that yielded no alternative is named among the things checked.
110
+ Before question one, verify the draft's `Framing:` line against primary code and add your own recon. This check adds to the premise verification above and never replaces it: trace every consumer, caller, and data path the ask's constraints name before writing the holds-statement or the question, and name those traces in the holds-statement, not only the files read. A framing concern exists when you can state a cited case that the ask is misdiagnosed, wrong-sized, already solved, a band-aid where the root cause is reachable, or cuts across a sound pattern - including a repo convention that is itself the debt - and can name the alternative it implies; an objection without an alternative is a lead to investigate, not a concern. With a concern, question one is the framing question in the questionary format:
111
+
112
+ ```text
113
+ A) as framed
114
+ B) pivot: <named alternative> - <cited reason>
115
+ C) smaller or none: <what ships, or what happens if nothing ships>
116
+ Recommendation: <letter> - <why>
117
+ ```
118
+
119
+ For a band-aid concern, put the root-cause alternative in B. Without a concern, open the first message with one sentence naming what was checked - `The framing holds: checked <x>, <y>` - and continue to the first ordinary question; a scout objection that yielded no alternative is named among the things checked.
104
120
 
105
121
  After any premise correction in the same message, ask the framing question or give the holds-statement. Ask the framing question at most once per brainstorm, including after pushback; the user's reply is final, and later doubts on the same point go to Open Questions or a `Pattern:` line. Recommend; never pivot without the reply. A ticket-recorded decision on the exact point follows the ticket rule above; an adopted decision records `Framing: kept (ticket <id>)`. A pivot never rewrites AC rows: rows it no longer satisfies take `deviates:` dispositions, and a pivot that changes the topic re-mints the slug.
106
122
 
107
123
  Record the outcome in the draft under `## Appended during questionary` as `Framing: kept` or `Framing: pivoted to <x> - <why>`; spec-writing carries that line into `## Problem`. On redraw entry (checklist step 4) the count restarts: run this evaluation once against the new draft. On redraw the draft is the approved spec: its `## Problem` `Framing:` line is context, not scout evidence, and the new outcome is recorded directly in `## Problem`. When the scout failed there is no `Framing:` line; the holds-statement or the question still comes from your own recon.
108
124
 
109
125
  Bad: `The ask adds another settings path - should we? Recommendation: no`
110
- Good: `The ask adds a second settings path beside the resolver that already owns every setting (settings/resolver.ts:11). A) as framed B) pivot: a key under the existing resolver - one read path, no new boundary C) smaller or none: document the default and ship no setting. Recommendation: B - the owning schema already exists.`
126
+ Good:
127
+ ```text
128
+ The ask adds a second settings path beside the resolver that already owns every setting (settings/resolver.ts:11).
129
+ A) as framed
130
+ B) pivot: a key under the existing resolver - one read path, no new boundary
131
+ C) smaller or none: document the default and ship no setting.
132
+ Recommendation: B - the owning schema already exists.
133
+ ```
111
134
 
112
135
  ### 4. Explore approaches
113
136