@tianhai/pi-workflow-kit 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +509 -0
- package/ROADMAP.md +16 -0
- package/agents/code-reviewer.md +18 -0
- package/agents/config.ts +5 -0
- package/agents/implementer.md +26 -0
- package/agents/spec-reviewer.md +13 -0
- package/agents/worker.md +17 -0
- package/banner.jpg +0 -0
- package/docs/developer-usage-guide.md +463 -0
- package/docs/oversight-model.md +49 -0
- package/docs/workflow-phases.md +71 -0
- package/extensions/constants.ts +9 -0
- package/extensions/lib/logging.ts +138 -0
- package/extensions/plan-tracker.ts +496 -0
- package/extensions/subagent/agents.ts +144 -0
- package/extensions/subagent/concurrency.ts +52 -0
- package/extensions/subagent/env.ts +47 -0
- package/extensions/subagent/index.ts +1116 -0
- package/extensions/subagent/lifecycle.ts +25 -0
- package/extensions/subagent/timeout.ts +13 -0
- package/extensions/workflow-monitor/debug-monitor.ts +98 -0
- package/extensions/workflow-monitor/git.ts +31 -0
- package/extensions/workflow-monitor/heuristics.ts +58 -0
- package/extensions/workflow-monitor/investigation.ts +52 -0
- package/extensions/workflow-monitor/reference-tool.ts +42 -0
- package/extensions/workflow-monitor/skip-confirmation.ts +19 -0
- package/extensions/workflow-monitor/tdd-monitor.ts +137 -0
- package/extensions/workflow-monitor/test-runner.ts +37 -0
- package/extensions/workflow-monitor/verification-monitor.ts +61 -0
- package/extensions/workflow-monitor/warnings.ts +81 -0
- package/extensions/workflow-monitor/workflow-handler.ts +358 -0
- package/extensions/workflow-monitor/workflow-tracker.ts +231 -0
- package/extensions/workflow-monitor/workflow-transitions.ts +55 -0
- package/extensions/workflow-monitor.ts +885 -0
- package/package.json +49 -0
- package/skills/brainstorming/SKILL.md +70 -0
- package/skills/dispatching-parallel-agents/SKILL.md +194 -0
- package/skills/executing-tasks/SKILL.md +247 -0
- package/skills/receiving-code-review/SKILL.md +196 -0
- package/skills/systematic-debugging/SKILL.md +170 -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 +266 -0
- package/skills/test-driven-development/reference/examples.md +101 -0
- package/skills/test-driven-development/reference/rationalizations.md +67 -0
- package/skills/test-driven-development/reference/when-stuck.md +33 -0
- package/skills/test-driven-development/testing-anti-patterns.md +299 -0
- package/skills/using-git-worktrees/SKILL.md +231 -0
- package/skills/writing-plans/SKILL.md +149 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Kit monitor extension.
|
|
3
|
+
*
|
|
4
|
+
* Observes tool_call and tool_result events to:
|
|
5
|
+
* - Track TDD phase (RED→GREEN→REFACTOR) and inject warnings on violations
|
|
6
|
+
* - Track debug fix-fail cycles and inject warnings on investigation skips / thrashing
|
|
7
|
+
* - Show workflow state in TUI widget
|
|
8
|
+
* - Register workflow_reference tool for on-demand reference content
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
16
|
+
import { Type } from "@sinclair/typebox";
|
|
17
|
+
import { PLAN_TRACKER_TOOL_NAME } from "./constants.js";
|
|
18
|
+
import { log } from "./lib/logging.js";
|
|
19
|
+
import type { PlanTrackerDetails } from "./plan-tracker.js";
|
|
20
|
+
import { getCurrentGitRef } from "./workflow-monitor/git";
|
|
21
|
+
import { loadReference, REFERENCE_TOPICS } from "./workflow-monitor/reference-tool";
|
|
22
|
+
import { getUnresolvedPhases, getUnresolvedPhasesBefore } from "./workflow-monitor/skip-confirmation";
|
|
23
|
+
import type { VerificationViolation } from "./workflow-monitor/verification-monitor";
|
|
24
|
+
import {
|
|
25
|
+
type DebugViolationType,
|
|
26
|
+
getDebugViolationWarning,
|
|
27
|
+
getTddViolationWarning,
|
|
28
|
+
getVerificationViolationWarning,
|
|
29
|
+
} from "./workflow-monitor/warnings";
|
|
30
|
+
import { createWorkflowHandler, type Violation, type WorkflowHandler } from "./workflow-monitor/workflow-handler";
|
|
31
|
+
import {
|
|
32
|
+
computeBoundaryToPrompt,
|
|
33
|
+
type Phase,
|
|
34
|
+
parseSkillName,
|
|
35
|
+
resolveSkillPhase,
|
|
36
|
+
type TransitionBoundary,
|
|
37
|
+
WORKFLOW_PHASES,
|
|
38
|
+
WORKFLOW_TRACKER_ENTRY_TYPE,
|
|
39
|
+
type WorkflowTrackerState,
|
|
40
|
+
} from "./workflow-monitor/workflow-tracker";
|
|
41
|
+
import { getTransitionPrompt } from "./workflow-monitor/workflow-transitions";
|
|
42
|
+
|
|
43
|
+
type SelectOption<T extends string> = { label: string; value: T };
|
|
44
|
+
|
|
45
|
+
async function selectValue<T extends string>(
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
title: string,
|
|
48
|
+
options: SelectOption<T>[],
|
|
49
|
+
): Promise<T> {
|
|
50
|
+
const labels = options.map((o) => o.label);
|
|
51
|
+
const pickedLabel = await ctx.ui.select(title, labels);
|
|
52
|
+
const picked = options.find((o) => o.label === pickedLabel);
|
|
53
|
+
return (picked?.value ?? "cancel") as T;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SUPERPOWERS_STATE_ENTRY_TYPE = "superpowers_state";
|
|
57
|
+
|
|
58
|
+
export function getStateFilePath(): string {
|
|
59
|
+
return path.join(process.cwd(), ".pi", "superpowers-state.json");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function reconstructState(ctx: ExtensionContext, handler: WorkflowHandler, stateFilePath?: string | false) {
|
|
63
|
+
handler.resetState();
|
|
64
|
+
|
|
65
|
+
// Read both file-based and session-based state, then pick the newer one.
|
|
66
|
+
let fileData: (Record<string, unknown> & { savedAt?: number }) | null = null;
|
|
67
|
+
let sessionData: (Record<string, unknown> & { savedAt?: number }) | null = null;
|
|
68
|
+
|
|
69
|
+
if (stateFilePath !== false) {
|
|
70
|
+
try {
|
|
71
|
+
const statePath = stateFilePath ?? getStateFilePath();
|
|
72
|
+
if (fs.existsSync(statePath)) {
|
|
73
|
+
const raw = fs.readFileSync(statePath, "utf-8");
|
|
74
|
+
fileData = JSON.parse(raw);
|
|
75
|
+
}
|
|
76
|
+
} catch (err) {
|
|
77
|
+
log.warn(
|
|
78
|
+
`Failed to read state file, falling back to session entries: ${err instanceof Error ? err.message : err}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Scan session branch for most recent superpowers state entry
|
|
84
|
+
const entries = ctx.sessionManager.getBranch();
|
|
85
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
86
|
+
const entry = entries[i];
|
|
87
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK session entry type
|
|
88
|
+
if (entry.type === "custom" && (entry as any).customType === SUPERPOWERS_STATE_ENTRY_TYPE) {
|
|
89
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK session entry type
|
|
90
|
+
sessionData = (entry as any).data;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
// Migration fallback: old-format workflow-only entries
|
|
94
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK session entry type
|
|
95
|
+
if (entry.type === "custom" && (entry as any).customType === WORKFLOW_TRACKER_ENTRY_TYPE) {
|
|
96
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK session entry type
|
|
97
|
+
sessionData = { workflow: (entry as any).data };
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Pick the newer source when both are available; otherwise use whichever exists.
|
|
103
|
+
if (fileData && sessionData) {
|
|
104
|
+
const fileSavedAt = fileData.savedAt ?? 0;
|
|
105
|
+
const sessionSavedAt = sessionData.savedAt ?? 0;
|
|
106
|
+
const winner = fileSavedAt >= sessionSavedAt ? fileData : sessionData;
|
|
107
|
+
handler.setFullState(winner);
|
|
108
|
+
} else if (fileData) {
|
|
109
|
+
handler.setFullState(fileData);
|
|
110
|
+
} else if (sessionData) {
|
|
111
|
+
handler.setFullState(sessionData);
|
|
112
|
+
} else {
|
|
113
|
+
// No entries found — reset to fresh defaults
|
|
114
|
+
handler.setFullState({});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default function (pi: ExtensionAPI) {
|
|
119
|
+
const handler = createWorkflowHandler();
|
|
120
|
+
|
|
121
|
+
// Pending warnings are keyed by toolCallId to avoid cross-call leakage when
|
|
122
|
+
// tool results are interleaved.
|
|
123
|
+
const pendingViolations = new Map<string, Violation>();
|
|
124
|
+
const pendingVerificationViolations = new Map<string, VerificationViolation>();
|
|
125
|
+
const pendingBranchGates = new Map<string, string>();
|
|
126
|
+
const pendingProcessWarnings = new Map<string, string>();
|
|
127
|
+
|
|
128
|
+
type ViolationBucket = "process" | "practice";
|
|
129
|
+
const strikes: Record<ViolationBucket, number> = { process: 0, practice: 0 };
|
|
130
|
+
const sessionAllowed: Partial<Record<ViolationBucket, boolean>> = {};
|
|
131
|
+
|
|
132
|
+
async function maybeEscalate(bucket: ViolationBucket, ctx: ExtensionContext): Promise<"allow" | "block"> {
|
|
133
|
+
if (!ctx.hasUI) return "allow";
|
|
134
|
+
if (sessionAllowed[bucket]) return "allow";
|
|
135
|
+
|
|
136
|
+
strikes[bucket] += 1;
|
|
137
|
+
if (strikes[bucket] < 2) return "allow";
|
|
138
|
+
|
|
139
|
+
const choice = await ctx.ui.select(
|
|
140
|
+
`The agent has repeatedly violated ${bucket} guardrails. Allow it to continue?`,
|
|
141
|
+
["Yes, continue", "Yes, allow all for this session", "No, stop"],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
if (choice === "Yes, continue") {
|
|
145
|
+
strikes[bucket] = 0;
|
|
146
|
+
return "allow";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (choice === "Yes, allow all for this session") {
|
|
150
|
+
sessionAllowed[bucket] = true;
|
|
151
|
+
return "allow";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return "block";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let branchNoticeShown = false;
|
|
158
|
+
let branchConfirmed = false;
|
|
159
|
+
|
|
160
|
+
const persistState = () => {
|
|
161
|
+
const stateWithTimestamp = { ...handler.getFullState(), savedAt: Date.now() };
|
|
162
|
+
pi.appendEntry(SUPERPOWERS_STATE_ENTRY_TYPE, stateWithTimestamp);
|
|
163
|
+
// Also persist to file for cross-session survival
|
|
164
|
+
try {
|
|
165
|
+
const statePath = getStateFilePath();
|
|
166
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
167
|
+
fs.writeFileSync(statePath, JSON.stringify(stateWithTimestamp, null, 2));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
log.warn(`Failed to persist state file: ${err instanceof Error ? err.message : err}`);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const phaseToSkill: Record<string, string> = {
|
|
174
|
+
brainstorm: "brainstorming",
|
|
175
|
+
plan: "writing-plans",
|
|
176
|
+
execute: "executing-tasks",
|
|
177
|
+
finalize: "executing-tasks",
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
function parseTargetPhase(text: string): Phase | null {
|
|
181
|
+
const lines = text.split(/\r?\n/);
|
|
182
|
+
let furthest: Phase | null = null;
|
|
183
|
+
let furthestIdx = -1;
|
|
184
|
+
const workflowState = handler.getWorkflowState();
|
|
185
|
+
|
|
186
|
+
for (const line of lines) {
|
|
187
|
+
const skill = parseSkillName(line);
|
|
188
|
+
if (!skill) continue;
|
|
189
|
+
const phase = resolveSkillPhase(skill, workflowState);
|
|
190
|
+
if (!phase) continue;
|
|
191
|
+
const idx = WORKFLOW_PHASES.indexOf(phase);
|
|
192
|
+
if (idx > furthestIdx) {
|
|
193
|
+
furthest = phase;
|
|
194
|
+
furthestIdx = idx;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return furthest;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const boundaryToPhase: Record<TransitionBoundary, keyof typeof phaseToSkill> = {
|
|
202
|
+
design_committed: "brainstorm",
|
|
203
|
+
plan_ready: "plan",
|
|
204
|
+
execution_complete: "execute",
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
// --- State reconstruction on session events ---
|
|
208
|
+
function resetSessionState(ctx: ExtensionContext) {
|
|
209
|
+
reconstructState(ctx, handler);
|
|
210
|
+
pendingViolations.clear();
|
|
211
|
+
pendingVerificationViolations.clear();
|
|
212
|
+
pendingBranchGates.clear();
|
|
213
|
+
pendingProcessWarnings.clear();
|
|
214
|
+
strikes.process = 0;
|
|
215
|
+
strikes.practice = 0;
|
|
216
|
+
delete sessionAllowed.process;
|
|
217
|
+
delete sessionAllowed.practice;
|
|
218
|
+
branchNoticeShown = false;
|
|
219
|
+
branchConfirmed = false;
|
|
220
|
+
updateWidget(ctx);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// session_start covers startup, reload, new, resume, fork (pi v0.65.0+)
|
|
224
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
225
|
+
resetSessionState(ctx);
|
|
226
|
+
});
|
|
227
|
+
// session_tree for /tree navigation where a different session branch is loaded
|
|
228
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
229
|
+
resetSessionState(ctx);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// --- Input observation (skill detection + skip-confirmation gate) ---
|
|
233
|
+
pi.on("input", async (event, ctx) => {
|
|
234
|
+
if (event.source === "extension") return;
|
|
235
|
+
const text = (event.text as string | undefined) ?? (event.input as string | undefined) ?? "";
|
|
236
|
+
|
|
237
|
+
const targetPhase = parseTargetPhase(text);
|
|
238
|
+
|
|
239
|
+
// If no UI or no target phase, just track and proceed
|
|
240
|
+
if (!ctx.hasUI || !targetPhase) {
|
|
241
|
+
if (handler.handleInputText(text)) {
|
|
242
|
+
persistState();
|
|
243
|
+
updateWidget(ctx);
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const currentState = handler.getWorkflowState();
|
|
249
|
+
if (!currentState) {
|
|
250
|
+
if (handler.handleInputText(text)) {
|
|
251
|
+
persistState();
|
|
252
|
+
updateWidget(ctx);
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const unresolved = getUnresolvedPhasesBefore(targetPhase, currentState);
|
|
258
|
+
|
|
259
|
+
if (unresolved.length === 0) {
|
|
260
|
+
if (handler.handleInputText(text)) {
|
|
261
|
+
persistState();
|
|
262
|
+
updateWidget(ctx);
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Single unresolved phase ---
|
|
268
|
+
if (unresolved.length === 1) {
|
|
269
|
+
const missing = unresolved[0];
|
|
270
|
+
const missingSkill = phaseToSkill[missing] ?? missing;
|
|
271
|
+
const options = [
|
|
272
|
+
{ label: `Do ${missing} now`, value: "do_now" as const },
|
|
273
|
+
{ label: `Skip ${missing}`, value: "skip" as const },
|
|
274
|
+
{ label: "Cancel", value: "cancel" as const },
|
|
275
|
+
];
|
|
276
|
+
const choice = await selectValue(ctx, `Phase "${missing}" is unresolved. What would you like to do?`, options);
|
|
277
|
+
|
|
278
|
+
if (choice === "skip") {
|
|
279
|
+
handler.skipWorkflowPhases([missing]);
|
|
280
|
+
handler.handleInputText(text);
|
|
281
|
+
persistState();
|
|
282
|
+
updateWidget(ctx);
|
|
283
|
+
return;
|
|
284
|
+
} else if (choice === "do_now") {
|
|
285
|
+
ctx.ui.setEditorText(`/skill:${missingSkill}`);
|
|
286
|
+
return { blocked: true };
|
|
287
|
+
} else {
|
|
288
|
+
// cancel
|
|
289
|
+
return { blocked: true };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Multiple unresolved phases ---
|
|
294
|
+
const summaryOptions = [
|
|
295
|
+
{ label: "Review one-by-one", value: "review_individually" as const },
|
|
296
|
+
{ label: "Skip all and continue", value: "skip_all" as const },
|
|
297
|
+
{ label: "Cancel", value: "cancel" as const },
|
|
298
|
+
];
|
|
299
|
+
const summaryChoice = await selectValue(
|
|
300
|
+
ctx,
|
|
301
|
+
`${unresolved.length} phases are unresolved: ${unresolved.join(", ")}. What would you like to do?`,
|
|
302
|
+
summaryOptions,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
if (summaryChoice === "skip_all") {
|
|
306
|
+
handler.skipWorkflowPhases(unresolved);
|
|
307
|
+
handler.handleInputText(text);
|
|
308
|
+
persistState();
|
|
309
|
+
updateWidget(ctx);
|
|
310
|
+
return;
|
|
311
|
+
} else if (summaryChoice === "cancel") {
|
|
312
|
+
return { blocked: true };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// review_individually: prompt for each
|
|
316
|
+
for (const phase of unresolved) {
|
|
317
|
+
const skill = phaseToSkill[phase] ?? phase;
|
|
318
|
+
const options = [
|
|
319
|
+
{ label: `Do ${phase} now`, value: "do_now" as const },
|
|
320
|
+
{ label: `Skip ${phase}`, value: "skip" as const },
|
|
321
|
+
{ label: "Cancel", value: "cancel" as const },
|
|
322
|
+
];
|
|
323
|
+
const choice = await selectValue(ctx, `Phase "${phase}" is unresolved. What would you like to do?`, options);
|
|
324
|
+
|
|
325
|
+
if (choice === "skip") {
|
|
326
|
+
handler.skipWorkflowPhases([phase]);
|
|
327
|
+
persistState();
|
|
328
|
+
updateWidget(ctx);
|
|
329
|
+
} else if (choice === "do_now") {
|
|
330
|
+
ctx.ui.setEditorText(`/skill:${skill}`);
|
|
331
|
+
return { blocked: true };
|
|
332
|
+
} else {
|
|
333
|
+
// cancel
|
|
334
|
+
return { blocked: true };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// All individually reviewed (all skipped) - allow transition
|
|
339
|
+
handler.handleInputText(text);
|
|
340
|
+
persistState();
|
|
341
|
+
updateWidget(ctx);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// --- Completion action gate prompt ---
|
|
345
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK context type
|
|
346
|
+
async function promptCompletionGate(unresolved: Phase[], ctx: any): Promise<"allowed" | "blocked"> {
|
|
347
|
+
if (unresolved.length === 1) {
|
|
348
|
+
const missing = unresolved[0];
|
|
349
|
+
const missingSkill = phaseToSkill[missing] ?? missing;
|
|
350
|
+
const options = [
|
|
351
|
+
{ label: `Do ${missing} now`, value: "do_now" as const },
|
|
352
|
+
{ label: `Skip ${missing}`, value: "skip" as const },
|
|
353
|
+
{ label: "Cancel", value: "cancel" as const },
|
|
354
|
+
];
|
|
355
|
+
const choice = await selectValue(ctx, `Phase "${missing}" is unresolved. What would you like to do?`, options);
|
|
356
|
+
|
|
357
|
+
if (choice === "skip") {
|
|
358
|
+
handler.skipWorkflowPhases([missing]);
|
|
359
|
+
persistState();
|
|
360
|
+
updateWidget(ctx);
|
|
361
|
+
return "allowed";
|
|
362
|
+
} else if (choice === "do_now") {
|
|
363
|
+
ctx.ui.setEditorText(`/skill:${missingSkill}`);
|
|
364
|
+
return "blocked";
|
|
365
|
+
} else {
|
|
366
|
+
return "blocked";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Multiple unresolved
|
|
371
|
+
const summaryOptions = [
|
|
372
|
+
{ label: "Review one-by-one", value: "review_individually" as const },
|
|
373
|
+
{ label: "Skip all and continue", value: "skip_all" as const },
|
|
374
|
+
{ label: "Cancel", value: "cancel" as const },
|
|
375
|
+
];
|
|
376
|
+
const summaryChoice = await selectValue(
|
|
377
|
+
ctx,
|
|
378
|
+
`${unresolved.length} phases are unresolved: ${unresolved.join(", ")}. What would you like to do?`,
|
|
379
|
+
summaryOptions,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
if (summaryChoice === "skip_all") {
|
|
383
|
+
handler.skipWorkflowPhases(unresolved);
|
|
384
|
+
persistState();
|
|
385
|
+
updateWidget(ctx);
|
|
386
|
+
return "allowed";
|
|
387
|
+
} else if (summaryChoice === "cancel") {
|
|
388
|
+
return "blocked";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// review_individually
|
|
392
|
+
for (const phase of unresolved) {
|
|
393
|
+
const skill = phaseToSkill[phase] ?? phase;
|
|
394
|
+
const options = [
|
|
395
|
+
{ label: `Do ${phase} now`, value: "do_now" as const },
|
|
396
|
+
{ label: `Skip ${phase}`, value: "skip" as const },
|
|
397
|
+
{ label: "Cancel", value: "cancel" as const },
|
|
398
|
+
];
|
|
399
|
+
const choice = await selectValue(ctx, `Phase "${phase}" is unresolved. What would you like to do?`, options);
|
|
400
|
+
|
|
401
|
+
if (choice === "skip") {
|
|
402
|
+
handler.skipWorkflowPhases([phase]);
|
|
403
|
+
persistState();
|
|
404
|
+
updateWidget(ctx);
|
|
405
|
+
} else if (choice === "do_now") {
|
|
406
|
+
ctx.ui.setEditorText(`/skill:${skill}`);
|
|
407
|
+
return "blocked";
|
|
408
|
+
} else {
|
|
409
|
+
return "blocked";
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return "allowed";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// --- Completion action detection helpers ---
|
|
417
|
+
const COMMIT_RE = /\bgit\s+commit\b/;
|
|
418
|
+
const PUSH_RE = /\bgit\s+push\b/;
|
|
419
|
+
const PR_RE = /\bgh\s+pr\s+create\b/;
|
|
420
|
+
|
|
421
|
+
function getCompletionActionTarget(command: string): Phase | null {
|
|
422
|
+
if (COMMIT_RE.test(command)) return "finalize";
|
|
423
|
+
if (PUSH_RE.test(command)) return "finalize";
|
|
424
|
+
if (PR_RE.test(command)) return "finalize";
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function getUnresolvedPhasesForAction(_target: Phase, state: WorkflowTrackerState): Phase[] {
|
|
429
|
+
// For all completion actions, check that finalize is complete
|
|
430
|
+
return getUnresolvedPhases(["finalize"], state);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// --- Tool call observation (detect file writes + verification gate) ---
|
|
434
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
435
|
+
const toolCallId = event.toolCallId;
|
|
436
|
+
|
|
437
|
+
if (event.toolName === "bash") {
|
|
438
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK event input type
|
|
439
|
+
const command = ((event.input as Record<string, any>).command as string | undefined) ?? "";
|
|
440
|
+
|
|
441
|
+
const state = handler.getWorkflowState();
|
|
442
|
+
const phaseIdx = state?.currentPhase ? WORKFLOW_PHASES.indexOf(state.currentPhase) : -1;
|
|
443
|
+
const finalizeIdx = WORKFLOW_PHASES.indexOf("finalize");
|
|
444
|
+
|
|
445
|
+
// Completion action gating (interactive only, finalize phase)
|
|
446
|
+
// Suppress during active plan execution — prompts only fire after execution completes
|
|
447
|
+
const isExecuting = state?.currentPhase === "execute" && state.phases.execute === "active";
|
|
448
|
+
if (ctx.hasUI && state && phaseIdx >= finalizeIdx && !isExecuting) {
|
|
449
|
+
const actionTarget = getCompletionActionTarget(command);
|
|
450
|
+
if (actionTarget) {
|
|
451
|
+
const unresolved = getUnresolvedPhasesForAction(actionTarget, state);
|
|
452
|
+
if (unresolved.length > 0) {
|
|
453
|
+
const gateResult = await promptCompletionGate(unresolved, ctx);
|
|
454
|
+
if (gateResult === "blocked") {
|
|
455
|
+
return { blocked: true };
|
|
456
|
+
}
|
|
457
|
+
if (unresolved.length > 0) {
|
|
458
|
+
handler.recordVerificationWaiver();
|
|
459
|
+
persistState();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const executeIdx = WORKFLOW_PHASES.indexOf("execute");
|
|
466
|
+
if (phaseIdx >= executeIdx) {
|
|
467
|
+
const verificationViolation = handler.checkCommitGate(command);
|
|
468
|
+
if (verificationViolation) {
|
|
469
|
+
pendingVerificationViolations.set(toolCallId, verificationViolation);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK event input type
|
|
475
|
+
const input = event.input as Record<string, any>;
|
|
476
|
+
const result = handler.handleToolCall(event.toolName, input);
|
|
477
|
+
if (result.violation) {
|
|
478
|
+
pendingViolations.set(toolCallId, result.violation);
|
|
479
|
+
persistState();
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let changed = false;
|
|
483
|
+
|
|
484
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
485
|
+
const filePath = input.path as string | undefined;
|
|
486
|
+
if (filePath) {
|
|
487
|
+
const state = handler.getWorkflowState();
|
|
488
|
+
const phase = state?.currentPhase;
|
|
489
|
+
const isThinkingPhase = phase === "brainstorm" || phase === "plan";
|
|
490
|
+
let normalizedForCheck = filePath;
|
|
491
|
+
if (normalizedForCheck.startsWith("./")) normalizedForCheck = normalizedForCheck.slice(2);
|
|
492
|
+
const resolved = path.resolve(process.cwd(), normalizedForCheck);
|
|
493
|
+
const plansRoot = path.join(process.cwd(), "docs", "plans") + path.sep;
|
|
494
|
+
const isPlansWrite = resolved.startsWith(plansRoot);
|
|
495
|
+
|
|
496
|
+
if (isThinkingPhase && !isPlansWrite) {
|
|
497
|
+
const escalation = await maybeEscalate("process", ctx);
|
|
498
|
+
if (escalation === "block") {
|
|
499
|
+
return { blocked: true };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
pendingProcessWarnings.set(
|
|
503
|
+
toolCallId,
|
|
504
|
+
`⚠️ PROCESS VIOLATION: Wrote ${filePath} during ${phase} phase.\n` +
|
|
505
|
+
"During brainstorming/planning you may only write to docs/plans/. Stop and return to docs/plans/ or advance workflow phases intentionally.",
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
changed = handler.handleFileWritten(filePath) || changed;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!branchConfirmed) {
|
|
513
|
+
const ref = getCurrentGitRef();
|
|
514
|
+
branchConfirmed = true;
|
|
515
|
+
|
|
516
|
+
if (ref) {
|
|
517
|
+
pendingBranchGates.set(
|
|
518
|
+
toolCallId,
|
|
519
|
+
`⚠️ First write of this session. You're on branch \`${ref}\`.\n` +
|
|
520
|
+
"Confirm with the user this is the correct branch before continuing, or create a new branch/worktree.",
|
|
521
|
+
);
|
|
522
|
+
} else {
|
|
523
|
+
// Not a git repo: disable branch messages silently.
|
|
524
|
+
branchNoticeShown = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// plan-tracker init advances workflow phase to execute — intentional integration contract
|
|
530
|
+
if (event.toolName === PLAN_TRACKER_TOOL_NAME) {
|
|
531
|
+
changed = handler.handlePlanTrackerToolCall(input) || changed;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (changed) {
|
|
535
|
+
persistState();
|
|
536
|
+
updateWidget(ctx);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// --- Tool result modification (inject warnings + track investigation) ---
|
|
541
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
542
|
+
const toolCallId = event.toolCallId;
|
|
543
|
+
|
|
544
|
+
if (event.toolName === "read") {
|
|
545
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK event input type
|
|
546
|
+
const path = ((event.input as Record<string, any>).path as string) ?? "";
|
|
547
|
+
if (handler.handleSkillFileRead(path)) {
|
|
548
|
+
persistState();
|
|
549
|
+
}
|
|
550
|
+
handler.handleReadOrInvestigation("read", path);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (
|
|
554
|
+
event.toolName === PLAN_TRACKER_TOOL_NAME &&
|
|
555
|
+
handler.handlePlanTrackerToolResult(event.details as PlanTrackerDetails | undefined)
|
|
556
|
+
) {
|
|
557
|
+
persistState();
|
|
558
|
+
updateWidget(ctx);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const injected: string[] = [];
|
|
562
|
+
|
|
563
|
+
// Layer 1: announce current branch on first tool result in session.
|
|
564
|
+
if (!branchNoticeShown) {
|
|
565
|
+
const ref = getCurrentGitRef();
|
|
566
|
+
if (ref) {
|
|
567
|
+
injected.push(`📌 Current branch: \`${ref}\``);
|
|
568
|
+
} else {
|
|
569
|
+
branchConfirmed = true;
|
|
570
|
+
}
|
|
571
|
+
branchNoticeShown = true;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Inject violation warning on write/edit for the matching tool call.
|
|
575
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
576
|
+
const violation = pendingViolations.get(toolCallId);
|
|
577
|
+
if (violation) {
|
|
578
|
+
const warningText = formatViolationWarning(violation);
|
|
579
|
+
injected.push(warningText);
|
|
580
|
+
|
|
581
|
+
// Wire practice escalation for TDD violations (post-write, warns but never blocks current call)
|
|
582
|
+
const isTddViolation =
|
|
583
|
+
violation.type === "source-before-test" ||
|
|
584
|
+
violation.type === "source-during-red" ||
|
|
585
|
+
violation.type === "existing-tests-not-run-before-change";
|
|
586
|
+
if (isTddViolation) {
|
|
587
|
+
const escalation = await maybeEscalate("practice", ctx);
|
|
588
|
+
if (escalation === "block") {
|
|
589
|
+
injected.push(
|
|
590
|
+
"🛑 STOP: The agent has repeatedly violated TDD practice guardrails. " +
|
|
591
|
+
"Do not write any more source code until you have addressed the TDD violations above. " +
|
|
592
|
+
"Review the test-driven-development skill before proceeding.",
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
pendingViolations.delete(toolCallId);
|
|
598
|
+
|
|
599
|
+
const processWarning = pendingProcessWarnings.get(toolCallId);
|
|
600
|
+
if (processWarning) {
|
|
601
|
+
injected.push(processWarning);
|
|
602
|
+
}
|
|
603
|
+
pendingProcessWarnings.delete(toolCallId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Handle bash results (test runs, commits, investigation)
|
|
607
|
+
if (event.toolName === "bash") {
|
|
608
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK event input type
|
|
609
|
+
const command = ((event.input as Record<string, any>).command as string) ?? "";
|
|
610
|
+
const output = event.content
|
|
611
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
612
|
+
.map((c) => c.text)
|
|
613
|
+
.join("\n");
|
|
614
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK event details type
|
|
615
|
+
const exitCode = (event.details as any)?.exitCode as number | undefined;
|
|
616
|
+
handler.handleBashResult(command, output, exitCode);
|
|
617
|
+
persistState();
|
|
618
|
+
|
|
619
|
+
const verificationViolation = pendingVerificationViolations.get(toolCallId);
|
|
620
|
+
if (verificationViolation) {
|
|
621
|
+
injected.push(getVerificationViolationWarning(verificationViolation.type, verificationViolation.command));
|
|
622
|
+
}
|
|
623
|
+
pendingVerificationViolations.delete(toolCallId);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (event.toolName === "write" || event.toolName === "edit") {
|
|
627
|
+
const branchGate = pendingBranchGates.get(toolCallId);
|
|
628
|
+
if (branchGate) {
|
|
629
|
+
injected.push(branchGate);
|
|
630
|
+
}
|
|
631
|
+
pendingBranchGates.delete(toolCallId);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (injected.length > 0) {
|
|
635
|
+
updateWidget(ctx);
|
|
636
|
+
return {
|
|
637
|
+
content: [{ type: "text", text: injected.join("\n\n") }, ...event.content],
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
updateWidget(ctx);
|
|
642
|
+
return undefined;
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// --- Boundary prompting at natural handoff points ---
|
|
646
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
647
|
+
if (!ctx.hasUI) return;
|
|
648
|
+
|
|
649
|
+
const latestState = handler.getWorkflowState();
|
|
650
|
+
if (!latestState) return;
|
|
651
|
+
|
|
652
|
+
const boundary = computeBoundaryToPrompt(latestState);
|
|
653
|
+
if (!boundary) return;
|
|
654
|
+
|
|
655
|
+
const boundaryPhase = boundaryToPhase[boundary];
|
|
656
|
+
const prompt = getTransitionPrompt(boundary, latestState.artifacts[boundaryPhase]);
|
|
657
|
+
|
|
658
|
+
const options = prompt.options.map((o) => o.label);
|
|
659
|
+
const pickedLabel = await ctx.ui.select(prompt.title, options);
|
|
660
|
+
|
|
661
|
+
const selected = prompt.options.find((o) => o.label === pickedLabel)?.choice ?? null;
|
|
662
|
+
|
|
663
|
+
const marked = handler.markWorkflowPrompted(boundaryPhase);
|
|
664
|
+
if (marked) {
|
|
665
|
+
persistState();
|
|
666
|
+
updateWidget(ctx);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const nextSkill = phaseToSkill[prompt.nextPhase] ?? "writing-plans";
|
|
670
|
+
const nextInSession = `/skill:${nextSkill}`;
|
|
671
|
+
const fresh = `/workflow-next ${prompt.nextPhase}${prompt.artifactPath ? ` ${prompt.artifactPath}` : ""}`;
|
|
672
|
+
const finishReminder =
|
|
673
|
+
"Before finishing:\n" +
|
|
674
|
+
"- Does this work require documentation updates? (README, CHANGELOG, API docs, inline docs)\n" +
|
|
675
|
+
"- What was learned during this implementation? (surprises, codebase knowledge, things to do differently)\n\n";
|
|
676
|
+
|
|
677
|
+
if (selected === "next") {
|
|
678
|
+
const advanced = prompt.nextPhase === "finalize" ? handler.advanceWorkflowTo("finalize") : false;
|
|
679
|
+
if (advanced) {
|
|
680
|
+
persistState();
|
|
681
|
+
updateWidget(ctx);
|
|
682
|
+
}
|
|
683
|
+
ctx.ui.setEditorText(prompt.nextPhase === "finalize" ? finishReminder + nextInSession : nextInSession);
|
|
684
|
+
} else if (selected === "fresh") {
|
|
685
|
+
const advanced = prompt.nextPhase === "finalize" ? handler.advanceWorkflowTo("finalize") : false;
|
|
686
|
+
if (advanced) {
|
|
687
|
+
persistState();
|
|
688
|
+
updateWidget(ctx);
|
|
689
|
+
}
|
|
690
|
+
ctx.ui.setEditorText(prompt.nextPhase === "finalize" ? finishReminder + fresh : fresh);
|
|
691
|
+
} else if (selected === "skip") {
|
|
692
|
+
// Explicit user-confirmed skip: mark the next phase as skipped, then move on.
|
|
693
|
+
handler.skipWorkflowPhases([prompt.nextPhase]);
|
|
694
|
+
|
|
695
|
+
const nextIdx = WORKFLOW_PHASES.indexOf(prompt.nextPhase);
|
|
696
|
+
const phaseAfterSkip = WORKFLOW_PHASES[nextIdx + 1] ?? null;
|
|
697
|
+
|
|
698
|
+
if (phaseAfterSkip) {
|
|
699
|
+
const currentState = handler.getWorkflowState();
|
|
700
|
+
const currentIdx = currentState?.currentPhase ? WORKFLOW_PHASES.indexOf(currentState.currentPhase) : -1;
|
|
701
|
+
const afterSkipIdx = WORKFLOW_PHASES.indexOf(phaseAfterSkip);
|
|
702
|
+
if (afterSkipIdx > currentIdx) {
|
|
703
|
+
handler.advanceWorkflowTo(phaseAfterSkip);
|
|
704
|
+
const skipSkill = phaseToSkill[phaseAfterSkip] ?? "writing-plans";
|
|
705
|
+
ctx.ui.setEditorText(`/skill:${skipSkill}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
persistState();
|
|
710
|
+
updateWidget(ctx);
|
|
711
|
+
} else if (selected === "discuss") {
|
|
712
|
+
// Don't advance phase. Set editor text to prompt discussion.
|
|
713
|
+
ctx.ui.setEditorText(
|
|
714
|
+
`Let's discuss before moving to the next step.\n` +
|
|
715
|
+
`We're at: ${prompt.title}\n` +
|
|
716
|
+
`What questions or concerns do you want to work through?`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// --- Format violation warning based on type ---
|
|
722
|
+
function formatViolationWarning(violation: Violation): string {
|
|
723
|
+
if (
|
|
724
|
+
violation.type === "source-before-test" ||
|
|
725
|
+
violation.type === "source-during-red" ||
|
|
726
|
+
violation.type === "existing-tests-not-run-before-change"
|
|
727
|
+
) {
|
|
728
|
+
const phase = handler.getWorkflowState()?.currentPhase;
|
|
729
|
+
return getTddViolationWarning(violation.type, violation.file, phase ?? undefined);
|
|
730
|
+
}
|
|
731
|
+
return getDebugViolationWarning(
|
|
732
|
+
violation.type as DebugViolationType,
|
|
733
|
+
violation.file,
|
|
734
|
+
"fixAttempts" in violation ? violation.fixAttempts : 0,
|
|
735
|
+
);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK theme type
|
|
739
|
+
function formatPhaseStrip(state: WorkflowTrackerState | null, theme: any): string {
|
|
740
|
+
if (!state?.currentPhase) return "";
|
|
741
|
+
|
|
742
|
+
const arrow = theme.fg("dim", " → ");
|
|
743
|
+
return WORKFLOW_PHASES.map((phase) => {
|
|
744
|
+
const status = state.phases[phase];
|
|
745
|
+
if (state.currentPhase === phase) return theme.fg("accent", `[${phase}]`);
|
|
746
|
+
if (status === "complete") return theme.fg("success", `✓${phase}`);
|
|
747
|
+
if (status === "skipped") return theme.fg("dim", `–${phase}`);
|
|
748
|
+
return theme.fg("dim", phase);
|
|
749
|
+
}).join(arrow);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// --- TUI Widget ---
|
|
753
|
+
function updateWidget(ctx: ExtensionContext) {
|
|
754
|
+
if (!ctx.hasUI) return;
|
|
755
|
+
|
|
756
|
+
const tddPhase = handler.getTddPhase().toUpperCase();
|
|
757
|
+
const hasDebug = handler.isDebugActive();
|
|
758
|
+
const workflow = handler.getWorkflowState();
|
|
759
|
+
const hasWorkflow = !!workflow?.currentPhase;
|
|
760
|
+
|
|
761
|
+
if (!hasWorkflow && tddPhase === "IDLE" && !hasDebug) {
|
|
762
|
+
ctx.ui.setWidget("workflow_monitor", undefined);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
ctx.ui.setWidget("workflow_monitor", (_tui, theme) => {
|
|
767
|
+
const parts: string[] = [];
|
|
768
|
+
|
|
769
|
+
const phaseStrip = formatPhaseStrip(workflow, theme);
|
|
770
|
+
if (phaseStrip) {
|
|
771
|
+
parts.push(phaseStrip);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// TDD phase
|
|
775
|
+
if (tddPhase !== "IDLE") {
|
|
776
|
+
const colorMap: Record<string, string> = {
|
|
777
|
+
"RED-PENDING": "error",
|
|
778
|
+
RED: "error",
|
|
779
|
+
GREEN: "success",
|
|
780
|
+
REFACTOR: "accent",
|
|
781
|
+
};
|
|
782
|
+
parts.push(theme.fg(colorMap[tddPhase] ?? "muted", `TDD: ${tddPhase}`));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Debug state
|
|
786
|
+
if (hasDebug) {
|
|
787
|
+
const attempts = handler.getDebugFixAttempts();
|
|
788
|
+
if (attempts >= 3) {
|
|
789
|
+
parts.push(theme.fg("error", `Debug: ${attempts} fix attempts ⚠️`));
|
|
790
|
+
} else if (attempts > 0) {
|
|
791
|
+
parts.push(theme.fg("warning", `Debug: ${attempts} fix attempt${attempts !== 1 ? "s" : ""}`));
|
|
792
|
+
} else {
|
|
793
|
+
parts.push(theme.fg("accent", "Debug: investigating"));
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
return parts.length > 0 ? new Text(parts.join(theme.fg("dim", " | ")), 0, 0) : undefined;
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
pi.registerCommand("workflow-reset", {
|
|
802
|
+
description: "Reset workflow tracker to fresh state for a new task",
|
|
803
|
+
async handler(_args, ctx) {
|
|
804
|
+
handler.resetState();
|
|
805
|
+
persistState();
|
|
806
|
+
updateWidget(ctx);
|
|
807
|
+
if (ctx.hasUI) {
|
|
808
|
+
ctx.ui.notify("Workflow reset. Ready for a new task.", "info");
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
pi.registerCommand("workflow-next", {
|
|
814
|
+
description: "Start a fresh session for the next workflow phase (optionally referencing an artifact path)",
|
|
815
|
+
async handler(args, ctx) {
|
|
816
|
+
if (!ctx.hasUI) {
|
|
817
|
+
ctx.ui.notify("workflow-next requires interactive mode", "error");
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const [phase, artifact] = args.trim().split(/\s+/, 2);
|
|
822
|
+
const validPhases = new Set(["brainstorm", "plan", "execute", "finalize"]);
|
|
823
|
+
if (!phase || !validPhases.has(phase)) {
|
|
824
|
+
ctx.ui.notify(
|
|
825
|
+
"Usage: /workflow-next <phase> [artifact-path] (phase: brainstorm|plan|execute|finalize)",
|
|
826
|
+
"error",
|
|
827
|
+
);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
832
|
+
const res = await ctx.newSession({ parentSession });
|
|
833
|
+
if (res.cancelled) return;
|
|
834
|
+
|
|
835
|
+
const lines: string[] = [];
|
|
836
|
+
if (artifact) lines.push(`Continue from artifact: ${artifact}`);
|
|
837
|
+
|
|
838
|
+
if (phase === "brainstorm") {
|
|
839
|
+
lines.push("/skill:brainstorming");
|
|
840
|
+
} else if (phase === "plan") {
|
|
841
|
+
lines.push("/skill:writing-plans");
|
|
842
|
+
} else if (phase === "execute") {
|
|
843
|
+
lines.push("/skill:executing-tasks");
|
|
844
|
+
lines.push("Execute the approved plan task-by-task.");
|
|
845
|
+
} else if (phase === "finalize") {
|
|
846
|
+
lines.push("/skill:executing-tasks");
|
|
847
|
+
lines.push("Finalize the completed work (review, PR, docs, archive, cleanup).");
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
ctx.ui.setEditorText(lines.join("\n"));
|
|
851
|
+
ctx.ui.notify("New session ready. Submit when ready.", "info");
|
|
852
|
+
},
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
// --- Reference Tool ---
|
|
856
|
+
pi.registerTool({
|
|
857
|
+
name: "workflow_reference",
|
|
858
|
+
label: "Workflow Guide",
|
|
859
|
+
description: `Detailed guidance for workflow skills. Topics: ${REFERENCE_TOPICS.join(", ")}`,
|
|
860
|
+
parameters: Type.Object({
|
|
861
|
+
topic: StringEnum(REFERENCE_TOPICS as unknown as readonly [string, ...string[]], {
|
|
862
|
+
description: "Reference topic to load",
|
|
863
|
+
}),
|
|
864
|
+
}),
|
|
865
|
+
async execute(_toolCallId, params) {
|
|
866
|
+
const content = await loadReference(params.topic);
|
|
867
|
+
return {
|
|
868
|
+
content: [{ type: "text", text: content }],
|
|
869
|
+
details: { topic: params.topic },
|
|
870
|
+
};
|
|
871
|
+
},
|
|
872
|
+
renderCall(args, theme) {
|
|
873
|
+
let text = theme.fg("toolTitle", theme.bold("workflow_reference "));
|
|
874
|
+
text += theme.fg("accent", args.topic);
|
|
875
|
+
return new Text(text, 0, 0);
|
|
876
|
+
},
|
|
877
|
+
renderResult(result, _options, theme) {
|
|
878
|
+
// biome-ignore lint/suspicious/noExplicitAny: pi SDK event details type
|
|
879
|
+
const topic = (result.details as any)?.topic ?? "unknown";
|
|
880
|
+
const content = result.content[0];
|
|
881
|
+
const len = content?.type === "text" ? content.text.length : 0;
|
|
882
|
+
return new Text(theme.fg("success", "✓ ") + theme.fg("muted", `${topic} (${len} chars)`), 0, 0);
|
|
883
|
+
},
|
|
884
|
+
});
|
|
885
|
+
}
|