supipowers 0.3.0 → 0.5.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 (37) hide show
  1. package/package.json +1 -1
  2. package/skills/fix-pr/SKILL.md +99 -0
  3. package/skills/qa-strategy/SKILL.md +103 -21
  4. package/src/commands/fix-pr.ts +324 -0
  5. package/src/commands/qa.ts +232 -148
  6. package/src/commands/supi.ts +2 -1
  7. package/src/config/defaults.ts +1 -0
  8. package/src/config/schema.ts +1 -0
  9. package/src/fix-pr/config.ts +36 -0
  10. package/src/fix-pr/prompt-builder.ts +201 -0
  11. package/src/fix-pr/scripts/diff-comments.sh +33 -0
  12. package/src/fix-pr/scripts/fetch-pr-comments.sh +25 -0
  13. package/src/fix-pr/scripts/trigger-review.sh +36 -0
  14. package/src/fix-pr/scripts/wait-and-check.sh +37 -0
  15. package/src/fix-pr/types.ts +71 -0
  16. package/src/index.ts +2 -0
  17. package/src/qa/config.ts +43 -0
  18. package/src/qa/matrix.ts +84 -0
  19. package/src/qa/prompt-builder.ts +212 -0
  20. package/src/qa/scripts/detect-app-type.sh +68 -0
  21. package/src/qa/scripts/discover-routes.sh +143 -0
  22. package/src/qa/scripts/ensure-playwright.sh +38 -0
  23. package/src/qa/scripts/run-e2e-tests.sh +99 -0
  24. package/src/qa/scripts/start-dev-server.sh +46 -0
  25. package/src/qa/scripts/stop-dev-server.sh +36 -0
  26. package/src/qa/session.ts +39 -55
  27. package/src/qa/types.ts +97 -0
  28. package/src/storage/fix-pr-sessions.ts +59 -0
  29. package/src/storage/qa-sessions.ts +9 -9
  30. package/src/types.ts +1 -70
  31. package/src/qa/detector.ts +0 -61
  32. package/src/qa/phases/discovery.ts +0 -34
  33. package/src/qa/phases/execution.ts +0 -65
  34. package/src/qa/phases/matrix.ts +0 -41
  35. package/src/qa/phases/reporting.ts +0 -71
  36. package/src/qa/report.ts +0 -22
  37. package/src/qa/runner.ts +0 -46
@@ -1,184 +1,268 @@
1
1
  import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
2
- import { detectAndCache } from "../qa/detector.js";
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
- };
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { notifyInfo, notifyError, notifyWarning } from "../notifications/renderer.js";
5
+ import { loadE2eQaConfig, saveE2eQaConfig, DEFAULT_E2E_QA_CONFIG } from "../qa/config.js";
6
+ import { loadE2eMatrix } from "../qa/matrix.js";
7
+ import { createNewE2eSession } from "../qa/session.js";
8
+ import { buildE2eOrchestratorPrompt } from "../qa/prompt-builder.js";
9
+ import { findActiveSession, getSessionDir } from "../storage/qa-sessions.js";
10
+ import type { E2eQaConfig, AppType, E2eRegression } from "../qa/types.js";
24
11
 
