sequant 2.8.0 → 2.9.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.
Files changed (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +9 -1
  4. package/dist/bin/cli.js +2 -1
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/README.md +2 -0
  7. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +18 -3
  8. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +330 -57
  9. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +96 -15
  10. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +9 -6
  11. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +1 -1
  12. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
  13. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  14. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  15. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +10 -2
  16. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  17. package/dist/src/commands/logs.js +6 -1
  18. package/dist/src/commands/run-display.d.ts +20 -0
  19. package/dist/src/commands/run-display.js +80 -1
  20. package/dist/src/commands/stats.js +47 -0
  21. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  22. package/dist/src/lib/assess-collision-detect.js +68 -4
  23. package/dist/src/lib/cli-ui/run-renderer.js +17 -9
  24. package/dist/src/lib/errors.d.ts +6 -0
  25. package/dist/src/lib/errors.js +9 -2
  26. package/dist/src/lib/manifest.js +1 -17
  27. package/dist/src/lib/version-check.js +1 -5
  28. package/dist/src/lib/workflow/batch-executor.d.ts +13 -0
  29. package/dist/src/lib/workflow/batch-executor.js +81 -18
  30. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  31. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  32. package/dist/src/lib/workflow/chain-resume.d.ts +116 -0
  33. package/dist/src/lib/workflow/chain-resume.js +166 -0
  34. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  35. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  36. package/dist/src/lib/workflow/drivers/claude-code.d.ts +7 -0
  37. package/dist/src/lib/workflow/drivers/claude-code.js +30 -6
  38. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  39. package/dist/src/lib/workflow/error-classifier.js +14 -1
  40. package/dist/src/lib/workflow/log-writer.js +6 -8
  41. package/dist/src/lib/workflow/metrics-schema.d.ts +39 -0
  42. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  43. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  44. package/dist/src/lib/workflow/phase-executor.d.ts +32 -0
  45. package/dist/src/lib/workflow/phase-executor.js +77 -5
  46. package/dist/src/lib/workflow/run-log-schema.d.ts +23 -0
  47. package/dist/src/lib/workflow/run-log-schema.js +45 -1
  48. package/dist/src/lib/workflow/run-orchestrator.d.ts +14 -0
  49. package/dist/src/lib/workflow/run-orchestrator.js +291 -30
  50. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  51. package/dist/src/lib/workflow/status-derivation.js +27 -0
  52. package/dist/src/lib/workflow/types.d.ts +23 -0
  53. package/dist/src/lib/workflow/worktree-manager.d.ts +43 -1
  54. package/dist/src/lib/workflow/worktree-manager.js +103 -33
  55. package/dist/src/mcp/tools/run.d.ts +2 -0
  56. package/dist/src/mcp/tools/run.js +2 -0
  57. package/package.json +2 -4
  58. package/templates/hooks/post-tool.sh +18 -3
  59. package/templates/hooks/pre-tool.sh +330 -57
  60. package/templates/scripts/cleanup-worktree.sh +103 -14
  61. package/templates/skills/assess/SKILL.md +96 -15
  62. package/templates/skills/assess/references/predicted-collision-detection.md +9 -6
  63. package/templates/skills/fullsolve/SKILL.md +1 -1
  64. package/templates/skills/reflect/SKILL.md +27 -13
  65. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  66. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  67. package/templates/skills/release/SKILL.md +10 -2
  68. package/templates/skills/spec/references/verification-criteria.md +1 -1
@@ -10,7 +10,7 @@ import chalk from "chalk";
10
10
  import { spawnSync } from "child_process";
11
11
  import pLimit from "p-limit";
12
12
  import { formatCoarseNowLine } from "./run-state.js";
13
- import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, } from "./worktree-manager.js";
13
+ import { detectDefaultBranch, ensureWorktrees, ensureWorktreesChain, getWorktreeDiffStats, rebaseOntoLocalBranch, } from "./worktree-manager.js";
14
14
  import { LogWriter } from "./log-writer.js";
15
15
  import { StateManager } from "./state-manager.js";
16
16
  import { ShutdownManager } from "../shutdown.js";
@@ -33,14 +33,53 @@ function formatSignalLine(issue, pid, result) {
33
33
  return ` Skipped signal for #${issue} (orchestrator mode)`;
34
34
  }
35
35
  }
