sequant 1.16.1 → 1.17.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/README.md +2 -0
- package/dist/bin/cli.js +2 -1
- package/dist/src/commands/run.d.ts +6 -0
- package/dist/src/commands/run.js +20 -2
- package/dist/src/lib/upstream/assessment.js +6 -3
- package/dist/src/lib/upstream/relevance.d.ts +5 -0
- package/dist/src/lib/upstream/relevance.js +24 -0
- package/dist/src/lib/upstream/report.js +18 -46
- package/dist/src/lib/upstream/types.d.ts +2 -0
- package/dist/src/lib/workflow/pr-status.d.ts +47 -0
- package/dist/src/lib/workflow/pr-status.js +129 -0
- package/dist/src/lib/workflow/run-reflect.d.ts +32 -0
- package/dist/src/lib/workflow/run-reflect.js +191 -0
- package/dist/src/lib/workflow/state-cleanup.d.ts +79 -0
- package/dist/src/lib/workflow/state-cleanup.js +250 -0
- package/dist/src/lib/workflow/state-rebuild.d.ts +38 -0
- package/dist/src/lib/workflow/state-rebuild.js +140 -0
- package/dist/src/lib/workflow/state-utils.d.ts +14 -162
- package/dist/src/lib/workflow/state-utils.js +10 -677
- package/dist/src/lib/workflow/worktree-discovery.d.ts +61 -0
- package/dist/src/lib/workflow/worktree-discovery.js +229 -0
- package/package.json +2 -2
- package/templates/skills/exec/SKILL.md +2 -2
- package/templates/skills/fullsolve/SKILL.md +4 -4
- package/templates/skills/loop/SKILL.md +1 -1
- package/templates/skills/qa/SKILL.md +6 -6
- package/templates/skills/solve/SKILL.md +6 -6
- package/templates/skills/spec/SKILL.md +4 -4
- package/templates/skills/testgen/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
Solve GitHub issues with structured phases and quality gates — from issue to merge-ready PR.
|
|
6
6
|
|
|
7
|
+
**[sequant.io](https://sequant.io)** — docs, guides, and getting started.
|
|
8
|
+
|
|
7
9
|
[](https://www.npmjs.com/package/sequant)
|
|
8
10
|
[](https://opensource.org/licenses/MIT)
|
|
9
11
|
|
package/dist/bin/cli.js
CHANGED
|
@@ -122,7 +122,7 @@ program
|
|
|
122
122
|
.description("Execute workflow for GitHub issues using Claude Agent SDK")
|
|
123
123
|
.argument("[issues...]", "Issue numbers to process")
|
|
124
124
|
.option("--phases <list>", "Phases to run (default: spec,exec,qa)")
|
|
125
|
-
.option("--sequential", "
|
|
125
|
+
.option("--sequential", "Stop on first issue failure (default: continue)")
|
|
126
126
|
.option("-d, --dry-run", "Preview without execution")
|
|
127
127
|
.option("-v, --verbose", "Verbose output with streaming")
|
|
128
128
|
.option("--timeout <seconds>", "Timeout per phase in seconds", parseInt)
|
|
@@ -145,6 +145,7 @@ program
|
|
|
145
145
|
.option("--no-rebase", "Skip pre-PR rebase onto origin/main (use when you want to handle rebasing manually)")
|
|
146
146
|
.option("--no-pr", "Skip PR creation after successful QA (manual PR workflow)")
|
|
147
147
|
.option("-f, --force", "Force re-execution of completed issues (bypass pre-flight state guard)")
|
|
148
|
+
.option("--reflect", "Analyze run results and suggest improvements")
|
|
148
149
|
.action(runCommand);
|
|
149
150
|
program
|
|
150
151
|
.command("merge")
|
|
@@ -248,6 +248,12 @@ interface RunOptions {
|
|
|
248
248
|
* Bypasses the pre-flight state guard that skips ready_for_merge/merged issues.
|
|
249
249
|
*/
|
|
250
250
|
force?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* Analyze run results and suggest workflow improvements.
|
|
253
|
+
* Displays observations about timing patterns, phase mismatches, and
|
|
254
|
+
* actionable suggestions after the summary output.
|
|
255
|
+
*/
|
|
256
|
+
reflect?: boolean;
|
|
251
257
|
}
|
|
252
258
|
/**
|
|
253
259
|
* Execute a single phase for an issue using Claude Agent SDK
|
package/dist/src/commands/run.js
CHANGED
|
@@ -26,6 +26,7 @@ import { PhaseSpinner } from "../lib/phase-spinner.js";
|
|
|
26
26
|
import { getGitDiffStats, getCommitHash, } from "../lib/workflow/git-diff-utils.js";
|
|
27
27
|
import { getTokenUsageForRun } from "../lib/workflow/token-utils.js";
|
|
28
28
|
import { reconcileStateAtStartup } from "../lib/workflow/state-utils.js";
|
|
29
|
+
import { analyzeRun, formatReflection } from "../lib/workflow/run-reflect.js";
|
|
29
30
|
/**
|
|
30
31
|
* Slugify a title for branch naming
|
|
31
32
|
*/
|
|
@@ -1753,7 +1754,7 @@ export async function runCommand(issues, options) {
|
|
|
1753
1754
|
else {
|
|
1754
1755
|
console.log(chalk.gray(` Phases: ${config.phases.join(" → ")}`));
|
|
1755
1756
|
}
|
|
1756
|
-
console.log(chalk.gray(` Mode: ${config.sequential ? "
|
|
1757
|
+
console.log(chalk.gray(` Mode: ${config.sequential ? "stop-on-failure" : "continue-on-failure"}`));
|
|
1757
1758
|
if (config.qualityLoop) {
|
|
1758
1759
|
console.log(chalk.gray(` Quality loop: enabled (max ${config.maxIterations} iterations)`));
|
|
1759
1760
|
}
|
|
@@ -1954,7 +1955,7 @@ export async function runCommand(issues, options) {
|
|
|
1954
1955
|
}
|
|
1955
1956
|
}
|
|
1956
1957
|
else {
|
|
1957
|
-
//
|
|
1958
|
+
// Default mode: run issues serially but continue on failure (don't stop)
|
|
1958
1959
|
// TODO: Add proper parallel execution with listr2
|
|
1959
1960
|
for (const issueNumber of issueNumbers) {
|
|
1960
1961
|
// Check if shutdown was triggered
|
|
@@ -2105,6 +2106,23 @@ export async function runCommand(issues, options) {
|
|
|
2105
2106
|
console.log(colors.muted(` 📝 Log: ${logPath}`));
|
|
2106
2107
|
console.log("");
|
|
2107
2108
|
}
|
|
2109
|
+
// Reflection analysis (--reflect flag)
|
|
2110
|
+
if (mergedOptions.reflect && results.length > 0) {
|
|
2111
|
+
const reflection = analyzeRun({
|
|
2112
|
+
results,
|
|
2113
|
+
issueInfoMap,
|
|
2114
|
+
runLog: logWriter?.getRunLog() ?? null,
|
|
2115
|
+
config: {
|
|
2116
|
+
phases: config.phases,
|
|
2117
|
+
qualityLoop: config.qualityLoop,
|
|
2118
|
+
},
|
|
2119
|
+
});
|
|
2120
|
+
const reflectionOutput = formatReflection(reflection);
|
|
2121
|
+
if (reflectionOutput) {
|
|
2122
|
+
console.log(reflectionOutput);
|
|
2123
|
+
console.log("");
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2108
2126
|
// Suggest merge checks for multi-issue batches
|
|
2109
2127
|
if (results.length > 1 && passed > 0 && !config.dryRun) {
|
|
2110
2128
|
console.log(colors.muted(" 💡 Verify batch integration before merging:"));
|
|
@@ -234,9 +234,11 @@ export async function assessVersion(version, options = {}) {
|
|
|
234
234
|
// Create assessment issue first (to get issue number for linking)
|
|
235
235
|
const assessmentBody = generateAssessmentReport(assessment);
|
|
236
236
|
const assessmentIssueNumber = await createAssessmentIssue(`Upstream: Claude Code ${release.tagName} Assessment`, assessmentBody, dryRun);
|
|
237
|
-
// Create issues for actionable findings
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
// Create issues for actionable findings (excluding opportunities —
|
|
238
|
+
// opportunities are listed in the assessment but don't get individual issues)
|
|
239
|
+
const issueWorthy = actionableFindings.filter((f) => f.category !== "opportunity");
|
|
240
|
+
for (let i = 0; i < issueWorthy.length; i++) {
|
|
241
|
+
const updatedFinding = await createOrLinkFinding(issueWorthy[i], release.tagName, assessmentIssueNumber, dryRun);
|
|
240
242
|
// Update in original findings array
|
|
241
243
|
const originalIndex = findings.findIndex((f) => f.description === updatedFinding.description);
|
|
242
244
|
if (originalIndex >= 0) {
|
|
@@ -370,6 +372,7 @@ function getDefaultBaseline() {
|
|
|
370
372
|
Task: [".claude/skills/**/*.md"],
|
|
371
373
|
MCP: [".claude/settings.json"],
|
|
372
374
|
},
|
|
375
|
+
outOfScope: [],
|
|
373
376
|
};
|
|
374
377
|
}
|
|
375
378
|
/**
|
|
@@ -36,6 +36,11 @@ export declare function getImpactFiles(matchedKeywords: string[], dependencyMap:
|
|
|
36
36
|
* Generate a title for a finding
|
|
37
37
|
*/
|
|
38
38
|
export declare function generateTitle(category: FindingCategory, change: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Check if a change matches any out-of-scope patterns from the baseline.
|
|
41
|
+
* Out-of-scope changes are skipped entirely during analysis.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isOutOfScope(change: string, outOfScope?: string[]): boolean;
|
|
39
44
|
/**
|
|
40
45
|
* Analyze a single change against the baseline
|
|
41
46
|
*/
|
|
@@ -158,10 +158,34 @@ export function generateTitle(category, change) {
|
|
|
158
158
|
return title;
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Check if a change matches any out-of-scope patterns from the baseline.
|
|
163
|
+
* Out-of-scope changes are skipped entirely during analysis.
|
|
164
|
+
*/
|
|
165
|
+
export function isOutOfScope(change, outOfScope = []) {
|
|
166
|
+
const changeLower = change.toLowerCase();
|
|
167
|
+
return outOfScope.some((pattern) => {
|
|
168
|
+
// Use the descriptive part before " - " as the match pattern
|
|
169
|
+
const matchPart = pattern.split(" - ")[0].toLowerCase();
|
|
170
|
+
return changeLower.includes(matchPart);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
161
173
|
/**
|
|
162
174
|
* Analyze a single change against the baseline
|
|
163
175
|
*/
|
|
164
176
|
export function analyzeChange(change, baseline) {
|
|
177
|
+
// Skip out-of-scope changes early
|
|
178
|
+
if (isOutOfScope(change, baseline.outOfScope)) {
|
|
179
|
+
return {
|
|
180
|
+
category: "no-action",
|
|
181
|
+
title: change,
|
|
182
|
+
description: change,
|
|
183
|
+
impact: "none",
|
|
184
|
+
matchedKeywords: [],
|
|
185
|
+
matchedPatterns: [],
|
|
186
|
+
sequantFiles: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
165
189
|
// Match keywords and patterns
|
|
166
190
|
const matchedKeywords = matchKeywords(change, baseline.keywords);
|
|
167
191
|
const matchedPatterns = matchPatterns(change);
|
|
@@ -52,8 +52,9 @@ function getActionStatus(count, category) {
|
|
|
52
52
|
return "Review needed";
|
|
53
53
|
case "new-tool":
|
|
54
54
|
case "hook-change":
|
|
55
|
-
case "opportunity":
|
|
56
55
|
return "Issues created";
|
|
56
|
+
case "opportunity":
|
|
57
|
+
return "Noted for review";
|
|
57
58
|
default:
|
|
58
59
|
return "None";
|
|
59
60
|
}
|
|
@@ -116,61 +117,32 @@ export function generateAssessmentReport(assessment) {
|
|
|
116
117
|
lines.push(`| Opportunities | ${summary.opportunities} | ${getActionStatus(summary.opportunities, "opportunity")} |`);
|
|
117
118
|
lines.push(`| No Action | ${summary.noAction} | None |`);
|
|
118
119
|
lines.push("");
|
|
119
|
-
//
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
lines.push(formatFinding(f, i));
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
lines.push("");
|
|
132
|
-
// Deprecations section
|
|
133
|
-
const deprecations = findings.filter((f) => f.category === "deprecation");
|
|
134
|
-
lines.push("### Deprecations");
|
|
135
|
-
lines.push("");
|
|
136
|
-
if (deprecations.length === 0) {
|
|
137
|
-
lines.push("None detected.");
|
|
138
|
-
}
|
|
139
|
-
else {
|
|
140
|
-
deprecations.forEach((f, i) => {
|
|
141
|
-
lines.push(formatFinding(f, i));
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
lines.push("");
|
|
145
|
-
// New Tools section
|
|
146
|
-
const newTools = findings.filter((f) => f.category === "new-tool");
|
|
147
|
-
lines.push("### New Tools");
|
|
120
|
+
// Actionable section — breaking changes, deprecations, new tools, hook changes
|
|
121
|
+
const actionableCategories = [
|
|
122
|
+
"breaking",
|
|
123
|
+
"deprecation",
|
|
124
|
+
"new-tool",
|
|
125
|
+
"hook-change",
|
|
126
|
+
];
|
|
127
|
+
const actionableFindings = findings.filter((f) => actionableCategories.includes(f.category));
|
|
128
|
+
lines.push("### Actionable");
|
|
148
129
|
lines.push("");
|
|
149
|
-
|
|
150
|
-
lines.push("None detected.");
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
newTools.forEach((f, i) => {
|
|
154
|
-
lines.push(formatFinding(f, i));
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
lines.push("");
|
|
158
|
-
// Hook Changes section
|
|
159
|
-
const hookChanges = findings.filter((f) => f.category === "hook-change");
|
|
160
|
-
lines.push("### Hook Changes");
|
|
130
|
+
lines.push("*Breaking changes, deprecations, and other items that affect sequant.*");
|
|
161
131
|
lines.push("");
|
|
162
|
-
if (
|
|
132
|
+
if (actionableFindings.length === 0) {
|
|
163
133
|
lines.push("None detected.");
|
|
164
134
|
}
|
|
165
135
|
else {
|
|
166
|
-
|
|
136
|
+
actionableFindings.forEach((f, i) => {
|
|
167
137
|
lines.push(formatFinding(f, i));
|
|
168
138
|
});
|
|
169
139
|
}
|
|
170
140
|
lines.push("");
|
|
171
|
-
//
|
|
141
|
+
// Informational section — opportunities noted for human triage
|
|
172
142
|
const opportunities = findings.filter((f) => f.category === "opportunity");
|
|
173
|
-
lines.push("###
|
|
143
|
+
lines.push("### Informational");
|
|
144
|
+
lines.push("");
|
|
145
|
+
lines.push("*Opportunities noted for human triage. No individual issues auto-created.*");
|
|
174
146
|
lines.push("");
|
|
175
147
|
if (opportunities.length === 0) {
|
|
176
148
|
lines.push("None detected.");
|
|
@@ -134,6 +134,8 @@ export interface Baseline {
|
|
|
134
134
|
keywords: string[];
|
|
135
135
|
/** Map of keywords to affected sequant files */
|
|
136
136
|
dependencyMap: Record<string, string[]>;
|
|
137
|
+
/** Patterns for changes that are out of scope for sequant (skipped during analysis) */
|
|
138
|
+
outOfScope?: string[];
|
|
137
139
|
}
|
|
138
140
|
/**
|
|
139
141
|
* Regex patterns for detecting change types
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR merge detection and branch status utilities
|
|
3
|
+
*
|
|
4
|
+
* @module pr-status
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { checkPRMergeStatus, isBranchMergedIntoMain, isIssueMergedIntoMain } from './pr-status';
|
|
8
|
+
*
|
|
9
|
+
* // Check PR status via GitHub CLI
|
|
10
|
+
* const status = checkPRMergeStatus(123);
|
|
11
|
+
* if (status === 'MERGED') {
|
|
12
|
+
* console.log('PR is merged');
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* // Check if branch is merged into main
|
|
16
|
+
* const isMerged = isBranchMergedIntoMain('feature/123-some-feature');
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* PR merge status from GitHub
|
|
21
|
+
*/
|
|
22
|
+
export type PRMergeStatus = "MERGED" | "CLOSED" | "OPEN" | null;
|
|
23
|
+
/**
|
|
24
|
+
* Check the merge status of a PR using the gh CLI
|
|
25
|
+
*
|
|
26
|
+
* @param prNumber - The PR number to check
|
|
27
|
+
* @returns "MERGED" | "CLOSED" | "OPEN" | null (null if PR not found or gh unavailable)
|
|
28
|
+
*/
|
|
29
|
+
export declare function checkPRMergeStatus(prNumber: number): PRMergeStatus;
|
|
30
|
+
/**
|
|
31
|
+
* Check if a branch has been merged into main using git
|
|
32
|
+
*
|
|
33
|
+
* @param branchName - The branch name to check (e.g., "feature/33-some-title")
|
|
34
|
+
* @returns true if the branch is merged into main, false otherwise
|
|
35
|
+
*/
|
|
36
|
+
export declare function isBranchMergedIntoMain(branchName: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Check if a feature branch for an issue is merged into main
|
|
39
|
+
*
|
|
40
|
+
* Tries multiple detection methods:
|
|
41
|
+
* 1. Check if branch exists and is merged via `git branch --merged main`
|
|
42
|
+
* 2. Check for merge commits mentioning the issue
|
|
43
|
+
*
|
|
44
|
+
* @param issueNumber - The issue number to check
|
|
45
|
+
* @returns true if the issue's work is merged into main
|
|
46
|
+
*/
|
|
47
|
+
export declare function isIssueMergedIntoMain(issueNumber: number): boolean;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR merge detection and branch status utilities
|
|
3
|
+
*
|
|
4
|
+
* @module pr-status
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { checkPRMergeStatus, isBranchMergedIntoMain, isIssueMergedIntoMain } from './pr-status';
|
|
8
|
+
*
|
|
9
|
+
* // Check PR status via GitHub CLI
|
|
10
|
+
* const status = checkPRMergeStatus(123);
|
|
11
|
+
* if (status === 'MERGED') {
|
|
12
|
+
* console.log('PR is merged');
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* // Check if branch is merged into main
|
|
16
|
+
* const isMerged = isBranchMergedIntoMain('feature/123-some-feature');
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from "child_process";
|
|
20
|
+
/**
|
|
21
|
+
* Check the merge status of a PR using the gh CLI
|
|
22
|
+
*
|
|
23
|
+
* @param prNumber - The PR number to check
|
|
24
|
+
* @returns "MERGED" | "CLOSED" | "OPEN" | null (null if PR not found or gh unavailable)
|
|
25
|
+
*/
|
|
26
|
+
export function checkPRMergeStatus(prNumber) {
|
|
27
|
+
try {
|
|
28
|
+
const result = spawnSync("gh", ["pr", "view", String(prNumber), "--json", "state", "-q", ".state"], { stdio: "pipe", timeout: 10000 });
|
|
29
|
+
if (result.status === 0 && result.stdout) {
|
|
30
|
+
const state = result.stdout.toString().trim().toUpperCase();
|
|
31
|
+
if (state === "MERGED")
|
|
32
|
+
return "MERGED";
|
|
33
|
+
if (state === "CLOSED")
|
|
34
|
+
return "CLOSED";
|
|
35
|
+
if (state === "OPEN")
|
|
36
|
+
return "OPEN";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// gh not available or error - return null
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Check if a branch has been merged into main using git
|
|
46
|
+
*
|
|
47
|
+
* @param branchName - The branch name to check (e.g., "feature/33-some-title")
|
|
48
|
+
* @returns true if the branch is merged into main, false otherwise
|
|
49
|
+
*/
|
|
50
|
+
export function isBranchMergedIntoMain(branchName) {
|
|
51
|
+
try {
|
|
52
|
+
// Get branches merged into main
|
|
53
|
+
const result = spawnSync("git", ["branch", "--merged", "main"], {
|
|
54
|
+
stdio: "pipe",
|
|
55
|
+
timeout: 10000,
|
|
56
|
+
});
|
|
57
|
+
if (result.status === 0 && result.stdout) {
|
|
58
|
+
const mergedBranches = result.stdout.toString();
|
|
59
|
+
// Check if our branch is in the list (handle both local and remote refs)
|
|
60
|
+
return (mergedBranches.includes(branchName) ||
|
|
61
|
+
mergedBranches.includes(`remotes/origin/${branchName}`));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// git command failed - return false
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Check if a feature branch for an issue is merged into main
|
|
71
|
+
*
|
|
72
|
+
* Tries multiple detection methods:
|
|
73
|
+
* 1. Check if branch exists and is merged via `git branch --merged main`
|
|
74
|
+
* 2. Check for merge commits mentioning the issue
|
|
75
|
+
*
|
|
76
|
+
* @param issueNumber - The issue number to check
|
|
77
|
+
* @returns true if the issue's work is merged into main
|
|
78
|
+
*/
|
|
79
|
+
export function isIssueMergedIntoMain(issueNumber) {
|
|
80
|
+
try {
|
|
81
|
+
// Method 1: Check if any feature branch for this issue is merged
|
|
82
|
+
const listResult = spawnSync("git", ["branch", "-a"], {
|
|
83
|
+
stdio: "pipe",
|
|
84
|
+
timeout: 10000,
|
|
85
|
+
});
|
|
86
|
+
if (listResult.status === 0 && listResult.stdout) {
|
|
87
|
+
const branches = listResult.stdout.toString();
|
|
88
|
+
// Find branches matching feature/<issue>-*
|
|
89
|
+
const branchPattern = new RegExp(`feature/${issueNumber}-[^\\s]+`, "g");
|
|
90
|
+
const matchedBranches = branches.match(branchPattern);
|
|
91
|
+
if (matchedBranches) {
|
|
92
|
+
for (const branch of matchedBranches) {
|
|
93
|
+
const cleanBranch = branch.replace(/^\*?\s*/, "").trim();
|
|
94
|
+
if (isBranchMergedIntoMain(cleanBranch)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Method 2: Check for merge commits mentioning the issue
|
|
101
|
+
// Use specific merge patterns to avoid false positives from
|
|
102
|
+
// unrelated commits that merely reference the issue number
|
|
103
|
+
const logResult = spawnSync("git", [
|
|
104
|
+
"log",
|
|
105
|
+
"main",
|
|
106
|
+
"--oneline",
|
|
107
|
+
"-20",
|
|
108
|
+
"--grep",
|
|
109
|
+
`Merge #${issueNumber}`,
|
|
110
|
+
"--grep",
|
|
111
|
+
`Merge.*#${issueNumber}`,
|
|
112
|
+
"--grep",
|
|
113
|
+
`(#${issueNumber})`,
|
|
114
|
+
], {
|
|
115
|
+
stdio: "pipe",
|
|
116
|
+
timeout: 10000,
|
|
117
|
+
});
|
|
118
|
+
if (logResult.status === 0 && logResult.stdout) {
|
|
119
|
+
const commits = logResult.stdout.toString().trim();
|
|
120
|
+
if (commits.length > 0) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// git command failed - return false
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run reflection analysis — analyzes completed run data and suggests improvements.
|
|
3
|
+
*
|
|
4
|
+
* Used by the `--reflect` flag on `sequant run` to provide post-run insights.
|
|
5
|
+
*/
|
|
6
|
+
import type { IssueResult } from "./types.js";
|
|
7
|
+
import type { RunLog } from "./run-log-schema.js";
|
|
8
|
+
export interface ReflectionInput {
|
|
9
|
+
results: IssueResult[];
|
|
10
|
+
issueInfoMap: Map<number, {
|
|
11
|
+
title: string;
|
|
12
|
+
labels: string[];
|
|
13
|
+
}>;
|
|
14
|
+
runLog: Omit<RunLog, "endTime"> | null;
|
|
15
|
+
config: {
|
|
16
|
+
phases: string[];
|
|
17
|
+
qualityLoop: boolean;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export interface ReflectionOutput {
|
|
21
|
+
observations: string[];
|
|
22
|
+
suggestions: string[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Analyze a completed run and return observations + suggestions.
|
|
26
|
+
*/
|
|
27
|
+
export declare function analyzeRun(input: ReflectionInput): ReflectionOutput;
|
|
28
|
+
/**
|
|
29
|
+
* Format reflection output as a box with observations and suggestions.
|
|
30
|
+
* Enforces max 10 content lines.
|
|
31
|
+
*/
|
|
32
|
+
export declare function formatReflection(output: ReflectionOutput): string;
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run reflection analysis — analyzes completed run data and suggests improvements.
|
|
3
|
+
*
|
|
4
|
+
* Used by the `--reflect` flag on `sequant run` to provide post-run insights.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_OUTPUT_LINES = 10;
|
|
7
|
+
/**
|
|
8
|
+
* Analyze a completed run and return observations + suggestions.
|
|
9
|
+
*/
|
|
10
|
+
export function analyzeRun(input) {
|
|
11
|
+
const observations = [];
|
|
12
|
+
const suggestions = [];
|
|
13
|
+
analyzeTimingPatterns(input, observations, suggestions);
|
|
14
|
+
detectPhaseMismatches(input, observations, suggestions);
|
|
15
|
+
suggestImprovements(input, observations, suggestions);
|
|
16
|
+
return { observations, suggestions };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Compare phase durations across issues to find timing anomalies.
|
|
20
|
+
*/
|
|
21
|
+
function analyzeTimingPatterns(input, observations, suggestions) {
|
|
22
|
+
const { results } = input;
|
|
23
|
+
// Compare spec phase durations (needs 2+ issues)
|
|
24
|
+
const specTimings = results
|
|
25
|
+
.map((r) => ({
|
|
26
|
+
issue: r.issueNumber,
|
|
27
|
+
duration: r.phaseResults.find((p) => p.phase === "spec")?.durationSeconds,
|
|
28
|
+
}))
|
|
29
|
+
.filter((t) => t.duration != null);
|
|
30
|
+
if (specTimings.length >= 2) {
|
|
31
|
+
const min = Math.min(...specTimings.map((t) => t.duration));
|
|
32
|
+
const max = Math.max(...specTimings.map((t) => t.duration));
|
|
33
|
+
// If spec times are similar (within 30%) despite different issues, flag it
|
|
34
|
+
if (max > 0 && min / max > 0.7 && max - min < 120) {
|
|
35
|
+
observations.push(`Spec times similar across issues (${formatSec(min)}–${formatSec(max)}) despite varying complexity`);
|
|
36
|
+
suggestions.push("Consider `--phases exec,qa` for simple fixes to skip spec");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Flag individual phases that took unusually long
|
|
40
|
+
for (const result of results) {
|
|
41
|
+
for (const phase of result.phaseResults) {
|
|
42
|
+
if (phase.phase === "qa" &&
|
|
43
|
+
phase.durationSeconds &&
|
|
44
|
+
phase.durationSeconds > 300) {
|
|
45
|
+
observations.push(`#${result.issueNumber} QA took ${formatSec(phase.durationSeconds)}`);
|
|
46
|
+
suggestions.push("Long QA may indicate sub-agent spawning issues");
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Detect mismatches between file changes and executed phases.
|
|
54
|
+
*/
|
|
55
|
+
function detectPhaseMismatches(input, observations, suggestions) {
|
|
56
|
+
const { runLog, results } = input;
|
|
57
|
+
// Check fileDiffStats from runLog for .tsx/.jsx changes without test phase
|
|
58
|
+
if (runLog?.issues) {
|
|
59
|
+
for (const issueLog of runLog.issues) {
|
|
60
|
+
const phases = issueLog.phases.map((p) => p.phase);
|
|
61
|
+
const hasTestPhase = phases.includes("test");
|
|
62
|
+
if (hasTestPhase)
|
|
63
|
+
continue;
|
|
64
|
+
// Collect all modified files across phases
|
|
65
|
+
const modifiedFiles = [];
|
|
66
|
+
for (const phase of issueLog.phases) {
|
|
67
|
+
if (phase.fileDiffStats) {
|
|
68
|
+
modifiedFiles.push(...phase.fileDiffStats.map((f) => f.path));
|
|
69
|
+
}
|
|
70
|
+
if (phase.filesModified) {
|
|
71
|
+
modifiedFiles.push(...phase.filesModified);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const hasTsxFiles = modifiedFiles.some((f) => f.endsWith(".tsx") || f.endsWith(".jsx"));
|
|
75
|
+
if (hasTsxFiles) {
|
|
76
|
+
observations.push(`#${issueLog.issueNumber} modified .tsx files but no browser test ran`);
|
|
77
|
+
suggestions.push(`Add \`ui\` label to #${issueLog.issueNumber} for browser testing`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Fallback: check labels if no runLog
|
|
82
|
+
if (!runLog) {
|
|
83
|
+
for (const result of results) {
|
|
84
|
+
const info = input.issueInfoMap.get(result.issueNumber);
|
|
85
|
+
const labels = info?.labels ?? [];
|
|
86
|
+
const hasUiLabel = labels.some((l) => ["ui", "frontend", "admin"].includes(l.toLowerCase()));
|
|
87
|
+
const hasTestPhase = result.phaseResults.some((p) => p.phase === "test");
|
|
88
|
+
if (hasUiLabel && !hasTestPhase) {
|
|
89
|
+
observations.push(`#${result.issueNumber} has UI label but no test phase ran`);
|
|
90
|
+
suggestions.push("Include test phase for UI-labeled issues");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Suggest workflow improvements based on run patterns.
|
|
97
|
+
*/
|
|
98
|
+
function suggestImprovements(input, observations, suggestions) {
|
|
99
|
+
const { results, config } = input;
|
|
100
|
+
// Check if all issues ran the same phases
|
|
101
|
+
if (results.length >= 2) {
|
|
102
|
+
const phaseSets = results.map((r) => r.phaseResults.map((p) => p.phase).join(","));
|
|
103
|
+
const allSame = phaseSets.every((s) => s === phaseSets[0]);
|
|
104
|
+
if (allSame) {
|
|
105
|
+
observations.push("All issues ran identical phases despite different requirements");
|
|
106
|
+
suggestions.push("Use `/solve` first to get per-issue phase recommendations");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Check if quality loop was triggered
|
|
110
|
+
const loopIssues = results.filter((r) => r.loopTriggered);
|
|
111
|
+
if (loopIssues.length > 0) {
|
|
112
|
+
const issueNums = loopIssues.map((r) => `#${r.issueNumber}`).join(", ");
|
|
113
|
+
observations.push(`Quality loop triggered for ${issueNums}`);
|
|
114
|
+
suggestions.push("Consider adding `complex` label upfront for similar issues");
|
|
115
|
+
}
|
|
116
|
+
// Check for failed issues
|
|
117
|
+
const failedIssues = results.filter((r) => !r.success);
|
|
118
|
+
if (failedIssues.length > 0 && results.length > 1) {
|
|
119
|
+
const failRate = ((failedIssues.length / results.length) * 100).toFixed(0);
|
|
120
|
+
observations.push(`${failRate}% failure rate (${failedIssues.length}/${results.length})`);
|
|
121
|
+
}
|
|
122
|
+
// Suggest quality loop if not enabled and failures occurred
|
|
123
|
+
if (!config.qualityLoop && failedIssues.length > 0) {
|
|
124
|
+
suggestions.push("Enable `--quality-loop` to auto-retry failed phases");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Format reflection output as a box with observations and suggestions.
|
|
129
|
+
* Enforces max 10 content lines.
|
|
130
|
+
*/
|
|
131
|
+
export function formatReflection(output) {
|
|
132
|
+
const { observations, suggestions } = output;
|
|
133
|
+
if (observations.length === 0 && suggestions.length === 0) {
|
|
134
|
+
return "";
|
|
135
|
+
}
|
|
136
|
+
const lines = [];
|
|
137
|
+
// Collect all content lines
|
|
138
|
+
const contentLines = [];
|
|
139
|
+
for (const obs of observations) {
|
|
140
|
+
contentLines.push(` \u2022 ${obs}`);
|
|
141
|
+
}
|
|
142
|
+
for (const sug of suggestions) {
|
|
143
|
+
contentLines.push(` \u2022 ${sug}`);
|
|
144
|
+
}
|
|
145
|
+
// Truncate if needed
|
|
146
|
+
const truncated = contentLines.length > MAX_OUTPUT_LINES;
|
|
147
|
+
const displayLines = truncated
|
|
148
|
+
? contentLines.slice(0, MAX_OUTPUT_LINES - 1)
|
|
149
|
+
: contentLines;
|
|
150
|
+
// Calculate box width
|
|
151
|
+
const maxLineLen = Math.max(...displayLines.map((l) => l.length), truncated ? 30 : 0, 20);
|
|
152
|
+
const boxWidth = Math.min(Math.max(maxLineLen + 4, 40), 66);
|
|
153
|
+
// Build box
|
|
154
|
+
lines.push(` \u250C\u2500 Run Analysis ${"─".repeat(Math.max(boxWidth - 16, 0))}\u2510`);
|
|
155
|
+
lines.push(` \u2502${" ".repeat(boxWidth - 2)}\u2502`);
|
|
156
|
+
if (observations.length > 0) {
|
|
157
|
+
lines.push(` \u2502 Observations:${" ".repeat(Math.max(boxWidth - 17, 0))}\u2502`);
|
|
158
|
+
for (const line of displayLines.slice(0, observations.length)) {
|
|
159
|
+
lines.push(` \u2502${padRight(line, boxWidth - 2)}\u2502`);
|
|
160
|
+
}
|
|
161
|
+
lines.push(` \u2502${" ".repeat(boxWidth - 2)}\u2502`);
|
|
162
|
+
}
|
|
163
|
+
const sugLines = displayLines.slice(observations.length);
|
|
164
|
+
if (sugLines.length > 0) {
|
|
165
|
+
lines.push(` \u2502 Suggestions:${" ".repeat(Math.max(boxWidth - 16, 0))}\u2502`);
|
|
166
|
+
for (const line of sugLines) {
|
|
167
|
+
lines.push(` \u2502${padRight(line, boxWidth - 2)}\u2502`);
|
|
168
|
+
}
|
|
169
|
+
lines.push(` \u2502${" ".repeat(boxWidth - 2)}\u2502`);
|
|
170
|
+
}
|
|
171
|
+
if (truncated) {
|
|
172
|
+
const remaining = contentLines.length - (MAX_OUTPUT_LINES - 1);
|
|
173
|
+
const moreText = ` ... and ${remaining} more`;
|
|
174
|
+
lines.push(` \u2502${padRight(moreText, boxWidth - 2)}\u2502`);
|
|
175
|
+
lines.push(` \u2502${" ".repeat(boxWidth - 2)}\u2502`);
|
|
176
|
+
}
|
|
177
|
+
lines.push(` \u2514${"─".repeat(boxWidth - 2)}\u2518`);
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
function padRight(str, len) {
|
|
181
|
+
return str.length >= len
|
|
182
|
+
? str.slice(0, len)
|
|
183
|
+
: str + " ".repeat(len - str.length);
|
|
184
|
+
}
|
|
185
|
+
function formatSec(seconds) {
|
|
186
|
+
if (seconds < 60)
|
|
187
|
+
return `${seconds.toFixed(0)}s`;
|
|
188
|
+
const mins = Math.floor(seconds / 60);
|
|
189
|
+
const secs = Math.round(seconds % 60);
|
|
190
|
+
return `${mins}m ${secs}s`;
|
|
191
|
+
}
|