pi-gauntlet 4.4.2 → 4.4.3

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## v4.4.3 - 2026-07-19
4
+
5
+ Brainstorming is the sole gauntlet entry point (#2): gate all three phase-tracker
6
+ enforcement surfaces (closure completion gate, closure-model guard, flow guards) on a
7
+ durable `gauntletEntered` marker so the gauntlet is dormant unless brainstorming started
8
+ the flow. A reflexive `phase_tracker start verify` on an ad-hoc one-liner no longer trips
9
+ the closure gate or forces a spurious conformance dispatch.
10
+
11
+ The marker is derived in `reconstructState` from the session branch (mirroring
12
+ `conformanceDispatched`: reset on reconstruct, threaded through replay, updated in the live
13
+ start/reset handlers via one `nextGauntletEntered` transition), so it survives pi-condense
14
+ pruning and `--session` resume. All three surfaces gate marker-first, so a dormant
15
+ out-of-flow session short-circuits before any settings load; in-flow behavior is unchanged
16
+ (`brainstormActive` implies `gauntletEntered`). New pure helpers `nextGauntletEntered` /
17
+ `closureGateBlocks` / `closureModelGuardApplies` / `flowGuardApplies` are unit-tested; the
18
+ two settings-dependent surfaces inline-match their predicate (the `markerGuardApplies`
19
+ convention) to preserve marker-first laziness. Docs (README, configuration.md, AGENTS.md,
20
+ conformance-check.md, the `phase_tracker` tool description) reworded: enforcement is opt-in
21
+ by brainstorming entry, not ambient.
22
+
3
23
  ## v4.4.2 - 2026-07-15
4
24
 
5
25
  Condense the finish-time conformance disposition gate to a dense, human-readable
package/README.md CHANGED
@@ -38,7 +38,7 @@ Concretely, one change through the gauntlet:
38
38
  1. You describe the change. **`brainstorming`** sets up an isolated worktree, explores the codebase, and turns your description into a written spec. A multi-model critique runs on it automatically. **You read and approve the spec - human gate 1.** No implementation code exists yet.
39
39
  2. **`writing-plans`** decomposes the approved spec into atomic, independently-verifiable tasks, grouped into parallel waves where they don't touch the same files.
40
40
  3. **`subagent-driven-development`** executes the plan one task at a time, each in a fresh subagent, behind spec-compliance review then code-quality review. TDD-locked: red, green, refactor.
41
- 4. **verify**: a whole-diff code review, then the **conformance gate** - a subagent reads the finished code and docs against your *original words* from step 1, not the plan, and reports per-requirement: delivered, partial, missing, drifted, or unauthorized. This gate is machine-blocked from being skipped. Compatible executable recommendations auto-run through an isolated fix-and-re-audit loop with no prompt; anything still open surfaces as a dense list - one line per decision, plain-language, with its recommended choice inline. Reply `1` to take every recommendation, or `2:` with per-item overrides; a current `CONFORMS` / no-concerns result goes straight to the branch options with no extra conformance sign-off.
41
+ 4. **verify**: a whole-diff code review, then the **conformance gate** - a subagent reads the finished code and docs against your *original words* from step 1, not the plan, and reports per-requirement: delivered, partial, missing, drifted, or unauthorized. Inside a brainstorming-entered flow this gate is machine-blocked from being skipped. Compatible executable recommendations auto-run through an isolated fix-and-re-audit loop with no prompt; anything still open surfaces as a dense list - one line per decision, plain-language, with its recommended choice inline. Reply `1` to take every recommendation, or `2:` with per-item overrides; a current `CONFORMS` / no-concerns result goes straight to the branch options with no extra conformance sign-off.
42
42
  5. **`finishing-a-development-branch`**: squash, PR, keep, or discard. **Human gate 2** - the only other decision you make.
43
43
 
44
44
  ```mermaid
@@ -66,13 +66,13 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
66
66
  - **7 subagent personas** - the specialized child agents the skills dispatch via pi-cohort: `implementer`, `code-reviewer`, `spec-reviewer`, `conformance-reviewer`, `spec-summarizer`, `spec-council-member`, `spec-council-synthesizer`. See [doc/personas.md](./doc/personas.md) for what each one does and why its permissions are scoped the way they are.
67
67
  - **3 runtime extensions** - the enforcement layer. `plan-tracker` and `phase-tracker` are tools skills call to track progress (with a TUI widget); `verify-before-ship` is a hook that warns if you commit/push without a passing test run since your last edit. See [doc/configuration.md](./doc/configuration.md) for the settings each one reads.
68
68
 
69
- pi-gauntlet is **opinionated**: every non-trivial change rides this one pipeline. There's no separate "just edit a file and commit" path - the skills gate each other, so the phase-tracker extension mechanically blocks a phase from closing before its gate runs. Reach for a shortcut and a gate stops you; that's the design, not friction.
69
+ pi-gauntlet is **opinionated**: every non-trivial change is *meant* to ride this one pipeline, entered through `brainstorming`. Enforcement is opt-in by entry, not ambient: once brainstorming starts a flow, the phase-tracker extension mechanically blocks a phase from closing before its gate runs. A change made *without* entering the flow (a typo, a formatting run, a dependency bump - see "When to use / when NOT to use") is not gated; the discipline of routing real work through the pipeline is a convention the tooling supports, not a trap it springs on every edit.
70
70
 
71
71
  ## Key concepts
72
72
 
73
73
  | Term | Meaning |
74
74
  | --- | --- |
75
- | Gate | A machine-enforced checkpoint between phases (e.g. `complete verify` is blocked until conformance review has run). Not a suggestion. |
75
+ | Gate | A machine-enforced checkpoint between phases (e.g. within a brainstorming-entered flow, `complete verify` is blocked until conformance review has run). Not a suggestion. |
76
76
  | Spec council | Multi-model critique of the spec before you see it (`roasting-the-spec`); falls back to a single-model critique if no council is configured. |
77
77
  | Conformance gate | The closing check: does the delivered code + docs match your *original prompt*, not the derived plan? Compatible executable recommendations auto-fix first; anything still open renders as a dense one-line-per-decision list with each recommended choice inline. Reply `1` to accept all recommendations or `2:` with per-item overrides; a current `CONFORMS` / no-concerns handoff goes straight to branch options with no extra sign-off. |
78
78
  | Wave | A batch of plan tasks that don't touch the same files, dispatched to implementers in parallel. |
@@ -3,6 +3,10 @@ import assert from "node:assert/strict";
3
3
  import {
4
4
  CONTEXT_DRAFT_MARKER,
5
5
  checkSubstep,
6
+ closureGateBlocks,
7
+ closureModelGuardApplies,
8
+ flowGuardApplies,
9
+ nextGauntletEntered,
6
10
  phaseLabel,
7
11
  parseGitCommit,
8
12
  resolveRepoDir,
@@ -118,3 +122,48 @@ test("markerGuardApplies: gated by flowGuards.enforce and brainstorm in_progress
118
122
  assert.equal(markerGuardApplies(true, "in_progress"), true);
119
123
  assert.equal(markerGuardApplies(true, "pending"), false);
120
124
  });
125
+
126
+ test("nextGauntletEntered: arms only when a start makes brainstorm in_progress", () => {
127
+ assert.equal(nextGauntletEntered(false, "start", "in_progress"), true);
128
+ assert.equal(nextGauntletEntered(true, "start", "in_progress"), true); // re-arm idempotent
129
+ });
130
+
131
+ test("nextGauntletEntered: reset always disarms", () => {
132
+ assert.equal(nextGauntletEntered(true, "reset", "pending"), false);
133
+ assert.equal(nextGauntletEntered(true, "reset", "in_progress"), false);
134
+ assert.equal(nextGauntletEntered(false, "reset", "pending"), false);
135
+ });
136
+
137
+ test("nextGauntletEntered: marker survives downstream actions", () => {
138
+ assert.equal(nextGauntletEntered(true, "start", "complete"), true); // start plan/implement (brainstorm already complete)
139
+ assert.equal(nextGauntletEntered(true, "complete", "complete"), true);
140
+ assert.equal(nextGauntletEntered(true, "substep", "in_progress"), true);
141
+ });
142
+
143
+ test("nextGauntletEntered: a non-brainstorm start never arms a dormant flow", () => {
144
+ assert.equal(nextGauntletEntered(false, "start", "complete"), false); // cold start verify/implement
145
+ assert.equal(nextGauntletEntered(false, "start", "skipped"), false);
146
+ assert.equal(nextGauntletEntered(false, "complete", "pending"), false);
147
+ });
148
+
149
+ test("closureGateBlocks: blocks only for verify + entered + enforce + not-yet-dispatched", () => {
150
+ assert.equal(closureGateBlocks("verify", true, true, false), true);
151
+ assert.equal(closureGateBlocks("verify", false, true, false), false); // incident: not entered -> no block
152
+ assert.equal(closureGateBlocks("verify", true, true, true), false); // already dispatched
153
+ assert.equal(closureGateBlocks("verify", true, false, false), false); // enforce off
154
+ assert.equal(closureGateBlocks("plan", true, true, false), false); // wrong phase
155
+ });
156
+
157
+ test("flowGuardApplies: requires both an active guard phase and an entered flow", () => {
158
+ assert.equal(flowGuardApplies(true, true), true);
159
+ assert.equal(flowGuardApplies(true, false), false); // not entered
160
+ assert.equal(flowGuardApplies(false, true), false); // phase not active
161
+ assert.equal(flowGuardApplies(false, false), false);
162
+ });
163
+
164
+ test("closureModelGuardApplies: requires both an entered flow and closure enforcement", () => {
165
+ assert.equal(closureModelGuardApplies(true, true), true);
166
+ assert.equal(closureModelGuardApplies(false, true), false); // not entered -> dormant
167
+ assert.equal(closureModelGuardApplies(true, false), false); // enforce off
168
+ assert.equal(closureModelGuardApplies(false, false), false);
169
+ });
@@ -90,6 +90,46 @@ export function markerGuardApplies(flowGuardsEnforced: boolean, brainstormStatus
90
90
  return flowGuardsEnforced && brainstormStatus === "in_progress";
91
91
  }
92
92
 
93
+ // Flow-entry marker (spec 2026-07-19-sole-gauntlet-entry-point). The gauntlet is
94
+ // opt-in: enforcement is dormant unless brainstorming started this flow. A `start`
95
+ // that leaves brainstorm in_progress is the unique arming signal (exactly one phase
96
+ // is in_progress at a time; the start target is not stored but is inferable). `reset`
97
+ // disarms; every other action preserves the running marker so it survives brainstorm
98
+ // -> plan -> implement -> verify. brainstormStatus is the phase status AFTER the action.
99
+ export function nextGauntletEntered(prev: boolean, action: string, brainstormStatus: string): boolean {
100
+ if (action === "reset") return false;
101
+ if (action === "start" && brainstormStatus === "in_progress") return true;
102
+ return prev;
103
+ }
104
+
105
+ // Closure completion-gate decision. Marker-first: a non-entered flow never blocks
106
+ // `complete verify`. Executable contract for the extension's inlined check (same
107
+ // define-and-test-but-inline pattern as markerGuardApplies).
108
+ export function closureGateBlocks(
109
+ phase: string,
110
+ gauntletEntered: boolean,
111
+ closureEnforced: boolean,
112
+ conformanceDispatched: boolean,
113
+ ): boolean {
114
+ return phase === "verify" && gauntletEntered && closureEnforced && !conformanceDispatched;
115
+ }
116
+
117
+ // Closure-model-guard applicability. Marker-first: the extension inlines this with the
118
+ // settings read (closureEnforced()) as a lazy call AFTER gauntletEntered, so a dormant
119
+ // (out-of-flow) session performs no settings I/O. This predicate is the tested contract
120
+ // for that inlined check (same define-test-and-inline pattern as markerGuardApplies /
121
+ // closureGateBlocks); it takes the already-resolved booleans, never triggering the read.
122
+ export function closureModelGuardApplies(gauntletEntered: boolean, closureEnforced: boolean): boolean {
123
+ return gauntletEntered && closureEnforced;
124
+ }
125
+
126
+ // Flow-guard applicability: a guard fires only when its phase is active AND the flow
127
+ // was entered via brainstorming. Both inputs are in-memory, so the extension calls
128
+ // this directly with no settings-load cost.
129
+ export function flowGuardApplies(phaseActive: boolean, gauntletEntered: boolean): boolean {
130
+ return phaseActive && gauntletEntered;
131
+ }
132
+
93
133
  export const markerBlockReason = (file: string): string =>
94
134
  `Blocked: ${file} still begins with the context-draft marker - the spec-writing ` +
95
135
  `overwrite has not happened. Overwrite the draft with the real spec (write tool, ` +
@@ -26,7 +26,9 @@ import { loadGauntletSettings } from "./lib/gauntlet-settings-loader.ts";
26
26
  import {
27
27
  checkSubstep,
28
28
  findMarkerFile,
29
+ flowGuardApplies,
29
30
  markerBlockReason,
31
+ nextGauntletEntered,
30
32
  parseGitCommit,
31
33
  phaseLabel,
32
34
  resolveRepoDir,
@@ -239,6 +241,7 @@ function formatStatus(phases: PhaseMap): string {
239
241
  export default function (pi: ExtensionAPI) {
240
242
  let phases: PhaseMap = emptyPhases();
241
243
  let conformanceDispatched = false;
244
+ let gauntletEntered = false;
242
245
 
243
246
  // Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct.
244
247
  const firedGuards = new Map<string, boolean>();
@@ -286,6 +289,7 @@ export default function (pi: ExtensionAPI) {
286
289
  const reconstructState = (ctx: ExtensionContext) => {
287
290
  phases = emptyPhases();
288
291
  conformanceDispatched = false;
292
+ gauntletEntered = false;
289
293
  firedGuards.clear();
290
294
  pendingGuardWarnings.clear();
291
295
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -296,6 +300,7 @@ export default function (pi: ExtensionAPI) {
296
300
  const details = msg.details as PhaseTrackerDetails | undefined;
297
301
  if (details && !details.error) {
298
302
  phases = details.phases;
303
+ gauntletEntered = nextGauntletEntered(gauntletEntered, details.action, details.phases.brainstorm.status);
299
304
  if (details.action === "reset") conformanceDispatched = false;
300
305
  }
301
306
  } else if (msg.toolName === "subagent") {
@@ -345,7 +350,11 @@ export default function (pi: ExtensionAPI) {
345
350
  const specDirs = () => resolveFlowGuards(g()).specDirs;
346
351
 
347
352
  // Closure-review model guard - independent of flowGuards, gated by closureReview.enforce.
348
- if (event.toolName === "subagent" && closureEnforced()) {
353
+ // gating contract: closureModelGuardApplies (see helpers). Marker-first: gauntletEntered
354
+ // short-circuits before closureEnforced()'s settings load, so an ad-hoc (out-of-flow)
355
+ // subagent dispatch never loads settings or leaks a settingsErrorWarning onto its result.
356
+ // Inline-matched (not called) so closureEnforced() stays a lazy second conjunct.
357
+ if (event.toolName === "subagent" && gauntletEntered && closureEnforced()) {
349
358
  const model = closureReviewModel();
350
359
  // Only execution-mode dispatches carry a model; management/control modes
351
360
  // (action: list/get/create/update/delete/status/...) execute nothing, so skip them.
@@ -371,8 +380,10 @@ export default function (pi: ExtensionAPI) {
371
380
  // event no guard inspects (any read-only tool, or any tool in a dormant session)
372
381
  // returns here without touching disk. Only genuinely guardable events pay for g().
373
382
  const brainstormActive = phases.brainstorm.status === "in_progress";
374
- const guardableWrite = (event.toolName === "write" || event.toolName === "edit") && brainstormActive;
375
- const guardableBash = event.toolName === "bash" && activeGuardPhase() !== undefined;
383
+ const guardableWrite =
384
+ (event.toolName === "write" || event.toolName === "edit") && flowGuardApplies(brainstormActive, gauntletEntered);
385
+ const guardableBash =
386
+ event.toolName === "bash" && flowGuardApplies(activeGuardPhase() !== undefined, gauntletEntered);
376
387
  if (!guardableWrite && !guardableBash) return undefined;
377
388
 
378
389
  if (!flowGuardsEnforced()) return undefined;
@@ -527,7 +538,8 @@ export default function (pi: ExtensionAPI) {
527
538
  description:
528
539
  "Track workflow phase progress (brainstorm → plan → implement → verify → ship). " +
529
540
  "Actions: start (mark phase in_progress), complete (mark phase complete), " +
530
- "skip (mark phase skipped with reason), status (show all phases), reset (clear all phases), substep (set/clear a substep label on an in_progress phase).",
541
+ "skip (mark phase skipped with reason), status (show all phases), reset (clear all phases), substep (set/clear a substep label on an in_progress phase). " +
542
+ "Drives gauntlet-flow enforcement entered via brainstorming; the closure gate, closure-model guard, and flow guards arm only when brainstorming started the flow. Ad-hoc start verify/start implement calls do not arm the gates. Not for ad-hoc use.",
531
543
  parameters: PhaseTrackerParams,
532
544
 
533
545
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -572,6 +584,7 @@ export default function (pi: ExtensionAPI) {
572
584
  };
573
585
  }
574
586
  phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState };
587
+ gauntletEntered = nextGauntletEntered(gauntletEntered, "start", phases.brainstorm.status);
575
588
  firedGuards.clear();
576
589
  updateWidget(ctx);
577
590
  return {
@@ -598,8 +611,12 @@ export default function (pi: ExtensionAPI) {
598
611
  } as PhaseTrackerDetails,
599
612
  };
600
613
  }
614
+ // gating contract: closureGateBlocks (see helpers). gauntletEntered is the leading
615
+ // conjunct so a cold-session `complete verify` (the #2 incident) neither blocks nor
616
+ // loads settings; the remaining conjuncts match closureGateBlocks exactly.
601
617
  if (
602
618
  params.phase === "verify" &&
619
+ gauntletEntered &&
603
620
  resolveClosureReview(loadGauntletSettings(ctx.cwd).gauntlet).enforce &&
604
621
  !conformanceDispatched
605
622
  ) {
@@ -694,6 +711,7 @@ export default function (pi: ExtensionAPI) {
694
711
  PHASES.map((p) => [p, transitionPhaseState("pending")]),
695
712
  ) as PhaseMap;
696
713
  conformanceDispatched = false;
714
+ gauntletEntered = nextGauntletEntered(gauntletEntered, "reset", phases.brainstorm.status);
697
715
  firedGuards.clear();
698
716
  updateWidget(ctx);
699
717
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-gauntlet",
3
- "version": "4.4.2",
3
+ "version": "4.4.3",
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",
@@ -168,10 +168,10 @@ Per round:
168
168
  including DELIVERED rows and their `evidence` `file:line`) and the round's
169
169
  fix diff. Inject `model:` call-site per `gauntlet_setting({ key:
170
170
  "closureReview" }).model` — same mechanism as the initial audit; omit
171
- `model:` when it is `undefined` to inherit the parent's model. The
172
- phase-tracker closure guard blocks a dispatch that omits `model:` when
173
- `closureReview.model` is set, and warns (non-blocking) on one whose model
174
- differs.
171
+ `model:` when it is `undefined` to inherit the parent's model. Inside a
172
+ brainstorming-entered flow, the phase-tracker closure guard blocks a dispatch
173
+ that omits `model:` when `closureReview.model` is set, and warns (non-blocking)
174
+ on one whose model differs.
175
175
  7. **Converge or continue**: verdict `CONFORMS` → record it, done. Open gaps
176
176
  within the cap → re-partition (per the rule above) and start the next
177
177
  round. Cap (`gauntlet_setting({ key: "closureReview" }).maxFixRounds`,