sequant 1.15.1 → 1.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +3 -3
  3. package/README.md +3 -3
  4. package/dist/bin/cli.js +3 -0
  5. package/dist/src/commands/init.js +1 -1
  6. package/dist/src/commands/logs.js +15 -0
  7. package/dist/src/commands/run.d.ts +114 -1
  8. package/dist/src/commands/run.js +513 -31
  9. package/dist/src/commands/stats.js +48 -0
  10. package/dist/src/lib/scope/index.d.ts +1 -0
  11. package/dist/src/lib/scope/index.js +2 -0
  12. package/dist/src/lib/scope/settings-converter.d.ts +28 -0
  13. package/dist/src/lib/scope/settings-converter.js +53 -0
  14. package/dist/src/lib/settings.d.ts +45 -0
  15. package/dist/src/lib/settings.js +30 -0
  16. package/dist/src/lib/test-tautology-detector.d.ts +122 -0
  17. package/dist/src/lib/test-tautology-detector.js +488 -0
  18. package/dist/src/lib/upstream/issues.js +5 -5
  19. package/dist/src/lib/workflow/git-diff-utils.d.ts +39 -0
  20. package/dist/src/lib/workflow/git-diff-utils.js +142 -0
  21. package/dist/src/lib/workflow/log-writer.d.ts +9 -2
  22. package/dist/src/lib/workflow/log-writer.js +9 -3
  23. package/dist/src/lib/workflow/metrics-schema.d.ts +9 -0
  24. package/dist/src/lib/workflow/metrics-schema.js +10 -1
  25. package/dist/src/lib/workflow/phase-detection.d.ts +3 -0
  26. package/dist/src/lib/workflow/phase-detection.js +27 -1
  27. package/dist/src/lib/workflow/qa-cache.d.ts +3 -1
  28. package/dist/src/lib/workflow/qa-cache.js +2 -0
  29. package/dist/src/lib/workflow/run-log-schema.d.ts +90 -3
  30. package/dist/src/lib/workflow/run-log-schema.js +44 -2
  31. package/dist/src/lib/workflow/state-utils.d.ts +46 -0
  32. package/dist/src/lib/workflow/state-utils.js +167 -0
  33. package/dist/src/lib/workflow/token-utils.d.ts +92 -0
  34. package/dist/src/lib/workflow/token-utils.js +170 -0
  35. package/dist/src/lib/workflow/types.d.ts +6 -0
  36. package/dist/src/lib/workflow/types.js +1 -0
  37. package/package.json +4 -4
  38. package/templates/hooks/pre-tool.sh +4 -0
  39. package/templates/skills/assess/SKILL.md +1 -1
  40. package/templates/skills/exec/SKILL.md +5 -4
  41. package/templates/skills/improve/SKILL.md +37 -24
  42. package/templates/skills/loop/SKILL.md +3 -3
  43. package/templates/skills/qa/SKILL.md +66 -1
  44. package/templates/skills/qa/references/code-review-checklist.md +10 -11
  45. package/templates/skills/qa/scripts/quality-checks.sh +16 -0
  46. package/templates/skills/security-review/references/security-checklists.md +89 -36
  47. package/templates/skills/solve/SKILL.md +3 -1
  48. package/templates/skills/spec/SKILL.md +8 -4
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import chalk from "chalk";
8
8
  import { spawnSync } from "child_process";
9
- import { existsSync } from "fs";
9
+ import { existsSync, readFileSync } from "fs";
10
10
  import path from "path";
11
11
  import { query } from "@anthropic-ai/claude-agent-sdk";
12
12
  import { getManifest } from "../lib/manifest.js";
@@ -23,6 +23,9 @@ import { determineOutcome, } from "../lib/workflow/metrics-schema.js";
23
23
  import { getResumablePhasesForIssue } from "../lib/workflow/phase-detection.js";
24
24
  import { ui, colors } from "../lib/cli-ui.js";
25
25
  import { PhaseSpinner } from "../lib/phase-spinner.js";
26
+ import { getGitDiffStats, getCommitHash, } from "../lib/workflow/git-diff-utils.js";
27
+ import { getTokenUsageForRun } from "../lib/workflow/token-utils.js";
28
+ import { reconcileStateAtStartup } from "../lib/workflow/state-utils.js";
26
29
  /**
27
30
  * Slugify a title for branch naming
28
31
  */
@@ -94,6 +97,92 @@ function findExistingWorktree(branch) {
94
97
  }
95
98
  return null;
96
99
  }
