sequant 1.16.0 → 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/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -1
- package/dist/bin/cli.js +2 -1
- package/dist/src/commands/run.d.ts +6 -0
- package/dist/src/commands/run.js +26 -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 +4 -5
- package/templates/skills/exec/SKILL.md +2 -2
- package/templates/skills/fullsolve/SKILL.md +18 -4
- package/templates/skills/loop/SKILL.md +1 -1
- package/templates/skills/qa/SKILL.md +6 -6
- package/templates/skills/solve/SKILL.md +28 -6
- package/templates/skills/spec/SKILL.md +16 -15
- package/templates/skills/testgen/SKILL.md +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State cleanup and reconciliation utilities
|
|
3
|
+
*
|
|
4
|
+
* @module state-cleanup
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { cleanupStaleEntries, reconcileStateAtStartup } from './state-cleanup';
|
|
8
|
+
*
|
|
9
|
+
* // Clean up orphaned entries
|
|
10
|
+
* const result = await cleanupStaleEntries({ dryRun: true });
|
|
11
|
+
* console.log(`Would remove ${result.removed.length} entries`);
|
|
12
|
+
*
|
|
13
|
+
* // Reconcile state at startup
|
|
14
|
+
* const reconcileResult = await reconcileStateAtStartup();
|
|
15
|
+
* console.log(`Advanced ${reconcileResult.advanced.length} issues to merged`);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export interface CleanupOptions {
|
|
19
|
+
/** State file path (default: .sequant/state.json) */
|
|
20
|
+
statePath?: string;
|
|
21
|
+
/** Only report what would be cleaned (don't modify) */
|
|
22
|
+
dryRun?: boolean;
|
|
23
|
+
/** Enable verbose logging */
|
|
24
|
+
verbose?: boolean;
|
|
25
|
+
/** Remove issues older than this many days */
|
|
26
|
+
maxAgeDays?: number;
|
|
27
|
+
/** Remove all orphaned entries (both merged and abandoned) in one step */
|
|
28
|
+
removeAll?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface CleanupResult {
|
|
31
|
+
/** Whether cleanup was successful */
|
|
32
|
+
success: boolean;
|
|
33
|
+
/** Issues that were removed or would be removed */
|
|
34
|
+
removed: number[];
|
|
35
|
+
/** Issues that were marked as orphaned (abandoned) */
|
|
36
|
+
orphaned: number[];
|
|
37
|
+
/** Issues detected as merged PRs */
|
|
38
|
+
merged: number[];
|
|
39
|
+
/** Error message if failed */
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Clean up stale and orphaned entries from workflow state
|
|
44
|
+
*
|
|
45
|
+
* - Checks GitHub to detect if associated PR was merged
|
|
46
|
+
* - Orphaned entries with merged PRs get status "merged" and are removed automatically
|
|
47
|
+
* - Orphaned entries without merged PRs get status "abandoned" (kept for review)
|
|
48
|
+
* - Use removeAll to remove both merged and abandoned orphaned entries in one step
|
|
49
|
+
* - Use maxAgeDays to remove old merged/abandoned issues
|
|
50
|
+
*/
|
|
51
|
+
export declare function cleanupStaleEntries(options?: CleanupOptions): Promise<CleanupResult>;
|
|
52
|
+
export interface ReconcileOptions {
|
|
53
|
+
/** State file path (default: .sequant/state.json) */
|
|
54
|
+
statePath?: string;
|
|
55
|
+
/** Enable verbose logging */
|
|
56
|
+
verbose?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export interface ReconcileResult {
|
|
59
|
+
/** Whether reconciliation was successful */
|
|
60
|
+
success: boolean;
|
|
61
|
+
/** Issues that were advanced from ready_for_merge to merged */
|
|
62
|
+
advanced: number[];
|
|
63
|
+
/** Issues checked but still ready_for_merge */
|
|
64
|
+
stillPending: number[];
|
|
65
|
+
/** Error message if failed */
|
|
66
|
+
error?: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Lightweight state reconciliation at run start
|
|
70
|
+
*
|
|
71
|
+
* Checks issues in `ready_for_merge` state and advances them to `merged`
|
|
72
|
+
* if their PRs are merged or their branches are in main.
|
|
73
|
+
*
|
|
74
|
+
* This prevents re-running already completed issues.
|
|
75
|
+
*
|
|
76
|
+
* @param options - Reconciliation options
|
|
77
|
+
* @returns Result with lists of advanced and still-pending issues
|
|
78
|
+
*/
|
|
79
|
+
export declare function reconcileStateAtStartup(options?: ReconcileOptions): Promise<ReconcileResult>;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State cleanup and reconciliation utilities
|
|
3
|
+
*
|
|
4
|
+
* @module state-cleanup
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { cleanupStaleEntries, reconcileStateAtStartup } from './state-cleanup';
|
|
8
|
+
*
|
|
9
|
+
* // Clean up orphaned entries
|
|
10
|
+
* const result = await cleanupStaleEntries({ dryRun: true });
|
|
11
|
+
* console.log(`Would remove ${result.removed.length} entries`);
|
|
12
|
+
*
|
|
13
|
+
* // Reconcile state at startup
|
|
14
|
+
* const reconcileResult = await reconcileStateAtStartup();
|
|
15
|
+
* console.log(`Advanced ${reconcileResult.advanced.length} issues to merged`);
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "child_process";
|
|
19
|
+
import { StateManager } from "./state-manager.js";
|
|
20
|
+
import { checkPRMergeStatus, isIssueMergedIntoMain } from "./pr-status.js";
|
|
21
|
+
/**
|
|
22
|
+
* Get list of active worktree paths
|
|
23
|
+
*/
|
|
24
|
+
function getActiveWorktrees() {
|
|
25
|
+
const result = spawnSync("git", ["worktree", "list", "--porcelain"], {
|
|
26
|
+
stdio: "pipe",
|
|
27
|
+
});
|
|
28
|
+
if (result.status !== 0) {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
const output = result.stdout.toString();
|
|
32
|
+
const paths = [];
|
|
33
|
+
for (const line of output.split("\n")) {
|
|
34
|
+
if (line.startsWith("worktree ")) {
|
|
35
|
+
paths.push(line.substring(9));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return paths;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Clean up stale and orphaned entries from workflow state
|
|
42
|
+
*
|
|
43
|
+
* - Checks GitHub to detect if associated PR was merged
|
|
44
|
+
* - Orphaned entries with merged PRs get status "merged" and are removed automatically
|
|
45
|
+
* - Orphaned entries without merged PRs get status "abandoned" (kept for review)
|
|
46
|
+
* - Use removeAll to remove both merged and abandoned orphaned entries in one step
|
|
47
|
+
* - Use maxAgeDays to remove old merged/abandoned issues
|
|
48
|
+
*/
|
|
49
|
+
export async function cleanupStaleEntries(options = {}) {
|
|
50
|
+
const manager = new StateManager({
|
|
51
|
+
statePath: options.statePath,
|
|
52
|
+
verbose: options.verbose,
|
|
53
|
+
});
|
|
54
|
+
if (!manager.stateExists()) {
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
removed: [],
|
|
58
|
+
orphaned: [],
|
|
59
|
+
merged: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const state = await manager.getState();
|
|
64
|
+
const removed = [];
|
|
65
|
+
const orphaned = [];
|
|
66
|
+
const merged = [];
|
|
67
|
+
// Get list of active worktrees
|
|
68
|
+
const activeWorktrees = getActiveWorktrees();
|
|
69
|
+
for (const [issueNumStr, issueState] of Object.entries(state.issues)) {
|
|
70
|
+
const issueNum = parseInt(issueNumStr, 10);
|
|
71
|
+
// Check if worktree exists (if issue has one)
|
|
72
|
+
if (issueState.worktree &&
|
|
73
|
+
!activeWorktrees.includes(issueState.worktree)) {
|
|
74
|
+
if (options.verbose) {
|
|
75
|
+
console.log(`🔍 Orphaned: #${issueNum} (worktree not found: ${issueState.worktree})`);
|
|
76
|
+
}
|
|
77
|
+
// Check if this issue has a PR and if it's merged
|
|
78
|
+
let prMerged = false;
|
|
79
|
+
if (issueState.pr?.number) {
|
|
80
|
+
if (options.verbose) {
|
|
81
|
+
console.log(` Checking PR #${issueState.pr.number} status...`);
|
|
82
|
+
}
|
|
83
|
+
const prStatus = checkPRMergeStatus(issueState.pr.number);
|
|
84
|
+
prMerged = prStatus === "MERGED";
|
|
85
|
+
if (options.verbose) {
|
|
86
|
+
console.log(` PR status: ${prStatus ?? "unknown"}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (!options.dryRun) {
|
|
90
|
+
if (prMerged || issueState.status === "merged") {
|
|
91
|
+
// Merged PRs are auto-removed
|
|
92
|
+
merged.push(issueNum);
|
|
93
|
+
removed.push(issueNum);
|
|
94
|
+
if (options.verbose) {
|
|
95
|
+
console.log(` ✓ Merged PR detected, removing entry`);
|
|
96
|
+
}
|
|
97
|
+
delete state.issues[issueNumStr];
|
|
98
|
+
}
|
|
99
|
+
else if (issueState.status === "abandoned" || options.removeAll) {
|
|
100
|
+
// Already abandoned or removeAll flag - remove it
|
|
101
|
+
orphaned.push(issueNum);
|
|
102
|
+
removed.push(issueNum);
|
|
103
|
+
if (options.verbose) {
|
|
104
|
+
console.log(` ✓ Removing abandoned entry`);
|
|
105
|
+
}
|
|
106
|
+
delete state.issues[issueNumStr];
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Mark as abandoned (kept for review)
|
|
110
|
+
orphaned.push(issueNum);
|
|
111
|
+
issueState.status = "abandoned";
|
|
112
|
+
if (options.verbose) {
|
|
113
|
+
console.log(` → Marked as abandoned (kept for review)`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Dry run - report what would happen
|
|
119
|
+
if (prMerged || issueState.status === "merged") {
|
|
120
|
+
merged.push(issueNum);
|
|
121
|
+
removed.push(issueNum);
|
|
122
|
+
}
|
|
123
|
+
else if (issueState.status === "abandoned" || options.removeAll) {
|
|
124
|
+
orphaned.push(issueNum);
|
|
125
|
+
removed.push(issueNum);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
orphaned.push(issueNum);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Check age for merged/abandoned issues
|
|
134
|
+
if (options.maxAgeDays &&
|
|
135
|
+
(issueState.status === "merged" || issueState.status === "abandoned")) {
|
|
136
|
+
const lastActivity = new Date(issueState.lastActivity);
|
|
137
|
+
const ageDays = (Date.now() - lastActivity.getTime()) / (1000 * 60 * 60 * 24);
|
|
138
|
+
if (ageDays > options.maxAgeDays) {
|
|
139
|
+
removed.push(issueNum);
|
|
140
|
+
if (options.verbose) {
|
|
141
|
+
console.log(`🗑️ Stale: #${issueNum} (${Math.floor(ageDays)} days old)`);
|
|
142
|
+
}
|
|
143
|
+
if (!options.dryRun) {
|
|
144
|
+
delete state.issues[issueNumStr];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Save updated state
|
|
150
|
+
if (!options.dryRun && (removed.length > 0 || orphaned.length > 0)) {
|
|
151
|
+
await manager.saveState(state);
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
success: true,
|
|
155
|
+
removed,
|
|
156
|
+
orphaned,
|
|
157
|
+
merged,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
removed: [],
|
|
164
|
+
orphaned: [],
|
|
165
|
+
merged: [],
|
|
166
|
+
error: String(error),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Lightweight state reconciliation at run start
|
|
172
|
+
*
|
|
173
|
+
* Checks issues in `ready_for_merge` state and advances them to `merged`
|
|
174
|
+
* if their PRs are merged or their branches are in main.
|
|
175
|
+
*
|
|
176
|
+
* This prevents re-running already completed issues.
|
|
177
|
+
*
|
|
178
|
+
* @param options - Reconciliation options
|
|
179
|
+
* @returns Result with lists of advanced and still-pending issues
|
|
180
|
+
*/
|
|
181
|
+
export async function reconcileStateAtStartup(options = {}) {
|
|
182
|
+
const manager = new StateManager({
|
|
183
|
+
statePath: options.statePath,
|
|
184
|
+
verbose: options.verbose,
|
|
185
|
+
});
|
|
186
|
+
// Graceful degradation: if state file doesn't exist, skip
|
|
187
|
+
if (!manager.stateExists()) {
|
|
188
|
+
return {
|
|
189
|
+
success: true,
|
|
190
|
+
advanced: [],
|
|
191
|
+
stillPending: [],
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const state = await manager.getState();
|
|
196
|
+
const advanced = [];
|
|
197
|
+
const stillPending = [];
|
|
198
|
+
// Find issues in ready_for_merge state
|
|
199
|
+
for (const [issueNumStr, issueState] of Object.entries(state.issues)) {
|
|
200
|
+
if (issueState.status !== "ready_for_merge") {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const issueNum = parseInt(issueNumStr, 10);
|
|
204
|
+
let isMerged = false;
|
|
205
|
+
// Check 1: If we have PR info, check PR status via gh
|
|
206
|
+
if (issueState.pr?.number) {
|
|
207
|
+
const prStatus = checkPRMergeStatus(issueState.pr.number);
|
|
208
|
+
if (prStatus === "MERGED") {
|
|
209
|
+
isMerged = true;
|
|
210
|
+
if (options.verbose) {
|
|
211
|
+
console.log(` #${issueNum}: PR #${issueState.pr.number} is merged`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Check 2: If no PR or PR check failed, check git for merged branch
|
|
216
|
+
if (!isMerged) {
|
|
217
|
+
isMerged = isIssueMergedIntoMain(issueNum);
|
|
218
|
+
if (isMerged && options.verbose) {
|
|
219
|
+
console.log(` #${issueNum}: Branch merged into main (git check)`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (isMerged) {
|
|
223
|
+
// Advance state to merged
|
|
224
|
+
issueState.status = "merged";
|
|
225
|
+
issueState.lastActivity = new Date().toISOString();
|
|
226
|
+
advanced.push(issueNum);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
stillPending.push(issueNum);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Save state if any issues were advanced
|
|
233
|
+
if (advanced.length > 0) {
|
|
234
|
+
await manager.saveState(state);
|
|
235
|
+
}
|
|
236
|
+
return {
|
|
237
|
+
success: true,
|
|
238
|
+
advanced,
|
|
239
|
+
stillPending,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return {
|
|
244
|
+
success: false,
|
|
245
|
+
advanced: [],
|
|
246
|
+
stillPending: [],
|
|
247
|
+
error: String(error),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State reconstruction from run logs
|
|
3
|
+
*
|
|
4
|
+
* @module state-rebuild
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* import { rebuildStateFromLogs } from './state-rebuild';
|
|
8
|
+
*
|
|
9
|
+
* // Rebuild state from run logs
|
|
10
|
+
* const result = await rebuildStateFromLogs();
|
|
11
|
+
* console.log(`Processed ${result.logsProcessed} logs, found ${result.issuesFound} issues`);
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export interface RebuildOptions {
|
|
15
|
+
/** Log directory path (default: .sequant/logs) */
|
|
16
|
+
logPath?: string;
|
|
17
|
+
/** State file path (default: .sequant/state.json) */
|
|
18
|
+
statePath?: string;
|
|
19
|
+
/** Enable verbose logging */
|
|
20
|
+
verbose?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export interface RebuildResult {
|
|
23
|
+
/** Whether rebuild was successful */
|
|
24
|
+
success: boolean;
|
|
25
|
+
/** Number of log files processed */
|
|
26
|
+
logsProcessed: number;
|
|
27
|
+
/** Number of issues found */
|
|
28
|
+
issuesFound: number;
|
|
29
|
+
/** Error message if failed */
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Rebuild workflow state from run logs
|
|
34
|
+
*
|
|
35
|
+
* Scans all run logs in .sequant/logs/ and reconstructs state
|
|
36
|
+
* based on the most recent activity for each issue.
|
|
37
|
+
*/
|
|
38
|
+
export declare function rebuildStateFromLogs(options?: RebuildOptions): Promise<RebuildResult>;
|