36
+ /**
37
+ * Resolve a git ref (branch name or base branch) to its tip commit SHA in the
38
+ * current repo, or undefined if the ref does not exist. Used by the chain
39
+ * resume planner (#760) to reconstruct — and validate the existence of — a
40
+ * completed link's committed tip.
41
+ */
42
+ function revParseRef(ref) {
43
+ const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", ref], {
44
+ stdio: "pipe",
45
+ encoding: "utf-8",
46
+ });
47
+ if (result.status !== 0) {
48
+ return undefined;
49
+ }
50
+ const sha = result.stdout.trim();
51
+ return sha.length > 0 ? sha : undefined;
52
+ }
53
+ /**
54
+ * True iff `worktreePath` has uncommitted changes (`git status --porcelain`,
55
+ * which already honours .gitignore). Used by the chain resume planner (#760) to
56
+ * reject a resume base whose checkpoint commit never landed.
57
+ *
58
+ * Returns false when the path is missing or not a worktree: an unreadable
59
+ * worktree can't be judged dirty, and the branch-tip check already covers the
60
+ * destroyed-link case.
61
+ */
62
+ function isWorktreeDirty(worktreePath) {
63
+ const result = spawnSync("git", ["-C", worktreePath, "status", "--porcelain"], {
64
+ stdio: "pipe",
65
+ encoding: "utf-8",
66
+ });
67
+ if (result.status !== 0) {
68
+ return false;
69
+ }
70
+ return result.stdout.trim().length > 0;
71
+ }
36
72
  import { getIssueInfo, sortByDependencies, parseBatches, runIssueWithLogging, emitRunIdLine, } from "./batch-executor.js";
37
73
  import { reconcileStateAtStartup } from "./state-utils.js";
74
+ import { runChainPreflight } from "./chain-preflight.js";
38
75
  import { getCommitHash } from "./git-diff-utils.js";
76
+ import { planChainResumeFromState, } from "./chain-resume.js";
39
77
  import { MetricsWriter } from "./metrics-writer.js";
40
78
  import { WorkflowEventEmitter } from "./event-emitter.js";
41
79
  import { determineOutcome } from "./metrics-schema.js";
42
80
  import { getTokenUsageForRun } from "./token-utils.js";
43
81
  import { resolveRunOptions, buildExecutionConfig } from "./config-resolver.js";