100
+ /**
101
+ * Check if a worktree is stale (behind origin/main) and should be recreated
102
+ *
103
+ * @param worktreePath - Path to the worktree
104
+ * @param verbose - Enable verbose output
105
+ * @returns Freshness check result
106
+ */
107
+ export function checkWorktreeFreshness(worktreePath, verbose) {
108
+ const result = {
109
+ isStale: false,
110
+ commitsBehind: 0,
111
+ hasUncommittedChanges: false,
112
+ hasUnpushedCommits: false,
113
+ };
114
+ // Fetch latest main to ensure accurate comparison
115
+ spawnSync("git", ["-C", worktreePath, "fetch", "origin", "main"], {
116
+ stdio: "pipe",
117
+ timeout: 30000,
118
+ });
119
+ // Check for uncommitted changes
120
+ const statusResult = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], { stdio: "pipe" });
121
+ if (statusResult.status === 0) {
122
+ result.hasUncommittedChanges =
123
+ statusResult.stdout.toString().trim().length > 0;
124
+ }
125
+ // Get merge base with origin/main
126
+ const mergeBaseResult = spawnSync("git", ["-C", worktreePath, "merge-base", "HEAD", "origin/main"], { stdio: "pipe" });
127
+ if (mergeBaseResult.status !== 0) {
128
+ // Can't determine merge base - not stale
129
+ return result;
130
+ }
131
+ const mergeBase = mergeBaseResult.stdout.toString().trim();
132
+ // Get origin/main HEAD
133
+ const mainHeadResult = spawnSync("git", ["-C", worktreePath, "rev-parse", "origin/main"], { stdio: "pipe" });
134
+ if (mainHeadResult.status !== 0) {
135
+ return result;
136
+ }
137
+ const mainHead = mainHeadResult.stdout.toString().trim();
138
+ // Count commits behind main
139
+ if (mergeBase !== mainHead) {
140
+ const countResult = spawnSync("git", ["-C", worktreePath, "rev-list", "--count", `${mergeBase}..${mainHead}`], { stdio: "pipe" });
141
+ if (countResult.status === 0) {
142
+ result.commitsBehind = parseInt(countResult.stdout.toString().trim(), 10);
143
+ // Consider stale if more than 5 commits behind (configurable threshold)
144
+ result.isStale = result.commitsBehind > 5;
145
+ }
146
+ }
147
+ // Check for unpushed commits (work in progress)
148
+ const unpushedResult = spawnSync("git", ["-C", worktreePath, "log", "--oneline", "@{u}..HEAD"], { stdio: "pipe" });
149
+ if (unpushedResult.status === 0) {
150
+ result.hasUnpushedCommits =
151
+ unpushedResult.stdout.toString().trim().length > 0;
152
+ }
153
+ if (verbose && result.isStale) {
154
+ console.log(chalk.gray(` 📊 Worktree is ${result.commitsBehind} commits behind origin/main`));
155
+ }
156
+ return result;
157
+ }
158
+ /**
159
+ * Remove and recreate a stale worktree
160
+ *
161
+ * @param existingPath - Path to existing worktree
162
+ * @param branch - Branch name
163
+ * @param verbose - Enable verbose output
164
+ * @returns true if worktree was removed
165
+ */
166
+ export function removeStaleWorktree(existingPath, branch, verbose) {
167
+ if (verbose) {
168
+ console.log(chalk.gray(` 🗑️ Removing stale worktree...`));
169
+ }
170
+ // Remove the worktree
171
+ const removeResult = spawnSync("git", ["worktree", "remove", "--force", existingPath], { stdio: "pipe" });
172
+ if (removeResult.status !== 0) {
173
+ const error = removeResult.stderr.toString();
174
+ console.log(chalk.yellow(` ⚠️ Could not remove worktree: ${error}`));
175
+ return false;
176
+ }
177
+ // Delete the branch so it can be recreated fresh
178
+ const deleteResult = spawnSync("git", ["branch", "-D", branch], {
179
+ stdio: "pipe",
180
+ });
181
+ if (deleteResult.status !== 0 && verbose) {
182
+ console.log(chalk.gray(` ℹ️ Branch ${branch} not deleted (may not exist locally)`));
183
+ }
184
+ return true;
185
+ }
97
186
  /**
98
187
  * List all active worktrees with their branches
99
188
  */
@@ -160,6 +249,37 @@ export function getWorktreeDiffStats(worktreePath) {
160
249
  linesAdded: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0,
161
250
  };
162
251
  }
