supipowers 0.2.7 → 0.4.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.
Files changed (55) hide show
  1. package/package.json +21 -6
  2. package/skills/debugging/SKILL.md +54 -15
  3. package/skills/fix-pr/SKILL.md +99 -0
  4. package/skills/planning/SKILL.md +70 -10
  5. package/skills/receiving-code-review/SKILL.md +87 -0
  6. package/skills/tdd/SKILL.md +83 -0
  7. package/skills/verification/SKILL.md +54 -0
  8. package/src/commands/fix-pr.ts +324 -0
  9. package/src/commands/plan.ts +96 -31
  10. package/src/commands/qa.ts +150 -29
  11. package/src/commands/release.ts +1 -1
  12. package/src/commands/review.ts +2 -2
  13. package/src/commands/run.ts +52 -2
  14. package/src/commands/supi.ts +1 -0
  15. package/src/commands/update.ts +2 -2
  16. package/src/discipline/debugging.ts +57 -0
  17. package/src/discipline/receiving-review.ts +65 -0
  18. package/src/discipline/tdd.ts +77 -0
  19. package/src/discipline/verification.ts +68 -0
  20. package/src/fix-pr/config.ts +36 -0
  21. package/src/fix-pr/prompt-builder.ts +201 -0
  22. package/src/fix-pr/scripts/diff-comments.sh +33 -0
  23. package/src/fix-pr/scripts/fetch-pr-comments.sh +25 -0
  24. package/src/fix-pr/scripts/trigger-review.sh +36 -0
  25. package/src/fix-pr/scripts/wait-and-check.sh +37 -0
  26. package/src/fix-pr/types.ts +71 -0
  27. package/src/git/branch-finish.ts +101 -0
  28. package/src/git/worktree.ts +119 -0
  29. package/src/index.ts +13 -2
  30. package/src/lsp/detector.ts +2 -2
  31. package/src/orchestrator/agent-prompts.ts +282 -0
  32. package/src/orchestrator/dispatcher.ts +150 -1
  33. package/src/orchestrator/prompts.ts +17 -31
  34. package/src/planning/plan-reviewer.ts +49 -0
  35. package/src/planning/plan-writer-prompt.ts +173 -0
  36. package/src/planning/prompt-builder.ts +178 -0
  37. package/src/planning/spec-reviewer.ts +43 -0
  38. package/src/qa/phases/discovery.ts +34 -0
  39. package/src/qa/phases/execution.ts +65 -0
  40. package/src/qa/phases/matrix.ts +41 -0
  41. package/src/qa/phases/reporting.ts +71 -0
  42. package/src/qa/session.ts +104 -0
  43. package/src/storage/fix-pr-sessions.ts +59 -0
  44. package/src/storage/qa-sessions.ts +83 -0
  45. package/src/storage/specs.ts +36 -0
  46. package/src/types.ts +70 -0
  47. package/src/visual/companion.ts +115 -0
  48. package/src/visual/prompt-instructions.ts +102 -0
  49. package/src/visual/scripts/frame-template.html +201 -0
  50. package/src/visual/scripts/helper.js +88 -0
  51. package/src/visual/scripts/index.js +148 -0
  52. package/src/visual/scripts/package.json +10 -0
  53. package/src/visual/scripts/start-server.sh +98 -0
  54. package/src/visual/scripts/stop-server.sh +21 -0
  55. package/src/visual/types.ts +16 -0
