pi-gauntlet 4.0.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 +300 -0
- package/LICENSE +24 -0
- package/README.md +278 -0
- package/agents/code-reviewer.md +48 -0
- package/agents/conformance-reviewer.md +139 -0
- package/agents/implementer.md +40 -0
- package/agents/spec-council-member.md +47 -0
- package/agents/spec-council-synthesizer.md +39 -0
- package/agents/spec-reviewer.md +47 -0
- package/agents/spec-summarizer.md +42 -0
- package/bin/install-agents.mjs +141 -0
- package/extensions/phase-tracker.ts +622 -0
- package/extensions/plan-tracker.ts +308 -0
- package/extensions/verify-before-ship.ts +132 -0
- package/package.json +43 -0
- package/skills/brainstorming/SKILL.md +290 -0
- package/skills/dispatching-parallel-agents/SKILL.md +192 -0
- package/skills/finishing-a-development-branch/SKILL.md +311 -0
- package/skills/receiving-code-review/SKILL.md +200 -0
- package/skills/requesting-code-review/SKILL.md +115 -0
- package/skills/requesting-code-review/code-reviewer.md +166 -0
- package/skills/roasting-the-spec/SKILL.md +139 -0
- package/skills/subagent-driven-development/SKILL.md +223 -0
- package/skills/subagent-driven-development/code-quality-reviewer-prompt.md +25 -0
- package/skills/subagent-driven-development/implementer-prompt.md +113 -0
- package/skills/subagent-driven-development/spec-reviewer-prompt.md +68 -0
- package/skills/systematic-debugging/SKILL.md +151 -0
- package/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
- package/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/skills/systematic-debugging/find-polluter.sh +63 -0
- package/skills/systematic-debugging/reference/rationalizations.md +61 -0
- package/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/skills/test-driven-development/SKILL.md +230 -0
- package/skills/test-driven-development/reference/examples.md +99 -0
- package/skills/test-driven-development/reference/rationalizations.md +65 -0
- package/skills/test-driven-development/reference/when-stuck.md +31 -0
- package/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/skills/using-git-worktrees/SKILL.md +193 -0
- package/skills/verification-before-completion/SKILL.md +169 -0
- package/skills/verification-before-completion/reference/conformance-check.md +220 -0
- package/skills/writing-plans/SKILL.md +244 -0
- package/skills/writing-skills/SKILL.md +429 -0
- package/skills/writing-skills/reference/anthropic-best-practices.md +1130 -0
- package/skills/writing-skills/reference/persuasion.md +187 -0
- package/skills/writing-skills/reference/testing-skills-with-subagents.md +384 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase Tracker Extension
|
|
3
|
+
*
|
|
4
|
+
* Tracks workflow phase progress: brainstorm → plan → implement → verify → ship.
|
|
5
|
+
* State is stored in tool result details for proper branching support.
|
|
6
|
+
* Shows a persistent single-line TUI widget alongside plan-tracker.
|
|
7
|
+
*
|
|
8
|
+
* Distinct from plan-tracker (per-task progress within a phase).
|
|
9
|
+
* Use phase_tracker for "what stage am I in?", plan_tracker for "which task?".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execSync } from "node:child_process";
|
|
13
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
17
|
+
|
|
18
|
+
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const;
|
|
19
|
+
type Phase = (typeof PHASES)[number];
|
|
20
|
+
type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped";
|
|
21
|
+
|
|
22
|
+
interface PhaseState {
|
|
23
|
+
status: PhaseStatus;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type PhaseMap = Record<Phase, PhaseState>;
|
|
28
|
+
|
|
29
|
+
interface PhaseTrackerDetails {
|
|
30
|
+
action: "start" | "complete" | "skip" | "status" | "reset";
|
|
31
|
+
phases: PhaseMap;
|
|
32
|
+
error?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Settings = {
|
|
36
|
+
piGauntlet?: {
|
|
37
|
+
closureReview?: {
|
|
38
|
+
enforce?: boolean;
|
|
39
|
+
model?: string;
|
|
40
|
+
};
|
|
41
|
+
flowGuards?: {
|
|
42
|
+
enforce?: boolean;
|
|
43
|
+
specDirs?: string[];
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Qualification per spec "Qualifying dispatch": a successful conformance-reviewer
|
|
49
|
+
// child run in the result details (pi-cohort SingleResult: { agent, exitCode }).
|
|
50
|
+
// Management mode and async dispatches return results: [] and fail this check.
|
|
51
|
+
const qualifiesAsClosureDispatch = (details: unknown): boolean => {
|
|
52
|
+
const d = details as { results?: { agent?: unknown; exitCode?: unknown }[] } | undefined;
|
|
53
|
+
if (!d || !Array.isArray(d.results)) return false;
|
|
54
|
+
return d.results.some((r) => r?.agent === "conformance-reviewer" && r?.exitCode === 0);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const CLOSURE_GATE_ERROR =
|
|
58
|
+
"Error: cannot complete 'verify': no conformance-reviewer dispatch observed.\n" +
|
|
59
|
+
"The closing loop is required before verify completes. Either:\n" +
|
|
60
|
+
'- dispatch subagent({ agent: "conformance-reviewer", ... }) with the spec, the\n' +
|
|
61
|
+
" user's verbatim request, and the full diff (model from\n" +
|
|
62
|
+
" piGauntlet.closureReview.model), then complete verify after its verdict, or\n" +
|
|
63
|
+
"- if the user explicitly waived closure review, record it:\n" +
|
|
64
|
+
' phase_tracker({ action: "skip", phase: "verify", reason: "<user waiver>" })';
|
|
65
|
+
|
|
66
|
+
const SHIP_ADVISORY =
|
|
67
|
+
"Verify is complete and ship is pending. If the conformance verdict is resolved\n" +
|
|
68
|
+
"(CONFORMS, or every gap dispositioned and approved), invoke\n" +
|
|
69
|
+
"/skill:finishing-a-development-branch now - do not add a 'ready to finish?' prompt;\n" +
|
|
70
|
+
"its squash/PR/keep/discard menu is the human gate. If a requirement decision is\n" +
|
|
71
|
+
"still open, you should not have completed verify - reopen it and surface the open\n" +
|
|
72
|
+
"decision instead.";
|
|
73
|
+
|
|
74
|
+
// --- Flow guards (spec 2026-06-17-gauntlet-flow-guards) ---
|
|
75
|
+
|
|
76
|
+
const GUARD_PHASES: Phase[] = ["brainstorm", "plan", "implement"];
|
|
77
|
+
|
|
78
|
+
// Guard 2 — branch ops in place. `git switch` never targets a file path;
|
|
79
|
+
// `git checkout -b/-B` is explicit branch creation. Bare `git checkout <x>`
|
|
80
|
+
// is excluded (ambiguous with file checkout). `git worktree ...` is exempt.
|
|
81
|
+
const STMT_START = "(?:^|[\\n;&|(])\\s*";
|
|
82
|
+
const BRANCH_SWITCH = new RegExp(STMT_START + "git\\s+switch\\b");
|
|
83
|
+
const BRANCH_CHECKOUT = new RegExp(STMT_START + "git\\s+checkout\\s+-[bB]\\b");
|
|
84
|
+
const GIT_WORKTREE = /\bgit\s+worktree\b/;
|
|
85
|
+
|
|
86
|
+
// Guard 3 — bash mutation forms during brainstorm.
|
|
87
|
+
const BASH_REDIRECT = /(?:^|\s)>>?\s*([^\s|&]+)/; // capture the redirect target
|
|
88
|
+
const BASH_TEE = /\btee\b/;
|
|
89
|
+
const BASH_SED_I = /\bsed\s+-i\b/;
|
|
90
|
+
const BASH_GIT_APPLY = /\bgit\s+apply\b/;
|
|
91
|
+
const TEMP_TARGET = /^(\/tmp\/|\/var\/folders\/|\/dev\/)/; // scratch paths are not project mutations
|
|
92
|
+
const SCRATCH_MENTION = /\/tmp\/|\/var\/folders\/|\/dev\//;
|
|
93
|
+
|
|
94
|
+
const branchBlockReason = (phase: Phase): string =>
|
|
95
|
+
`Branch switch/creation in the primary checkout is blocked during the ${phase} phase. ` +
|
|
96
|
+
"Gauntlet flows run in a dedicated worktree (git worktree add ... is allowed here); " +
|
|
97
|
+
"create/enter one with /skill:using-git-worktrees and run this there. " +
|
|
98
|
+
"To override, set piGauntlet.flowGuards.enforce: false.";
|
|
99
|
+
|
|
100
|
+
// Closure-review model guard: when piGauntlet.closureReview.model is configured,
|
|
101
|
+
// a conformance-reviewer dispatch MUST inject that model call-site. The persona ships
|
|
102
|
+
// model-free, so a bare omission silently inherits the parent session's builder model -
|
|
103
|
+
// defeating the point of an independent closing gate on a different model. Walk the
|
|
104
|
+
// single / tasks / chain / parallel dispatch shapes and report any conformance-reviewer
|
|
105
|
+
// entry that carries no model. An explicit model (even a fallback) is fine; only a bare
|
|
106
|
+
// omission is caught - that preserves the documented "retry once inherited" escape hatch,
|
|
107
|
+
// which the orchestrator takes by passing the inherited model explicitly.
|
|
108
|
+
const conformanceEntriesMissingModel = (input: unknown): boolean => {
|
|
109
|
+
const entries: { agent?: unknown; model?: unknown }[] = [];
|
|
110
|
+
const collect = (node: unknown): void => {
|
|
111
|
+
if (!node || typeof node !== "object") return;
|
|
112
|
+
const o = node as Record<string, unknown>;
|
|
113
|
+
if (typeof o.agent === "string") entries.push({ agent: o.agent, model: o.model });
|
|
114
|
+
for (const key of ["tasks", "chain", "parallel"] as const) {
|
|
115
|
+
const v = o[key];
|
|
116
|
+
if (Array.isArray(v)) for (const item of v) collect(item);
|
|
117
|
+
else if (v && typeof v === "object") collect(v);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
collect(input);
|
|
121
|
+
return entries.some((e) => e.agent === "conformance-reviewer" && !e.model);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const closureModelBlockReason = (model: string): string =>
|
|
125
|
+
`Blocked: conformance-reviewer dispatched without a model while ` +
|
|
126
|
+
`piGauntlet.closureReview.model is set to "${model}".\n` +
|
|
127
|
+
`The persona ships model-free, so a bare omission silently inherits this session's ` +
|
|
128
|
+
`builder model - the closing gate would then run on the same model that built the work, ` +
|
|
129
|
+
`not the independent one the preset pins. Re-dispatch with model: "${model}" injected ` +
|
|
130
|
+
`call-site. If that model is unreachable, pass an explicit fallback model (the documented ` +
|
|
131
|
+
`one-retry escape hatch) - only a bare omission is blocked.\n` +
|
|
132
|
+
`To disable this gate, set piGauntlet.closureReview.enforce: false.`;
|
|
133
|
+
|
|
134
|
+
const brainstormWriteWarning = (specDirs: string[]): string =>
|
|
135
|
+
"⚠️ Writing outside the spec directory during the brainstorm phase.\n" +
|
|
136
|
+
`Brainstorming may only edit the spec under ${specDirs.join(", ")}. Implementation\n` +
|
|
137
|
+
"waits for the spec approval gate. If this edit IS the spec, place it under the spec dir.";
|
|
138
|
+
|
|
139
|
+
// Contiguous-subsequence match of a configured spec dir against path components.
|
|
140
|
+
const pathInSpecDirs = (rawPath: string, specDirs: string[]): boolean => {
|
|
141
|
+
const comps = rawPath.split("/").filter((c) => c.length > 0 && c !== ".");
|
|
142
|
+
return specDirs.some((dir) => {
|
|
143
|
+
const dcomps = dir.split("/").filter((c) => c.length > 0);
|
|
144
|
+
if (dcomps.length === 0) return false;
|
|
145
|
+
for (let i = 0; i + dcomps.length <= comps.length; i++) {
|
|
146
|
+
if (dcomps.every((dc, j) => comps[i + j] === dc)) return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const PhaseTrackerParams = Type.Object({
|
|
153
|
+
action: StringEnum(["start", "complete", "skip", "status", "reset"] as const, {
|
|
154
|
+
description: "Action to perform",
|
|
155
|
+
}),
|
|
156
|
+
phase: Type.Optional(
|
|
157
|
+
StringEnum(PHASES, {
|
|
158
|
+
description: "Phase name (required for start, complete, skip)",
|
|
159
|
+
}),
|
|
160
|
+
),
|
|
161
|
+
reason: Type.Optional(
|
|
162
|
+
Type.String({
|
|
163
|
+
description: "Reason for skipping (required for skip action)",
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
force: Type.Optional(
|
|
167
|
+
Type.Boolean({
|
|
168
|
+
description: "Reset and re-start a phase that is already complete or skipped (rare; default false)",
|
|
169
|
+
}),
|
|
170
|
+
),
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export type PhaseTrackerInput = Static<typeof PhaseTrackerParams>;
|
|
174
|
+
|
|
175
|
+
function emptyPhases(): PhaseMap {
|
|
176
|
+
return Object.fromEntries(PHASES.map((p) => [p, { status: "pending" as PhaseStatus }])) as PhaseMap;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function phaseIcon(status: PhaseStatus, theme: Theme): string {
|
|
180
|
+
switch (status) {
|
|
181
|
+
case "complete":
|
|
182
|
+
return theme.fg("success", "✓");
|
|
183
|
+
case "in_progress":
|
|
184
|
+
return theme.fg("warning", "→");
|
|
185
|
+
case "skipped":
|
|
186
|
+
return theme.fg("dim", "⊘");
|
|
187
|
+
default:
|
|
188
|
+
return theme.fg("dim", "○");
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function hasActivity(phases: PhaseMap): boolean {
|
|
193
|
+
return PHASES.some((p) => phases[p].status !== "pending");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function formatWidget(phases: PhaseMap, theme: Theme): string {
|
|
197
|
+
const parts = PHASES.map((p) => {
|
|
198
|
+
const icon = phaseIcon(phases[p].status, theme);
|
|
199
|
+
const name = phases[p].status === "skipped" ? theme.fg("dim", p) : p;
|
|
200
|
+
return `${icon} ${name}`;
|
|
201
|
+
});
|
|
202
|
+
return `${theme.fg("muted", "Phases:")} ${parts.join(theme.fg("dim", " → "))}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function formatStatus(phases: PhaseMap): string {
|
|
206
|
+
const lines: string[] = ["Phases:"];
|
|
207
|
+
for (const p of PHASES) {
|
|
208
|
+
const s = phases[p];
|
|
209
|
+
const icon = s.status === "complete" ? "✓" : s.status === "in_progress" ? "→" : s.status === "skipped" ? "⊘" : "○";
|
|
210
|
+
const suffix = s.reason ? ` (${s.reason})` : "";
|
|
211
|
+
lines.push(` ${icon} ${p}${suffix}`);
|
|
212
|
+
}
|
|
213
|
+
return lines.join("\n");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default function (pi: ExtensionAPI) {
|
|
217
|
+
let phases: PhaseMap = emptyPhases();
|
|
218
|
+
let conformanceDispatched = false;
|
|
219
|
+
const closureEnforced = () =>
|
|
220
|
+
((pi.settings ?? {}) as Settings).piGauntlet?.closureReview?.enforce !== false;
|
|
221
|
+
const closureReviewModel = () =>
|
|
222
|
+
((pi.settings ?? {}) as Settings).piGauntlet?.closureReview?.model;
|
|
223
|
+
|
|
224
|
+
const flowGuardsCfg = () => ((pi.settings ?? {}) as Settings).piGauntlet?.flowGuards ?? {};
|
|
225
|
+
const flowGuardsEnforced = () => flowGuardsCfg().enforce !== false;
|
|
226
|
+
const specDirs = () => flowGuardsCfg().specDirs ?? ["doc/specs"];
|
|
227
|
+
|
|
228
|
+
// Warn-once-per-phase ledger; cleared on every phase transition and on reconstruct.
|
|
229
|
+
const firedGuards = new Map<string, boolean>();
|
|
230
|
+
// Warnings stashed at tool_call, prepended at tool_result (verify-before-ship pattern).
|
|
231
|
+
// Relies on tool_result firing for every tool_call; reconstructState clears any stragglers.
|
|
232
|
+
const pendingGuardWarnings = new Map<string, string>();
|
|
233
|
+
|
|
234
|
+
const activeGuardPhase = (): Phase | undefined =>
|
|
235
|
+
GUARD_PHASES.find((p) => phases[p].status === "in_progress");
|
|
236
|
+
|
|
237
|
+
// Guard 2 is active only when pi was launched in the primary checkout, not a
|
|
238
|
+
// linked worktree. Computed once: a linked worktree has --git-dir != --git-common-dir.
|
|
239
|
+
// (Inside a submodule the comparison is git-version-dependent and irrelevant here -
|
|
240
|
+
// gauntlet flows do not run inside submodule git internals; whichever way it
|
|
241
|
+
// resolves, the guard merely staying off in that edge case is harmless.)
|
|
242
|
+
const inPrimaryCheckout = (() => {
|
|
243
|
+
try {
|
|
244
|
+
const lines = execSync("git rev-parse --git-dir --git-common-dir", {
|
|
245
|
+
encoding: "utf8",
|
|
246
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
247
|
+
timeout: 5000,
|
|
248
|
+
})
|
|
249
|
+
.trim()
|
|
250
|
+
.split("\n");
|
|
251
|
+
return lines.length === 2 && lines[0] === lines[1];
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
|
|
257
|
+
// Auto-complete the implement phase from plan_tracker once every task is done,
|
|
258
|
+
// but only when a skill has explicitly started it (TDD, or the SDD
|
|
259
|
+
// execution preamble). The tracker never *fabricates* implement from task activity:
|
|
260
|
+
// outside a gauntlet flow (ad-hoc plan_tracker use) no phase is ever started, so
|
|
261
|
+
// the phase widget stays dormant. Phases are entered explicitly by the phase-owning
|
|
262
|
+
// skills; plan-tracker tracks tasks independently.
|
|
263
|
+
const applyPlanActivity = (tasks?: { status: string }[]) => {
|
|
264
|
+
if (!tasks || tasks.length === 0) return;
|
|
265
|
+
if (phases.implement.status === "in_progress" && tasks.every((t) => t.status === "complete")) {
|
|
266
|
+
phases = { ...phases, implement: { status: "complete" } };
|
|
267
|
+
firedGuards.clear();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const reconstructState = (ctx: ExtensionContext) => {
|
|
272
|
+
phases = emptyPhases();
|
|
273
|
+
conformanceDispatched = false;
|
|
274
|
+
firedGuards.clear();
|
|
275
|
+
pendingGuardWarnings.clear();
|
|
276
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
277
|
+
if (entry.type !== "message") continue;
|
|
278
|
+
const msg = entry.message;
|
|
279
|
+
if (msg.role !== "toolResult") continue;
|
|
280
|
+
if (msg.toolName === "phase_tracker") {
|
|
281
|
+
const details = msg.details as PhaseTrackerDetails | undefined;
|
|
282
|
+
if (details && !details.error) {
|
|
283
|
+
phases = details.phases;
|
|
284
|
+
if (details.action === "reset") conformanceDispatched = false;
|
|
285
|
+
}
|
|
286
|
+
} else if (msg.toolName === "subagent") {
|
|
287
|
+
if (qualifiesAsClosureDispatch(msg.details)) conformanceDispatched = true;
|
|
288
|
+
} else if (msg.toolName === "plan_tracker") {
|
|
289
|
+
const details = msg.details as { tasks?: { status: string }[]; error?: string } | undefined;
|
|
290
|
+
if (details && !details.error) applyPlanActivity(details.tasks);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const updateWidget = (ctx: ExtensionContext) => {
|
|
296
|
+
if (!ctx.hasUI) return;
|
|
297
|
+
if (!hasActivity(phases)) {
|
|
298
|
+
ctx.ui.setWidget("phase_tracker", undefined);
|
|
299
|
+
} else {
|
|
300
|
+
ctx.ui.setWidget("phase_tracker", (_tui, theme) => {
|
|
301
|
+
return new Text(formatWidget(phases, theme), 0, 0);
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
for (const event of ["session_start", "session_switch", "session_fork", "session_tree"] as const) {
|
|
307
|
+
pi.on(event, async (_event, ctx) => {
|
|
308
|
+
reconstructState(ctx);
|
|
309
|
+
updateWidget(ctx);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
pi.on("tool_call", async (event) => {
|
|
314
|
+
// Closure-review model guard - independent of flowGuards, gated by closureReview.enforce.
|
|
315
|
+
if (event.toolName === "subagent" && closureEnforced()) {
|
|
316
|
+
const model = closureReviewModel();
|
|
317
|
+
// Only execution-mode dispatches carry a model; management/control modes
|
|
318
|
+
// (action: list/get/create/update/delete/status/...) execute nothing, so skip them.
|
|
319
|
+
const hasAction = !!(event.input as { action?: unknown })?.action;
|
|
320
|
+
if (model && !hasAction && conformanceEntriesMissingModel(event.input)) {
|
|
321
|
+
return { block: true, reason: closureModelBlockReason(model) };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!flowGuardsEnforced()) return undefined;
|
|
326
|
+
|
|
327
|
+
// Guard 3 — write/edit outside the spec dir during brainstorm.
|
|
328
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
329
|
+
if (phases.brainstorm.status !== "in_progress" || firedGuards.get("brainstorm-write")) return undefined;
|
|
330
|
+
const p = (event.input as { path?: unknown } | undefined)?.path;
|
|
331
|
+
if (typeof p !== "string" || pathInSpecDirs(p, specDirs())) return undefined;
|
|
332
|
+
firedGuards.set("brainstorm-write", true);
|
|
333
|
+
pendingGuardWarnings.set(event.toolCallId, brainstormWriteWarning(specDirs()));
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (event.toolName !== "bash") return undefined;
|
|
338
|
+
const command = (event.input as { command?: unknown } | undefined)?.command;
|
|
339
|
+
if (typeof command !== "string" || !command) return undefined;
|
|
340
|
+
|
|
341
|
+
const warnings: string[] = [];
|
|
342
|
+
|
|
343
|
+
// Guard 2 — branch op in place (primary checkout only, brainstorm/plan/implement).
|
|
344
|
+
const gphase = activeGuardPhase();
|
|
345
|
+
if (
|
|
346
|
+
inPrimaryCheckout &&
|
|
347
|
+
gphase &&
|
|
348
|
+
!GIT_WORKTREE.test(command) &&
|
|
349
|
+
(BRANCH_SWITCH.test(command) || BRANCH_CHECKOUT.test(command))
|
|
350
|
+
) {
|
|
351
|
+
return { block: true, reason: branchBlockReason(gphase) };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Guard 3 — bash mutation outside the spec dir during brainstorm.
|
|
355
|
+
if (phases.brainstorm.status === "in_progress" && !firedGuards.get("brainstorm-write")) {
|
|
356
|
+
// Redirect target is cleanly extractable: judge it directly against the spec dirs,
|
|
357
|
+
// so `cat doc/specs/x.md > src/foo.ts` (writes OUTSIDE the spec dir) still fires
|
|
358
|
+
// even though the command text mentions the spec dir.
|
|
359
|
+
const target = command.match(BASH_REDIRECT)?.[1];
|
|
360
|
+
const redirectOutsideSpec =
|
|
361
|
+
target !== undefined && !TEMP_TARGET.test(target) && !pathInSpecDirs(target, specDirs());
|
|
362
|
+
// tee / sed -i / git apply targets are not cleanly extractable; fall back to a
|
|
363
|
+
// best-effort whole-command spec-dir mention check (accepted heuristic, advisory + warn-once).
|
|
364
|
+
const otherMutation = BASH_TEE.test(command) || BASH_SED_I.test(command) || BASH_GIT_APPLY.test(command);
|
|
365
|
+
const otherOutsideSpec =
|
|
366
|
+
otherMutation && !specDirs().some((d) => command.includes(d)) && !SCRATCH_MENTION.test(command);
|
|
367
|
+
if (redirectOutsideSpec || otherOutsideSpec) {
|
|
368
|
+
firedGuards.set("brainstorm-write", true);
|
|
369
|
+
warnings.push(brainstormWriteWarning(specDirs()));
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (warnings.length > 0) pendingGuardWarnings.set(event.toolCallId, warnings.join("\n\n"));
|
|
374
|
+
return undefined;
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
pi.on("tool_result", async (event) => {
|
|
378
|
+
if (event.toolName === "subagent") {
|
|
379
|
+
if (qualifiesAsClosureDispatch(event.details)) conformanceDispatched = true;
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Guards 2/3 — prepend the stashed advisory warning to the bash/write/edit result.
|
|
384
|
+
if (event.toolName === "bash" || event.toolName === "write" || event.toolName === "edit") {
|
|
385
|
+
const warning = pendingGuardWarnings.get(event.toolCallId);
|
|
386
|
+
if (!warning) return undefined;
|
|
387
|
+
pendingGuardWarnings.delete(event.toolCallId);
|
|
388
|
+
return { content: [{ type: "text" as const, text: warning }, ...event.content] };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return undefined;
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
pi.on("tool_execution_end", async (event, ctx) => {
|
|
395
|
+
if (event.toolName !== "plan_tracker" || event.isError) return;
|
|
396
|
+
const details = (event.result as { details?: { tasks?: { status: string }[]; error?: string } } | undefined)
|
|
397
|
+
?.details;
|
|
398
|
+
if (!details || details.error) return;
|
|
399
|
+
applyPlanActivity(details.tasks);
|
|
400
|
+
updateWidget(ctx);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
pi.registerTool({
|
|
404
|
+
name: "phase_tracker",
|
|
405
|
+
label: "Phase Tracker",
|
|
406
|
+
description:
|
|
407
|
+
"Track workflow phase progress (brainstorm → plan → implement → verify → ship). " +
|
|
408
|
+
"Actions: start (mark phase in_progress), complete (mark phase complete), " +
|
|
409
|
+
"skip (mark phase skipped with reason), status (show all phases), reset (clear all phases).",
|
|
410
|
+
parameters: PhaseTrackerParams,
|
|
411
|
+
|
|
412
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
413
|
+
switch (params.action) {
|
|
414
|
+
case "start": {
|
|
415
|
+
if (!params.phase) {
|
|
416
|
+
return {
|
|
417
|
+
content: [{ type: "text", text: "Error: phase required for start" }],
|
|
418
|
+
details: { action: "start", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const current = phases[params.phase].status;
|
|
422
|
+
if ((current === "complete" || current === "skipped") && !params.force) {
|
|
423
|
+
return {
|
|
424
|
+
content: [
|
|
425
|
+
{
|
|
426
|
+
type: "text",
|
|
427
|
+
text: `Error: phase_tracker start: phase '${params.phase}' is already ${current}. Pass force: true to restart it intentionally.`,
|
|
428
|
+
},
|
|
429
|
+
],
|
|
430
|
+
details: {
|
|
431
|
+
action: "start",
|
|
432
|
+
phases: { ...phases },
|
|
433
|
+
error: `phase '${params.phase}' is already ${current}`,
|
|
434
|
+
} as PhaseTrackerDetails,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
const blocked = PHASES.find((p) => p !== params.phase && phases[p].status === "in_progress");
|
|
438
|
+
if (blocked) {
|
|
439
|
+
return {
|
|
440
|
+
content: [
|
|
441
|
+
{
|
|
442
|
+
type: "text",
|
|
443
|
+
text: `Error: phase "${blocked}" is already in_progress. Complete or skip it first.`,
|
|
444
|
+
},
|
|
445
|
+
],
|
|
446
|
+
details: {
|
|
447
|
+
action: "start",
|
|
448
|
+
phases: { ...phases },
|
|
449
|
+
error: `${blocked} already in_progress`,
|
|
450
|
+
} as PhaseTrackerDetails,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
phases = { ...phases, [params.phase]: { status: "in_progress" } };
|
|
454
|
+
firedGuards.clear();
|
|
455
|
+
updateWidget(ctx);
|
|
456
|
+
return {
|
|
457
|
+
content: [{ type: "text", text: `Phase "${params.phase}" → in_progress\n${formatStatus(phases)}` }],
|
|
458
|
+
details: { action: "start", phases: { ...phases } } as PhaseTrackerDetails,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case "complete": {
|
|
463
|
+
if (!params.phase) {
|
|
464
|
+
return {
|
|
465
|
+
content: [{ type: "text", text: "Error: phase required for complete" }],
|
|
466
|
+
details: { action: "complete", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
const current = phases[params.phase].status;
|
|
470
|
+
if (current !== "in_progress" && current !== "pending") {
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: "text", text: `Error: phase "${params.phase}" is ${current}, cannot complete.` }],
|
|
473
|
+
details: {
|
|
474
|
+
action: "complete",
|
|
475
|
+
phases: { ...phases },
|
|
476
|
+
error: `${params.phase} is ${current}`,
|
|
477
|
+
} as PhaseTrackerDetails,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (params.phase === "verify" && closureEnforced() && !conformanceDispatched) {
|
|
481
|
+
return {
|
|
482
|
+
content: [{ type: "text", text: CLOSURE_GATE_ERROR }],
|
|
483
|
+
details: {
|
|
484
|
+
action: "complete",
|
|
485
|
+
phases: { ...phases },
|
|
486
|
+
error: "no conformance-reviewer dispatch observed",
|
|
487
|
+
} as PhaseTrackerDetails,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
phases = { ...phases, [params.phase]: { status: "complete" } };
|
|
491
|
+
firedGuards.clear();
|
|
492
|
+
updateWidget(ctx);
|
|
493
|
+
const advisory =
|
|
494
|
+
params.phase === "verify" && phases.ship.status === "pending" ? `\n\n${SHIP_ADVISORY}` : "";
|
|
495
|
+
return {
|
|
496
|
+
content: [
|
|
497
|
+
{ type: "text", text: `Phase "${params.phase}" → complete\n${formatStatus(phases)}${advisory}` },
|
|
498
|
+
],
|
|
499
|
+
details: { action: "complete", phases: { ...phases } } as PhaseTrackerDetails,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
case "skip": {
|
|
504
|
+
if (!params.phase) {
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: "text", text: "Error: phase required for skip" }],
|
|
507
|
+
details: { action: "skip", phases: { ...phases }, error: "phase required" } as PhaseTrackerDetails,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
if (!params.reason || params.reason.trim() === "") {
|
|
511
|
+
return {
|
|
512
|
+
content: [
|
|
513
|
+
{
|
|
514
|
+
type: "text",
|
|
515
|
+
text: "Error: phase_tracker skip requires a 'reason' string explaining why the phase is being skipped",
|
|
516
|
+
},
|
|
517
|
+
],
|
|
518
|
+
details: { action: "skip", phases: { ...phases }, error: "reason required for skip" } as PhaseTrackerDetails,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const reason = params.reason;
|
|
522
|
+
phases = { ...phases, [params.phase]: { status: "skipped", reason } };
|
|
523
|
+
firedGuards.clear();
|
|
524
|
+
updateWidget(ctx);
|
|
525
|
+
return {
|
|
526
|
+
content: [
|
|
527
|
+
{ type: "text", text: `Phase "${params.phase}" → skipped (${reason})\n${formatStatus(phases)}` },
|
|
528
|
+
],
|
|
529
|
+
details: { action: "skip", phases: { ...phases } } as PhaseTrackerDetails,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
case "status": {
|
|
534
|
+
return {
|
|
535
|
+
content: [{ type: "text", text: formatStatus(phases) }],
|
|
536
|
+
details: { action: "status", phases: { ...phases } } as PhaseTrackerDetails,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
case "reset": {
|
|
541
|
+
phases = emptyPhases();
|
|
542
|
+
conformanceDispatched = false;
|
|
543
|
+
firedGuards.clear();
|
|
544
|
+
updateWidget(ctx);
|
|
545
|
+
return {
|
|
546
|
+
content: [{ type: "text", text: "Phase tracker reset. All phases pending." }],
|
|
547
|
+
details: { action: "reset", phases: { ...phases } } as PhaseTrackerDetails,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
default:
|
|
552
|
+
return {
|
|
553
|
+
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
|
|
554
|
+
details: { action: "status", phases: { ...phases }, error: "unknown action" } as PhaseTrackerDetails,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
renderCall(args, theme) {
|
|
560
|
+
let text = theme.fg("toolTitle", theme.bold("phase_tracker "));
|
|
561
|
+
text += theme.fg("muted", args.action);
|
|
562
|
+
if (args.phase) {
|
|
563
|
+
text += ` ${theme.fg("accent", args.phase)}`;
|
|
564
|
+
}
|
|
565
|
+
if (args.action === "skip" && args.reason) {
|
|
566
|
+
text += ` ${theme.fg("dim", `(${args.reason})`)}`;
|
|
567
|
+
}
|
|
568
|
+
return new Text(text, 0, 0);
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
renderResult(result, _options, theme) {
|
|
572
|
+
const details = result.details as PhaseTrackerDetails | undefined;
|
|
573
|
+
if (!details) {
|
|
574
|
+
const text = result.content[0];
|
|
575
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (details.error) {
|
|
579
|
+
return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const p = details.phases;
|
|
583
|
+
const completeCount = PHASES.filter((ph) => p[ph].status === "complete").length;
|
|
584
|
+
|
|
585
|
+
switch (details.action) {
|
|
586
|
+
case "start": {
|
|
587
|
+
const active = PHASES.find((ph) => p[ph].status === "in_progress") ?? "";
|
|
588
|
+
return new Text(theme.fg("warning", "→ ") + theme.fg("muted", `${active} in progress`), 0, 0);
|
|
589
|
+
}
|
|
590
|
+
case "complete":
|
|
591
|
+
return new Text(
|
|
592
|
+
theme.fg("success", "✓ ") + theme.fg("muted", `${completeCount}/${PHASES.length} phases complete`),
|
|
593
|
+
0,
|
|
594
|
+
0,
|
|
595
|
+
);
|
|
596
|
+
case "skip":
|
|
597
|
+
return new Text(theme.fg("dim", "⊘ ") + theme.fg("muted", "phase skipped"), 0, 0);
|
|
598
|
+
case "status": {
|
|
599
|
+
let text = theme.fg("muted", "Phases:");
|
|
600
|
+
for (const ph of PHASES) {
|
|
601
|
+
const icon =
|
|
602
|
+
p[ph].status === "complete"
|
|
603
|
+
? theme.fg("success", "✓")
|
|
604
|
+
: p[ph].status === "in_progress"
|
|
605
|
+
? theme.fg("warning", "→")
|
|
606
|
+
: p[ph].status === "skipped"
|
|
607
|
+
? theme.fg("dim", "⊘")
|
|
608
|
+
: theme.fg("dim", "○");
|
|
609
|
+
const suffix = p[ph].reason ? theme.fg("dim", ` (${p[ph].reason})`) : "";
|
|
610
|
+
const nameColor = p[ph].status === "skipped" ? "dim" : "muted";
|
|
611
|
+
text += `\n${icon} ${theme.fg(nameColor, ph)}${suffix}`;
|
|
612
|
+
}
|
|
613
|
+
return new Text(text, 0, 0);
|
|
614
|
+
}
|
|
615
|
+
case "reset":
|
|
616
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", "Phase tracker reset"), 0, 0);
|
|
617
|
+
default:
|
|
618
|
+
return new Text(theme.fg("dim", "Done"), 0, 0);
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
}
|