pi-superteam 0.1.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 +37 -0
- package/CONTRIBUTING.md +83 -0
- package/LICENSE +21 -0
- package/README.md +360 -0
- package/agents/architect.md +45 -0
- package/agents/implementer.md +40 -0
- package/agents/performance-reviewer.md +51 -0
- package/agents/quality-reviewer.md +53 -0
- package/agents/scout.md +24 -0
- package/agents/security-reviewer.md +52 -0
- package/agents/spec-reviewer.md +46 -0
- package/docs/guides/agents.md +200 -0
- package/docs/guides/configuration.md +164 -0
- package/docs/guides/rules.md +91 -0
- package/docs/guides/sdd-workflow.md +173 -0
- package/docs/guides/tdd-guard.md +144 -0
- package/package.json +53 -0
- package/prompts/implement.md +9 -0
- package/prompts/review-parallel.md +11 -0
- package/prompts/scout.md +8 -0
- package/prompts/sdd.md +9 -0
- package/rules/no-impl-before-spec.md +17 -0
- package/rules/test-first.md +11 -0
- package/rules/yagni.md +11 -0
- package/skills/acceptance-test-driven-development/SKILL.md +60 -0
- package/skills/brainstorming/SKILL.md +49 -0
- package/skills/subagent-driven-development/SKILL.md +86 -0
- package/skills/test-driven-development/SKILL.md +97 -0
- package/skills/writing-plans/SKILL.md +65 -0
- package/src/config.ts +181 -0
- package/src/dispatch.ts +567 -0
- package/src/index.ts +721 -0
- package/src/review-parser.ts +212 -0
- package/src/rules/engine.ts +215 -0
- package/src/workflow/sdd.ts +379 -0
- package/src/workflow/state.ts +422 -0
- package/src/workflow/tdd-guard.ts +516 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow state — plan tracking, TDD mode, review cycles, session persistence.
|
|
3
|
+
*
|
|
4
|
+
* Branch-aware: all state derived from session entries via getBranch().
|
|
5
|
+
* No global mutable state — state is reconstructed on resume.
|
|
6
|
+
* All types use plain objects (Record, arrays) for JSON serialization.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
|
|
12
|
+
// --- Types ---
|
|
13
|
+
|
|
14
|
+
export type TddMode = "off" | "tdd" | "atdd";
|
|
15
|
+
|
|
16
|
+
export type TaskStatus = "pending" | "implementing" | "reviewing" | "fixing" | "complete" | "skipped";
|
|
17
|
+
|
|
18
|
+
export interface PlanTask {
|
|
19
|
+
id: number;
|
|
20
|
+
title: string;
|
|
21
|
+
description: string;
|
|
22
|
+
files: string[];
|
|
23
|
+
status: TaskStatus;
|
|
24
|
+
reviewsPassed: string[];
|
|
25
|
+
reviewsFailed: string[];
|
|
26
|
+
fixAttempts: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ReviewCycle {
|
|
30
|
+
taskId: number;
|
|
31
|
+
reviewType: string;
|
|
32
|
+
agent: string;
|
|
33
|
+
status: "pending" | "passed" | "failed" | "inconclusive";
|
|
34
|
+
findings?: Record<string, unknown>;
|
|
35
|
+
fixedBy?: string;
|
|
36
|
+
timestamp: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface WorkflowState {
|
|
40
|
+
tddMode: TddMode;
|
|
41
|
+
planFile?: string;
|
|
42
|
+
tasks: PlanTask[];
|
|
43
|
+
currentTaskIndex: number;
|
|
44
|
+
reviewCycles: ReviewCycle[];
|
|
45
|
+
cumulativeCostUsd: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// --- Custom entry types ---
|
|
49
|
+
|
|
50
|
+
const ENTRY_TYPE = "superteam-state";
|
|
51
|
+
|
|
52
|
+
interface StateEntry {
|
|
53
|
+
version: 1;
|
|
54
|
+
state: WorkflowState;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Default state ---
|
|
58
|
+
|
|
59
|
+
function defaultState(): WorkflowState {
|
|
60
|
+
return {
|
|
61
|
+
tddMode: "off",
|
|
62
|
+
planFile: undefined,
|
|
63
|
+
tasks: [],
|
|
64
|
+
currentTaskIndex: -1,
|
|
65
|
+
reviewCycles: [],
|
|
66
|
+
cumulativeCostUsd: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Plan parsing ---
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse tasks from a ```superteam-tasks fenced block (YAML-like format).
|
|
74
|
+
*
|
|
75
|
+
* Expected format:
|
|
76
|
+
* ```superteam-tasks
|
|
77
|
+
* - title: Setup models
|
|
78
|
+
* description: Create data models for the application
|
|
79
|
+
* files: [src/models.ts, src/types.ts]
|
|
80
|
+
* - title: Add validation
|
|
81
|
+
* description: Input validation layer
|
|
82
|
+
* files: [src/validation.ts]
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function parseTaskBlock(content: string): PlanTask[] | null {
|
|
86
|
+
const fenceRegex = /```superteam-tasks\s*\n([\s\S]*?)```/;
|
|
87
|
+
const match = content.match(fenceRegex);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
|
|
90
|
+
const block = match[1];
|
|
91
|
+
return parseYamlLikeTasks(block);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Heuristic fallback: parse tasks from ### Task N: headings.
|
|
96
|
+
*/
|
|
97
|
+
export function parseTaskHeadings(content: string): PlanTask[] {
|
|
98
|
+
const tasks: PlanTask[] = [];
|
|
99
|
+
const headingRegex = /^###\s+Task\s+(\d+):\s*(.+)$/gm;
|
|
100
|
+
|
|
101
|
+
let match: RegExpExecArray | null;
|
|
102
|
+
const headingPositions: { id: number; title: string; start: number }[] = [];
|
|
103
|
+
|
|
104
|
+
while ((match = headingRegex.exec(content)) !== null) {
|
|
105
|
+
headingPositions.push({
|
|
106
|
+
id: parseInt(match[1], 10),
|
|
107
|
+
title: match[2].trim(),
|
|
108
|
+
start: match.index + match[0].length,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (let i = 0; i < headingPositions.length; i++) {
|
|
113
|
+
const h = headingPositions[i];
|
|
114
|
+
const end = i + 1 < headingPositions.length
|
|
115
|
+
? headingPositions[i + 1].start - headingPositions[i + 1].title.length - 15 // approximate heading start
|
|
116
|
+
: content.length;
|
|
117
|
+
const body = content.slice(h.start, end).trim();
|
|
118
|
+
|
|
119
|
+
// Extract file references from the body
|
|
120
|
+
const files = extractFileRefs(body);
|
|
121
|
+
|
|
122
|
+
tasks.push({
|
|
123
|
+
id: h.id,
|
|
124
|
+
title: h.title,
|
|
125
|
+
description: body.split("\n").slice(0, 3).join("\n").trim(),
|
|
126
|
+
files,
|
|
127
|
+
status: "pending",
|
|
128
|
+
reviewsPassed: [],
|
|
129
|
+
reviewsFailed: [],
|
|
130
|
+
fixAttempts: 0,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return tasks;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Load and parse a plan file. Tries fenced block first, falls back to headings.
|
|
139
|
+
*/
|
|
140
|
+
export function loadPlan(filePath: string): { tasks: PlanTask[]; source: "fenced" | "headings" | "empty" } {
|
|
141
|
+
let content: string;
|
|
142
|
+
try {
|
|
143
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
144
|
+
} catch {
|
|
145
|
+
return { tasks: [], source: "empty" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const fenced = parseTaskBlock(content);
|
|
149
|
+
if (fenced && fenced.length > 0) {
|
|
150
|
+
return { tasks: fenced, source: "fenced" };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const headings = parseTaskHeadings(content);
|
|
154
|
+
if (headings.length > 0) {
|
|
155
|
+
return { tasks: headings, source: "headings" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { tasks: [], source: "empty" };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// --- YAML-like parser (minimal, no dependency) ---
|
|
162
|
+
|
|
163
|
+
function parseYamlLikeTasks(block: string): PlanTask[] {
|
|
164
|
+
const tasks: PlanTask[] = [];
|
|
165
|
+
const lines = block.split("\n");
|
|
166
|
+
let current: Partial<PlanTask> | null = null;
|
|
167
|
+
let id = 1;
|
|
168
|
+
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
const trimmed = line.trim();
|
|
171
|
+
if (!trimmed) continue;
|
|
172
|
+
|
|
173
|
+
// New task item
|
|
174
|
+
if (trimmed.startsWith("- title:")) {
|
|
175
|
+
if (current?.title) {
|
|
176
|
+
tasks.push(finalizePlanTask(current, id++));
|
|
177
|
+
}
|
|
178
|
+
current = { title: trimmed.slice("- title:".length).trim() };
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!current) continue;
|
|
183
|
+
|
|
184
|
+
if (trimmed.startsWith("description:")) {
|
|
185
|
+
current.description = trimmed.slice("description:".length).trim();
|
|
186
|
+
} else if (trimmed.startsWith("files:")) {
|
|
187
|
+
const filesStr = trimmed.slice("files:".length).trim();
|
|
188
|
+
current.files = parseInlineArray(filesStr);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (current?.title) {
|
|
193
|
+
tasks.push(finalizePlanTask(current, id));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return tasks;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function finalizePlanTask(partial: Partial<PlanTask>, id: number): PlanTask {
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
title: partial.title || `Task ${id}`,
|
|
203
|
+
description: partial.description || "",
|
|
204
|
+
files: partial.files || [],
|
|
205
|
+
status: "pending",
|
|
206
|
+
reviewsPassed: [],
|
|
207
|
+
reviewsFailed: [],
|
|
208
|
+
fixAttempts: 0,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function parseInlineArray(str: string): string[] {
|
|
213
|
+
// [a, b, c] or a, b, c
|
|
214
|
+
const cleaned = str.replace(/^\[/, "").replace(/\]$/, "");
|
|
215
|
+
return cleaned
|
|
216
|
+
.split(",")
|
|
217
|
+
.map((s) => s.trim())
|
|
218
|
+
.filter(Boolean);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function extractFileRefs(body: string): string[] {
|
|
222
|
+
const files: string[] = [];
|
|
223
|
+
// Match backtick-wrapped file paths
|
|
224
|
+
const backtickRegex = /`([^`]+\.[a-zA-Z]+)`/g;
|
|
225
|
+
let match: RegExpExecArray | null;
|
|
226
|
+
while ((match = backtickRegex.exec(body)) !== null) {
|
|
227
|
+
const candidate = match[1];
|
|
228
|
+
// Simple heuristic: looks like a file path
|
|
229
|
+
if (candidate.includes("/") || candidate.includes(".")) {
|
|
230
|
+
files.push(candidate);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return [...new Set(files)];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// --- State management ---
|
|
237
|
+
|
|
238
|
+
/** In-memory state cache (reconstructed from session on resume) */
|
|
239
|
+
let currentState: WorkflowState = defaultState();
|
|
240
|
+
let piRef: ExtensionAPI | null = null;
|
|
241
|
+
|
|
242
|
+
export function initState(pi: ExtensionAPI): void {
|
|
243
|
+
piRef = pi;
|
|
244
|
+
currentState = defaultState();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function getState(): WorkflowState {
|
|
248
|
+
return currentState;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Update state and persist to session.
|
|
253
|
+
*/
|
|
254
|
+
export function updateState(updater: (state: WorkflowState) => void): void {
|
|
255
|
+
updater(currentState);
|
|
256
|
+
persist();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function persist(): void {
|
|
260
|
+
if (!piRef) return;
|
|
261
|
+
const entry: StateEntry = { version: 1, state: { ...currentState } };
|
|
262
|
+
piRef.appendEntry(ENTRY_TYPE, entry);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Reconstruct state from session branch entries.
|
|
267
|
+
* Called on session_start to restore from persisted state.
|
|
268
|
+
*/
|
|
269
|
+
export function restoreFromBranch(ctx: ExtensionContext): void {
|
|
270
|
+
const entries = ctx.sessionManager.getBranch();
|
|
271
|
+
let lastState: WorkflowState | null = null;
|
|
272
|
+
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
if (entry.type === "custom" && (entry as any).customType === ENTRY_TYPE) {
|
|
275
|
+
const data = (entry as any).data as StateEntry | undefined;
|
|
276
|
+
if (data?.version === 1 && data.state) {
|
|
277
|
+
lastState = data.state;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (lastState) {
|
|
283
|
+
currentState = lastState;
|
|
284
|
+
} else {
|
|
285
|
+
currentState = defaultState();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- Task operations ---
|
|
290
|
+
|
|
291
|
+
export function setTddMode(mode: TddMode): void {
|
|
292
|
+
updateState((s) => { s.tddMode = mode; });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function loadPlanIntoState(filePath: string): { count: number; source: string } {
|
|
296
|
+
const { tasks, source } = loadPlan(filePath);
|
|
297
|
+
updateState((s) => {
|
|
298
|
+
s.planFile = filePath;
|
|
299
|
+
s.tasks = tasks;
|
|
300
|
+
s.currentTaskIndex = tasks.length > 0 ? 0 : -1;
|
|
301
|
+
});
|
|
302
|
+
return { count: tasks.length, source };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function getCurrentTask(): PlanTask | null {
|
|
306
|
+
if (currentState.currentTaskIndex < 0 || currentState.currentTaskIndex >= currentState.tasks.length) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return currentState.tasks[currentState.currentTaskIndex];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function advanceTask(): PlanTask | null {
|
|
313
|
+
const next = currentState.currentTaskIndex + 1;
|
|
314
|
+
if (next >= currentState.tasks.length) return null;
|
|
315
|
+
updateState((s) => { s.currentTaskIndex = next; });
|
|
316
|
+
return currentState.tasks[next];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function updateTaskStatus(taskId: number, status: TaskStatus): void {
|
|
320
|
+
updateState((s) => {
|
|
321
|
+
const task = s.tasks.find((t) => t.id === taskId);
|
|
322
|
+
if (task) task.status = status;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function addReviewCycle(cycle: ReviewCycle): void {
|
|
327
|
+
updateState((s) => {
|
|
328
|
+
s.reviewCycles.push(cycle);
|
|
329
|
+
const task = s.tasks.find((t) => t.id === cycle.taskId);
|
|
330
|
+
if (task) {
|
|
331
|
+
if (cycle.status === "passed") {
|
|
332
|
+
if (!task.reviewsPassed.includes(cycle.reviewType)) {
|
|
333
|
+
task.reviewsPassed.push(cycle.reviewType);
|
|
334
|
+
}
|
|
335
|
+
// Remove from failed if previously failed
|
|
336
|
+
task.reviewsFailed = task.reviewsFailed.filter((r) => r !== cycle.reviewType);
|
|
337
|
+
} else if (cycle.status === "failed") {
|
|
338
|
+
if (!task.reviewsFailed.includes(cycle.reviewType)) {
|
|
339
|
+
task.reviewsFailed.push(cycle.reviewType);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function incrementFixAttempts(taskId: number): void {
|
|
347
|
+
updateState((s) => {
|
|
348
|
+
const task = s.tasks.find((t) => t.id === taskId);
|
|
349
|
+
if (task) task.fixAttempts++;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export function addCostToState(cost: number): void {
|
|
354
|
+
updateState((s) => { s.cumulativeCostUsd += cost; });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// --- Widget rendering ---
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Build status widget lines for display.
|
|
361
|
+
*/
|
|
362
|
+
export function buildStatusLines(theme?: any): string[] {
|
|
363
|
+
const state = currentState;
|
|
364
|
+
if (state.tddMode === "off" && state.tasks.length === 0) return [];
|
|
365
|
+
|
|
366
|
+
const lines: string[] = [];
|
|
367
|
+
|
|
368
|
+
// TDD mode indicator
|
|
369
|
+
if (state.tddMode !== "off") {
|
|
370
|
+
const modeLabel = state.tddMode.toUpperCase();
|
|
371
|
+
lines.push(`[${modeLabel}]`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Plan progress
|
|
375
|
+
if (state.tasks.length > 0) {
|
|
376
|
+
const current = getCurrentTask();
|
|
377
|
+
const completedCount = state.tasks.filter((t) => t.status === "complete").length;
|
|
378
|
+
|
|
379
|
+
if (current) {
|
|
380
|
+
const taskNum = state.currentTaskIndex + 1;
|
|
381
|
+
const total = state.tasks.length;
|
|
382
|
+
const title = current.title.length > 40
|
|
383
|
+
? `${current.title.slice(0, 37)}...`
|
|
384
|
+
: current.title;
|
|
385
|
+
|
|
386
|
+
// Build review status
|
|
387
|
+
let reviewStr = "";
|
|
388
|
+
if (current.reviewsPassed.length > 0 || current.reviewsFailed.length > 0) {
|
|
389
|
+
const parts = [
|
|
390
|
+
...current.reviewsPassed.map((r) => `${r} ✓`),
|
|
391
|
+
...current.reviewsFailed.map((r) => `${r} ✗`),
|
|
392
|
+
];
|
|
393
|
+
reviewStr = ` (${parts.join(" ")})`;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const costStr = state.cumulativeCostUsd > 0
|
|
397
|
+
? ` | $${state.cumulativeCostUsd.toFixed(2)}`
|
|
398
|
+
: "";
|
|
399
|
+
|
|
400
|
+
const prefix = state.tddMode !== "off" ? `[${state.tddMode.toUpperCase()}] ` : "";
|
|
401
|
+
lines[0] = `${prefix}Task ${taskNum}/${total}: "${title}" — ${current.status}${reviewStr}${costStr}`;
|
|
402
|
+
} else {
|
|
403
|
+
lines[0] = `Plan: ${completedCount}/${state.tasks.length} complete`;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return lines;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Update the status widget in the TUI.
|
|
412
|
+
*/
|
|
413
|
+
export function updateWidget(ctx: ExtensionContext): void {
|
|
414
|
+
if (!ctx.hasUI) return;
|
|
415
|
+
|
|
416
|
+
const lines = buildStatusLines(ctx.ui.theme);
|
|
417
|
+
if (lines.length > 0) {
|
|
418
|
+
ctx.ui.setWidget("superteam-status", lines);
|
|
419
|
+
} else {
|
|
420
|
+
ctx.ui.setWidget("superteam-status", undefined);
|
|
421
|
+
}
|
|
422
|
+
}
|