252
+ /**
253
+ * Read cache metrics from QA phase (AC-7)
254
+ *
255
+ * @param worktreePath - Path to the worktree
256
+ * @returns CacheMetrics or undefined if not available
257
+ */
258
+ function readCacheMetrics(worktreePath) {
259
+ const cacheMetricsPath = worktreePath
260
+ ? path.join(worktreePath, ".sequant/.cache/qa/cache-metrics.json")
261
+ : ".sequant/.cache/qa/cache-metrics.json";
262
+ if (!existsSync(cacheMetricsPath)) {
263
+ return undefined;
264
+ }
265
+ try {
266
+ const content = readFileSync(cacheMetricsPath, "utf-8");
267
+ const data = JSON.parse(content);
268
+ if (typeof data.hits === "number" &&
269
+ typeof data.misses === "number" &&
270
+ typeof data.skipped === "number") {
271
+ return {
272
+ hits: data.hits,
273
+ misses: data.misses,
274
+ skipped: data.skipped,
275
+ };
276
+ }
277
+ }
278
+ catch {
279
+ // Ignore parse errors
280
+ }
281
+ return undefined;
282
+ }
163
283
  /**
164
284
  * Filter phases based on resume status.
165
285
  *
@@ -196,11 +316,76 @@ async function ensureWorktree(issueNumber, title, verbose, packageManager, baseB
196
316
  const worktreesDir = path.join(path.dirname(gitRoot), "worktrees");
197
317
  const worktreePath = path.join(worktreesDir, branch);
198
318
  // Check if worktree already exists
199
- const existingPath = findExistingWorktree(branch);
319
+ let existingPath = findExistingWorktree(branch);
320
+ if (existingPath) {
321
+ // AC-3: Check if worktree is stale and needs recreation
322
+ const freshness = checkWorktreeFreshness(existingPath, verbose);
323
+ if (freshness.isStale) {
324
+ // AC-3: Handle stale worktrees - check for work in progress
325
+ if (freshness.hasUncommittedChanges) {
326
+ console.log(chalk.yellow(` ⚠️ Worktree is ${freshness.commitsBehind} commits behind main but has uncommitted changes`));
327
+ console.log(chalk.yellow(` ℹ️ Keeping existing worktree. Commit or stash changes, then re-run.`));
328
+ // Continue with existing worktree
329
+ }
330
+ else if (freshness.hasUnpushedCommits) {
331
+ console.log(chalk.yellow(` ⚠️ Worktree is ${freshness.commitsBehind} commits behind main but has unpushed commits`));
332
+ console.log(chalk.yellow(` ℹ️ Keeping existing worktree with WIP commits.`));
333
+ // Continue with existing worktree
334
+ }
335
+ else {
336
+ // Safe to recreate - no uncommitted/unpushed work
337
+ console.log(chalk.yellow(` ⚠️ Worktree is ${freshness.commitsBehind} commits behind main — recreating fresh`));
338
+ if (removeStaleWorktree(existingPath, branch, verbose)) {
339
+ existingPath = null; // Will fall through to create new worktree
340
+ }
341
+ }
342
+ }
343
+ }
200
344
  if (existingPath) {
201
345
  if (verbose) {
202
346
  console.log(chalk.gray(` 📂 Reusing existing worktree: ${existingPath}`));
203
347
  }
348
+ // In chain mode, rebase existing worktree onto previous chain link
349
+ if (chainMode && baseBranch) {
350
+ if (verbose) {
351
+ console.log(chalk.gray(` 🔄 Rebasing existing worktree onto chain base (${baseBranch})...`));
352
+ }
353
+ const rebaseResult = spawnSync("git", ["-C", existingPath, "rebase", baseBranch], { stdio: "pipe" });
354
+ if (rebaseResult.status !== 0) {
355
+ const rebaseError = rebaseResult.stderr.toString();
356
+ // Check if it's a conflict
357
+ if (rebaseError.includes("CONFLICT") ||
358
+ rebaseError.includes("could not apply")) {
359
+ console.log(chalk.yellow(` ⚠️ Rebase conflict detected. Aborting rebase and keeping original branch state.`));
360
+ console.log(chalk.yellow(` ℹ️ Branch ${branch} is not properly chained. Manual rebase may be required.`));
361
+ // Abort the rebase to restore branch state
362
+ spawnSync("git", ["-C", existingPath, "rebase", "--abort"], {
363
+ stdio: "pipe",
364
+ });
365
+ }
366
+ else {
367
+ console.log(chalk.yellow(` ⚠️ Rebase failed: ${rebaseError.trim()}`));
368
+ console.log(chalk.yellow(` ℹ️ Continuing with branch in its original state.`));
369
+ }
370
+ return {
371
+ issue: issueNumber,
372
+ path: existingPath,
373
+ branch,
374
+ existed: true,
375
+ rebased: false,
376
+ };
377
+ }
378
+ if (verbose) {
379
+ console.log(chalk.green(` ✅ Existing worktree rebased onto ${baseBranch}`));
380
+ }
381
+ return {
382
+ issue: issueNumber,
383
+ path: existingPath,
384
+ branch,
385
+ existed: true,
386
+ rebased: true,
387
+ };
388
+ }
204
389
  return {
205
390
  issue: issueNumber,
206
391
  path: existingPath,
@@ -444,9 +629,7 @@ export function createCheckpointCommit(worktreePath, issueNumber, verbose) {
444
629
  const commitMessage = `checkpoint(#${issueNumber}): QA passed
445
630
 
446
631
  This is an automatic checkpoint commit created after issue #${issueNumber}
447
- passed QA in chain mode. It serves as a recovery point if later issues fail.
448
-
449
- Co-Authored-By: Sequant <noreply@sequant.dev>`;
632
+ passed QA in chain mode. It serves as a recovery point if later issues fail.`;
450
633
  const commitResult = spawnSync("git", ["-C", worktreePath, "commit", "-m", commitMessage], { stdio: "pipe" });
451
634
  if (commitResult.status !== 0) {
452
635
  const error = commitResult.stderr.toString();
@@ -458,6 +641,137 @@ Co-Authored-By: Sequant <noreply@sequant.dev>`;
458
641
  console.log(chalk.green(` 📌 Checkpoint commit created for #${issueNumber}`));
459
642
  return true;
460
643
  }
644
+ /**
645
+ * Lockfile names for different package managers
646
+ */
647
+ const LOCKFILES = [
648
+ "package-lock.json",
649
+ "pnpm-lock.yaml",
650
+ "bun.lock",
651
+ "yarn.lock",
652
+ ];
653
+ /**
654
+ * Check if any lockfile changed during a rebase and re-run install if needed.
655
+ * This prevents dependency drift when the lockfile was updated on main.
656
+ * @param worktreePath Path to the worktree
657
+ * @param packageManager Package manager to use for install
658
+ * @param verbose Whether to show verbose output
659
+ * @param preRebaseRef Git ref pointing to pre-rebase HEAD (defaults to ORIG_HEAD,
660
+ * which git sets automatically after rebase). Using ORIG_HEAD captures all
661
+ * lockfile changes across multi-commit rebases, unlike HEAD~1 which only
662
+ * checks the last commit.
663
+ * @returns true if reinstall was performed, false otherwise
664
+ * @internal Exported for testing
665
+ */
666
+ export function reinstallIfLockfileChanged(worktreePath, packageManager, verbose, preRebaseRef = "ORIG_HEAD") {
667
+ // Compare pre-rebase state to current HEAD to detect all lockfile changes
668
+ // introduced by the rebase (including changes from main that were pulled in)
669
+ let lockfileChanged = false;
670
+ for (const lockfile of LOCKFILES) {
671
+ const result = spawnSync("git", [
672
+ "-C",
673
+ worktreePath,
674
+ "diff",
675
+ "--name-only",
676
+ `${preRebaseRef}..HEAD`,
677
+ "--",
678
+ lockfile,
679
+ ], { stdio: "pipe" });
680
+ if (result.status === 0 && result.stdout.toString().trim().length > 0) {
681
+ lockfileChanged = true;
682
+ if (verbose) {
683
+ console.log(chalk.gray(` 📦 Lockfile changed: ${lockfile}`));
684
+ }
685
+ break;
686
+ }
687
+ }
688
+ if (!lockfileChanged) {
689
+ if (verbose) {
690
+ console.log(chalk.gray(` 📦 No lockfile changes detected`));
691
+ }
692
+ return false;
693
+ }
694
+ // Re-run install to sync node_modules with updated lockfile
695
+ console.log(chalk.blue(` 📦 Reinstalling dependencies (lockfile changed)...`));
696
+ const pm = packageManager || "npm";
697
+ const pmConfig = PM_CONFIG[pm];
698
+ const [cmd, ...args] = pmConfig.installSilent.split(" ");
699
+ const installResult = spawnSync(cmd, args, {
700
+ cwd: worktreePath,
701
+ stdio: "pipe",
702
+ });
703
+ if (installResult.status !== 0) {
704
+ const error = installResult.stderr.toString();
705
+ console.log(chalk.yellow(` ⚠️ Dependency reinstall failed: ${error.trim()}`));
706
+ return false;
707
+ }
708
+ console.log(chalk.green(` ✅ Dependencies reinstalled`));
709
+ return true;
710
+ }
711
+ /**
712
+ * Rebase the worktree branch onto origin/main before PR creation.
713
+ * This ensures the branch is up-to-date and prevents lockfile drift.
714
+ *
715
+ * @param worktreePath Path to the worktree
716
+ * @param issueNumber Issue number (for logging)
717
+ * @param packageManager Package manager to use if reinstall needed
718
+ * @param verbose Whether to show verbose output
719
+ * @returns RebaseResult indicating success/failure and whether reinstall was performed
720
+ * @internal Exported for testing
721
+ */
722
+ export function rebaseBeforePR(worktreePath, issueNumber, packageManager, verbose) {
723
+ if (verbose) {
724
+ console.log(chalk.gray(` 🔄 Rebasing #${issueNumber} onto origin/main before PR...`));
725
+ }
726
+ // Fetch latest main to ensure we're rebasing onto fresh state
727
+ const fetchResult = spawnSync("git", ["-C", worktreePath, "fetch", "origin", "main"], {
728
+ stdio: "pipe",
729
+ });
730
+ if (fetchResult.status !== 0) {
731
+ const error = fetchResult.stderr.toString();
732
+ console.log(chalk.yellow(` ⚠️ Could not fetch origin/main: ${error.trim()}`));
733
+ // Continue anyway - might work with local state
734
+ }
735
+ // Perform the rebase
736
+ const rebaseResult = spawnSync("git", ["-C", worktreePath, "rebase", "origin/main"], { stdio: "pipe" });
737
+ if (rebaseResult.status !== 0) {
738
+ const rebaseError = rebaseResult.stderr.toString();
739
+ // Check if it's a conflict
740
+ if (rebaseError.includes("CONFLICT") ||
741
+ rebaseError.includes("could not apply")) {
742
+ console.log(chalk.yellow(` ⚠️ Rebase conflict detected. Aborting rebase and keeping original branch state.`));
743
+ console.log(chalk.yellow(` ℹ️ PR will be created without rebase. Manual rebase may be required before merge.`));
744
+ // Abort the rebase to restore branch state
745
+ spawnSync("git", ["-C", worktreePath, "rebase", "--abort"], {
746
+ stdio: "pipe",
747
+ });
748
+ return {
749
+ performed: true,
750
+ success: false,
751
+ reinstalled: false,
752
+ error: "Rebase conflict - manual resolution required",
753
+ };
754
+ }
755
+ else {
756
+ console.log(chalk.yellow(` ⚠️ Rebase failed: ${rebaseError.trim()}`));
757
+ console.log(chalk.yellow(` ℹ️ Continuing with branch in its original state.`));
758
+ return {
759
+ performed: true,
760
+ success: false,
761
+ reinstalled: false,
762
+ error: rebaseError.trim(),
763
+ };
764
+ }
765
+ }
766
+ console.log(chalk.green(` ✅ Branch rebased onto origin/main`));
767
+ // Check if lockfile changed and reinstall if needed
768
+ const reinstalled = reinstallIfLockfileChanged(worktreePath, packageManager, verbose);
769
+ return {
770
+ performed: true,
771
+ success: true,
772
+ reinstalled,
773
+ };
774
+ }
461
775
  /**
462
776
  * Natural language prompts for each phase
463
777
  * These prompts will invoke the corresponding skills via natural language
@@ -671,6 +985,11 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
671
985
  // Get MCP servers config if enabled
672
986
  // Reads from Claude Desktop config and passes to SDK for headless MCP support
673
987
  const mcpServers = config.mcp ? getMcpServersConfig() : undefined;
988
+ // Track whether we're actively streaming verbose output
989
+ // Pausing spinner once per streaming session prevents truncation from rapid pause/resume cycles
990
+ // (Issue #283: ora's stop() clears the current line, which can truncate output when
991
+ // pause/resume is called for every chunk in rapid succession)
992
+ let verboseStreamingActive = false;
674
993
  const queryInstance = query({
675
994
  prompt,
676
995
  options: {
@@ -693,10 +1012,14 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
693
1012
  // Capture stderr for debugging (helps diagnose early exit failures)
694
1013
  stderr: (data) => {
695
1014
  capturedStderr += data;
1015
+ // Write stderr in verbose mode
696
1016
  if (config.verbose) {
697
- spinner?.pause();
1017
+ // Pause spinner once to avoid truncation (Issue #283)
1018
+ if (!verboseStreamingActive) {
1019
+ spinner?.pause();
1020
+ verboseStreamingActive = true;
1021
+ }
698
1022
  process.stderr.write(chalk.red(data));
699
- spinner?.resume();
700
1023
  }
701
1024
  },
702
1025
  },
@@ -719,10 +1042,13 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
719
1042
  capturedOutput += textContent;
720
1043
  // Show streaming output in verbose mode
721
1044
  if (config.verbose) {
722
- // Pause spinner during verbose streaming to avoid terminal corruption
723
- spinner?.pause();
1045
+ // Pause spinner once at start of streaming to avoid truncation
1046
+ // (Issue #283: repeated pause/resume causes ora to clear lines between chunks)
1047
+ if (!verboseStreamingActive) {
1048
+ spinner?.pause();
1049
+ verboseStreamingActive = true;
1050
+ }
724
1051
  process.stdout.write(chalk.gray(textContent));
725
- spinner?.resume();
726
1052
  }
727
1053
  }
728
1054
  }
@@ -731,6 +1057,11 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
731
1057
  resultMessage = message;
732
1058
  }
733
1059
  }
1060
+ // Resume spinner after streaming completes (if we paused it)
1061
+ if (verboseStreamingActive) {
1062
+ spinner?.resume();
1063
+ verboseStreamingActive = false;
1064
+ }
734
1065
  clearTimeout(timeoutId);
735
1066
  // Clear abort controller from shutdown manager
736
1067
  if (shutdownManager) {
@@ -844,14 +1175,30 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
844
1175
  const COLD_START_THRESHOLD_SECONDS = 60;
845
1176
  const COLD_START_MAX_RETRIES = 2;
846
1177
  /**
847
- * Execute a phase with automatic retry for cold-start failures.
848
- * If a phase fails within COLD_START_THRESHOLD_SECONDS, it's likely a subprocess
849
- * initialization issue — retry up to COLD_START_MAX_RETRIES times before giving up.
1178
+ * Execute a phase with automatic retry for cold-start failures and MCP fallback.
1179
+ *
1180
+ * Retry strategy:
1181
+ * 1. If phase fails within COLD_START_THRESHOLD_SECONDS, retry up to COLD_START_MAX_RETRIES times
1182
+ * 2. If still failing and MCP is enabled, retry once with MCP disabled (npx-based MCP servers
1183
+ * can fail on first run due to cold-cache issues)
1184
+ *
1185
+ * The MCP fallback is safe because MCP servers are optional enhancements, not required
1186
+ * for core functionality.
1187
+ */
1188
+ /**
1189
+ * @internal Exported for testing only
850
1190
  */
