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 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
  [![npm version](https://img.shields.io/npm/v/sequant.svg)](https://www.npmjs.com/package/sequant)
8
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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", "Run issues sequentially")
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
@@ -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 ? "sequential" : "parallel"}`));
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
- // Parallel execution (for now, just run sequentially but don't stop on failure)
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
- for (let i = 0; i < actionableFindings.length; i++) {
239
- const updatedFinding = await createOrLinkFinding(actionableFindings[i], release.tagName, assessmentIssueNumber, dryRun);
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
- // Breaking Changes section
120
- const breakingFindings = findings.filter((f) => f.category === "breaking");
121
- lines.push("### Breaking Changes");
122
- lines.push("");
123
- if (breakingFindings.length === 0) {
124
- lines.push("None detected.");
125
- }
126
- else {
127
- breakingFindings.forEach((f, i) => {
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
- if (newTools.length === 0) {
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 (hookChanges.length === 0) {
132
+ if (actionableFindings.length === 0) {
163
133
  lines.push("None detected.");
164
134
  }
165
135
  else {
166
- hookChanges.forEach((f, i) => {
136
+ actionableFindings.forEach((f, i) => {
167
137
  lines.push(formatFinding(f, i));
168
138
  });
169
139
  }
170
140
  lines.push("");
171
- // Opportunities section
141
+ // Informational section — opportunities noted for human triage
172
142
  const opportunities = findings.filter((f) => f.category === "opportunity");
173
- lines.push("### Feature Opportunities");
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
+ }