25
- export function registerQaCommand(pi: ExtensionAPI): void {
26
- pi.registerCommand("supi:qa", {
27
- description: "Run QA pipeline with session management (discovery → matrix → execution → reporting)",
28
- async handler(args, ctx) {
29
- const framework = detectAndCache(ctx.cwd);
30
-
31
- if (!framework) {
32
- notifyError(
33
- ctx,
34
- "No test framework detected",
35
- "Configure manually via /supi:config"
36
- );
37
- return;
38
- }
12
+ function getScriptsDir(): string {
13
+ return path.join(path.dirname(new URL(import.meta.url).pathname), "..", "qa", "scripts");
14
+ }
39
15
 
40
- // ── Step 1: Session selection ──────────────────────────────────
41
- let ledger: QaSessionLedger | null = null;
16
+ function findSkillPath(skillName: string): string | null {
17
+ const candidates = [
18
+ path.join(process.cwd(), "skills", skillName, "SKILL.md"),
19
+ path.join(path.dirname(new URL(import.meta.url).pathname), "..", "..", "skills", skillName, "SKILL.md"),
20
+ ];
21
+ for (const p of candidates) {
22
+ if (fs.existsSync(p)) return p;
23
+ }
24
+ return null;
25
+ }
42
26
 
43
- const activeSession = findActiveSession(ctx.cwd);
44
- const failedSession = findSessionWithFailures(ctx.cwd);
27
+ const APP_TYPE_OPTIONS = [
28
+ "nextjs-app Next.js App Router",
29
+ "nextjs-pages — Next.js Pages Router",
30
+ "react-router — React with React Router",
31
+ "vite — Vite-based app",
32
+ "express — Express.js server",
33
+ "generic — Other web app",
34
+ ];
45
35
 
46
- if (ctx.hasUI && !args?.trim()) {
47
- const sessionOptions: string[] = [];
36
+ const BROWSER_OPTIONS = [
37
+ "chromium (recommended)",
38
+ "firefox",
39
+ "webkit",
40
+ ];
48
41
 
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
- }
42
+ const RETRY_OPTIONS = [
43
+ "1",
44
+ "2 (recommended)",
45
+ "3",
46
+ ];
56
47
 
57
- sessionOptions.push("Start new session");
48
+ async function runSetupWizard(
49
+ ctx: any,
50
+ detectedAppType: string | null,
51
+ detectedDevCommand: string | null,
52
+ detectedPort: number | null,
53
+ ): Promise<E2eQaConfig | null> {
54
+ // 1. App type
55
+ const appTypeChoice = await ctx.ui.select(
56
+ "App type",
57
+ APP_TYPE_OPTIONS,
58
+ { helpText: detectedAppType ? `Auto-detected: ${detectedAppType}` : "Select your web app framework" },
59
+ );
60
+ if (!appTypeChoice) return null;
61
+ const appType = appTypeChoice.split(" ")[0] as AppType;
58
62
 
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;
63
+ // 2. Dev command
64
+ const defaultDev = detectedDevCommand || "npm run dev";
65
+ const devCommand = await ctx.ui.input(
66
+ "Dev server command",
67
+ defaultDev,
68
+ { helpText: "Command to start your development server" },
69
+ );
70
+ if (devCommand === undefined) return null;
66
71
 
67
- if (choice.startsWith("Resume")) {
68
- ledger = failedSession ?? activeSession;
69
- }
70
- }
71
- }
72
+ // 3. Port
73
+ const defaultPort = String(detectedPort || 3000);
74
+ const portStr = await ctx.ui.input(
75
+ "Dev server port",
76
+ defaultPort,
77
+ { helpText: "Port your dev server runs on" },
78
+ );
79
+ if (portStr === undefined) return null;
80
+ const port = parseInt(portStr, 10) || 3000;
72
81
 
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
- }
82
+ // 4. Browser
83
+ const browserChoice = await ctx.ui.select(
84
+ "Browser for E2E tests",
85
+ BROWSER_OPTIONS,
86
+ { helpText: "Playwright browser to use" },
87
+ );
88
+ if (!browserChoice) return null;
89
+ const browser = browserChoice.split(" ")[0] as "chromium" | "firefox" | "webkit";
78
90
 
79
- // ── Step 2: Phase selection ────────────────────────────────────
80
- type PhaseAction =
81
- | { type: "run-phase"; phase: QaPhase }
82
- | { type: "rerun-failed" };
91
+ // 5. Max retries
92
+ const retryChoice = await ctx.ui.select(
93
+ "Max test retries",
94
+ RETRY_OPTIONS,
95
+ { helpText: "How many times to retry failing tests" },
96
+ );
97
+ if (!retryChoice) return null;
98
+ const maxRetries = parseInt(retryChoice, 10);
83
99
 
84
- let action: PhaseAction | null = null;
85
- const nextPhase = getNextPhase(ledger);
86
- const failedTests = getFailedTests(ledger);
100
+ return {
101
+ app: {
102
+ type: appType,
103
+ devCommand: devCommand || defaultDev,
104
+ port,
105
+ baseUrl: `http://localhost:${port}`,
106
+ },
107
+ playwright: {
108
+ browser,
109
+ headless: true,
110
+ timeout: 30000,
111
+ },
112
+ execution: {
113
+ maxRetries,
114
+ maxFlows: 20,
115
+ },
116
+ };
117
+ }
87
118
 
88
- if (ctx.hasUI && !args?.trim()) {
89
- const phaseOptions: string[] = [];
119
+ export function registerQaCommand(pi: ExtensionAPI): void {
120
+ pi.registerCommand("supi:qa", {
121
+ description: "Run autonomous E2E product testing pipeline with playwright",
122
+ async handler(args, ctx) {
123
+ const scriptsDir = getScriptsDir();
90
124
 
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`);
125
+ // ── Step 1: Detect app type ─────────────────────────────────────
126
+ let detectedType: string | null = null;
127
+ let detectedDevCommand: string | null = null;
128
+ let detectedPort: number | null = null;
129
+
130
+ try {
131
+ const detectResult = await pi.exec("bash", [
132
+ path.join(scriptsDir, "detect-app-type.sh"),
133
+ ctx.cwd,
134
+ ], { cwd: ctx.cwd });
135
+
136
+ if (detectResult.code === 0) {
137
+ const detected = JSON.parse(detectResult.stdout.trim());
138
+ detectedType = detected.type;
139
+ detectedDevCommand = detected.devCommand;
140
+ detectedPort = detected.port;
94
141
  }
142
+ } catch { /* proceed without detection */ }
143
+
144
+ // ── Step 2: Ensure playwright ────────────────────────────────────
145
+ try {
146
+ const pwResult = await pi.exec("bash", [
147
+ path.join(scriptsDir, "ensure-playwright.sh"),
148
+ ctx.cwd,
149
+ ], { cwd: ctx.cwd });
95
150
 
96
- // Offer starting from next pending phase
97
- if (nextPhase) {
98
- phaseOptions.push(PHASE_LABELS[nextPhase]);
151
+ if (pwResult.code !== 0) {
152
+ notifyWarning(ctx, "Playwright setup issue", "Could not verify playwright installation. The agent will handle it.");
99
153
  }
154
+ } catch {
155
+ notifyWarning(ctx, "Playwright check skipped", "Will be handled during execution");
156
+ }
157
+
158
+ // ── Step 3: Load or create config ────────────────────────────────
159
+ let config = loadE2eQaConfig(ctx.cwd);
160
+
161
+ if (!config && ctx.hasUI) {
162
+ config = await runSetupWizard(ctx, detectedType, detectedDevCommand, detectedPort);
163
+ if (!config) return; // user cancelled
164
+ saveE2eQaConfig(ctx.cwd, config);
165
+ ctx.ui.notify("E2E QA config saved to .omp/supipowers/e2e-qa.json", "info");
166
+ }
167
+
168
+ if (!config) {
169
+ // Use defaults with detected values
170
+ config = {
171
+ ...DEFAULT_E2E_QA_CONFIG,
172
+ app: {
173
+ type: (detectedType as AppType) || "generic",
174
+ devCommand: detectedDevCommand || "npm run dev",
175
+ port: detectedPort || 3000,
176
+ baseUrl: `http://localhost:${detectedPort || 3000}`,
177
+ },
178
+ };
179
+ }
100
180
 
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
- }
181
+ // ── Step 4: Check for unresolved regressions ─────────────────────
182
+ const activeSession = findActiveSession(ctx.cwd);
183
+ if (activeSession && activeSession.regressions.length > 0 && ctx.hasUI) {
184
+ const unresolvedRegressions = activeSession.regressions.filter((r: E2eRegression) => !r.resolution);
185
+ if (unresolvedRegressions.length > 0) {
186
+ for (const regression of unresolvedRegressions) {
187
+ const choice = await ctx.ui.select(
188
+ `Regression: ${regression.flowName}`,
189
+ [
190
+ "This is a bug — needs fixing",
191
+ "Behavior changed intentionally update the test",
192
+ "Skip for now",
193
+ ],
194
+ { helpText: `Was passing, now fails: ${regression.error}` },
195
+ );
196
+ if (!choice) continue;
197
+ if (choice.startsWith("This is a bug")) regression.resolution = "bug";
198
+ else if (choice.startsWith("Behavior changed")) regression.resolution = "intentional-change";
199
+ else regression.resolution = "skipped";
119
200
  }
120
- } else if (nextPhase) {
121
- // Only one option — just run the next phase
122
- action = { type: "run-phase", phase: nextPhase };
123
201
  }
124
- } else if (nextPhase) {
125
- action = { type: "run-phase", phase: nextPhase };
126
202
  }
127
203
 
128
- if (!action) {
129
- notifyInfo(ctx, "QA pipeline complete", getPhaseStatusLine(ledger));
130
- return;
131
- }
204
+ // ── Step 5: Route discovery ──────────────────────────────────────
205
+ let discoveredRoutes = "";
206
+ try {
207
+ const routeResult = await pi.exec("bash", [
208
+ path.join(scriptsDir, "discover-routes.sh"),
209
+ ctx.cwd,
210
+ config.app.type,
211
+ ], { cwd: ctx.cwd });
132
212
 
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;
213
+ if (routeResult.code === 0 && routeResult.stdout.trim()) {
214
+ discoveredRoutes = routeResult.stdout.trim();
157
215
  }
216
+ } catch { /* agent will discover routes manually */ }
158
217
 
159
- notifyInfo(ctx, `QA phase: ${phase}`, `session: ${ledger.id}`);
218
+ if (!discoveredRoutes) {
219
+ discoveredRoutes = '{"path": "/", "file": "unknown", "type": "page", "hasForm": false}';
160
220
  }
161
221
 
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");
222
+ // ── Step 6: Load previous matrix ─────────────────────────────────
223
+ const previousMatrix = loadE2eMatrix(ctx.cwd);
224
+ const matrixJson = previousMatrix ? JSON.stringify(previousMatrix, null, 2) : null;
225
+
226
+ // ── Step 7: Create session ───────────────────────────────────────
227
+ const ledger = createNewE2eSession(ctx.cwd, config);
228
+ const sessionDir = getSessionDir(ctx.cwd, ledger.id);
229
+
230
+ // ── Step 8: Load skill ───────────────────────────────────────────
231
+ let skillContent = "";
232
+ const skillPath = findSkillPath("qa-strategy");
233
+ if (skillPath) {
234
+ try {
235
+ skillContent = fs.readFileSync(skillPath, "utf-8");
236
+ } catch { /* proceed without */ }
237
+ }
238
+
239
+ // ── Step 9: Build and send prompt ────────────────────────────────
240
+ const routeCount = discoveredRoutes.split("\n").filter(Boolean).length;
241
+
242
+ const prompt = buildE2eOrchestratorPrompt({
243
+ cwd: ctx.cwd,
244
+ appType: config.app,
245
+ sessionDir,
246
+ scriptsDir,
247
+ config,
248
+ discoveredRoutes,
249
+ previousMatrix: matrixJson,
250
+ skillContent,
251
+ });
174
252
 
175
253
  pi.sendMessage(
176
254
  {
177
255
  customType: "supi-qa",
178
- content: [{ type: "text", text: prompt + sessionContext }],
256
+ content: [{ type: "text", text: prompt }],
179
257
  display: "none",
180
258
  },
181
- { deliverAs: "steer" }
259
+ { deliverAs: "steer" },
260
+ );
261
+
262
+ notifyInfo(
263
+ ctx,
264
+ `E2E QA started: ${config.app.type}`,
265
+ `${routeCount} routes discovered | session ${ledger.id}`,
182
266
  );
183
267
  },
184
268
  });