82
+ import { pipelineHasFailed } from "./status-derivation.js";
44
83
  /**
45
84
  * Build the stack-manifest line emitted into PR bodies under --stacked.
46
85
  *
@@ -218,8 +257,15 @@ export class RunOrchestrator {
218
257
  const durationSec = elapsedMs !== undefined ? Math.round(elapsedMs / 1000) : undefined;
219
258
  if (event === "failed") {
220
259
  const prev = state.status;
221
- state.status = "failed";
222
- state.completedAt = new Date();
260
+ // #766: a loop-phase failure alone must not pin the issue `failed` — the
261
+ // outer quality loop may still recover on a later iteration. Derive from
262
+ // the phase slots (loop excluded) so only a genuine non-loop failure marks
263
+ // the issue failed; otherwise hold `running` until a later event reaches a
264
+ // terminal verdict (a subsequent iteration always follows a loop failure).
265
+ const nowFailed = pipelineHasFailed(state.phases);
266
+ state.status = nowFailed ? "failed" : "running";
267
+ if (nowFailed)
268
+ state.completedAt = new Date();
223
269
  void this.emitter.emit("phase_failed", {
224
270
  issueNumber: issue,
225
271
  phase,
@@ -227,11 +273,11 @@ export class RunOrchestrator {
227
273
  error: extra?.error ?? "unknown",
228
274
  iteration: extra?.iteration,
229
275
  });
230
- if (prev !== "failed") {
276
+ if (prev !== state.status) {
231
277
  void this.emitter.emit("issue_status_changed", {
232
278
  issueNumber: issue,
233
279
  from: prev,
234
- to: "failed",
280
+ to: state.status,
235
281
  });
236
282
  }
237
283
  return;
@@ -246,9 +292,10 @@ export class RunOrchestrator {
246
292
  const allDone = state.phases.every((ph) => ph.status === "done" || ph.status === "failed");
247
293
  if (allDone) {
248
294
  const prev = state.status;
249
- state.status = state.phases.some((ph) => ph.status === "failed")
250
- ? "failed"
251
- : "passed";
295
+ // #766: exclude a stale failed `loop` slot so a run that failed the loop
296
+ // on an early iteration and then recovered finalizes as `passed`, not
297
+ // `failed`. An unrecovered failure still leaves a non-loop phase failed.
298
+ state.status = pipelineHasFailed(state.phases) ? "failed" : "passed";
252
299
  state.completedAt = new Date();
253
300
  if (prev !== state.status) {
254
301
  void this.emitter.emit("issue_status_changed", {
@@ -291,6 +338,8 @@ export class RunOrchestrator {
291
338
  .map((i) => parseInt(i, 10))
292
339
  .filter((n) => !isNaN(n));
293
340
  }
341
+ // Capture the raw CLI order before dep-sorting (#762 pre-flight needs it).
342
+ const rawIssueOrder = [...issueNumbers];
294
343
  if (issueNumbers.length > 1 && !resolvedBatches) {
295
344
  issueNumbers = sortByDependencies(issueNumbers);
296
345
  }
@@ -302,6 +351,7 @@ export class RunOrchestrator {
302
351
  mergedOptions,
303
352
  config,
304
353
  issueNumbers,
354
+ rawIssueOrder,
305
355
  batches: resolvedBatches,
306
356
  baseBranch,
307
357
  stack: manifest.stack,
@@ -392,29 +442,46 @@ export class RunOrchestrator {
392
442
  logNonFatalWarning(" ! State reconciliation failed, continuing...", error, config.verbose);
393
443
  }
394
444
  }
445
+ let chainResume;
395
446
  if (stateManager && !config.dryRun && !mergedOptions.force) {
396
- const activeIssues = [];
397
- for (const issueNumber of issueNumbers) {
398
- try {
399
- const issueState = await stateManager.getIssueState(issueNumber);
400
- if (issueState &&
401
- (issueState.status === "ready_for_merge" ||
402
- issueState.status === "merged")) {
403
- console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
404
- }
405
- else {
406
- activeIssues.push(issueNumber);
407
- }
408
- }
409
- catch (error) {
410
- logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
411
- activeIssues.push(issueNumber);
447
+ if (mergedOptions.chain) {
448
+ // ── Chain-aware resume (#760) ──────────────────────────────────
449
+ // Don't just drop completed links (chain-unaware skip leaves the
450
+ // first incomplete link at index 0, where the #748 successor-rebase
451
+ // never fires → it silently builds on main). Instead compute a
452
+ // chain-correct plan that resumes at the first incomplete link,
453
+ // rebased onto the completed prefix's committed tip.
454
+ const resolver = {
455
+ resolveBranchTip: (branch) => revParseRef(branch),
456
+ resolveBaseTip: () => revParseRef(baseBranch),
457
+ isWorktreeDirty,
458
+ };
459
+ const plan = await planChainResumeFromState(issueNumbers, baseBranch, (issueNumber) => stateManager.getIssueState(issueNumber), resolver, (issueNumber, error) => logNonFatalWarning(` ! State lookup failed for #${issueNumber}, treating as incomplete...`, error, config.verbose));
460
+ if (plan.failFast) {
461
+ console.log(chalk.red(`\n Chain resume aborted: ${plan.failFast}`));
462
+ shutdown.dispose();
463
+ return {
464
+ results: [
465
+ {
466
+ issueNumber: plan.resumeIssue ?? issueNumbers[0],
467
+ success: false,
468
+ phaseResults: [],
469
+ durationSeconds: 0,
470
+ loopTriggered: false,
471
+ abortReason: `chain resume aborted: ${plan.failFast}`,
472
+ },
473
+ ],
474
+ logPath: null,
475
+ exitCode: 1,
476
+ worktreeMap: new Map(),
477
+ issueInfoMap: new Map(),
478
+ config,
479
+ mergedOptions,
480
+ logWriter: null,
481
+ };
412
482
  }
413
- }
414
- if (activeIssues.length < issueNumbers.length) {
415
- issueNumbers = activeIssues;
416
- if (issueNumbers.length === 0) {
417
- console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
483
+ if (plan.allComplete) {
484
+ console.log(chalk.yellow(`\n All chain links already completed. Use --force to re-run.`));
418
485
  shutdown.dispose();
419
486
  return {
420
487
  results: [],
@@ -427,6 +494,56 @@ export class RunOrchestrator {
427
494
  logWriter: null,
428
495
  };
429
496
  }
497
+ if (plan.skipped.length > 0) {
498
+ chainResume = plan;
499
+ issueNumbers = plan.active;
500
+ const resumeAt = plan.resumeBaseCommit
501
+ ? plan.resumeBaseCommit.slice(0, 8)
502
+ : (plan.resumeBase ?? "base");
503
+ for (const s of plan.skipped) {
504
+ console.log(chalk.yellow(` ! #${s.issueNumber}: already ${s.status} — skipping (use --force to re-run)`));
505
+ }
506
+ console.log(chalk.cyan(` ↻ Resuming chain at #${plan.resumeIssue} from ${resumeAt}` +
507
+ ` (${plan.resumeBase})`));
508
+ }
509
+ }
510
+ else {
511
+ // ── Non-chain skip guard (unchanged) ───────────────────────────
512
+ const activeIssues = [];
513
+ for (const issueNumber of issueNumbers) {
514
+ try {
515
+ const issueState = await stateManager.getIssueState(issueNumber);
516
+ if (issueState &&
517
+ (issueState.status === "ready_for_merge" ||
518
+ issueState.status === "merged")) {
519
+ console.log(chalk.yellow(` ! #${issueNumber}: already ${issueState.status} — skipping (use --force to re-run)`));
520
+ }
521
+ else {
522
+ activeIssues.push(issueNumber);
523
+ }
524
+ }
525
+ catch (error) {
526
+ logNonFatalWarning(` ! State lookup failed for #${issueNumber}, including anyway...`, error, config.verbose);
527
+ activeIssues.push(issueNumber);
528
+ }
529
+ }
530
+ if (activeIssues.length < issueNumbers.length) {
531
+ issueNumbers = activeIssues;
532
+ if (issueNumbers.length === 0) {
533
+ console.log(chalk.yellow(`\n All issues already completed. Use --force to re-run.`));
534
+ shutdown.dispose();
535
+ return {
536
+ results: [],
537
+ logPath: null,
538
+ exitCode: 0,
539
+ worktreeMap: new Map(),
540
+ issueInfoMap: new Map(),
541
+ config,
542
+ mergedOptions,
543
+ logWriter: null,
544
+ };
545
+ }
546
+ }
430
547
  }
431
548
  }
432
549
  // ── Concurrency lock (#625) ────────────────────────────────────────
@@ -486,6 +603,37 @@ export class RunOrchestrator {
486
603
  };
487
604
  }
488
605
  }
606
+ // ── Chain content pre-flight (#762) ────────────────────────────────
607
+ // Warn-by-default; `--strict-preflight` turns warnings fatal. Runs BEFORE
608
+ // any worktree is provisioned. Uses the RAW CLI order (not the dep-sorted
609
+ // `issueNumbers`) so the dependency-order / file-overlap checks aren't
610
+ // masked by `sortByDependencies`, filtered to issues still active after the
611
+ // state guard / lock acquisition.
612
+ if (mergedOptions.chain && issueNumbers.length > 1) {
613
+ const activeSet = new Set(issueNumbers);
614
+ const preflightOrder = resolved.rawIssueOrder.filter((n) => activeSet.has(n));
615
+ const preflightWarnings = await runChainPreflight(preflightOrder);
616
+ if (preflightWarnings.length > 0) {
617
+ for (const w of preflightWarnings) {
618
+ bracketedConsoleLog(phasePauseHandle, chalk.yellow(` ⚠ ${w.message}`));
619
+ }
620
+ if (mergedOptions.strictPreflight) {
621
+ bracketedConsoleLog(phasePauseHandle, chalk.red(`\n ✖ --strict-preflight: ${preflightWarnings.length} pre-flight ` +
622
+ `warning(s) — aborting before any worktree is provisioned.`));
623
+ shutdown.dispose();
624
+ return {
625
+ results: [],
626
+ logPath: null,
627
+ exitCode: 1,
628
+ worktreeMap: new Map(),
629
+ issueInfoMap: new Map(),
630
+ config,
631
+ mergedOptions,
632
+ logWriter: null,
633
+ };
634
+ }
635
+ }
636
+ }
489
637
  // ── Issue info + worktree setup ────────────────────────────────────
490
638
  const issueInfoMap = new Map();
491
639
  for (const issueNumber of issueNumbers) {
@@ -499,7 +647,11 @@ export class RunOrchestrator {
499
647
  title: issueInfoMap.get(num)?.title || `Issue #${num}`,
500
648
  }));
501
649
  if (mergedOptions.chain) {
502
- worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, baseBranch);
650
+ // On resume (#760), provision the first incomplete link from the
651
+ // completed prefix's committed tip (resumeBase) instead of the base
652
+ // branch, so the chain rebuilds onto the work already done.
653
+ const chainBase = chainResume?.resumeBase ?? baseBranch;
654
+ worktreeMap = await ensureWorktreesChain(issueData, config.verbose, manifest.packageManager, chainBase);
503
655
  }
504
656
  else {
505
657
  worktreeMap = await ensureWorktrees(issueData, config.verbose, manifest.packageManager, baseBranch);
@@ -524,6 +676,7 @@ export class RunOrchestrator {
524
676
  services: { logWriter, stateManager, shutdownManager: shutdown },
525
677
  packageManager: manifest.packageManager,
526
678
  baseBranch,
679
+ chainResume,
527
680
  onProgress,
528
681
  onPhasePlan: init.onPhasePlan,
529
682
  phasePauseHandle,
@@ -639,6 +792,108 @@ export class RunOrchestrator {
639
792
  if (shutdown?.shuttingDown) {
640
793
  break;
641
794
  }
795
+ // #760: On chain resume, the completed prefix was skipped, so the first
796
+ // active link (i === 0) is now the resume point. The #748 successor-rebase
797
+ // below only fires for i > 0, so explicitly rebase this first link onto
798
+ // the last completed link's committed tip (resumeBase). This is the
799
+ // authoritative correctness gate: provisioning may have created the
800
+ // worktree fresh (already on resumeBase → no-op) or rebased an existing
801
+ // one, but a provisioning-time rebase failure only warns and continues —
802
+ // here we fail fast so the link never silently executes on the wrong base.
803
+ const chainResume = this.cfg.chainResume;
804
+ if (options.chain && i === 0 && chainResume?.resumeBase) {
805
+ const activeWorktree = this.cfg.worktreeMap.get(issueNumber);
806
+ if (activeWorktree) {
807
+ const rebase = rebaseOntoLocalBranch(activeWorktree.path, chainResume.resumeBase, this.cfg.config.verbose);
808
+ if (!rebase.success) {
809
+ console.log(chalk.yellow(` ⚠️ Chain resume broken: could not rebase #${issueNumber} onto resume base ${chainResume.resumeBase}` +
810
+ (rebase.conflict ? " — merge conflict" : "") +
811
+ `. Stopping the chain; #${issueNumber} and any later issues were not run.`));
812
+ results.push({
813
+ issueNumber,
814
+ success: false,
815
+ phaseResults: [],
816
+ durationSeconds: 0,
817
+ loopTriggered: false,
818
+ abortReason: rebase.conflict
819
+ ? `chain resume rebase conflict onto ${chainResume.resumeBase}`
820
+ : `chain resume rebase failed onto ${chainResume.resumeBase}: ${rebase.error ?? "unknown error"}`,
821
+ });
822
+ break;
823
+ }
824
+ }
825
+ else {
826
+ console.log(chalk.yellow(` ⚠️ Chain resume broken: no worktree for #${issueNumber}. ` +
827
+ `Stopping the chain; #${issueNumber} and any later issues were not run.`));
828
+ results.push({
829
+ issueNumber,
830
+ success: false,
831
+ phaseResults: [],
832
+ durationSeconds: 0,
833
+ loopTriggered: false,
834
+ abortReason: `chain resume could not be established: missing worktree for #${issueNumber}`,
835
+ });
836
+ break;
837
+ }
838
+ }
839
+ // #748: Successors were provisioned up-front (ensureWorktreesChain) while
840
+ // the predecessor branch still pointed at the base, so on a fresh run each
841
+ // successor effectively branched from main. Now that the predecessor has
842
+ // executed and committed, re-rebase this successor's worktree onto the
843
+ // predecessor's *local* committed tip — independent of --stacked, and
844
+ // targeting the local feature branch (not origin/main). This is what makes
845
+ // the chain contract ("each branches from previous") actually hold.
846
+ if (options.chain && i > 0) {
847
+ const successorWorktree = this.cfg.worktreeMap.get(issueNumber);
848
+ const predecessorLocalBranch = this.cfg.worktreeMap.get(issueNumbers[i - 1])?.branch;
849
+ if (successorWorktree && predecessorLocalBranch) {
850
+ const rebase = rebaseOntoLocalBranch(successorWorktree.path, predecessorLocalBranch, this.cfg.config.verbose);
851
+ if (!rebase.success) {
852
+ // A broken chain link must NOT silently produce a successor built on
853
+ // the wrong base — that successor would miss the predecessor's work
854
+ // (the original #748 bug) and the break would propagate to every
855
+ // downstream successor. Treat it like a predecessor failure: warn
856
+ // loudly, record the break, and stop the chain so a human can
857
+ // resolve the conflict and re-run.
858
+ console.log(chalk.yellow(` ⚠️ Chain link broken: could not rebase #${issueNumber} onto #${issueNumbers[i - 1]} (${predecessorLocalBranch})` +
859
+ (rebase.conflict ? " — merge conflict" : "") +
860
+ `. Stopping the chain; #${issueNumber} and any later issues were not run.`));
861
+ results.push({
862
+ issueNumber,
863
+ success: false,
864
+ phaseResults: [],
865
+ durationSeconds: 0,
866
+ loopTriggered: false,
867
+ abortReason: rebase.conflict
868
+ ? `chain rebase conflict onto #${issueNumbers[i - 1]} (${predecessorLocalBranch})`
869
+ : `chain rebase failed onto #${issueNumbers[i - 1]} (${predecessorLocalBranch}): ${rebase.error ?? "unknown error"}`,
870
+ });
871
+ break;
872
+ }
873
+ }
874
+ else {
875
+ // The worktree map is expected to be fully populated for a chain (the
876
+ // --stacked block below treats a missing predecessor branch as
877
+ // unreachable). If it isn't, the successor cannot be chained onto its
878
+ // predecessor's work — the same end state as a rebase conflict — so we
879
+ // break the chain identically rather than letting the successor
880
+ // silently branch from its un-rebased base (the original #748 bug).
881
+ const missing = !successorWorktree
882
+ ? "successor worktree"
883
+ : `predecessor branch for #${issueNumbers[i - 1]}`;
884
+ console.log(chalk.yellow(` ⚠️ Chain link could not be established for #${issueNumber}: missing ${missing} in worktree map. ` +
885
+ `Stopping the chain; #${issueNumber} and any later issues were not run.`));
886
+ results.push({
887
+ issueNumber,
888
+ success: false,
889
+ phaseResults: [],
890
+ durationSeconds: 0,
891
+ loopTriggered: false,
892
+ abortReason: `chain link could not be established onto #${issueNumbers[i - 1]}: missing ${missing} in worktree map`,
893
+ });
894
+ break;
895
+ }
896
+ }
642
897
  // #605: under --stacked, non-first PRs target the predecessor branch.
643
898
  // The final PR still targets `main` (AC-3 open-question default) so the
644
899
  // stack can land partially. Manifest renders for every PR in the stack.
@@ -826,6 +1081,11 @@ export class RunOrchestrator {
826
1081
  cliFlags.push("--testgen");
827
1082
  const tokenUsage = getTokenUsageForRun(undefined, true);
828
1083
  const passed = results.filter((r) => r.success).length;
1084
+ // #761 AC-7: record why the run failed, as a bounded enum. Sequential and
1085
+ // chain runs halt at the first failure, so the first categorized failed
1086
+ // issue is the halting one; in parallel runs it is the earliest-listed
1087
+ // failure. Undefined on success — the field is omitted from the record.
1088
+ const failureCategory = results.find((r) => !r.success && r.failureCategory)?.failureCategory;
829
1089
  await metricsWriter.recordRun({
830
1090
  issues: issueNumbers,
831
1091
  phases: Array.from(allPhases),
@@ -833,6 +1093,7 @@ export class RunOrchestrator {
833
1093
  duration: totalDuration,
834
1094
  model: process.env.ANTHROPIC_MODEL ?? "opus",
835
1095
  flags: cliFlags,
1096
+ failureCategory,
836
1097
  metrics: {
837
1098
  tokensUsed: tokenUsage.tokensUsed,
838
1099
  filesChanged: totalFilesChanged,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared status derivation for the run's live state machines (#766).
3
+ *
4
+ * Both the orchestrator's `IssueRuntimeState` machine and the renderer's
5
+ * `IssueState` machine used to *pin* an issue `failed` on any phase failure and
6
+ * never de-escalate — so a phase that failed on an early quality-loop iteration
7
+ * left the card red even after a later iteration recovered and shipped the PR.
8
+ *
9
+ * The fix derives the failed verdict from the CURRENT phase slots instead.
10
+ * `findOrAppendPhase` (orchestrator) and the renderer both key phases by name,
11
+ * so a retried phase overwrites its slot with the latest attempt — the slot
12
+ * already holds "the last attempt". The one phase that is NOT overwritten on
13
+ * recovery is `loop`: it is an auxiliary recovery step that only runs after a
14
+ * failure, so a stale failed `loop` slot must not pin the issue. Excluding
15
+ * `loop` never hides a genuine failure — an unrecovered pipeline failure always
16
+ * leaves a non-loop phase's latest slot failed too (the qa/exec the loop was
17
+ * trying to fix).
18
+ */
19
+ /** Auxiliary recovery phase — excluded from the failed verdict (see above). */
20
+ export declare const LOOP_PHASE = "loop";
21
+ /** Minimal phase shape both live state machines satisfy. */
22
+ export interface DerivablePhase {
23
+ name: string;
24
+ status: string;
25
+ }
26
+ /**
27
+ * True iff a non-loop phase's latest slot is `failed`. The single source of
28
+ * truth for "is this issue failed" across both live state machines (#766 AC-2).
29
+ */
30
+ export declare function pipelineHasFailed(phases: DerivablePhase[]): boolean;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Shared status derivation for the run's live state machines (#766).
3
+ *
4
+ * Both the orchestrator's `IssueRuntimeState` machine and the renderer's
5
+ * `IssueState` machine used to *pin* an issue `failed` on any phase failure and
6
+ * never de-escalate — so a phase that failed on an early quality-loop iteration
7
+ * left the card red even after a later iteration recovered and shipped the PR.
8
+ *
9
+ * The fix derives the failed verdict from the CURRENT phase slots instead.
10
+ * `findOrAppendPhase` (orchestrator) and the renderer both key phases by name,
11
+ * so a retried phase overwrites its slot with the latest attempt — the slot
12
+ * already holds "the last attempt". The one phase that is NOT overwritten on
13
+ * recovery is `loop`: it is an auxiliary recovery step that only runs after a
14
+ * failure, so a stale failed `loop` slot must not pin the issue. Excluding
15
+ * `loop` never hides a genuine failure — an unrecovered pipeline failure always
16
+ * leaves a non-loop phase's latest slot failed too (the qa/exec the loop was
17
+ * trying to fix).
18
+ */
19
+ /** Auxiliary recovery phase — excluded from the failed verdict (see above). */
20
+ export const LOOP_PHASE = "loop";
21
+ /**
22
+ * True iff a non-loop phase's latest slot is `failed`. The single source of
23
+ * truth for "is this issue failed" across both live state machines (#766 AC-2).
24
+ */
25
+ export function pipelineHasFailed(phases) {
26
+ return phases.some((p) => p.name !== LOOP_PHASE && p.status === "failed");
27
+ }
@@ -8,6 +8,7 @@ import type { StateManager } from "./state-manager.js";
8
8
  import type { ShutdownManager } from "../shutdown.js";
