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.
@@ -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>;