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
@@ -0,0 +1,201 @@
1
+ import type { FixPrConfig } from "./types.js";
2
+ import { buildReceivingReviewInstructions } from "../discipline/receiving-review.js";
3
+
4
+ export interface FixPrPromptOptions {
5
+ prNumber: number;
6
+ repo: string;
7
+ comments: string;
8
+ sessionDir: string;
9
+ scriptsDir: string;
10
+ config: FixPrConfig;
11
+ iteration: number;
12
+ skillContent: string;
13
+ }
14
+
15
+ function buildReplyInstructions(config: FixPrConfig): string {
16
+ const { commentPolicy, repo, } = config;
17
+ const replyCmd = `gh api repos/REPO/pulls/PR/comments/COMMENT_ID/replies -f body="..."`;
18
+
19
+ switch (commentPolicy) {
20
+ case "no-answer":
21
+ return [
22
+ "### Comment Replies",
23
+ "",
24
+ "Policy: **Do not reply** to any comments. Focus only on fixing the code.",
25
+ "Do not post any replies via gh api.",
26
+ ].join("\n");
27
+ case "answer-all":
28
+ return [
29
+ "### Comment Replies",
30
+ "",
31
+ "Policy: **Answer all** comments — both accepted and rejected.",
32
+ "For each comment, post a reply explaining what was done or why it was rejected.",
33
+ `Use: \`${replyCmd}\``,
34
+ "Keep replies factual and technical. No performative agreement.",
35
+ ].join("\n");
36
+ case "answer-selective":
37
+ return [
38
+ "### Comment Replies",
39
+ "",
40
+ "Policy: **Answer selectively** — only reply to comments you reject or where clarification adds value.",
41
+ "For ACCEPT: fix silently (the code change speaks for itself).",
42
+ "For REJECT: explain why with technical reasoning.",
43
+ `Use: \`${replyCmd}\``,
44
+ "Keep replies factual. No performative agreement.",
45
+ ].join("\n");
46
+ }
47
+ }
48
+
49
+ export function buildFixPrOrchestratorPrompt(options: FixPrPromptOptions): string {
50
+ const { prNumber, repo, comments, sessionDir, scriptsDir, config, iteration, skillContent } = options;
51
+ const { loop, models, reviewer } = config;
52
+ const maxIter = loop.maxIterations;
53
+ const delay = loop.delaySeconds;
54
+
55
+ const sections: string[] = [
56
+ "# PR Review Fix Orchestration",
57
+ "",
58
+ `You are the orchestrator for fixing PR #${prNumber} on \`${repo}\`.`,
59
+ "",
60
+ "## Session Context",
61
+ "",
62
+ `- Session dir: \`${sessionDir}\``,
63
+ `- Iteration: ${iteration} of ${maxIter}`,
64
+ `- Comment reply policy: ${config.commentPolicy}`,
65
+ `- Reviewer: ${reviewer.type}${reviewer.triggerMethod ? ` (trigger: ${reviewer.triggerMethod})` : ""}`,
66
+ "",
67
+ "## Review Comments to Process",
68
+ "",
69
+ "Each line is a JSON object with comment data:",
70
+ "",
71
+ "```jsonl",
72
+ comments,
73
+ "```",
74
+ "",
75
+ ];
76
+
77
+ // Embedded skill
78
+ if (skillContent) {
79
+ sections.push(
80
+ "## Assessment Methodology",
81
+ "",
82
+ skillContent,
83
+ "",
84
+ );
85
+ }
86
+
87
+ // Receiving review discipline
88
+ sections.push(
89
+ "## Review Discipline",
90
+ "",
91
+ buildReceivingReviewInstructions(),
92
+ "",
93
+ );
94
+
95
+ // Step 1: Assess
96
+ sections.push(
97
+ "## Step 1: Assess Each Comment",
98
+ "",
99
+ "For each comment:",
100
+ "1. Read the actual code at the file and line referenced",
101
+ "2. Determine the verdict: **ACCEPT** / **REJECT** / **INVESTIGATE**",
102
+ "3. Check ripple effects — who calls this, what tests cover it",
103
+ "4. YAGNI check — does the reviewer's suggestion address a real problem?",
104
+ "",
105
+ "Record your assessment:",
106
+ "```",
107
+ "Comment #ID by @user on file:line",
108
+ "Verdict: ACCEPT | REJECT | INVESTIGATE",
109
+ "Reasoning: [1-2 sentences]",
110
+ "Ripple effects: [list or none]",
111
+ "Group: [group-id]",
112
+ "```",
113
+ "",
114
+ );
115
+
116
+ // Step 2: Group
117
+ sections.push(
118
+ "## Step 2: Group Comments",
119
+ "",
120
+ "Group accepted comments for parallel execution:",
121
+ "- Same file or tightly coupled files → same group",
122
+ "- Independent files/areas → separate groups",
123
+ "- Cosmetic vs functional → separate groups",
124
+ "",
125
+ );
126
+
127
+ // Step 3: Plan
128
+ sections.push(
129
+ "## Step 3: Plan Each Group",
130
+ "",
131
+ "For each group, create a fix plan:",
132
+ "- What changes are needed and why",
133
+ "- Which files to modify",
134
+ "- Expected ripple effects and how to handle them",
135
+ "- How to verify the fix (which tests to run)",
136
+ "",
137
+ );
138
+
139
+ // Step 4: Execute
140
+ sections.push(
141
+ "## Step 4: Execute Fixes",
142
+ "",
143
+ "For each group:",
144
+ "1. Make the code changes",
145
+ "2. Run relevant tests to verify",
146
+ "3. If tests fail, fix before moving on",
147
+ "",
148
+ );
149
+
150
+ // Step 5: Reply
151
+ sections.push(buildReplyInstructions(config), "");
152
+
153
+ // Step 6: Push and loop
154
+ sections.push(
155
+ "## Step 6: Push and Check for New Comments",
156
+ "",
157
+ '1. Stage and commit: `git add -A && git commit -m "fix: address PR review comments (iteration ' + iteration + ')"`',
158
+ "2. Push: `git push`",
159
+ );
160
+
161
+ if (reviewer.type !== "none" && reviewer.triggerMethod) {
162
+ sections.push(
163
+ `3. Trigger re-review: \`bash ${scriptsDir}/trigger-review.sh "${repo}" ${prNumber} "${reviewer.type}" "${reviewer.triggerMethod}"\``,
164
+ );
165
+ }
166
+
167
+ sections.push(
168
+ `${reviewer.type !== "none" ? "4" : "3"}. Run the check script:`,
169
+ "```bash",
170
+ `bash ${scriptsDir}/wait-and-check.sh "${sessionDir}" ${delay} ${iteration + 1} "${repo}" ${prNumber}`,
171
+ "```",
172
+ `${reviewer.type !== "none" ? "5" : "4"}. Read the last line of output:`,
173
+ ` - If \`hasNewComments: true\` and iteration < ${maxIter}: process the new comments (go back to Step 1)`,
174
+ ` - If \`hasNewComments: false\` or iteration >= ${maxIter}: report done`,
175
+ "",
176
+ );
177
+
178
+ // Script paths reference
179
+ sections.push(
180
+ "## Script Paths",
181
+ "",
182
+ `- fetch-pr-comments.sh: \`${scriptsDir}/fetch-pr-comments.sh\``,
183
+ `- diff-comments.sh: \`${scriptsDir}/diff-comments.sh\``,
184
+ `- trigger-review.sh: \`${scriptsDir}/trigger-review.sh\``,
185
+ `- wait-and-check.sh: \`${scriptsDir}/wait-and-check.sh\``,
186
+ "",
187
+ );
188
+
189
+ // Model guidance
190
+ sections.push(
191
+ "## Model Guidance",
192
+ "",
193
+ `- **Orchestrator** (assessment, grouping): ${models.orchestrator.model} (${models.orchestrator.tier} tier) — thorough analysis`,
194
+ `- **Planner** (fix planning): ${models.planner.model} (${models.planner.tier} tier) — detailed planning`,
195
+ `- **Fixer** (code changes): ${models.fixer.model} (${models.fixer.tier} tier) — focused execution`,
196
+ "",
197
+ "These indicate the expected reasoning depth for each phase of work.",
198
+ );
199
+
200
+ return sections.join("\n");
201
+ }
@@ -0,0 +1,33 @@
1
+ #!/bin/bash
2
+ # Compares two JSONL comment snapshots, outputs only new/changed comments
3
+ # Usage: diff-comments.sh <prev_snapshot> <new_snapshot>
4
+ # Exit 0 if new comments found, exit 1 if identical
5
+ set -euo pipefail
6
+
7
+ PREV="$1"
8
+ NEW="$2"
9
+
10
+ # If no previous snapshot, all comments are new
11
+ if [[ ! -f "$PREV" ]]; then
12
+ cat "$NEW"
13
+ exit 0
14
+ fi
15
+
16
+ # Build fingerprint: id + updatedAt for each comment
17
+ prev_fingerprints=$(jq -r '[.id, .updatedAt] | @tsv' "$PREV" 2>/dev/null | sort)
18
+ new_fingerprints=$(jq -r '[.id, .updatedAt] | @tsv' "$NEW" 2>/dev/null | sort)
19
+
20
+ # Find IDs that are new or changed
21
+ new_ids=$(comm -13 <(echo "$prev_fingerprints") <(echo "$new_fingerprints") | cut -f1)
22
+
23
+ if [[ -z "$new_ids" ]]; then
24
+ exit 1
25
+ fi
26
+
27
+ # Output the full comment objects for new/changed IDs
28
+ while IFS= read -r id; do
29
+ [[ -z "$id" ]] && continue
30
+ jq -c "select(.id == $id)" "$NEW"
31
+ done <<< "$new_ids"
32
+
33
+ exit 0
@@ -0,0 +1,25 @@
1
+ #!/bin/bash
2
+ # Fetches all review comments for a PR, outputs JSONL
3
+ # Usage: fetch-pr-comments.sh <owner/repo> <pr_number> <output_file>
4
+ set -euo pipefail
5
+
6
+ REPO="$1"
7
+ PR="$2"
8
+ OUTPUT="$3"
9
+
10
+ # Ensure output directory exists
11
+ mkdir -p "$(dirname "$OUTPUT")"
12
+
13
+ # Fetch inline review comments (code-level)
14
+ gh api --paginate "repos/${REPO}/pulls/${PR}/comments" \
15
+ --jq '.[] | {id, path, line: .line, body, user: .user.login, createdAt: .created_at, updatedAt: .updated_at, inReplyToId: .in_reply_to_id, diffHunk: .diff_hunk, state: "COMMENTED"}' \
16
+ > "$OUTPUT" 2>/dev/null || true
17
+
18
+ # Fetch review-level comments (top-level reviews with body text)
19
+ gh api --paginate "repos/${REPO}/pulls/${PR}/reviews" \
20
+ --jq '.[] | select(.body != null and .body != "") | {id, path: null, line: null, body, user: .user.login, createdAt: .submitted_at, updatedAt: .submitted_at, inReplyToId: null, diffHunk: null, state}' \
21
+ >> "$OUTPUT" 2>/dev/null || true
22
+
23
+ # Output summary to stderr for caller
24
+ TOTAL=$(wc -l < "$OUTPUT" | tr -d ' ')
25
+ echo "{\"total\": ${TOTAL}}" >&2
@@ -0,0 +1,36 @@
1
+ #!/bin/bash
2
+ # Triggers automated reviewer to re-review a PR
3
+ # Usage: trigger-review.sh <owner/repo> <pr_number> <reviewer_type> <trigger_method>
4
+ set -euo pipefail
5
+
6
+ REPO="$1"
7
+ PR="$2"
8
+ REVIEWER="$3"
9
+ METHOD="${4:-}"
10
+
11
+ case "$REVIEWER" in
12
+ coderabbit)
13
+ gh api "repos/${REPO}/issues/${PR}/comments" -f body="$METHOD" >/dev/null 2>&1
14
+ echo '{"triggered": true, "reviewer": "coderabbit"}'
15
+ ;;
16
+ copilot)
17
+ if [[ -n "$METHOD" ]]; then
18
+ gh api "repos/${REPO}/issues/${PR}/comments" -f body="$METHOD" >/dev/null 2>&1
19
+ else
20
+ gh api "repos/${REPO}/pulls/${PR}/requested_reviewers" \
21
+ --method POST -f "reviewers[]=copilot" >/dev/null 2>&1 || true
22
+ fi
23
+ echo '{"triggered": true, "reviewer": "copilot"}'
24
+ ;;
25
+ gemini)
26
+ gh api "repos/${REPO}/issues/${PR}/comments" -f body="$METHOD" >/dev/null 2>&1
27
+ echo '{"triggered": true, "reviewer": "gemini"}'
28
+ ;;
29
+ none)
30
+ echo '{"triggered": false, "reviewer": "none"}'
31
+ ;;
32
+ *)
33
+ echo '{"triggered": false, "error": "unknown reviewer type: '"$REVIEWER"'"}'
34
+ exit 1
35
+ ;;
36
+ esac
@@ -0,0 +1,37 @@
1
+ #!/bin/bash
2
+ # Waits for delay, fetches new PR comments, diffs against previous snapshot
3
+ # Usage: wait-and-check.sh <session_dir> <delay_seconds> <iteration> <owner/repo> <pr_number>
4
+ # Output: new comment lines + JSON summary on last line
5
+ set -euo pipefail
6
+
7
+ SESSION_DIR="$1"
8
+ DELAY="$2"
9
+ ITERATION="$3"
10
+ REPO="$4"
11
+ PR="$5"
12
+
13
+ SNAPSHOTS_DIR="${SESSION_DIR}/snapshots"
14
+ PREV_ITERATION=$((ITERATION - 1))
15
+ PREV_SNAPSHOT="${SNAPSHOTS_DIR}/comments-${PREV_ITERATION}.jsonl"
16
+ NEW_SNAPSHOT="${SNAPSHOTS_DIR}/comments-${ITERATION}.jsonl"
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
19
+
20
+ # Wait for reviewer to process
21
+ echo "Waiting ${DELAY}s for reviewer to process changes..." >&2
22
+ sleep "$DELAY"
23
+
24
+ # Fetch new comments
25
+ echo "Fetching PR comments (iteration ${ITERATION})..." >&2
26
+ bash "${SCRIPT_DIR}/fetch-pr-comments.sh" "$REPO" "$PR" "$NEW_SNAPSHOT"
27
+
28
+ # Diff against previous
29
+ DIFF_OUTPUT=$(bash "${SCRIPT_DIR}/diff-comments.sh" "$PREV_SNAPSHOT" "$NEW_SNAPSHOT" 2>/dev/null) || true
30
+
31
+ if [[ -n "$DIFF_OUTPUT" ]]; then
32
+ DIFF_COUNT=$(echo "$DIFF_OUTPUT" | wc -l | tr -d ' ')
33
+ echo "$DIFF_OUTPUT"
34
+ echo "{\"hasNewComments\": true, \"count\": ${DIFF_COUNT}, \"iteration\": ${ITERATION}}"
35
+ else
36
+ echo "{\"hasNewComments\": false, \"count\": 0, \"iteration\": ${ITERATION}}"
37
+ fi
@@ -0,0 +1,71 @@
1
+ /** Supported automated PR reviewers */
2
+ export type ReviewerType = "coderabbit" | "copilot" | "gemini" | "none";
3
+
4
+ /** How to handle comment replies */
5
+ export type CommentReplyPolicy = "answer-all" | "answer-selective" | "no-answer";
6
+
7
+ /** Model preference for a specific role */
8
+ export interface ModelPref {
9
+ provider: string;
10
+ model: string;
11
+ tier: "low" | "high";
12
+ }
13
+
14
+ /** Per-repo fix-pr configuration */
15
+ export interface FixPrConfig {
16
+ reviewer: {
17
+ type: ReviewerType;
18
+ triggerMethod: string | null;
19
+ };
20
+ commentPolicy: CommentReplyPolicy;
21
+ loop: {
22
+ delaySeconds: number;
23
+ maxIterations: number;
24
+ };
25
+ models: {
26
+ orchestrator: ModelPref;
27
+ planner: ModelPref;
28
+ fixer: ModelPref;
29
+ };
30
+ }
31
+
32
+ /** A PR review comment from GitHub API */
33
+ export interface PrComment {
34
+ id: number;
35
+ path: string | null;
36
+ line: number | null;
37
+ body: string;
38
+ user: string;
39
+ createdAt: string;
40
+ updatedAt: string;
41
+ inReplyToId: number | null;
42
+ diffHunk: string | null;
43
+ state: string;
44
+ }
45
+
46
+ /** Assessment verdict for a single comment */
47
+ export type CommentVerdict = "accept" | "reject" | "investigate";
48
+
49
+ /** A group of related comments to fix together */
50
+ export interface FixGroup {
51
+ id: string;
52
+ commentIds: number[];
53
+ files: string[];
54
+ description: string;
55
+ }
56
+
57
+ /** Session status */
58
+ export type FixPrSessionStatus = "running" | "completed" | "failed";
59
+
60
+ /** Session ledger for a fix-pr run */
61
+ export interface FixPrSessionLedger {
62
+ id: string;
63
+ createdAt: string;
64
+ updatedAt: string;
65
+ prNumber: number;
66
+ repo: string;
67
+ status: FixPrSessionStatus;
68
+ iteration: number;
69
+ config: FixPrConfig;
70
+ commentsProcessed: number[];
71
+ }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ import { registerReviewCommand } from "./commands/review.js";
12
12
  import { registerQaCommand } from "./commands/qa.js";