9
9
  import type { WorktreeInfo } from "./worktree-manager.js";
10
10
  import type { SequantError } from "../errors.js";
11
+ import type { ErrorCategory } from "./error-classifier.js";
11
12
  export type { WorkflowEventEmitter, WorkflowEvents, WorkflowEventListener, IssueEventStatus, BaseEventPayload, RunEventPayload, PhaseStartedPayload, PhaseCompletedPayload, PhaseFailedPayload, IssueStatusChangedPayload, QaVerdictPayload, ProgressPayload, } from "./event-emitter.js";
12
13
  /**
13
14
  * Canonical Zod schema for all workflow phases.
@@ -216,6 +217,21 @@ export interface IssueResult {
216
217
  startedAt: string;
217
218
  command: string;
218
219
  };
220
+ /**
221
+ * Set true when the chain-mode checkpoint commit could not be written after
222
+ * this link passed QA (#760). The link's own work is done, but the recovery
223
+ * point resume depends on is missing — surfaced prominently so a later resume
224
+ * failing fast (AC-3) is expected, not surprising.
225
+ */
226
+ checkpointFailed?: boolean;
227
+ /**
228
+ * Bounded-enum classification of the failure that halted this issue (#761
229
+ * AC-7), derived from the last non-loop failing phase's `structuredError`
230
+ * (preferred) or stderr-regex classification. Carried into
231
+ * `.sequant/metrics.json` — enum only, never a message string (metrics
232
+ * privacy contract). Absent on success.
233
+ */
234
+ failureCategory?: ErrorCategory;
219
235
  }