851
- async function executePhaseWithRetry(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner) {
1191
+ export async function executePhaseWithRetry(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner,
1192
+ /** @internal Injected for testing — defaults to module-level executePhase */
1193
+ executePhaseFn = executePhase) {
1194
+ // Skip retry logic if explicitly disabled
1195
+ if (config.retry === false) {
1196
+ return executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
1197
+ }
852
1198
  let lastResult;
1199
+ // Phase 1: Cold-start retry attempts (with MCP enabled if configured)
853
1200
  for (let attempt = 0; attempt <= COLD_START_MAX_RETRIES; attempt++) {
854
- lastResult = await executePhase(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
1201
+ lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
855
1202
  const duration = lastResult.durationSeconds ?? 0;
856
1203
  // Success or genuine failure (took long enough to be real work)
857
1204
  if (lastResult.success || duration >= COLD_START_THRESHOLD_SECONDS) {
@@ -864,6 +1211,28 @@ async function executePhaseWithRetry(issueNumber, phase, config, sessionId, work
864
1211
  }
865
1212
  }
866
1213
  }
1214
+ // Capture the original error for better diagnostics
1215
+ const originalError = lastResult.error;
1216
+ // Phase 2: MCP fallback - if MCP is enabled and we're still failing, try without MCP
1217
+ // This handles npx-based MCP servers that fail on first run due to cold-cache issues
1218
+ if (config.mcp && !lastResult.success) {
1219
+ console.log(chalk.yellow(`\n ⚠️ Phase failed with MCP enabled, retrying without MCP...`));
1220
+ // Create config copy with MCP disabled
1221
+ const configWithoutMcp = {
1222
+ ...config,
1223
+ mcp: false,
1224
+ };
1225
+ const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, sessionId, worktreePath, shutdownManager, spinner);
1226
+ if (retryResult.success) {
1227
+ console.log(chalk.green(` ✓ Phase succeeded without MCP (MCP cold-start issue detected)`));
1228
+ return retryResult;
1229
+ }
1230
+ // Both attempts failed - return original error for better diagnostics
1231
+ return {
1232
+ ...lastResult,
1233
+ error: originalError,
1234
+ };
1235
+ }
867
1236
  return lastResult;
868
1237
  }
869
1238
  /**
@@ -1160,6 +1529,10 @@ export async function runCommand(issues, options) {
1160
1529
  const mcpEnabled = mergedOptions.noMcp
1161
1530
  ? false
1162
1531
  : (settings.run.mcp ?? DEFAULT_CONFIG.mcp);
1532
+ // Resolve retry setting: CLI flag → settings.run.retry → default (true)
1533
+ const retryEnabled = mergedOptions.noRetry
1534
+ ? false
1535
+ : (settings.run.retry ?? true);
1163
1536
  const config = {
1164
1537
  ...DEFAULT_CONFIG,
1165
1538
  phases: explicitPhases ?? DEFAULT_PHASES,
@@ -1171,6 +1544,7 @@ export async function runCommand(issues, options) {
1171
1544
  maxIterations: mergedOptions.maxIterations ?? DEFAULT_CONFIG.maxIterations,
1172
1545
  noSmartTests: mergedOptions.noSmartTests ?? false,
1173
1546
  mcp: mcpEnabled,
1547
+ retry: retryEnabled,
1174
1548
  };
1175
1549
  // Propagate verbose mode to UI config so spinners use text-only mode.
1176
1550
  // This prevents animated spinner control characters from colliding with
@@ -1190,12 +1564,24 @@ export async function runCommand(issues, options) {
1190
1564
  sequential: config.sequential,
1191
1565
  qualityLoop: config.qualityLoop,
1192
1566
  maxIterations: config.maxIterations,
1567
+ chain: mergedOptions.chain,
1568
+ qaGate: mergedOptions.qaGate,
1193
1569
  };
1194
- logWriter = new LogWriter({
1195
- logPath: mergedOptions.logPath ?? settings.run.logPath,
1196
- verbose: config.verbose,
1197
- });
1198
- await logWriter.initialize(runConfig);
1570
+ try {
1571
+ logWriter = new LogWriter({
1572
+ logPath: mergedOptions.logPath ?? settings.run.logPath,
1573
+ verbose: config.verbose,
1574
+ startCommit: getCommitHash(process.cwd()),
1575
+ });
1576
+ await logWriter.initialize(runConfig);
1577
+ }
1578
+ catch (err) {
1579
+ // Log initialization failure is non-fatal - warn and continue without logging
1580
+ // Common causes: permissions issues, disk full, invalid path
1581
+ const errorMessage = err instanceof Error ? err.message : String(err);
1582
+ console.log(chalk.yellow(` ⚠️ Log initialization failed, continuing without logging: ${errorMessage}`));
1583
+ logWriter = null;
1584
+ }
1199
1585
  }
1200
1586
  // Initialize state manager for persistent workflow state tracking
1201
1587
  // State tracking is always enabled (unless dry run)
@@ -1239,7 +1625,62 @@ export async function runCommand(issues, options) {
1239
1625
  if (stateManager) {
1240
1626
  console.log(chalk.gray(` State tracking: enabled`));
1241
1627
  }
1628
+ if (mergedOptions.force) {
1629
+ console.log(chalk.yellow(` Force mode: enabled (bypass state guard)`));
1630
+ }
1242
1631
  console.log(chalk.gray(` Issues: ${issueNumbers.map((n) => `#${n}`).join(", ")}`));
1632
+ // ============================================================================
1633
+ // Pre-flight State Guard (#305)
1634
+ // ============================================================================
1635
+ // AC-5: Auto-cleanup at run start - reconcile stale ready_for_merge states
1636
+ if (stateManager && !config.dryRun) {
1637
+ try {
1638
+ const reconcileResult = await reconcileStateAtStartup({
1639
+ verbose: config.verbose,
1640
+ });
1641
+ if (reconcileResult.success && reconcileResult.advanced.length > 0) {
1642
+ console.log(chalk.gray(` State reconciled: ${reconcileResult.advanced.map((n) => `#${n}`).join(", ")} → merged`));
1643
+ }
1644
+ }
1645
+ catch {
1646
+ // AC-8: Graceful degradation - don't block execution on reconciliation failure
1647
+ if (config.verbose) {
1648
+ console.log(chalk.yellow(` ⚠️ State reconciliation failed, continuing...`));
1649
+ }
1650
+ }
1651
+ }
1652
+ // AC-1 & AC-2: Pre-flight state guard - skip completed issues unless --force
1653
+ if (stateManager && !config.dryRun && !mergedOptions.force) {
1654
+ const skippedIssues = [];
1655
+ const activeIssues = [];
1656
+ for (const issueNumber of issueNumbers) {
1657
+ try {
1658
+ const issueState = await stateManager.getIssueState(issueNumber);
1659
+ if (issueState &&
1660
+ (issueState.status === "ready_for_merge" ||
1661
+ issueState.status === "merged")) {
1662
+ skippedIssues.push(issueNumber);
1663
+ console.log(chalk.yellow(` ⚠️ #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
1664
+ }
1665
+ else {
1666
+ activeIssues.push(issueNumber);
1667
+ }
1668
+ }
1669
+ catch {
1670
+ // AC-8: Graceful degradation - if state check fails, include the issue
1671
+ activeIssues.push(issueNumber);
1672
+ }
1673
+ }
1674
+ // Update issueNumbers to only include active issues
1675
+ if (skippedIssues.length > 0) {
1676
+ issueNumbers = activeIssues;
1677
+ if (issueNumbers.length === 0) {
1678
+ console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
1679
+ return;
1680
+ }
1681
+ console.log(chalk.gray(` Active issues: ${issueNumbers.map((n) => `#${n}`).join(", ")}`));
1682
+ }
1683
+ }
1243
1684
  // Worktree isolation is enabled by default for multi-issue runs
1244
1685
  const useWorktreeIsolation = mergedOptions.worktreeIsolation !== false && issueNumbers.length > 0;
1245
1686
  if (useWorktreeIsolation) {
@@ -1297,7 +1738,7 @@ export async function runCommand(issues, options) {
1297
1738
  for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
1298
1739
  const batch = batches[batchIdx];
1299
1740
  console.log(chalk.blue(`\n Batch ${batchIdx + 1}/${batches.length}: Issues ${batch.map((n) => `#${n}`).join(", ")}`));
1300
- const batchResults = await executeBatch(batch, config, logWriter, stateManager, mergedOptions, issueInfoMap, worktreeMap, shutdown);
1741
+ const batchResults = await executeBatch(batch, config, logWriter, stateManager, mergedOptions, issueInfoMap, worktreeMap, shutdown, manifest.packageManager);
1301
1742
  results.push(...batchResults);
1302
1743
  // Check if batch failed and we should stop
1303
1744
  const batchFailed = batchResults.some((r) => !r.success);
@@ -1309,7 +1750,8 @@ export async function runCommand(issues, options) {
1309
1750
  }
1310
1751
  else if (config.sequential) {
1311
1752
  // Sequential execution
1312
- for (const issueNumber of issueNumbers) {
1753
+ for (let i = 0; i < issueNumbers.length; i++) {
1754
+ const issueNumber = issueNumbers[i];
1313
1755
  const issueInfo = issueInfoMap.get(issueNumber) ?? {
1314
1756
  title: `Issue #${issueNumber}`,
1315
1757
  labels: [],
@@ -1319,7 +1761,10 @@ export async function runCommand(issues, options) {
1319
1761
  if (logWriter) {
1320
1762
  logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
1321
1763
  }
1322
- const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, mergedOptions, worktreeInfo?.path, worktreeInfo?.branch, shutdown, mergedOptions.chain);
1764
+ const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, mergedOptions, worktreeInfo?.path, worktreeInfo?.branch, shutdown, mergedOptions.chain, // Enable checkpoint commits in chain mode
1765
+ manifest.packageManager,
1766
+ // In chain mode, only the last issue should trigger pre-PR rebase
1767
+ mergedOptions.chain ? i === issueNumbers.length - 1 : undefined);
1323
1768
  results.push(result);
1324
1769
  // Complete issue logging
1325
1770
  if (logWriter) {
@@ -1374,7 +1819,8 @@ export async function runCommand(issues, options) {
1374
1819
  if (logWriter) {
1375
1820
  logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
1376
1821
  }
1377
- const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, mergedOptions, worktreeInfo?.path, worktreeInfo?.branch, shutdown, false);
1822
+ const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, mergedOptions, worktreeInfo?.path, worktreeInfo?.branch, shutdown, false, // Parallel mode doesn't support chain
1823
+ manifest.packageManager);
1378
1824
  results.push(result);
1379
1825
  // Complete issue logging
1380
1826
  if (logWriter) {
@@ -1385,7 +1831,9 @@ export async function runCommand(issues, options) {
1385
1831
  // Finalize log
1386
1832
  let logPath = null;
1387
1833
  if (logWriter) {
1388
- logPath = await logWriter.finalize();
1834
+ logPath = await logWriter.finalize({
1835
+ endCommit: getCommitHash(process.cwd()),
1836
+ });
1389
1837
  }
1390
1838
  // Calculate success/failure counts
1391
1839
  const passed = results.filter((r) => r.success).length;
@@ -1443,6 +1891,8 @@ export async function runCommand(issues, options) {
1443
1891
  cliFlags.push("--quality-loop");
1444
1892
  if (mergedOptions.testgen)
1445
1893
  cliFlags.push("--testgen");
1894
+ // Read token usage from SessionEnd hook files (AC-5, AC-6)
1895
+ const tokenUsage = getTokenUsageForRun(undefined, true); // cleanup after reading
1446
1896
  // Record the run
1447
1897
  await metricsWriter.recordRun({
1448
1898
  issues: issueNumbers,
@@ -1452,11 +1902,15 @@ export async function runCommand(issues, options) {
1452
1902
  model: process.env.ANTHROPIC_MODEL ?? "opus",
1453
1903
  flags: cliFlags,
1454
1904
  metrics: {
1455
- tokensUsed: 0, // Token tracking not available from SDK
1905
+ tokensUsed: tokenUsage.tokensUsed,
1456
1906
  filesChanged: totalFilesChanged,
1457
1907
  linesAdded: totalLinesAdded,
1458
1908
  acceptanceCriteria: 0, // Would need to parse from issue
1459
1909
  qaIterations: totalQaIterations,
1910
+ // Token breakdown (AC-6)
1911
+ inputTokens: tokenUsage.inputTokens || undefined,
1912
+ outputTokens: tokenUsage.outputTokens || undefined,
1913
+ cacheTokens: tokenUsage.cacheTokens || undefined,
1460
1914
  },
1461
1915
  });
1462
1916
  if (config.verbose) {
@@ -1514,7 +1968,7 @@ export async function runCommand(issues, options) {
1514
1968
  /**
1515
1969
  * Execute a batch of issues
1516
1970
  */
1517
- async function executeBatch(issueNumbers, config, logWriter, stateManager, options, issueInfoMap, worktreeMap, shutdownManager) {
1971
+ async function executeBatch(issueNumbers, config, logWriter, stateManager, options, issueInfoMap, worktreeMap, shutdownManager, packageManager) {
1518
1972
  const results = [];
1519
1973
  for (const issueNumber of issueNumbers) {
1520
1974
  // Check if shutdown was triggered
@@ -1530,7 +1984,8 @@ async function executeBatch(issueNumbers, config, logWriter, stateManager, optio
1530
1984
  if (logWriter) {
1531
1985
  logWriter.startIssue(issueNumber, issueInfo.title, issueInfo.labels);
1532
1986
  }
1533
- const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, options, worktreeInfo?.path, worktreeInfo?.branch, shutdownManager, false);
1987
+ const result = await runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueInfo.title, issueInfo.labels, options, worktreeInfo?.path, worktreeInfo?.branch, shutdownManager, false, // Batch mode doesn't support chain
1988
+ packageManager);
1534
1989
  results.push(result);
1535
1990
  // Complete issue logging
1536
1991
  if (logWriter) {
@@ -1542,7 +1997,7 @@ async function executeBatch(issueNumbers, config, logWriter, stateManager, optio
1542
1997
  /**
1543
1998
  * Execute all phases for a single issue with logging and quality loop
1544
1999
  */
1545
- async function runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueTitle, labels, options, worktreePath, branch, shutdownManager, chainMode) {
2000
+ async function runIssueWithLogging(issueNumber, config, logWriter, stateManager, issueTitle, labels, options, worktreePath, branch, shutdownManager, chainMode, packageManager, isLastInChain) {
1546
2001
  const startTime = Date.now();
1547
2002
  const phaseResults = [];
1548
2003
  let loopTriggered = false;
@@ -1630,6 +2085,7 @@ async function runIssueWithLogging(issueNumber, config, logWriter, stateManager,
1630
2085
  phaseResults.push(specResult);
1631
2086
  specAlreadyRan = true;
1632
2087
  // Log spec phase result
2088
+ // Note: Spec runs in main repo, not worktree, so no git diff stats
1633
2089
  if (logWriter) {
1634
2090
  const phaseLog = createPhaseLogFromTiming("spec", issueNumber, specStartTime, specEndTime, specResult.success
1635
2091
  ? "success"
@@ -1771,16 +2227,30 @@ async function runIssueWithLogging(issueNumber, config, logWriter, stateManager,
1771
2227
  }
1772
2228
  }
1773
2229
  phaseResults.push(result);
1774
- // Log phase result
2230
+ // Log phase result with observability data (AC-1, AC-2, AC-3, AC-7)
1775
2231
  if (logWriter) {
2232
+ // Capture git diff stats for worktree phases (AC-1, AC-3)
2233
+ const diffStats = worktreePath
2234
+ ? getGitDiffStats(worktreePath)
2235
+ : undefined;
2236
+ // Capture commit hash after phase (AC-2)
2237
+ const commitHash = worktreePath
2238
+ ? getCommitHash(worktreePath)
2239
+ : undefined;
2240
+ // Read cache metrics for QA phase (AC-7)
2241
+ const cacheMetrics = phase === "qa" ? readCacheMetrics(worktreePath) : undefined;
1776
2242
  const phaseLog = createPhaseLogFromTiming(phase, issueNumber, phaseStartTime, phaseEndTime, result.success
1777
2243
  ? "success"
1778
2244
  : result.error?.includes("Timeout")
1779
2245
  ? "timeout"
1780
2246
  : "failure", {
1781
2247
  error: result.error,
1782
- // Include verdict for QA phase (AC-6)
1783
2248
  verdict: result.verdict,
2249
+ // Observability fields (AC-1, AC-2, AC-3, AC-7)
2250
+ filesModified: diffStats?.filesModified,
2251
+ fileDiffStats: diffStats?.fileDiffStats,
2252
+ commitHash,
2253
+ cacheMetrics,
1784
2254
  });
1785
2255
  logWriter.logPhase(phaseLog);
1786
2256
  }
@@ -1861,6 +2331,18 @@ async function runIssueWithLogging(issueNumber, config, logWriter, stateManager,
1861
2331
  if (success && chainMode && worktreePath) {
1862
2332
  createCheckpointCommit(worktreePath, issueNumber, config.verbose);
1863
2333
  }
2334
+ // Rebase onto origin/main before PR creation (unless --no-rebase)
2335
+ // This ensures the branch is up-to-date and prevents lockfile drift
2336
+ // AC-1: Non-chain mode rebases onto origin/main before PR
2337
+ // AC-2: Chain mode rebases only the final branch onto origin/main before PR
2338
+ // (intermediate branches must stay based on their predecessor)
2339
+ const shouldRebase = success &&
2340
+ worktreePath &&
2341
+ !options.noRebase &&
2342
+ (!chainMode || isLastInChain);
2343
+ if (shouldRebase) {
2344
+ rebaseBeforePR(worktreePath, issueNumber, packageManager, config.verbose);
2345
+ }
1864
2346
  return {
1865
2347
  issueNumber,
1866
2348
  success,