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.
@@ -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
+ }