@@ -15,7 +15,8 @@ export function handleSupi(pi: ExtensionAPI, ctx: ExtensionContext): void {
15
15
  "/supi:plan — Start collaborative planning",
16
16
  "/supi:run — Execute a plan with sub-agents",
17
17
  "/supi:review — Run quality gates",
18
- "/supi:qa — Run QA pipeline",
18
+ "/supi:qa — E2E product testing with Playwright",
19
+ "/supi:fix-pr — Fix PR review comments",
19
20
  "/supi:release — Release automation",
20
21
  "/supi:config — Manage configuration",
21
22
  "/supi:status — Check running tasks",
@@ -19,6 +19,7 @@ export const DEFAULT_CONFIG: SupipowersConfig = {
19
19
  qa: {
20
20
  framework: null,
21
21
  command: null,
22
+ e2e: false,
22
23
  },
23
24
  release: {
24
25
  pipeline: null,
@@ -25,6 +25,7 @@ const ConfigSchema = Type.Object({
25
25
  qa: Type.Object({
26
26
  framework: Type.Union([Type.String(), Type.Null()]),
27
27
  command: Type.Union([Type.String(), Type.Null()]),
28
+ e2e: Type.Boolean(),
28
29
  }),
29
30
  release: Type.Object({
30
31
  pipeline: Type.Union([Type.String(), Type.Null()]),
@@ -0,0 +1,36 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { FixPrConfig } from "./types.js";
4
+
5
+ const CONFIG_FILENAME = "fix-pr.json";
6
+
7
+ function getConfigPath(cwd: string): string {
8
+ return path.join(cwd, ".omp", "supipowers", CONFIG_FILENAME);
9
+ }
10
+
11
+ export const DEFAULT_FIX_PR_CONFIG: FixPrConfig = {
12
+ reviewer: { type: "none", triggerMethod: null },
13
+ commentPolicy: "answer-selective",
14
+ loop: { delaySeconds: 180, maxIterations: 3 },
15
+ models: {
16
+ orchestrator: { provider: "anthropic", model: "claude-opus-4-6", tier: "high" },
17
+ planner: { provider: "anthropic", model: "claude-opus-4-6", tier: "high" },
18
+ fixer: { provider: "anthropic", model: "claude-sonnet-4-6", tier: "low" },
19
+ },
20
+ };
21
+
22
+ export function loadFixPrConfig(cwd: string): FixPrConfig | null {
23
+ const configPath = getConfigPath(cwd);
24
+ if (!fs.existsSync(configPath)) return null;
25
+ try {
26
+ return JSON.parse(fs.readFileSync(configPath, "utf-8")) as FixPrConfig;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function saveFixPrConfig(cwd: string, config: FixPrConfig): void {
33
+ const configPath = getConfigPath(cwd);
34
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
35
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
36
+ }