pi-gauntlet 4.4.2 → 4.5.0
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 +36 -0
- package/README.md +5 -3
- package/extensions/lib/phase-tracker-helpers.test.ts +92 -0
- package/extensions/lib/phase-tracker-helpers.ts +55 -0
- package/extensions/phase-tracker.test.ts +159 -0
- package/extensions/phase-tracker.ts +70 -4
- package/extensions/test-support/pi-stubs.mjs +41 -0
- package/package.json +1 -1
- package/skills/subagent-driven-development/SKILL.md +2 -1
- package/skills/verification-before-completion/reference/conformance-check.md +4 -4
- package/skills/writing-plans/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v4.5.0 - 2026-07-30
|
|
4
|
+
|
|
5
|
+
Recover stalled automatic handoffs without weakening gauntlet gates (#3). The existing
|
|
6
|
+
phase-tracker now sends one branch-local, fire-and-forget continuation nudge when an
|
|
7
|
+
`agent_settled` event leaves either `plan -> implement` or `verify -> ship` pending. The
|
|
8
|
+
handler requires brainstorming entry, no active phase, a non-aborted stop, an exact
|
|
9
|
+
recognized edge, an unspent ancestry-local attempt, and a final idle check. Recovery
|
|
10
|
+
messages persist their edge in custom-message details, so repeated settlements and session
|
|
11
|
+
reconstruction do not re-trigger the same unchanged handoff.
|
|
12
|
+
|
|
13
|
+
The implementation remains deliberately narrow: no transition engine, retry loop, timer,
|
|
14
|
+
settings key, phase mutation, or older-host fallback. Prompt wording now reinforces both
|
|
15
|
+
automatic handoffs, while specification approval and the final branch disposition remain
|
|
16
|
+
human gates. Pure classifier tests and real extension event tests cover edge selection,
|
|
17
|
+
persistence, abort/idleness guards, competing-handler ordering, and one-shot behavior.
|
|
18
|
+
|
|
19
|
+
## v4.4.3 - 2026-07-19
|
|
20
|
+
|
|
21
|
+
Brainstorming is the sole gauntlet entry point (#2): gate all three phase-tracker
|
|
22
|
+
enforcement surfaces (closure completion gate, closure-model guard, flow guards) on a
|
|
23
|
+
durable `gauntletEntered` marker so the gauntlet is dormant unless brainstorming started
|
|
24
|
+
the flow. A reflexive `phase_tracker start verify` on an ad-hoc one-liner no longer trips
|
|
25
|
+
the closure gate or forces a spurious conformance dispatch.
|
|
26
|
+
|
|
27
|
+
The marker is derived in `reconstructState` from the session branch (mirroring
|
|
28
|
+
`conformanceDispatched`: reset on reconstruct, threaded through replay, updated in the live
|
|
29
|
+
start/reset handlers via one `nextGauntletEntered` transition), so it survives pi-condense
|
|
30
|
+
pruning and `--session` resume. All three surfaces gate marker-first, so a dormant
|
|
31
|
+
out-of-flow session short-circuits before any settings load; in-flow behavior is unchanged
|
|
32
|
+
(`brainstormActive` implies `gauntletEntered`). New pure helpers `nextGauntletEntered` /
|
|
33
|
+
`closureGateBlocks` / `closureModelGuardApplies` / `flowGuardApplies` are unit-tested; the
|
|
34
|
+
two settings-dependent surfaces inline-match their predicate (the `markerGuardApplies`
|
|
35
|
+
convention) to preserve marker-first laziness. Docs (README, configuration.md, AGENTS.md,
|
|
36
|
+
conformance-check.md, the `phase_tracker` tool description) reworded: enforcement is opt-in
|
|
37
|
+
by brainstorming entry, not ambient.
|
|
38
|
+
|
|
3
39
|
## v4.4.2 - 2026-07-15
|
|
4
40
|
|
|
5
41
|
Condense the finish-time conformance disposition gate to a dense, human-readable
|
package/README.md
CHANGED
|
@@ -38,9 +38,11 @@ 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.
|
|
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
|
+
Only the machine-owned `plan -> implement` and `verify -> ship` handoffs receive a branch-local one-shot nudge after an unexpected settled stop; it is fire-and-forget, does not bypass either human gate, and older Pi hosts without `agent_settled` retain existing behavior.
|
|
45
|
+
|
|
44
46
|
```mermaid
|
|
45
47
|
flowchart LR
|
|
46
48
|
R([request]) --> B[brainstorm<br/>+ spec]
|
|
@@ -66,13 +68,13 @@ pi-gauntlet ships three kinds of pieces, layered on top of pi-cohort's dispatch:
|
|
|
66
68
|
- **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
69
|
- **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
70
|
|
|
69
|
-
pi-gauntlet is **opinionated**: every non-trivial change
|
|
71
|
+
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
72
|
|
|
71
73
|
## Key concepts
|
|
72
74
|
|
|
73
75
|
| Term | Meaning |
|
|
74
76
|
| --- | --- |
|
|
75
|
-
| Gate | A machine-enforced checkpoint between phases (e.g. `complete verify` is blocked until conformance review has run). Not a suggestion. |
|
|
77
|
+
| 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
78
|
| 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
79
|
| 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
80
|
| 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,
|
|
@@ -10,8 +14,51 @@ import {
|
|
|
10
14
|
markerBlockReason,
|
|
11
15
|
transitionPhaseState,
|
|
12
16
|
markerGuardApplies,
|
|
17
|
+
recoverableEdge,
|
|
18
|
+
type RecoveryPhaseMap,
|
|
19
|
+
type RecoveryPhaseStatus,
|
|
13
20
|
} from "./phase-tracker-helpers.ts";
|
|
14
21
|
|
|
22
|
+
const recoveryPhases = (
|
|
23
|
+
overrides: Partial<Record<keyof RecoveryPhaseMap, RecoveryPhaseStatus>> = {},
|
|
24
|
+
): RecoveryPhaseMap =>
|
|
25
|
+
Object.fromEntries(
|
|
26
|
+
(["brainstorm", "plan", "implement", "verify", "ship"] as const).map((phase) => [
|
|
27
|
+
phase,
|
|
28
|
+
{ status: overrides[phase] ?? "pending" },
|
|
29
|
+
]),
|
|
30
|
+
) as RecoveryPhaseMap;
|
|
31
|
+
|
|
32
|
+
test("recoverableEdge: recognizes only the two authorized edges", () => {
|
|
33
|
+
assert.equal(recoverableEdge(recoveryPhases({ plan: "complete" })), "plan-implement");
|
|
34
|
+
assert.equal(recoverableEdge(recoveryPhases({ verify: "complete" })), "verify-ship");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("recoverableEdge: rejects any active phase", () => {
|
|
38
|
+
for (const phase of ["brainstorm", "plan", "implement", "verify", "ship"] as const) {
|
|
39
|
+
const recoverable: Partial<Record<keyof RecoveryPhaseMap, RecoveryPhaseStatus>> = phase === "verify" || phase === "ship" ? { plan: "complete" } : { verify: "complete" };
|
|
40
|
+
assert.equal(recoverableEdge(recoveryPhases(recoverable)), phase === "verify" || phase === "ship" ? "plan-implement" : "verify-ship");
|
|
41
|
+
assert.equal(recoverableEdge(recoveryPhases({ ...recoverable, [phase]: "in_progress" })), undefined);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("recoverableEdge: rejects invalid source and destination states", () => {
|
|
46
|
+
for (const status of ["pending", "in_progress", "skipped"] as const) {
|
|
47
|
+
assert.equal(recoverableEdge(recoveryPhases({ plan: status })), undefined);
|
|
48
|
+
assert.equal(recoverableEdge(recoveryPhases({ verify: status })), undefined);
|
|
49
|
+
}
|
|
50
|
+
for (const status of ["in_progress", "complete", "skipped"] as const) {
|
|
51
|
+
assert.equal(recoverableEdge(recoveryPhases({ plan: "complete", implement: status })), undefined);
|
|
52
|
+
assert.equal(recoverableEdge(recoveryPhases({ verify: "complete", ship: status })), undefined);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("recoverableEdge: rejects unrelated and ambiguous phase maps", () => {
|
|
57
|
+
assert.equal(recoverableEdge(recoveryPhases()), undefined);
|
|
58
|
+
assert.equal(recoverableEdge(recoveryPhases({ brainstorm: "complete", implement: "complete" })), undefined);
|
|
59
|
+
assert.equal(recoverableEdge(recoveryPhases({ plan: "complete", verify: "complete" })), undefined);
|
|
60
|
+
});
|
|
61
|
+
|
|
15
62
|
test("checkSubstep: in_progress -> ok", () => {
|
|
16
63
|
assert.deepEqual(checkSubstep("in_progress"), { ok: true });
|
|
17
64
|
});
|
|
@@ -118,3 +165,48 @@ test("markerGuardApplies: gated by flowGuards.enforce and brainstorm in_progress
|
|
|
118
165
|
assert.equal(markerGuardApplies(true, "in_progress"), true);
|
|
119
166
|
assert.equal(markerGuardApplies(true, "pending"), false);
|
|
120
167
|
});
|
|
168
|
+
|
|
169
|
+
test("nextGauntletEntered: arms only when a start makes brainstorm in_progress", () => {
|
|
170
|
+
assert.equal(nextGauntletEntered(false, "start", "in_progress"), true);
|
|
171
|
+
assert.equal(nextGauntletEntered(true, "start", "in_progress"), true); // re-arm idempotent
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("nextGauntletEntered: reset always disarms", () => {
|
|
175
|
+
assert.equal(nextGauntletEntered(true, "reset", "pending"), false);
|
|
176
|
+
assert.equal(nextGauntletEntered(true, "reset", "in_progress"), false);
|
|
177
|
+
assert.equal(nextGauntletEntered(false, "reset", "pending"), false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("nextGauntletEntered: marker survives downstream actions", () => {
|
|
181
|
+
assert.equal(nextGauntletEntered(true, "start", "complete"), true); // start plan/implement (brainstorm already complete)
|
|
182
|
+
assert.equal(nextGauntletEntered(true, "complete", "complete"), true);
|
|
183
|
+
assert.equal(nextGauntletEntered(true, "substep", "in_progress"), true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("nextGauntletEntered: a non-brainstorm start never arms a dormant flow", () => {
|
|
187
|
+
assert.equal(nextGauntletEntered(false, "start", "complete"), false); // cold start verify/implement
|
|
188
|
+
assert.equal(nextGauntletEntered(false, "start", "skipped"), false);
|
|
189
|
+
assert.equal(nextGauntletEntered(false, "complete", "pending"), false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("closureGateBlocks: blocks only for verify + entered + enforce + not-yet-dispatched", () => {
|
|
193
|
+
assert.equal(closureGateBlocks("verify", true, true, false), true);
|
|
194
|
+
assert.equal(closureGateBlocks("verify", false, true, false), false); // incident: not entered -> no block
|
|
195
|
+
assert.equal(closureGateBlocks("verify", true, true, true), false); // already dispatched
|
|
196
|
+
assert.equal(closureGateBlocks("verify", true, false, false), false); // enforce off
|
|
197
|
+
assert.equal(closureGateBlocks("plan", true, true, false), false); // wrong phase
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("flowGuardApplies: requires both an active guard phase and an entered flow", () => {
|
|
201
|
+
assert.equal(flowGuardApplies(true, true), true);
|
|
202
|
+
assert.equal(flowGuardApplies(true, false), false); // not entered
|
|
203
|
+
assert.equal(flowGuardApplies(false, true), false); // phase not active
|
|
204
|
+
assert.equal(flowGuardApplies(false, false), false);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("closureModelGuardApplies: requires both an entered flow and closure enforcement", () => {
|
|
208
|
+
assert.equal(closureModelGuardApplies(true, true), true);
|
|
209
|
+
assert.equal(closureModelGuardApplies(false, true), false); // not entered -> dormant
|
|
210
|
+
assert.equal(closureModelGuardApplies(true, false), false); // enforce off
|
|
211
|
+
assert.equal(closureModelGuardApplies(false, false), false);
|
|
212
|
+
});
|
|
@@ -10,6 +10,21 @@ export const CONTEXT_DRAFT_MARKER = "# CONTEXT DRAFT - NOT A SPEC - fully replac
|
|
|
10
10
|
|
|
11
11
|
export type SubstepCheck = { ok: true } | { ok: false; error: string };
|
|
12
12
|
|
|
13
|
+
export type RecoveryEdge = "plan-implement" | "verify-ship";
|
|
14
|
+
export type RecoveryPhaseStatus = "pending" | "in_progress" | "complete" | "skipped";
|
|
15
|
+
export type RecoveryPhaseMap = Record<
|
|
16
|
+
"brainstorm" | "plan" | "implement" | "verify" | "ship",
|
|
17
|
+
{ status: RecoveryPhaseStatus }
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
export function recoverableEdge(phases: RecoveryPhaseMap): RecoveryEdge | undefined {
|
|
21
|
+
if (Object.values(phases).some((phase) => phase.status === "in_progress")) return undefined;
|
|
22
|
+
const planImplement = phases.plan.status === "complete" && phases.implement.status === "pending";
|
|
23
|
+
const verifyShip = phases.verify.status === "complete" && phases.ship.status === "pending";
|
|
24
|
+
if (planImplement === verifyShip) return undefined;
|
|
25
|
+
return planImplement ? "plan-implement" : "verify-ship";
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
export function checkSubstep(phaseStatus: string): SubstepCheck {
|
|
14
29
|
if (phaseStatus === "in_progress") return { ok: true };
|
|
15
30
|
return { ok: false, error: `substep requires an in_progress phase (status is ${phaseStatus})` };
|
|
@@ -90,6 +105,46 @@ export function markerGuardApplies(flowGuardsEnforced: boolean, brainstormStatus
|
|
|
90
105
|
return flowGuardsEnforced && brainstormStatus === "in_progress";
|
|
91
106
|
}
|
|
92
107
|
|
|
108
|
+
// Flow-entry marker (spec 2026-07-19-sole-gauntlet-entry-point). The gauntlet is
|
|
109
|
+
// opt-in: enforcement is dormant unless brainstorming started this flow. A `start`
|
|
110
|
+
// that leaves brainstorm in_progress is the unique arming signal (exactly one phase
|
|
111
|
+
// is in_progress at a time; the start target is not stored but is inferable). `reset`
|
|
112
|
+
// disarms; every other action preserves the running marker so it survives brainstorm
|
|
113
|
+
// -> plan -> implement -> verify. brainstormStatus is the phase status AFTER the action.
|
|
114
|
+
export function nextGauntletEntered(prev: boolean, action: string, brainstormStatus: string): boolean {
|
|
115
|
+
if (action === "reset") return false;
|
|
116
|
+
if (action === "start" && brainstormStatus === "in_progress") return true;
|
|
117
|
+
return prev;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Closure completion-gate decision. Marker-first: a non-entered flow never blocks
|
|
121
|
+
// `complete verify`. Executable contract for the extension's inlined check (same
|
|
122
|
+
// define-and-test-but-inline pattern as markerGuardApplies).
|
|
123
|
+
export function closureGateBlocks(
|
|
124
|
+
phase: string,
|
|
125
|
+
gauntletEntered: boolean,
|
|
126
|
+
closureEnforced: boolean,
|
|
127
|
+
conformanceDispatched: boolean,
|
|
128
|
+
): boolean {
|
|
129
|
+
return phase === "verify" && gauntletEntered && closureEnforced && !conformanceDispatched;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Closure-model-guard applicability. Marker-first: the extension inlines this with the
|
|
133
|
+
// settings read (closureEnforced()) as a lazy call AFTER gauntletEntered, so a dormant
|
|
134
|
+
// (out-of-flow) session performs no settings I/O. This predicate is the tested contract
|
|
135
|
+
// for that inlined check (same define-test-and-inline pattern as markerGuardApplies /
|
|
136
|
+
// closureGateBlocks); it takes the already-resolved booleans, never triggering the read.
|
|
137
|
+
export function closureModelGuardApplies(gauntletEntered: boolean, closureEnforced: boolean): boolean {
|
|
138
|
+
return gauntletEntered && closureEnforced;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Flow-guard applicability: a guard fires only when its phase is active AND the flow
|
|
142
|
+
// was entered via brainstorming. Both inputs are in-memory, so the extension calls
|
|
143
|
+
// this directly with no settings-load cost.
|
|
144
|
+
export function flowGuardApplies(phaseActive: boolean, gauntletEntered: boolean): boolean {
|
|
145
|
+
return phaseActive && gauntletEntered;
|
|
146
|
+
}
|
|
147
|
+
|
|
93
148
|
export const markerBlockReason = (file: string): string =>
|
|
94
149
|
`Blocked: ${file} still begins with the context-draft marker - the spec-writing ` +
|
|
95
150
|
`overwrite has not happened. Overwrite the draft with the real spec (write tool, ` +
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import registerPhaseTracker from "./phase-tracker.ts";
|
|
4
|
+
|
|
5
|
+
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
|
|
6
|
+
type Phase = (typeof PHASES)[number];
|
|
7
|
+
type Status = "pending" | "in_progress" | "complete" | "skipped";
|
|
8
|
+
|
|
9
|
+
const phases = (overrides: Partial<Record<Phase, Status>> = {}) =>
|
|
10
|
+
Object.fromEntries(PHASES.map((phase) => [phase, { status: overrides[phase] ?? "pending" }])) as Record<
|
|
11
|
+
Phase,
|
|
12
|
+
{ status: Status }
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
const phaseResult = (action: string, state: Record<Phase, { status: Status }>) => ({
|
|
16
|
+
type: "message",
|
|
17
|
+
message: { role: "toolResult", toolName: "phase_tracker", details: { action, phases: state } },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const assistant = (stopReason = "stop") => ({ type: "message", message: { role: "assistant", stopReason } });
|
|
21
|
+
|
|
22
|
+
const enteredBranch = (state: Record<Phase, { status: Status }>, extra: unknown[] = []) => [
|
|
23
|
+
phaseResult("start", phases({ brainstorm: "in_progress" })),
|
|
24
|
+
phaseResult("complete", state),
|
|
25
|
+
...extra,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function harness(options: { branch?: unknown[]; idle?: boolean; beforeSettled?: (setIdle: (idle: boolean) => void) => void; sendThrows?: boolean } = {}) {
|
|
29
|
+
const handlers = new Map<string, ((event: unknown, ctx: unknown) => unknown)[]>();
|
|
30
|
+
const tools: { name: string; execute: (...args: any[]) => unknown }[] = [];
|
|
31
|
+
const sent: { message: any; options: any }[] = [];
|
|
32
|
+
let idle = options.idle ?? true;
|
|
33
|
+
const ctx = {
|
|
34
|
+
cwd: process.cwd(),
|
|
35
|
+
hasUI: false,
|
|
36
|
+
isIdle: () => idle,
|
|
37
|
+
sessionManager: { getBranch: () => options.branch ?? [] },
|
|
38
|
+
};
|
|
39
|
+
const pi = {
|
|
40
|
+
on(event: string, handler: (event: unknown, context: unknown) => unknown) {
|
|
41
|
+
const registered = handlers.get(event) ?? [];
|
|
42
|
+
registered.push(handler);
|
|
43
|
+
handlers.set(event, registered);
|
|
44
|
+
},
|
|
45
|
+
registerTool(tool: { name: string; execute: (...args: any[]) => unknown }) {
|
|
46
|
+
tools.push(tool);
|
|
47
|
+
},
|
|
48
|
+
sendMessage(message: unknown, sendOptions: unknown) {
|
|
49
|
+
sent.push({ message, options: sendOptions });
|
|
50
|
+
if (options.sendThrows) throw new Error("send failed");
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
if (options.beforeSettled) pi.on("agent_settled", () => options.beforeSettled!(next => (idle = next)));
|
|
54
|
+
registerPhaseTracker(pi as any);
|
|
55
|
+
const emit = async (event: string) => {
|
|
56
|
+
for (const handler of handlers.get(event) ?? []) await handler({ type: event }, ctx);
|
|
57
|
+
};
|
|
58
|
+
return { emit, sent, tools, setIdle: (next: boolean) => (idle = next) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const settle = async (h: ReturnType<typeof harness>) => {
|
|
62
|
+
await h.emit("session_start");
|
|
63
|
+
await h.emit("agent_settled");
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
test("agent_settled nudges each exact edge with persisted details", async () => {
|
|
67
|
+
for (const [state, edge, skill] of [
|
|
68
|
+
[phases({ brainstorm: "complete", plan: "complete" }), "plan-implement", "/skill:subagent-driven-development"],
|
|
69
|
+
[
|
|
70
|
+
phases({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete" }),
|
|
71
|
+
"verify-ship",
|
|
72
|
+
"/skill:finishing-a-development-branch",
|
|
73
|
+
],
|
|
74
|
+
] as const) {
|
|
75
|
+
const h = harness({ branch: enteredBranch(state, [assistant()]) });
|
|
76
|
+
await settle(h);
|
|
77
|
+
assert.equal(h.sent.length, 1);
|
|
78
|
+
assert.deepEqual(h.sent[0].options, { triggerTurn: true });
|
|
79
|
+
assert.equal(h.sent[0].message.customType, "pi-gauntlet-transition-recovery");
|
|
80
|
+
assert.equal(h.sent[0].message.display, true);
|
|
81
|
+
assert.deepEqual(h.sent[0].message.details, { piGauntletRecoveryEdge: edge });
|
|
82
|
+
assert.match(h.sent[0].message.content, new RegExp(skill.replace(/[/-]/g, "\\$&")));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("repeated settlement and persisted matching recovery details suppress a second nudge", async () => {
|
|
87
|
+
const state = phases({ brainstorm: "complete", plan: "complete" });
|
|
88
|
+
const h = harness({ branch: enteredBranch(state, [assistant()]) });
|
|
89
|
+
await settle(h);
|
|
90
|
+
await h.emit("agent_settled");
|
|
91
|
+
assert.equal(h.sent.length, 1);
|
|
92
|
+
|
|
93
|
+
const restored = harness({
|
|
94
|
+
branch: enteredBranch(state, [
|
|
95
|
+
{ type: "custom_message", customType: "pi-gauntlet-transition-recovery", details: { piGauntletRecoveryEdge: "plan-implement" } },
|
|
96
|
+
assistant(),
|
|
97
|
+
]),
|
|
98
|
+
});
|
|
99
|
+
await settle(restored);
|
|
100
|
+
assert.equal(restored.sent.length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("foreign or malformed custom-message details do not suppress recovery", async () => {
|
|
104
|
+
const state = phases({ brainstorm: "complete", plan: "complete" });
|
|
105
|
+
for (const entry of [
|
|
106
|
+
{ type: "custom_message", customType: "other", details: { piGauntletRecoveryEdge: "plan-implement" } },
|
|
107
|
+
{ type: "custom_message", customType: "pi-gauntlet-transition-recovery", details: null },
|
|
108
|
+
{ type: "custom_message", customType: "pi-gauntlet-transition-recovery", details: { piGauntletRecoveryEdge: "nope" } },
|
|
109
|
+
]) {
|
|
110
|
+
const h = harness({ branch: enteredBranch(state, [entry, assistant()]) });
|
|
111
|
+
await settle(h);
|
|
112
|
+
assert.equal(h.sent.length, 1);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("only brainstorming-entered, non-aborted settled flows recover", async () => {
|
|
117
|
+
const state = phases({ brainstorm: "complete", plan: "complete" });
|
|
118
|
+
const cold = harness({ branch: [phaseResult("complete", state), assistant()] });
|
|
119
|
+
await settle(cold);
|
|
120
|
+
assert.equal(cold.sent.length, 0);
|
|
121
|
+
|
|
122
|
+
const branch = enteredBranch(state, [assistant("aborted")]);
|
|
123
|
+
const aborted = harness({ branch });
|
|
124
|
+
await settle(aborted);
|
|
125
|
+
assert.equal(aborted.sent.length, 0);
|
|
126
|
+
branch.push(assistant());
|
|
127
|
+
await aborted.emit("agent_settled");
|
|
128
|
+
assert.equal(aborted.sent.length, 1);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("active phases, non-idleness, and earlier competing handlers do not spend recovery", async () => {
|
|
132
|
+
for (const phase of PHASES) {
|
|
133
|
+
const h = harness({ branch: enteredBranch(phases({ brainstorm: phase === "brainstorm" ? "in_progress" : "complete", plan: phase === "plan" ? "in_progress" : "complete", implement: phase === "implement" ? "in_progress" : "pending", verify: phase === "verify" ? "in_progress" : "pending", ship: phase === "ship" ? "in_progress" : "pending" }), [assistant()]) });
|
|
134
|
+
await settle(h);
|
|
135
|
+
assert.equal(h.sent.length, 0, phase);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const state = phases({ brainstorm: "complete", plan: "complete" });
|
|
139
|
+
const notIdle = harness({ branch: enteredBranch(state, [assistant()]), idle: false });
|
|
140
|
+
await settle(notIdle);
|
|
141
|
+
notIdle.setIdle(true);
|
|
142
|
+
await notIdle.emit("agent_settled");
|
|
143
|
+
assert.equal(notIdle.sent.length, 1);
|
|
144
|
+
|
|
145
|
+
const ordered = harness({
|
|
146
|
+
branch: enteredBranch(state, [assistant()]),
|
|
147
|
+
beforeSettled: setIdle => setIdle(false),
|
|
148
|
+
});
|
|
149
|
+
await settle(ordered);
|
|
150
|
+
assert.equal(ordered.sent.length, 0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a throwing send spends the in-memory edge", async () => {
|
|
154
|
+
const h = harness({ branch: enteredBranch(phases({ brainstorm: "complete", plan: "complete" }), [assistant()]), sendThrows: true });
|
|
155
|
+
await h.emit("session_start");
|
|
156
|
+
await assert.rejects(h.emit("agent_settled"), /send failed/);
|
|
157
|
+
await h.emit("agent_settled");
|
|
158
|
+
assert.equal(h.sent.length, 1);
|
|
159
|
+
});
|
|
@@ -26,12 +26,16 @@ 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,
|
|
34
|
+
recoverableEdge,
|
|
32
35
|
resolveRepoDir,
|
|
33
36
|
STMT_START,
|
|
34
37
|
transitionPhaseState,
|
|
38
|
+
type RecoveryEdge,
|
|
35
39
|
} from "./lib/phase-tracker-helpers.ts";
|
|
36
40
|
|
|
37
41
|
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
|
|
@@ -78,6 +82,18 @@ const SHIP_ADVISORY =
|
|
|
78
82
|
"menu is the human gate that resolves any carried-open decision. Only reopen verify if\n" +
|
|
79
83
|
"a `fix` gap was left unresolved (neither fixed nor deferred).";
|
|
80
84
|
|
|
85
|
+
const RECOVERY_CUSTOM_TYPE = "pi-gauntlet-transition-recovery";
|
|
86
|
+
const RECOVERY_MESSAGES: Record<RecoveryEdge, string> = {
|
|
87
|
+
"plan-implement": "Continue the approved workflow now. Invoke /skill:subagent-driven-development.",
|
|
88
|
+
"verify-ship": "Continue the approved workflow now. Invoke /skill:finishing-a-development-branch.",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const recoveryEdgeFromDetails = (details: unknown): RecoveryEdge | undefined => {
|
|
92
|
+
if (!details || typeof details !== "object") return undefined;
|
|
93
|
+
const edge = (details as { piGauntletRecoveryEdge?: unknown }).piGauntletRecoveryEdge;
|
|
94
|
+
return edge === "plan-implement" || edge === "verify-ship" ? edge : undefined;
|
|
95
|
+
};
|
|
96
|
+
|
|
81
97
|
// --- Flow guards (spec 2026-06-17-gauntlet-flow-guards) ---
|
|
82
98
|
|
|
83
99
|
const GUARD_PHASES: Phase[] = ["brainstorm", "plan", "implement"];
|
|
@@ -239,6 +255,8 @@ function formatStatus(phases: PhaseMap): string {
|
|
|
239
255
|
export default function (pi: ExtensionAPI) {
|
|
240
256
|
let phases: PhaseMap = emptyPhases();
|
|
241
257
|
let conformanceDispatched = false;
|
|
258
|
+
let gauntletEntered = false;
|
|
259
|
+
const attemptedRecoveryEdges = new Set<RecoveryEdge>();
|
|
242
260
|
|
|
243
261
|
// Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct.
|
|
244
262
|
const firedGuards = new Map<string, boolean>();
|
|
@@ -283,12 +301,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
283
301
|
}
|
|
284
302
|
};
|
|
285
303
|
|
|
304
|
+
const branchLatestAssistantStopReason = (ctx: ExtensionContext): string | undefined => {
|
|
305
|
+
let stopReason: string | undefined;
|
|
306
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
307
|
+
if (entry.type === "message" && entry.message.role === "assistant") stopReason = entry.message.stopReason;
|
|
308
|
+
}
|
|
309
|
+
return stopReason;
|
|
310
|
+
};
|
|
311
|
+
|
|
286
312
|
const reconstructState = (ctx: ExtensionContext) => {
|
|
287
313
|
phases = emptyPhases();
|
|
288
314
|
conformanceDispatched = false;
|
|
315
|
+
gauntletEntered = false;
|
|
316
|
+
attemptedRecoveryEdges.clear();
|
|
289
317
|
firedGuards.clear();
|
|
290
318
|
pendingGuardWarnings.clear();
|
|
291
319
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
320
|
+
if (entry.type === "custom_message") {
|
|
321
|
+
if (entry.customType === RECOVERY_CUSTOM_TYPE) {
|
|
322
|
+
const edge = recoveryEdgeFromDetails(entry.details);
|
|
323
|
+
if (edge) attemptedRecoveryEdges.add(edge);
|
|
324
|
+
}
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
292
327
|
if (entry.type !== "message") continue;
|
|
293
328
|
const msg = entry.message;
|
|
294
329
|
if (msg.role !== "toolResult") continue;
|
|
@@ -296,6 +331,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
296
331
|
const details = msg.details as PhaseTrackerDetails | undefined;
|
|
297
332
|
if (details && !details.error) {
|
|
298
333
|
phases = details.phases;
|
|
334
|
+
gauntletEntered = nextGauntletEntered(gauntletEntered, details.action, details.phases.brainstorm.status);
|
|
299
335
|
if (details.action === "reset") conformanceDispatched = false;
|
|
300
336
|
}
|
|
301
337
|
} else if (msg.toolName === "subagent") {
|
|
@@ -325,6 +361,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
325
361
|
});
|
|
326
362
|
}
|
|
327
363
|
|
|
364
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
365
|
+
const latestAssistantStopReason = branchLatestAssistantStopReason(ctx);
|
|
366
|
+
if (!gauntletEntered || latestAssistantStopReason === "aborted") return;
|
|
367
|
+
const edge = recoverableEdge(phases);
|
|
368
|
+
if (!edge || attemptedRecoveryEdges.has(edge) || !ctx.isIdle()) return;
|
|
369
|
+
attemptedRecoveryEdges.add(edge);
|
|
370
|
+
pi.sendMessage(
|
|
371
|
+
{
|
|
372
|
+
customType: RECOVERY_CUSTOM_TYPE,
|
|
373
|
+
content: RECOVERY_MESSAGES[edge],
|
|
374
|
+
display: true,
|
|
375
|
+
details: { piGauntletRecoveryEdge: edge },
|
|
376
|
+
},
|
|
377
|
+
{ triggerTurn: true },
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
328
381
|
pi.on("tool_call", async (event, ctx) => {
|
|
329
382
|
// D3.b: one fresh settings read per event, lazily, only if a guard needs it.
|
|
330
383
|
const addGuardWarning = (id: string, text: string) => {
|
|
@@ -345,7 +398,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
345
398
|
const specDirs = () => resolveFlowGuards(g()).specDirs;
|
|
346
399
|
|
|
347
400
|
// Closure-review model guard - independent of flowGuards, gated by closureReview.enforce.
|
|
348
|
-
|
|
401
|
+
// gating contract: closureModelGuardApplies (see helpers). Marker-first: gauntletEntered
|
|
402
|
+
// short-circuits before closureEnforced()'s settings load, so an ad-hoc (out-of-flow)
|
|
403
|
+
// subagent dispatch never loads settings or leaks a settingsErrorWarning onto its result.
|
|
404
|
+
// Inline-matched (not called) so closureEnforced() stays a lazy second conjunct.
|
|
405
|
+
if (event.toolName === "subagent" && gauntletEntered && closureEnforced()) {
|
|
349
406
|
const model = closureReviewModel();
|
|
350
407
|
// Only execution-mode dispatches carry a model; management/control modes
|
|
351
408
|
// (action: list/get/create/update/delete/status/...) execute nothing, so skip them.
|
|
@@ -371,8 +428,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
371
428
|
// event no guard inspects (any read-only tool, or any tool in a dormant session)
|
|
372
429
|
// returns here without touching disk. Only genuinely guardable events pay for g().
|
|
373
430
|
const brainstormActive = phases.brainstorm.status === "in_progress";
|
|
374
|
-
const guardableWrite =
|
|
375
|
-
|
|
431
|
+
const guardableWrite =
|
|
432
|
+
(event.toolName === "write" || event.toolName === "edit") && flowGuardApplies(brainstormActive, gauntletEntered);
|
|
433
|
+
const guardableBash =
|
|
434
|
+
event.toolName === "bash" && flowGuardApplies(activeGuardPhase() !== undefined, gauntletEntered);
|
|
376
435
|
if (!guardableWrite && !guardableBash) return undefined;
|
|
377
436
|
|
|
378
437
|
if (!flowGuardsEnforced()) return undefined;
|
|
@@ -527,7 +586,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
527
586
|
description:
|
|
528
587
|
"Track workflow phase progress (brainstorm → plan → implement → verify → ship). " +
|
|
529
588
|
"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)."
|
|
589
|
+
"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). " +
|
|
590
|
+
"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
591
|
parameters: PhaseTrackerParams,
|
|
532
592
|
|
|
533
593
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
@@ -572,6 +632,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
572
632
|
};
|
|
573
633
|
}
|
|
574
634
|
phases = { ...phases, [params.phase]: transitionPhaseState("in_progress") as PhaseState };
|
|
635
|
+
gauntletEntered = nextGauntletEntered(gauntletEntered, "start", phases.brainstorm.status);
|
|
575
636
|
firedGuards.clear();
|
|
576
637
|
updateWidget(ctx);
|
|
577
638
|
return {
|
|
@@ -598,8 +659,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
598
659
|
} as PhaseTrackerDetails,
|
|
599
660
|
};
|
|
600
661
|
}
|
|
662
|
+
// gating contract: closureGateBlocks (see helpers). gauntletEntered is the leading
|
|
663
|
+
// conjunct so a cold-session `complete verify` (the #2 incident) neither blocks nor
|
|
664
|
+
// loads settings; the remaining conjuncts match closureGateBlocks exactly.
|
|
601
665
|
if (
|
|
602
666
|
params.phase === "verify" &&
|
|
667
|
+
gauntletEntered &&
|
|
603
668
|
resolveClosureReview(loadGauntletSettings(ctx.cwd).gauntlet).enforce &&
|
|
604
669
|
!conformanceDispatched
|
|
605
670
|
) {
|
|
@@ -694,6 +759,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
694
759
|
PHASES.map((p) => [p, transitionPhaseState("pending")]),
|
|
695
760
|
) as PhaseMap;
|
|
696
761
|
conformanceDispatched = false;
|
|
762
|
+
gauntletEntered = nextGauntletEntered(gauntletEntered, "reset", phases.brainstorm.status);
|
|
697
763
|
firedGuards.clear();
|
|
698
764
|
updateWidget(ctx);
|
|
699
765
|
return {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const sources = {
|
|
2
|
+
"@earendil-works/pi-ai": `export const StringEnum = (values, options = {}) => ({ values, ...options });`,
|
|
3
|
+
"@earendil-works/pi-coding-agent": `
|
|
4
|
+
export class SettingsManager {
|
|
5
|
+
static create() { return new SettingsManager(); }
|
|
6
|
+
getGlobalSettings() { return {}; }
|
|
7
|
+
getProjectSettings() { return {}; }
|
|
8
|
+
drainErrors() { return []; }
|
|
9
|
+
}
|
|
10
|
+
export const getAgentDir = () => "/tmp/pi-gauntlet-test-agent";
|
|
11
|
+
`,
|
|
12
|
+
"@earendil-works/pi-tui": `
|
|
13
|
+
export class Text {
|
|
14
|
+
constructor(text) { this.text = text; }
|
|
15
|
+
}
|
|
16
|
+
`,
|
|
17
|
+
"@sinclair/typebox": `
|
|
18
|
+
const schema = (...args) => ({ args });
|
|
19
|
+
export const Type = {
|
|
20
|
+
Object: schema,
|
|
21
|
+
Optional: schema,
|
|
22
|
+
String: schema,
|
|
23
|
+
Boolean: schema,
|
|
24
|
+
Union: schema,
|
|
25
|
+
Null: schema,
|
|
26
|
+
};
|
|
27
|
+
`,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
31
|
+
if (specifier in sources) return { url: `pi-gauntlet-test:${encodeURIComponent(specifier)}`, shortCircuit: true };
|
|
32
|
+
return nextResolve(specifier, context);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function load(url, context, nextLoad) {
|
|
36
|
+
if (url.startsWith("pi-gauntlet-test:")) {
|
|
37
|
+
const specifier = decodeURIComponent(url.slice("pi-gauntlet-test:".length));
|
|
38
|
+
return { format: "module", source: sources[specifier], shortCircuit: true };
|
|
39
|
+
}
|
|
40
|
+
return nextLoad(url, context);
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -30,7 +30,8 @@ You are the **orchestrator**. You read the plan, dispatch, review the review, de
|
|
|
30
30
|
- A subagent returns `NEEDS_CONTEXT` or `BLOCKED` (see [Implementer Status](#implementer-status))
|
|
31
31
|
- A reviewer finds issues the implementer cannot resolve in two attempts
|
|
32
32
|
- A ⚠️ workflow warning fires
|
|
33
|
-
|
|
33
|
+
|
|
34
|
+
Reaching the end of the plan is not a pause: continue through verification and invoke `/skill:finishing-a-development-branch` as defined in [After All Tasks](#after-all-tasks-complete).
|
|
34
35
|
|
|
35
36
|
Periodic "should I continue?" prompts add latency without adding safety. The plan is the contract; execute it.
|
|
36
37
|
|
|
@@ -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.
|
|
172
|
-
phase-tracker closure guard blocks a dispatch
|
|
173
|
-
`closureReview.model` is set, and warns (non-blocking)
|
|
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`,
|
|
@@ -231,7 +231,7 @@ Then auto-select the execution mode and proceed — no pause, no picker. The mod
|
|
|
231
231
|
- **If any wave contains ≥2 tasks → Parallel-Wave Mode.** The strengthened Wave Grouping contract (files + runtime-resource disjoint) guarantees every multi-task wave is parallel-safe.
|
|
232
232
|
- **Otherwise (pure dependency chain, one task per wave) → Sequential Mode.**
|
|
233
233
|
|
|
234
|
-
|
|
234
|
+
Auto-invoke `/skill:subagent-driven-development` in this session. Do not wait for confirmation — the spec gate already happened, and the plan is a mechanical derivative. The only pauses from here are in-flight STOPs (`BLOCKED` / `NEEDS_CONTEXT`) and the end gate, both owned by the executor.
|
|
235
235
|
|
|
236
236
|
## Red Flags — STOP
|
|
237
237
|
|