220
236
  /**
221
237
  * CLI options for the run command, merged with settings and env config.
@@ -265,6 +281,13 @@ export interface RunOptions {
265
281
  * from building on potentially broken code.
266
282
  */
267
283
  qaGate?: boolean;
284
+ /**
285
+ * Make `--chain` content pre-flight warnings fatal (#762).
286
+ * By default the pre-flight (missing AC section, dependency-order and
287
+ * file-overlap-order contradictions, closed issues) only warns. When true,
288
+ * any warning aborts the run BEFORE the first worktree is provisioned.
289
+ */
290
+ strictPreflight?: boolean;
268
291
  /**
269
292
  * Base branch for worktree creation.
270
293
  * Resolution priority: this CLI flag → settings.run.defaultBase → 'main'
@@ -43,6 +43,19 @@ export interface RebaseResult {
43
43
  /** Error message if rebase failed */
44
44
  error?: string;
45
45
  }
46
+ /**
47
+ * Result of rebasing a worktree onto a local branch (chain successor → predecessor).
48
+ */
49
+ export interface LocalRebaseResult {
50
+ /** Whether the rebase was attempted */
51
+ performed: boolean;
52
+ /** Whether the rebase succeeded (branch now contains `ontoBranch`'s commits) */
53
+ success: boolean;
54
+ /** Whether a merge conflict caused the rebase to be aborted */
55
+ conflict: boolean;
56
+ /** Error message if the rebase failed */
57
+ error?: string;
58
+ }
46
59
  /**
47
60
  * Result of PR creation
48
61
  */