@@ -0,0 +1,324 @@
1
+ import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { loadFixPrConfig, saveFixPrConfig, DEFAULT_FIX_PR_CONFIG } from "../fix-pr/config.js";
5
+ import { buildFixPrOrchestratorPrompt } from "../fix-pr/prompt-builder.js";
6
+ import type { FixPrConfig, ReviewerType, CommentReplyPolicy } from "../fix-pr/types.js";
7
+ import {
8
+ generateFixPrSessionId,
9
+ createFixPrSession,
10
+ findActiveFixPrSession,
11
+ getSessionDir,
12
+ } from "../storage/fix-pr-sessions.js";
13
+ import { notifyInfo, notifyError, notifyWarning } from "../notifications/renderer.js";
14
+
15
+ function getScriptsDir(): string {
16
+ return path.join(path.dirname(new URL(import.meta.url).pathname), "..", "fix-pr", "scripts");
17
+ }
18
+
19
+ function findSkillPath(skillName: string): string | null {
20
+ const candidates = [
21
+ path.join(process.cwd(), "skills", skillName, "SKILL.md"),
22
+ path.join(path.dirname(new URL(import.meta.url).pathname), "..", "..", "skills", skillName, "SKILL.md"),
23
+ ];
24
+ for (const p of candidates) {
25
+ if (fs.existsSync(p)) return p;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function registerFixPrCommand(pi: ExtensionAPI): void {
31
+ pi.registerCommand("supi:fix-pr", {
32
+ description: "Fix PR review comments with token-optimized agent orchestration",
33
+ async handler(args, ctx) {
34
+ // ── Step 1: Detect PR ──────────────────────────────────────────
35
+ let prNumber: number | null = null;
36
+ let repo: string | null = null;
37
+
38
+ // Try to parse from args
39
+ const argTrimmed = args?.trim().replace("#", "") || "";
40
+ if (/^\d+$/.test(argTrimmed)) {
41
+ prNumber = parseInt(argTrimmed, 10);
42
+ }
43
+
44
+ // Detect repo
45
+ try {
46
+ const repoResult = await pi.exec("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], { cwd: ctx.cwd });
47
+ if (repoResult.code === 0) repo = repoResult.stdout.trim();
48
+ } catch { /* ignore */ }
49
+
50
+ if (!repo) {
51
+ notifyError(ctx, "Could not detect repository", "Run from a git repo with gh CLI configured");
52
+ return;
53
+ }
54
+
55
+ // Detect PR number from current branch if not provided
56
+ if (!prNumber) {
57
+ try {
58
+ const prResult = await pi.exec("gh", ["pr", "view", "--json", "number", "-q", ".number"], { cwd: ctx.cwd });
59
+ if (prResult.code === 0) prNumber = parseInt(prResult.stdout.trim(), 10);
60
+ } catch { /* ignore */ }
61
+ }
62
+
63
+ if (!prNumber) {
64
+ notifyError(ctx, "No PR found", "Provide PR number as argument or run from a PR branch");
65
+ return;
66
+ }
67
+
68
+ // ── Step 2: Load or create config ──────────────────────────────
69
+ let config = loadFixPrConfig(ctx.cwd);
70
+
71
+ if (!config && ctx.hasUI) {
72
+ config = await runSetupWizard(ctx);
73
+ if (!config) return; // user cancelled
74
+ saveFixPrConfig(ctx.cwd, config);
75
+ ctx.ui.notify("Fix-PR config saved to .omp/supipowers/fix-pr.json", "info");
76
+ }
77
+
78
+ if (!config) {
79
+ notifyError(ctx, "No fix-pr config", "Run interactively first to set up configuration");
80
+ return;
81
+ }
82
+
83
+ // ── Step 3: Session handling ───────────────────────────────────
84
+ let activeSession = findActiveFixPrSession(ctx.cwd);
85
+
86
+ if (activeSession && ctx.hasUI) {
87
+ const choice = await ctx.ui.select(
88
+ "Fix-PR Session",
89
+ [
90
+ `Resume ${activeSession.id} (iteration ${activeSession.iteration}, PR #${activeSession.prNumber})`,
91
+ "Start new session",
92
+ ],
93
+ { helpText: "Select session · Esc to cancel" },
94
+ );
95
+ if (!choice) return;
96
+ if (choice.startsWith("Start new")) activeSession = null;
97
+ }
98
+
99
+ const ledger = activeSession ?? {
100
+ id: generateFixPrSessionId(),
101
+ createdAt: new Date().toISOString(),
102
+ updatedAt: new Date().toISOString(),
103
+ prNumber,
104
+ repo,
105
+ status: "running" as const,
106
+ iteration: 0,
107
+ config,
108
+ commentsProcessed: [],
109
+ };
110
+
111
+ if (!activeSession) {
112
+ createFixPrSession(ctx.cwd, ledger);
113
+ }
114
+
115
+ // ── Step 4: Fetch initial comments ─────────────────────────────
116
+ const sessionDir = getSessionDir(ctx.cwd, ledger.id);
117
+ const scriptsDir = getScriptsDir();
118
+ const snapshotPath = path.join(sessionDir, "snapshots", `comments-${ledger.iteration}.jsonl`);
119
+
120
+ const fetchResult = await pi.exec("bash", [
121
+ path.join(scriptsDir, "fetch-pr-comments.sh"),
122
+ repo,
123
+ String(prNumber),
124
+ snapshotPath,
125
+ ], { cwd: ctx.cwd });
126
+
127
+ if (fetchResult.code !== 0) {
128
+ notifyError(ctx, "Failed to fetch PR comments", fetchResult.stderr);
129
+ return;
130
+ }
131
+
132
+ // Read the snapshot
133
+ let comments = "";
134
+ try {
135
+ comments = fs.readFileSync(snapshotPath, "utf-8").trim();
136
+ } catch {
137
+ notifyWarning(ctx, "No comments found", "PR has no review comments to process");
138
+ return;
139
+ }
140
+
141
+ if (!comments) {
142
+ notifyInfo(ctx, "No comments to process", "PR has no review comments");
143
+ return;
144
+ }
145
+
146
+ const commentCount = comments.split("\n").length;
147
+
148
+ // ── Step 5: Load skill ─────────────────────────────────────────
149
+ let skillContent = "";
150
+ const skillPath = findSkillPath("fix-pr");
151
+ if (skillPath) {
152
+ try {
153
+ skillContent = fs.readFileSync(skillPath, "utf-8");
154
+ } catch { /* proceed without */ }
155
+ }
156
+
157
+ // ── Step 6: Build and send prompt ──────────────────────────────
158
+ const prompt = buildFixPrOrchestratorPrompt({
159
+ prNumber,
160
+ repo,
161
+ comments,
162
+ sessionDir,
163
+ scriptsDir,
164
+ config,
165
+ iteration: ledger.iteration,
166
+ skillContent,
167
+ });
168
+
169
+ pi.sendMessage(
170
+ {
171
+ customType: "supi-fix-pr",
172
+ content: [{ type: "text", text: prompt }],
173
+ display: "none",
174
+ },
175
+ { deliverAs: "steer" },
176
+ );
177
+
178
+ notifyInfo(ctx, `Fix-PR started: PR #${prNumber}`, `${commentCount} comments to assess | session ${ledger.id}`);
179
+ },
180
+ });
181
+ }
182
+
183
+ // ── Setup Wizard ───────────────────────────────────────────────────────
184
+
185
+ const REVIEWER_OPTIONS = [
186
+ "CodeRabbit",
187
+ "GitHub Copilot",
188
+ "Gemini Code Review",
189
+ "None",
190
+ ];
191
+
192
+ const REVIEWER_DEFAULTS: Record<string, string> = {
193
+ "CodeRabbit": "/review",
194
+ "GitHub Copilot": "@copilot review",
195
+ "Gemini Code Review": "/gemini review",
196
+ };
197
+
198
+ const POLICY_OPTIONS = [
199
+ "Answer all comments",
200
+ "Only answer wrong/unnecessary ones (recommended)",
201
+ "Don't answer, just fix",
202
+ ];
203
+
204
+ const DELAY_OPTIONS = [
205
+ "60 seconds",
206
+ "120 seconds",
207
+ "180 seconds (recommended)",
208
+ "300 seconds",
209
+ ];
210
+
211
+ const ITERATION_OPTIONS = [
212
+ "1",
213
+ "2",
214
+ "3 (recommended)",
215
+ "5",
216
+ ];
217
+
218
+ const MODEL_TIER_OPTIONS = [
219
+ "high — thorough reasoning, more tokens",
220
+ "low — fast execution, fewer tokens",
221
+ ];
222
+
223
+ async function runSetupWizard(ctx: any): Promise<FixPrConfig | null> {
224
+ // 1. Automated reviewer
225
+ const reviewerChoice = await ctx.ui.select(
226
+ "Automated PR reviewer",
227
+ REVIEWER_OPTIONS,
228
+ { helpText: "Select your automated reviewer, if any" },
229
+ );
230
+ if (!reviewerChoice) return null;
231
+
232
+ let reviewerType: ReviewerType = "none";
233
+ let triggerMethod: string | null = null;
234
+
235
+ if (reviewerChoice !== "None") {
236
+ reviewerType = reviewerChoice.toLowerCase().replace(/ /g, "").replace("github", "") as ReviewerType;
237
+ // Normalize to our type names
238
+ if (reviewerChoice === "CodeRabbit") reviewerType = "coderabbit";
239
+ else if (reviewerChoice === "GitHub Copilot") reviewerType = "copilot";
240
+ else if (reviewerChoice === "Gemini Code Review") reviewerType = "gemini";
241
+
242
+ const defaultTrigger = REVIEWER_DEFAULTS[reviewerChoice] || "";
243
+ triggerMethod = await ctx.ui.input(
244
+ "How to trigger re-review?",
245
+ defaultTrigger,
246
+ { helpText: `Default for ${reviewerChoice}: ${defaultTrigger}` },
247
+ );
248
+ if (triggerMethod === undefined) return null;
249
+ if (!triggerMethod) triggerMethod = defaultTrigger;
250
+ }
251
+
252
+ // 2. Comment reply policy
253
+ const policyChoice = await ctx.ui.select(
254
+ "Comment reply policy",
255
+ POLICY_OPTIONS,
256
+ { helpText: "How should we handle replying to comments?" },
257
+ );
258
+ if (!policyChoice) return null;
259
+
260
+ let commentPolicy: CommentReplyPolicy = "answer-selective";
261
+ if (policyChoice.startsWith("Answer all")) commentPolicy = "answer-all";
262
+ else if (policyChoice.startsWith("Don't")) commentPolicy = "no-answer";
263
+
264
+ // 3. Loop timing
265
+ const delayChoice = await ctx.ui.select(
266
+ "Delay between review checks",
267
+ DELAY_OPTIONS,
268
+ { helpText: "How long to wait for reviewer after pushing changes" },
269
+ );
270
+ if (!delayChoice) return null;
271
+ const delaySeconds = parseInt(delayChoice, 10);
272
+
273
+ const iterChoice = await ctx.ui.select(
274
+ "Max review iterations",
275
+ ITERATION_OPTIONS,
276
+ { helpText: "Maximum fix-check-fix cycles" },
277
+ );
278
+ if (!iterChoice) return null;
279
+ const maxIterations = parseInt(iterChoice, 10);
280
+
281
+ // 4. Model preferences
282
+ const orchestratorTier = await ctx.ui.select(
283
+ "Orchestrator model tier (assessment & grouping)",
284
+ MODEL_TIER_OPTIONS,
285
+ { helpText: "Higher tier = more thorough analysis" },
286
+ );
287
+ if (!orchestratorTier) return null;
288
+
289
+ const plannerTier = await ctx.ui.select(
290
+ "Planner model tier (fix planning)",
291
+ MODEL_TIER_OPTIONS,
292
+ { helpText: "Higher tier = more detailed plans" },
293
+ );
294
+ if (!plannerTier) return null;
295
+
296
+ const fixerTier = await ctx.ui.select(
297
+ "Fixer model tier (code changes)",
298
+ MODEL_TIER_OPTIONS,
299
+ { helpText: "Lower tier usually sufficient for execution" },
300
+ );
301
+ if (!fixerTier) return null;
302
+
303
+ const config: FixPrConfig = {
304
+ reviewer: { type: reviewerType, triggerMethod },
305
+ commentPolicy,
306
+ loop: { delaySeconds, maxIterations },
307
+ models: {
308
+ orchestrator: {
309
+ ...DEFAULT_FIX_PR_CONFIG.models.orchestrator,
310
+ tier: orchestratorTier.startsWith("high") ? "high" : "low",
311
+ },
312
+ planner: {
313
+ ...DEFAULT_FIX_PR_CONFIG.models.planner,
314
+ tier: plannerTier.startsWith("high") ? "high" : "low",
315
+ },
316
+ fixer: {
317
+ ...DEFAULT_FIX_PR_CONFIG.models.fixer,
318
+ tier: fixerTier.startsWith("high") ? "high" : "low",
319
+ },
320
+ },
321
+ };
322
+
323
+ return config;
324
+ }
@@ -1,10 +1,29 @@
1
1
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
2
  import { loadConfig } from "../config/loader.js";
3
3
  import { savePlan } from "../storage/plans.js";
4
- import { notifySuccess, notifyInfo } from "../notifications/renderer.js";
4
+ import { notifySuccess, notifyInfo, notifyError } from "../notifications/renderer.js";
5
+ import {
6
+ generateVisualSessionId,
7
+ createSessionDir,
8
+ getScriptsDir,
9
+ parseServerInfo,
10
+ } from "../visual/companion.js";
11
+ import { buildVisualInstructions } from "../visual/prompt-instructions.js";
12
+ import { buildPlanningPrompt, buildQuickPlanPrompt } from "../planning/prompt-builder.js";
5
13
  import * as fs from "node:fs";
6
14
  import * as path from "node:path";
7
15
 
16
+ /** Module-level tracking for cleanup */
17
+ let activeSessionDir: string | null = null;
18
+
19
+ export function getActiveVisualSessionDir(): string | null {
20
+ return activeSessionDir;
21
+ }
22
+
23
+ export function setActiveVisualSessionDir(dir: string | null): void {
24
+ activeSessionDir = dir;
25
+ }
26
+
8
27
  export function registerPlanCommand(pi: ExtensionAPI): void {
9
28
  pi.registerCommand("supi:plan", {
10
29
  description: "Start collaborative planning for a feature or task",
@@ -24,39 +43,85 @@ export function registerPlanCommand(pi: ExtensionAPI): void {
24
43
  const isQuick = args?.startsWith("--quick");
25
44
  const quickDesc = isQuick ? args.replace("--quick", "").trim() : "";
26
45
 
46
+ // ── Visual companion consent ──────────────────────────────────
47
+ let visualUrl: string | null = null;
48
+ let visualSessionDir: string | null = null;
49
+
50
+ if (ctx.hasUI && !isQuick) {
51
+ const modeChoice = await ctx.ui.select(
52
+ "Planning mode",
53
+ [
54
+ "Terminal only",
55
+ "Terminal + Visual companion (opens browser)",
56
+ ],
57
+ { helpText: "Visual companion shows mockups and diagrams in a browser · Esc to cancel" },
58
+ );
59
+ if (!modeChoice) return;
60
+
61
+ if (modeChoice.startsWith("Terminal + Visual")) {
62
+ const sessionId = generateVisualSessionId();
63
+ visualSessionDir = createSessionDir(ctx.cwd, sessionId);
64
+ const scriptsDir = getScriptsDir();
65
+
66
+ // Install server dependencies if needed
67
+ const nodeModules = path.join(scriptsDir, "node_modules");
68
+ if (!fs.existsSync(nodeModules)) {
69
+ notifyInfo(ctx, "Installing visual companion dependencies...");
70
+ const installResult = await pi.exec("npm", ["install", "--production"], { cwd: scriptsDir });
71
+ if (installResult.code !== 0) {
72
+ notifyError(ctx, "Failed to install visual companion dependencies", installResult.stderr);
73
+ visualSessionDir = null;
74
+ }
75
+ }
76
+
77
+ if (visualSessionDir) {
78
+ // Stop any previous visual companion
79
+ if (activeSessionDir) {
80
+ const stopScript = path.join(scriptsDir, "stop-server.sh");
81
+ await pi.exec("bash", [stopScript, activeSessionDir], { cwd: scriptsDir });
82
+ }
83
+
84
+ // Start the server (pass session dir via env command since ExecOptions has no env)
85
+ const startScript = path.join(scriptsDir, "start-server.sh");
86
+ const startResult = await pi.exec("env", [
87
+ `SUPI_VISUAL_DIR=${visualSessionDir}`,
88
+ "bash",
89
+ startScript,
90
+ ], { cwd: scriptsDir });
91
+
92
+ if (startResult.code === 0) {
93
+ const serverInfo = parseServerInfo(startResult.stdout);
94
+ if (serverInfo) {
95
+ visualUrl = serverInfo.url;
96
+ activeSessionDir = visualSessionDir;
97
+ notifyInfo(ctx, "Visual companion ready", visualUrl);
98
+ } else {
99
+ notifyError(ctx, "Visual companion started but no connection info received");
100
+ visualSessionDir = null;
101
+ }
102
+ } else {
103
+ const errorMsg = startResult.stderr || startResult.stdout;
104
+ notifyError(ctx, "Failed to start visual companion", errorMsg);
105
+ visualSessionDir = null;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ // ── Build prompt ──────────────────────────────────────────────
27
112
  let prompt: string;
28
113
  if (isQuick && quickDesc) {
29
- prompt = [
30
- "Generate a concise implementation plan for the following task.",
31
- "Skip brainstorming — go straight to task breakdown.",
32
- "",
33
- `Task: ${quickDesc}`,
34
- "",
35
- "Format the plan as markdown with YAML frontmatter (name, created, tags).",
36
- "Each task should have: name, [parallel-safe] or [sequential] annotation,",
37
- "**files**, **criteria**, and **complexity** (small/medium/large).",
38
- "",
39
- skillContent ? "Follow these planning guidelines:\n" + skillContent : "",
40
- "",
41
- "After generating the plan, save it and confirm with the user.",
42
- ].join("\n");
114
+ prompt = buildQuickPlanPrompt(quickDesc, skillContent || undefined);
43
115
  } else {
44
- prompt = [
45
- "You are starting a collaborative planning session with the user.",
46
- "",
47
- args ? `The user wants to plan: ${args}` : "Ask the user what they want to build or accomplish.",
48
- "",
49
- "Process:",
50
- "1. Understand the goal ask clarifying questions (one at a time)",
51
- "2. Propose 2-3 approaches with trade-offs",
52
- "3. Generate a task breakdown once aligned",
53
- "",
54
- "Format the final plan as markdown with YAML frontmatter (name, created, tags).",
55
- "Each task: name, [parallel-safe] or [sequential] annotation,",
56
- "**files**, **criteria**, **complexity** (small/medium/large).",
57
- "",
58
- skillContent ? "Follow these planning guidelines:\n" + skillContent : "",
59
- ].join("\n");
116
+ prompt = buildPlanningPrompt({
117
+ topic: args || undefined,
118
+ skillContent: skillContent || undefined,
119
+ });
120
+ }
121
+
122
+ // Append visual companion instructions if active
123
+ if (visualUrl && visualSessionDir) {
124
+ prompt += "\n\n" + buildVisualInstructions(visualUrl, visualSessionDir);
60
125
  }
61
126
 
62
127
  pi.sendMessage(
@@ -1,11 +1,30 @@
1
1
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
2
  import { detectAndCache } from "../qa/detector.js";
3
- import { buildQaRunPrompt } from "../qa/runner.js";
4
3
  import { notifyInfo, notifyError } from "../notifications/renderer.js";
4
+ import { findActiveSession, findSessionWithFailures } from "../storage/qa-sessions.js";
5
+ import {
6
+ createNewSession,
7
+ advancePhase,
8
+ getFailedTests,
9
+ getNextPhase,
10
+ getPhaseStatusLine,
11
+ } from "../qa/session.js";
12
+ import { buildDiscoveryPrompt } from "../qa/phases/discovery.js";
13
+ import { buildMatrixPrompt } from "../qa/phases/matrix.js";
14
+ import { buildExecutionPrompt } from "../qa/phases/execution.js";
15
+ import { buildReportingPrompt } from "../qa/phases/reporting.js";
16
+ import type { QaPhase, QaSessionLedger } from "../types.js";
17
+
18
+ const PHASE_LABELS: Record<QaPhase, string> = {
19
+ discovery: "Discovery — Scan for test cases",
20
+ matrix: "Matrix — Build traceability matrix",
21
+ execution: "Execution — Run tests",
22
+ reporting: "Reporting — Generate summary",
23
+ };
5
24
 
6
25
  export function registerQaCommand(pi: ExtensionAPI): void {
7
26
  pi.registerCommand("supi:qa", {
8
- description: "Run QA pipeline (test suite, E2E)",
27
+ description: "Run QA pipeline with session management (discovery matrix → execution → reporting)",
9
28
  async handler(args, ctx) {
10
29
  const framework = detectAndCache(ctx.cwd);
11
30
 
@@ -13,48 +32,150 @@ export function registerQaCommand(pi: ExtensionAPI): void {
13
32
  notifyError(
14
33
  ctx,
15
34
  "No test framework detected",
16
- "Configure manually: /supi:config set qa.framework vitest && /supi:config set qa.command 'npx vitest run'"
35
+ "Configure manually via /supi:config"
17
36
  );
18
37
  return;
19
38
  }
20
39
 
21
- let scope: "all" | "changed" | "e2e" = "all";
22
- let changedFiles: string[] | undefined;
23
-
24
- if (args?.includes("--changed")) {
25
- scope = "changed";
26
- } else if (args?.includes("--e2e")) {
27
- scope = "e2e";
28
- } else if (ctx.hasUI && !args?.trim()) {
29
- // No flag provided — let the user pick
30
- const choice = await ctx.ui.select(
31
- "QA scope",
32
- ["all Run all tests", "changed Only changed files", "e2e E2E / Playwright only"],
33
- { helpText: "Select test scope · Esc to cancel" },
34
- );
35
- if (!choice) return;
36
- scope = choice.split(" — ")[0] as "all" | "changed" | "e2e";
40
+ // ── Step 1: Session selection ──────────────────────────────────
41
+ let ledger: QaSessionLedger | null = null;
42
+
43
+ const activeSession = findActiveSession(ctx.cwd);
44
+ const failedSession = findSessionWithFailures(ctx.cwd);
45
+
46
+ if (ctx.hasUI && !args?.trim()) {
47
+ const sessionOptions: string[] = [];
48
+
49
+ if (failedSession) {
50
+ const failCount = failedSession.results.filter((r) => r.status === "fail").length;
51
+ sessionOptions.push(`Resume ${failedSession.id} (${failCount} failed test${failCount !== 1 ? "s" : ""})`);
52
+ } else if (activeSession) {
53
+ const next = getNextPhase(activeSession);
54
+ sessionOptions.push(`Resume ${activeSession.id} (${next ?? "all phases done"} pending)`);
55
+ }
56
+
57
+ sessionOptions.push("Start new session");
58
+
59
+ if (sessionOptions.length > 1) {
60
+ const choice = await ctx.ui.select(
61
+ "QA Session",
62
+ sessionOptions,
63
+ { helpText: "Select session · Esc to cancel" },
64
+ );
65
+ if (!choice) return;
66
+
67
+ if (choice.startsWith("Resume")) {
68
+ ledger = failedSession ?? activeSession;
69
+ }
70
+ }
37
71
  }
38
72
 
39
- if (scope === "changed") {
40
- try {
41
- const result = await pi.exec("git", ["diff", "--name-only", "HEAD"], { cwd: ctx.cwd });
42
- if (result.exitCode === 0) {
43
- changedFiles = result.stdout.split("\n").filter((f) => f.trim().length > 0);
73
+ // Create new session if none selected
74
+ if (!ledger) {
75
+ ledger = createNewSession(ctx.cwd, framework.name);
76
+ notifyInfo(ctx, "QA session created", ledger.id);
77
+ }
78
+
79
+ // ── Step 2: Phase selection ────────────────────────────────────
80
+ type PhaseAction =
81
+ | { type: "run-phase"; phase: QaPhase }
82
+ | { type: "rerun-failed" };
83
+
84
+ let action: PhaseAction | null = null;
85
+ const nextPhase = getNextPhase(ledger);
86
+ const failedTests = getFailedTests(ledger);
87
+
88
+ if (ctx.hasUI && !args?.trim()) {
89
+ const phaseOptions: string[] = [];
90
+
91
+ // Offer re-run failed if there are failures
92
+ if (failedTests.length > 0) {
93
+ phaseOptions.push(`Re-run ${failedTests.length} failed test${failedTests.length !== 1 ? "s" : ""} only`);
94
+ }
95
+
96
+ // Offer starting from next pending phase
97
+ if (nextPhase) {
98
+ phaseOptions.push(PHASE_LABELS[nextPhase]);
99
+ }
100
+
101
+ if (phaseOptions.length > 1) {
102
+ const statusLine = getPhaseStatusLine(ledger);
103
+ const choice = await ctx.ui.select(
104
+ `QA Phase · ${statusLine}`,
105
+ phaseOptions,
106
+ { helpText: "Select action · Esc to cancel" },
107
+ );
108
+ if (!choice) return;
109
+
110
+ if (choice.startsWith("Re-run")) {
111
+ action = { type: "rerun-failed" };
112
+ } else {
113
+ // Extract phase from label
114
+ const selectedPhase = (Object.entries(PHASE_LABELS) as [QaPhase, string][])
115
+ .find(([, label]) => label === choice)?.[0];
116
+ if (selectedPhase) {
117
+ action = { type: "run-phase", phase: selectedPhase };
118
+ }
44
119
  }
45
- } catch {
46
- scope = "all";
120
+ } else if (nextPhase) {
121
+ // Only one option — just run the next phase
122
+ action = { type: "run-phase", phase: nextPhase };
47
123
  }
124
+ } else if (nextPhase) {
125
+ action = { type: "run-phase", phase: nextPhase };
126
+ }
127
+
128
+ if (!action) {
129
+ notifyInfo(ctx, "QA pipeline complete", getPhaseStatusLine(ledger));
130
+ return;
48
131
  }
49
132
 
50
- notifyInfo(ctx, "QA started", `${framework.name} | scope: ${scope}`);
133
+ // ── Step 3: Execute ────────────────────────────────────────────
134
+ let prompt: string;
135
+
136
+ if (action.type === "rerun-failed") {
137
+ ledger = advancePhase(ctx.cwd, ledger, "execution", "running");
138
+ prompt = buildExecutionPrompt(ledger, { failedOnly: true, failedTests });
139
+ notifyInfo(ctx, "QA re-running failed tests", `${failedTests.length} test(s)`);
140
+ } else {
141
+ const phase = action.phase;
142
+ ledger = advancePhase(ctx.cwd, ledger, phase, "running");
143
+
144
+ switch (phase) {
145
+ case "discovery":
146
+ prompt = buildDiscoveryPrompt(framework, ctx.cwd);
147
+ break;
148
+ case "matrix":
149
+ prompt = buildMatrixPrompt(ledger);
150
+ break;
151
+ case "execution":
152
+ prompt = buildExecutionPrompt(ledger);
153
+ break;
154
+ case "reporting":
155
+ prompt = buildReportingPrompt(ledger);
156
+ break;
157
+ }
158
+
159
+ notifyInfo(ctx, `QA phase: ${phase}`, `session: ${ledger.id}`);
160
+ }
51
161
 
52
- const prompt = buildQaRunPrompt(framework.command, scope, changedFiles);
162
+ // Include session context for the sub-agent
163
+ const sessionContext = [
164
+ `\n\n## QA Session Context`,
165
+ ``,
166
+ `Session ID: ${ledger.id}`,
167
+ `Session ledger path: .omp/supipowers/qa-sessions/${ledger.id}/ledger.json`,
168
+ ``,
169
+ `Current ledger state:`,
170
+ "```json",
171
+ JSON.stringify(ledger, null, 2),
172
+ "```",
173
+ ].join("\n");
53
174
 
54
175
  pi.sendMessage(
55
176
  {
56
177
  customType: "supi-qa",
57
- content: [{ type: "text", text: prompt }],
178
+ content: [{ type: "text", text: prompt + sessionContext }],
58
179
  display: "none",
59
180
  },
60
181
  { deliverAs: "steer" }