13
13
  import { registerReleaseCommand } from "./commands/release.js";
14
14
  import { registerUpdateCommand, handleUpdate } from "./commands/update.js";
15
+ import { registerFixPrCommand } from "./commands/fix-pr.js";
15
16
 
16
17
  // TUI-only commands — intercepted at the input level to prevent
17
18
  // message submission and "Working..." indicator
@@ -43,6 +44,7 @@ export default function supipowers(pi: ExtensionAPI): void {
43
44
  registerQaCommand(pi);
44
45
  registerReleaseCommand(pi);
45
46
  registerUpdateCommand(pi);
47
+ registerFixPrCommand(pi);
46
48
 
47
49
  // Intercept TUI-only commands at the input level — this runs BEFORE
48
50
  // message submission, so no chat message appears and no "Working..." indicator
@@ -0,0 +1,43 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { E2eQaConfig } from "./types.js";
4
+
5
+ const CONFIG_FILENAME = "e2e-qa.json";
6
+
7
+ function getConfigPath(cwd: string): string {
8
+ return path.join(cwd, ".omp", "supipowers", CONFIG_FILENAME);
9
+ }
10
+
11
+ export const DEFAULT_E2E_QA_CONFIG: E2eQaConfig = {
12
+ app: {
13
+ type: "generic",
14
+ devCommand: "npm run dev",
15
+ port: 3000,
16
+ baseUrl: "http://localhost:3000",
17
+ },
18
+ playwright: {
19
+ browser: "chromium",
20
+ headless: true,
21
+ timeout: 30000,
22
+ },
23
+ execution: {
24
+ maxRetries: 2,
25
+ maxFlows: 20,
26
+ },
27
+ };
28
+
29
+ export function loadE2eQaConfig(cwd: string): E2eQaConfig | null {
30
+ const configPath = getConfigPath(cwd);
31
+ if (!fs.existsSync(configPath)) return null;
32
+ try {
33
+ return JSON.parse(fs.readFileSync(configPath, "utf-8")) as E2eQaConfig;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function saveE2eQaConfig(cwd: string, config: E2eQaConfig): void {
40
+ const configPath = getConfigPath(cwd);
41
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
42
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
43
+ }
@@ -0,0 +1,84 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { E2eMatrix, E2eFlowRecord, E2eTestResult, E2eRegression } from "./types.js";
4
+
5
+ const MATRIX_FILENAME = "e2e-matrix.json";
6
+
7
+ function getMatrixPath(cwd: string): string {
8
+ return path.join(cwd, ".omp", "supipowers", MATRIX_FILENAME);
9
+ }
10
+
11
+ export function createEmptyMatrix(appType: string): E2eMatrix {
12
+ return {
13
+ version: "1.0.0",
14
+ updatedAt: new Date().toISOString(),
15
+ appType,
16
+ flows: [],
17
+ };
18
+ }
19
+
20
+ export function loadE2eMatrix(cwd: string): E2eMatrix | null {
21
+ const matrixPath = getMatrixPath(cwd);
22
+ if (!fs.existsSync(matrixPath)) return null;
23
+ try {
24
+ return JSON.parse(fs.readFileSync(matrixPath, "utf-8")) as E2eMatrix;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ export function saveE2eMatrix(cwd: string, matrix: E2eMatrix): void {
31
+ const matrixPath = getMatrixPath(cwd);
32
+ fs.mkdirSync(path.dirname(matrixPath), { recursive: true });
33
+ fs.writeFileSync(matrixPath, JSON.stringify(matrix, null, 2));
34
+ }
35
+
36
+ export function detectRegressions(
37
+ previousFlows: E2eFlowRecord[],
38
+ results: E2eTestResult[],
39
+ ): E2eRegression[] {
40
+ const regressions: E2eRegression[] = [];
41
+
42
+ for (const result of results) {
43
+ if (result.status !== "fail") continue;
44
+
45
+ const previousFlow = previousFlows.find((f) => f.id === result.flowId);
46
+ if (!previousFlow || previousFlow.lastStatus !== "pass") continue;
47
+
48
+ regressions.push({
49
+ flowId: result.flowId,
50
+ flowName: previousFlow.name,
51
+ previousStatus: "pass",
52
+ currentStatus: "fail",
53
+ error: result.error ?? "Unknown error",
54
+ });
55
+ }
56
+
57
+ return regressions;
58
+ }
59
+
60
+ export function updateMatrixFromResults(
61
+ matrix: E2eMatrix,
62
+ results: E2eTestResult[],
63
+ ): E2eMatrix {
64
+ const now = new Date().toISOString();
65
+ const resultMap = new Map(results.map((r) => [r.flowId, r]));
66
+
67
+ const updatedFlows = matrix.flows.map((flow) => {
68
+ const result = resultMap.get(flow.id);
69
+ if (!result) return flow;
70
+
71
+ return {
72
+ ...flow,
73
+ lastStatus: result.status === "skip" ? flow.lastStatus : (result.status as "pass" | "fail"),
74
+ lastTestedAt: now,
75
+ lastError: result.status === "fail" ? result.error : undefined,
76
+ };
77
+ });
78
+
79
+ return {
80
+ ...matrix,
81
+ updatedAt: now,
82
+ flows: updatedFlows,
83
+ };
84
+ }