@@ -176,6 +189,17 @@ export declare function createCheckpointCommit(worktreePath: string, issueNumber
176
189
  * @internal Exported for testing
177
190
  */
178
191
  export declare function reinstallIfLockfileChanged(worktreePath: string, packageManager: string | undefined, verbose: boolean, preRebaseRef?: string): boolean;
192
+ /**
193
+ * Rebase a worktree's branch onto a *local* branch ref (e.g. a chain
194
+ * predecessor's feature branch), NOT origin/main. Used to chain a successor
195
+ * onto its predecessor's committed work at execution time (#748), and to
196
+ * re-chain a pre-existing worktree onto its chain base.
197
+ *
198
+ * On conflict, aborts the rebase to restore the original branch state and
199
+ * returns `{ success: false, conflict: true }` so callers can warn rather than
200
+ * silently treat a broken link as healthy.
201
+ */
202
+ export declare function rebaseOntoLocalBranch(worktreePath: string, ontoBranch: string, verbose?: boolean): LocalRebaseResult;
179
203
  /**
180
204
  * Rebase the worktree branch onto the base branch before PR creation.
181
205
  * This ensures the branch is up-to-date and prevents lockfile drift.
@@ -189,6 +213,24 @@ export declare function reinstallIfLockfileChanged(worktreePath: string, package
189
213
  * @internal Exported for testing
190
214
  */
191
215
  export declare function rebaseBeforePR(worktreePath: string, issueNumber: number, packageManager: string | undefined, verbose: boolean, baseBranch?: string): RebaseResult;
216
+ /**
217
+ * Build the automated PR body for `sequant run`.
218
+ *
219
+ * Pure and I/O-free, kept separate from {@link createPR} (which shells out to
220
+ * `gh`) so the body — including the non-A+ QA note (#749) — is unit-testable
221
+ * directly.
222
+ *
223
+ * @param issueNumber Issue the PR closes
224
+ * @param opts.stackManifest Appended before the trailer under --stacked (#605)
225
+ * @param opts.qaVerdict QA verdict for the run; a non-A+ stopping state
226
+ * (anything other than `READY_FOR_MERGE`) surfaces a note so a human reviewer
227
+ * sees why the run broke to PR without reaching A+ (#749).
228
+ * @internal Exported for testing
229
+ */
230
+ export declare function buildAutomatedPRBody(issueNumber: number, opts?: {
231
+ stackManifest?: string;
232
+ qaVerdict?: string;
233
+ }): string;
192
234
  /**
193
235
  * Push branch and create a PR after successful QA.
194
236
  *
@@ -210,4 +252,4 @@ export declare function rebaseBeforePR(worktreePath: string, issueNumber: number
210
252
  export declare function createPR(worktreePath: string, issueNumber: number, issueTitle: string, branch: string, verbose: boolean, labels?: string[], stackOptions?: {
211
253
  prBase?: string;
212
254
  stackManifest?: string;
213
- }): PRCreationResult;
255
+ }, qaVerdict?: string): PRCreationResult;