sequant 2.3.0 → 2.5.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 (101) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +125 -160
  4. package/dist/bin/cli.js +59 -4
  5. package/dist/dashboard/server.js +1 -0
  6. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +2 -2
  7. package/dist/marketplace/external_plugins/sequant/README.md +6 -3
  8. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +92 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +18 -9
  10. package/dist/marketplace/external_plugins/sequant/hooks/relay-check.sh +107 -0
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/behavior-rule-detection.md +205 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +21 -8
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +302 -86
  14. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +109 -0
  15. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +141 -22
  16. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +83 -78
  17. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +377 -137
  18. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +28 -0
  19. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +621 -0
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +741 -232
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +47 -1
  22. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +12 -6
  23. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +217 -964
  24. package/dist/marketplace/external_plugins/sequant/skills/spec/references/parallel-groups.md +7 -0
  25. package/dist/marketplace/external_plugins/sequant/skills/spec/references/quality-checklist.md +75 -0
  26. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +4 -2
  27. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +0 -27
  28. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +24 -44
  29. package/dist/src/commands/abort.d.ts +36 -0
  30. package/dist/src/commands/abort.js +138 -0
  31. package/dist/src/commands/prompt.d.ts +7 -0
  32. package/dist/src/commands/prompt.js +101 -7
  33. package/dist/src/commands/ready-tui-adapter.d.ts +59 -0
  34. package/dist/src/commands/ready-tui-adapter.js +130 -0
  35. package/dist/src/commands/ready.d.ts +49 -0
  36. package/dist/src/commands/ready.js +243 -0
  37. package/dist/src/commands/run-progress.d.ts +11 -1
  38. package/dist/src/commands/run-progress.js +20 -3
  39. package/dist/src/commands/run.js +12 -2
  40. package/dist/src/commands/status.js +4 -0
  41. package/dist/src/commands/watch.d.ts +2 -0
  42. package/dist/src/commands/watch.js +67 -3
  43. package/dist/src/lib/assess-collision-detect.js +1 -1
  44. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
  45. package/dist/src/lib/cli-ui/run-renderer.d.ts +34 -2
  46. package/dist/src/lib/cli-ui/run-renderer.js +250 -33
  47. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  48. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  49. package/dist/src/lib/merge-check/types.js +1 -1
  50. package/dist/src/lib/relay/archive.js +6 -0
  51. package/dist/src/lib/relay/types.d.ts +2 -0
  52. package/dist/src/lib/relay/types.js +9 -0
  53. package/dist/src/lib/settings.d.ts +34 -0
  54. package/dist/src/lib/settings.js +23 -1
  55. package/dist/src/lib/workflow/batch-executor.js +34 -18
  56. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  57. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  58. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  59. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  60. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  61. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  62. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  63. package/dist/src/lib/workflow/event-emitter.js +102 -0
  64. package/dist/src/lib/workflow/notice.d.ts +32 -0
  65. package/dist/src/lib/workflow/notice.js +38 -0
  66. package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
  67. package/dist/src/lib/workflow/phase-executor.js +105 -117
  68. package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
  69. package/dist/src/lib/workflow/phase-mapper.js +55 -33
  70. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  71. package/dist/src/lib/workflow/phase-registry.js +233 -0
  72. package/dist/src/lib/workflow/platforms/github.d.ts +6 -0
  73. package/dist/src/lib/workflow/platforms/github.js +17 -0
  74. package/dist/src/lib/workflow/ready-gate.d.ts +155 -0
  75. package/dist/src/lib/workflow/ready-gate.js +374 -0
  76. package/dist/src/lib/workflow/reconcile.js +6 -0
  77. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  78. package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
  79. package/dist/src/lib/workflow/run-orchestrator.js +125 -11
  80. package/dist/src/lib/workflow/state-manager.d.ts +19 -1
  81. package/dist/src/lib/workflow/state-manager.js +27 -1
  82. package/dist/src/lib/workflow/state-schema.d.ts +23 -35
  83. package/dist/src/lib/workflow/state-schema.js +29 -3
  84. package/dist/src/lib/workflow/types.d.ts +74 -15
  85. package/dist/src/lib/workflow/types.js +18 -13
  86. package/dist/src/ui/tui/App.js +8 -2
  87. package/dist/src/ui/tui/IssueBox.js +3 -4
  88. package/dist/src/ui/tui/index.d.ts +13 -4
  89. package/dist/src/ui/tui/index.js +19 -5
  90. package/dist/src/ui/tui/row-cap.d.ts +51 -0
  91. package/dist/src/ui/tui/row-cap.js +76 -0
  92. package/dist/src/ui/tui/teardown.d.ts +20 -0
  93. package/dist/src/ui/tui/teardown.js +29 -0
  94. package/dist/src/ui/tui/theme.d.ts +3 -0
  95. package/dist/src/ui/tui/theme.js +3 -0
  96. package/package.json +23 -11
  97. package/templates/hooks/post-tool.sh +81 -0
  98. package/templates/skills/assess/SKILL.md +28 -28
  99. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  100. package/templates/skills/qa/SKILL.md +5 -2
  101. package/templates/skills/setup/SKILL.md +6 -6
@@ -13,77 +13,21 @@ import { readAgentsMd } from "../agents-md.js";
13
13
  import { getDriver } from "./drivers/index.js";
14
14
  import { classifyError } from "./error-classifier.js";
15
15
  import { ApiError } from "../errors.js";
16
+ import { phaseRegistry } from "./phase-registry.js";
17
+ import { bracketedConsoleLog } from "./notice.js";
16
18
  /**
17
- * Natural language prompts for each phase.
18
- * Claude Code invokes the corresponding skills via natural language.
19
- */
20
- const PHASE_PROMPTS = {
21
- spec: "Review GitHub issue #{issue} and create an implementation plan with verification criteria. Run the /spec {issue} workflow.",
22
- "security-review": "Perform a deep security analysis for GitHub issue #{issue} focusing on auth, permissions, and sensitive operations. Run the /security-review {issue} workflow.",
23
- testgen: "Generate test stubs for GitHub issue #{issue} based on the specification. Run the /testgen {issue} workflow.",
24
- exec: "Implement the feature for GitHub issue #{issue} following the spec. Run the /exec {issue} workflow.",
25
- test: "Execute structured browser-based testing for GitHub issue #{issue}. Run the /test {issue} workflow.",
26
- verify: "Verify the implementation for GitHub issue #{issue} by running commands and capturing output. Run the /verify {issue} workflow.",
27
- qa: "Review the implementation for GitHub issue #{issue} against acceptance criteria. Run the /qa {issue} workflow.",
28
- loop: "Parse test/QA findings for GitHub issue #{issue} and iterate until quality gates pass. Run the /loop {issue} workflow.",
29
- merger: "Integrate and merge completed worktrees for GitHub issue #{issue}. Run the /merger {issue} workflow.",
30
- };
31
- /**
32
- * Self-contained prompts for non-Claude agents (Aider, Codex, etc.).
33
- * These agents don't have a skill system, so prompts must include
34
- * full instructions rather than skill invocations.
35
- */
36
- const AIDER_PHASE_PROMPTS = {
37
- spec: `Read GitHub issue #{issue} using 'gh issue view #{issue}'.
38
- Create a spec comment on the issue with:
39
- 1. Implementation plan
40
- 2. Acceptance criteria as a checklist
41
- 3. Risk assessment
42
- Post the comment using 'gh issue comment #{issue} --body "<comment>"'.`,
43
- "security-review": `Perform a security review for GitHub issue #{issue}.
44
- Read the issue with 'gh issue view #{issue}'.
45
- Check for auth, permissions, injection, and sensitive data issues.
46
- Post findings as a comment on the issue.`,
47
- testgen: `Generate test stubs for GitHub issue #{issue}.
48
- Read the spec comments on the issue with 'gh issue view #{issue} --comments'.
49
- Create test files with describe/it blocks covering the acceptance criteria.
50
- Use the project's existing test framework.`,
51
- exec: `Implement the feature described in GitHub issue #{issue}.
52
- Read the issue and any spec comments with 'gh issue view #{issue} --comments'.
53
- Follow the implementation plan from the spec.
54
- Write tests for new functionality.
55
- Ensure the build passes with 'npm test' and 'npm run build'.`,
56
- test: `Test the implementation for GitHub issue #{issue}.
57
- Run 'npm test' and verify all tests pass.
58
- Check for edge cases and error handling.`,
59
- verify: `Verify the implementation for GitHub issue #{issue}.
60
- Run relevant commands and capture their output for review.`,
61
- qa: `Review the changes for GitHub issue #{issue}.
62
- Run 'npm test' and 'npm run build' to verify everything works.
63
- Check each acceptance criterion from the issue comments.
64
- Output a verdict: READY_FOR_MERGE, AC_MET_BUT_NOT_A_PLUS, or AC_NOT_MET
65
- with format "### Verdict: <VERDICT>" followed by an explanation.`,
66
- loop: `Review test and QA findings for GitHub issue #{issue}.
67
- Fix any issues identified in the QA feedback.
68
- Re-run 'npm test' and 'npm run build' until all quality gates pass.`,
69
- merger: `Integrate and merge completed worktrees for GitHub issue #{issue}.
70
- Ensure all branches are up to date and merge cleanly.`,
71
- };
72
- /**
73
- * Phases that require worktree isolation.
74
- * Only `spec` runs in the main repo (planning-only, no file changes).
75
- * All other phases must run in the worktree because:
76
- * 1. They need to read/modify the worktree code
77
- * 2. Resuming a session created in a different cwd crashes the SDK
19
+ * Determine whether a phase's session must run inside the issue worktree.
20
+ *
21
+ * Sourced from `phaseRegistry.get(phase).requiresWorktree` — replaces the
22
+ * previous hardcoded `ISOLATED_PHASES` array. Phases must:
23
+ * 1. Read/modify worktree code
24
+ * 2. Resume a session from the same cwd it was created in (SDK constraint)
78
25
  */
79
- const ISOLATED_PHASES = [
80
- "exec",
81
- "security-review",
82
- "testgen",
83
- "test",
84
- "qa",
85
- "loop",
86
- ];
26
+ function phaseRequiresWorktree(phase) {
27
+ return phaseRegistry.has(phase)
28
+ ? phaseRegistry.get(phase).requiresWorktree
29
+ : false;
30
+ }
87
31
  /**
88
32
  * Cold-start retry threshold in seconds.
89
33
  * Failures under this duration are likely Claude Code subprocess initialization
@@ -137,15 +81,23 @@ export function createThrottledReporter(fn, intervalMs) {
137
81
  return { report, cancel };
138
82
  }
139
83
  /**
140
- * Spec-specific retry configuration.
84
+ * Spec-specific retry configuration. Sourced from the phase registry's
85
+ * `retryStrategy` field — `phase-registry.ts` is the source of truth.
86
+ *
141
87
  * Spec failures have a higher failure rate (~8.6%) than other phases due to
142
88
  * transient GitHub API issues and rate limits. One extra retry with backoff
143
89
  * recovers most of these without user intervention.
90
+ *
91
+ * Fallback literals (5000 / 1) match the legacy hardcoded values and only
92
+ * fire if the spec registration is removed or its `retryStrategy` is unset,
93
+ * which would be a misconfiguration. Tests pin these at 5000 / 1, so any
94
+ * drift surfaces immediately.
144
95
  */
96
+ const SPEC_RETRY_STRATEGY = phaseRegistry.get("spec").retryStrategy;
145
97
  /** @internal Exported for testing only */
146
- export const SPEC_RETRY_BACKOFF_MS = 5000;
98
+ export const SPEC_RETRY_BACKOFF_MS = SPEC_RETRY_STRATEGY?.backoffMs ?? 5000;
147
99
  /** @internal Exported for testing only */
148
- export const SPEC_EXTRA_RETRIES = 1;
100
+ export const SPEC_EXTRA_RETRIES = SPEC_RETRY_STRATEGY?.extraRetries ?? 1;
149
101
  export function parseQaVerdict(output) {
150
102
  if (!output)
151
103
  return null;
@@ -154,8 +106,16 @@ export function parseQaVerdict(output) {
154
106
  // - "**Verdict:** X" (bold label with colon inside)
155
107
  // - "**Verdict:** **X**" (bold label and bold value)
156
108
  // - "Verdict: X" (plain)
157
- // Case insensitive, handles optional markdown formatting
158
- const verdictMatch = output.match(/(?:###?\s*)?(?:\*\*)?Verdict:?\*?\*?\s*\*?\*?\s*(READY_FOR_MERGE|AC_MET_BUT_NOT_A_PLUS|AC_NOT_MET|NEEDS_VERIFICATION)\*?\*?/i);
109
+ // - "Verdict: X" (emoji-prefixed value — QA agents commonly write this)
110
+ // The gap between "Verdict:" and the token tolerates any run of
111
+ // non-alphanumeric characters (emoji, ✅/❌/⚠️, asterisks, whitespace). A
112
+ // negated ASCII class (not an emoji literal class) keeps this ReDoS-safe and
113
+ // avoids the no-misleading-character-class lint, matching parseQaSummary's
114
+ // approach below. Without this, `Verdict: ✅ READY_FOR_MERGE` parsed as null
115
+ // and a genuine PASS was recorded as "completed without a parseable verdict"
116
+ // (live repro: `sequant run 687 --phases exec,qa`, 2026-06-01).
117
+ // Case insensitive, handles optional markdown formatting.
118
+ const verdictMatch = output.match(/(?:###?\s*)?(?:\*\*)?Verdict:?[^A-Za-z0-9_]*(READY_FOR_MERGE|AC_MET_BUT_NOT_A_PLUS|AC_NOT_MET|NEEDS_VERIFICATION)\*?\*?/i);
159
119
  if (!verdictMatch)
160
120
  return null;
161
121
  // Normalize to uppercase with underscores
@@ -377,6 +337,10 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
377
337
  stdoutTail: agentResult.stdoutTail,
378
338
  exitCode: agentResult.exitCode,
379
339
  };
340
+ const resume = {
341
+ sessionId: agentResult.sessionId,
342
+ resumeHandle: agentResult.resumeHandle,
343
+ };
380
344
  if (phase === "qa") {
381
345
  const verdict = agentResult.output
382
346
  ? parseQaVerdict(agentResult.output)
@@ -392,7 +356,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
392
356
  success: false,
393
357
  durationSeconds,
394
358
  error: `QA verdict: ${verdict}`,
395
- sessionId: agentResult.sessionId,
359
+ ...resume,
396
360
  output: agentResult.output,
397
361
  verdict,
398
362
  summary,
@@ -406,7 +370,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
406
370
  success: false,
407
371
  durationSeconds,
408
372
  error: "QA completed without a parseable verdict",
409
- sessionId: agentResult.sessionId,
373
+ ...resume,
410
374
  output: agentResult.output,
411
375
  summary,
412
376
  ...tails,
@@ -416,7 +380,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
416
380
  phase,
417
381
  success: true,
418
382
  durationSeconds,
419
- sessionId: agentResult.sessionId,
383
+ ...resume,
420
384
  output: agentResult.output,
421
385
  verdict,
422
386
  summary,
@@ -430,7 +394,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
430
394
  success: false,
431
395
  durationSeconds,
432
396
  error: "exec produced no changes (no commits, no uncommitted work)",
433
- sessionId: agentResult.sessionId,
397
+ ...resume,
434
398
  output: agentResult.output,
435
399
  ...tails,
436
400
  };
@@ -439,7 +403,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
439
403
  phase,
440
404
  success: true,
441
405
  durationSeconds,
442
- sessionId: agentResult.sessionId,
406
+ ...resume,
443
407
  output: agentResult.output,
444
408
  ...tails,
445
409
  };
@@ -453,8 +417,14 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
453
417
  * @internal Exported for testing only
454
418
  */
455
419
  export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
456
- const prompts = agent && agent !== "claude-code" ? AIDER_PHASE_PROMPTS : PHASE_PROMPTS;
457
- let basePrompt = prompts[phase].replace(/\{issue\}/g, String(issueNumber));
420
+ const definition = phaseRegistry.get(phase);
421
+ // Non-claude drivers consult driverOverrides[<driver>] first; fall back to
422
+ // the default promptTemplate when no override is registered for the driver.
423
+ const driverPrompt = agent && agent !== "claude-code"
424
+ ? definition.driverOverrides?.[agent]?.promptTemplate
425
+ : undefined;
426
+ const template = driverPrompt ?? definition.promptTemplate;
427
+ let basePrompt = template.replace(/\{issue\}/g, String(issueNumber));
458
428
  // Append phase-specific context (e.g., QA findings for loop phase)
459
429
  if (promptContext) {
460
430
  basePrompt += `\n\n---\n\n${promptContext}`;
@@ -471,14 +441,14 @@ export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
471
441
  /**
472
442
  * Execute a single phase for an issue using the configured AgentDriver.
473
443
  */
474
- async function executePhase(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner) {
444
+ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner) {
475
445
  const startTime = Date.now();
476
446
  const prompt = await getPhasePrompt(phase, issueNumber, config.agent, config.promptContext);
477
447
  if (config.dryRun) {
478
448
  // Dry run - show the prompt that would be sent, then return
479
449
  if (config.verbose) {
480
- console.log(chalk.gray(` Would execute: /${phase} ${issueNumber}`));
481
- console.log(chalk.gray(` Prompt: ${prompt}`));
450
+ bracketedConsoleLog(spinner, chalk.gray(` Would execute: /${phase} ${issueNumber}`));
451
+ bracketedConsoleLog(spinner, chalk.gray(` Prompt: ${prompt}`));
482
452
  }
483
453
  return {
484
454
  phase,
@@ -488,13 +458,13 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
488
458
  };
489
459
  }
490
460
  if (config.verbose) {
491
- console.log(chalk.gray(` Prompt: ${prompt}`));
492
- if (worktreePath && ISOLATED_PHASES.includes(phase)) {
493
- console.log(chalk.gray(` Worktree: ${worktreePath}`));
461
+ bracketedConsoleLog(spinner, chalk.gray(` Prompt: ${prompt}`));
462
+ if (worktreePath && phaseRequiresWorktree(phase)) {
463
+ bracketedConsoleLog(spinner, chalk.gray(` Worktree: ${worktreePath}`));
494
464
  }
495
465
  }
496
466
  // Determine working directory and environment
497
- const shouldUseWorktree = worktreePath && ISOLATED_PHASES.includes(phase);
467
+ const shouldUseWorktree = worktreePath && phaseRequiresWorktree(phase);
498
468
  const cwd = shouldUseWorktree ? worktreePath : process.cwd();
499
469
  // Resolve file context for file-oriented drivers (e.g., Aider --file)
500
470
  let files;
@@ -546,6 +516,13 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
546
516
  // Skills can check these to skip redundant pre-flight checks
547
517
  env.SEQUANT_ORCHESTRATOR = "sequant-run";
548
518
  env.SEQUANT_PHASE = phase;
519
+ // #683: force full-weight QA. `sequant ready` sets config.fullQa so its QA
520
+ // pass runs the standalone branch-freshness / process-state pre-flight checks
521
+ // even though SEQUANT_ORCHESTRATOR is set unconditionally above. Scoped to the
522
+ // qa phase — the loop/exec phases don't have a git-trust skip to override.
523
+ if (config.fullQa && phase === "qa") {
524
+ env.SEQUANT_FULL_QA = "1";
525
+ }
549
526
  // Propagate issue type for skills to adapt behavior (e.g., lighter QA for docs)
550
527
  if (config.issueType) {
551
528
  env.SEQUANT_ISSUE_TYPE = config.issueType;
@@ -599,12 +576,19 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
599
576
  }, ACTIVITY_THROTTLE_MS)
600
577
  : undefined;
601
578
  const reportActivity = throttle ? throttle.report : undefined;
602
- // Safety: never resume a session when worktree isolation is active.
603
- // Even if THIS phase doesn't use the worktree, a previous phase may have
604
- // created the session there. Resuming from a different cwd crashes the SDK
605
- // (exit code 1). ISOLATED_PHASES prevents this by design, but this guard
606
- // catches edge cases (e.g. a new phase added without updating ISOLATED_PHASES).
607
- const canResume = sessionId && !worktreePath;
579
+ // Resolve driver before the resume check eligibility is now driver-owned
580
+ // (#674). Each driver's `canResume(handle, cwd)` enforces its own contract:
581
+ // Claude Code requires byte-equal cwd match (session storage is
582
+ // cwd-namespaced); Aider declines all resume (no session concept); Codex
583
+ // (when added in #497) folds in AGENTS.md parity. Replacing the prior
584
+ // `sessionId && !worktreePath` heuristic also unblocks same-worktree resume
585
+ // across phases.
586
+ const driver = getDriver(config.agent, {
587
+ aiderSettings: config.aiderSettings,
588
+ });
589
+ const eligibleHandle = resumeHandle && driver.canResume(resumeHandle, cwd)
590
+ ? resumeHandle
591
+ : undefined;
608
592
  // Build AgentExecutionConfig for the driver
609
593
  const agentConfig = {
610
594
  cwd,
@@ -613,7 +597,8 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
613
597
  phaseTimeout: config.phaseTimeout,
614
598
  verbose: config.verbose,
615
599
  mcp: config.mcp,
616
- sessionId: canResume ? sessionId : undefined,
600
+ resumeHandle: eligibleHandle,
601
+ sessionId: eligibleHandle?.token,
617
602
  files,
618
603
  onOutput: config.verbose || reportActivity
619
604
  ? (text) => {
@@ -622,6 +607,7 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
622
607
  spinner?.pause();
623
608
  verboseStreamingActive = true;
624
609
  }
610
+ // eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
625
611
  process.stdout.write(chalk.gray(text));
626
612
  }
627
613
  reportActivity?.(text);
@@ -633,14 +619,11 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
633
619
  spinner?.pause();
634
620
  verboseStreamingActive = true;
635
621
  }
622
+ // eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
636
623
  process.stderr.write(chalk.red(data));
637
624
  }
638
625
  : undefined,
639
626
  };
640
- // Resolve driver from config or default
641
- const driver = getDriver(config.agent, {
642
- aiderSettings: config.aiderSettings,
643
- });
644
627
  const agentResult = await driver.executePhase(prompt, agentConfig);
645
628
  // Cancel any pending trailing activity fire — phase is done; the
646
629
  // orchestrator's stale-phase guard would no-op a late call anyway, but
@@ -665,6 +648,7 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
665
648
  durationSeconds,
666
649
  error: agentResult.error,
667
650
  sessionId: agentResult.sessionId,
651
+ resumeHandle: agentResult.resumeHandle,
668
652
  stderrTail: agentResult.stderrTail,
669
653
  stdoutTail: agentResult.stdoutTail,
670
654
  exitCode: agentResult.exitCode,
@@ -684,24 +668,28 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
684
668
  /**
685
669
  * @internal Exported for testing only
686
670
  */
687
- export async function executePhaseWithRetry(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner,
671
+ export async function executePhaseWithRetry(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner,
688
672
  /** @internal Injected for testing — defaults to module-level executePhase */
689
673
  executePhaseFn = executePhase,
690
674
  /** @internal Injected for testing — defaults to setTimeout-based delay */
691
675
  delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
692
676
  // Skip retry logic if explicitly disabled
693
677
  if (config.retry === false) {
694
- return executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
695
- }
696
- // Skip cold-start retries for `loop` phase (#488).
697
- // Loop is always a re-run after a failed QA — never a first boot.
698
- // Failures at 47-51s are genuine skill failures, not cold-start issues.
699
- // Without this guard, 2 cold-start retries + 1 MCP fallback = 3 wasted spawns per loop.
700
- const skipColdStartRetry = phase === "loop";
678
+ return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
679
+ }
680
+ // Skip cold-start retries for phases registered with `retryStrategy.maxRetries: 0`.
681
+ // `loop` is the canonical user (#488) — it's always a re-run after a failed QA,
682
+ // never a first boot. Failures at 47-51s are genuine skill failures, not cold-start
683
+ // issues. Without this guard, 2 cold-start retries + 1 MCP fallback = 3 wasted
684
+ // spawns per loop. Sourcing the decision from the registry makes the rule
685
+ // data-driven — any future phase registered with `maxRetries: 0` inherits the
686
+ // same behavior without a code change here.
687
+ const skipColdStartRetry = phaseRegistry.has(phase) &&
688
+ phaseRegistry.get(phase).retryStrategy?.maxRetries === 0;
701
689
  let lastResult;
702
690
  if (skipColdStartRetry) {
703
691
  // Single attempt — no cold-start retry loop
704
- lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
692
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
705
693
  if (lastResult.success) {
706
694
  return lastResult;
707
695
  }
@@ -709,7 +697,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
709
697
  else {
710
698
  // Phase 1: Cold-start retry attempts (with MCP enabled if configured)
711
699
  for (let attempt = 0; attempt <= COLD_START_MAX_RETRIES; attempt++) {
712
- lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
700
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
713
701
  const duration = lastResult.durationSeconds ?? 0;
714
702
  // Success → return immediately
715
703
  if (lastResult.success) {
@@ -725,7 +713,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
725
713
  const label = typedError instanceof ApiError
726
714
  ? `API error (status ${typedError.metadata.statusCode ?? "unknown"})`
727
715
  : typedError.name;
728
- console.log(chalk.yellow(`\n ⟳ Retryable error: ${label}, retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
716
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Retryable error: ${label}, retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
729
717
  }
730
718
  continue;
731
719
  }
@@ -737,7 +725,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
737
725
  // Cold-start failure detected — retry
738
726
  if (attempt < COLD_START_MAX_RETRIES) {
739
727
  if (config.verbose) {
740
- console.log(chalk.yellow(`\n ⟳ Cold-start failure detected (${duration.toFixed(1)}s), retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
728
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Cold-start failure detected (${duration.toFixed(1)}s), retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
741
729
  }
742
730
  }
743
731
  }
@@ -748,15 +736,15 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
748
736
  // This handles npx-based MCP servers that fail on first run due to cold-cache issues.
749
737
  // Skip for `loop` phase — MCP is never the cause of loop failures (#488).
750
738
  if (config.mcp && !lastResult.success && !skipColdStartRetry) {
751
- console.log(chalk.yellow(`\n ! Phase failed with MCP enabled, retrying without MCP...`));
739
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ! Phase failed with MCP enabled, retrying without MCP...`));
752
740
  // Create config copy with MCP disabled
753
741
  const configWithoutMcp = {
754
742
  ...config,
755
743
  mcp: false,
756
744
  };
757
- const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, sessionId, worktreePath, shutdownManager, spinner);
745
+ const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, resumeHandle, worktreePath, shutdownManager, spinner);
758
746
  if (retryResult.success) {
759
- console.log(chalk.green(` ✓ Phase succeeded without MCP (MCP cold-start issue detected)`));
747
+ bracketedConsoleLog(spinner, chalk.green(` ✓ Phase succeeded without MCP (MCP cold-start issue detected)`));
760
748
  return retryResult;
761
749
  }
762
750
  // Update lastResult for Phase 3 (spec retry)
@@ -773,11 +761,11 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
773
761
  // than other phases (~8.6%), so one extra retry with backoff recovers most cases.
774
762
  if (phase === "spec" && !lastResult.success) {
775
763
  for (let i = 0; i < SPEC_EXTRA_RETRIES; i++) {
776
- console.log(chalk.yellow(`\n ⟳ Spec phase failed, retrying with ${SPEC_RETRY_BACKOFF_MS}ms backoff... (spec retry ${i + 1}/${SPEC_EXTRA_RETRIES})`));
764
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Spec phase failed, retrying with ${SPEC_RETRY_BACKOFF_MS}ms backoff... (spec retry ${i + 1}/${SPEC_EXTRA_RETRIES})`));
777
765
  await delayFn(SPEC_RETRY_BACKOFF_MS);
778
- const specRetryResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
766
+ const specRetryResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
779
767
  if (specRetryResult.success) {
780
- console.log(chalk.green(` ✓ Spec phase succeeded on retry`));
768
+ bracketedConsoleLog(spinner, chalk.green(` ✓ Spec phase succeeded on retry`));
781
769
  return specRetryResult;
782
770
  }
783
771
  lastResult = specRetryResult;
@@ -17,27 +17,35 @@ interface PhaseMapperOptions {
17
17
  securityReview?: boolean;
18
18
  }
19
19
  /**
20
- * UI-related labels that trigger automatic test phase
21
- */
22
- export declare const UI_LABELS: string[];
23
- /**
24
- * Bug-related labels (used by downstream metadata consumers)
20
+ * Bug-related labels (used by downstream metadata consumers).
21
+ *
22
+ * Issue-type metadata — NOT phase-trigger rules. The registry-driven
23
+ * `detectPhasesFromLabels` below does not consult this list. It stays
24
+ * here because `batch-executor.ts` and other modules read it for
25
+ * `issueType` propagation and similar non-phase concerns.
25
26
  */
26
27
  export declare const BUG_LABELS: string[];
27
28
  /**
28
- * Documentation labels (used for issueType propagation and downstream metadata)
29
+ * Documentation labels (used for issueType propagation and downstream metadata).
30
+ *
31
+ * Issue-type metadata — NOT phase-trigger rules. See BUG_LABELS comment.
29
32
  */
30
33
  export declare const DOCS_LABELS: string[];
31
34
  /**
32
- * Complex labels that enable quality loop
35
+ * Complex labels that enable quality loop.
36
+ *
37
+ * Quality-loop trigger — NOT a phase-trigger rule (does not add the loop
38
+ * *phase*; only flips the `qualityLoop` flag on the run config). Kept
39
+ * out of the phase registry by design.
33
40
  */
34
41
  export declare const COMPLEX_LABELS: string[];
35
42
  /**
36
- * Security-related labels that trigger security-review phase
37
- */
38
- export declare const SECURITY_LABELS: string[];
39
- /**
40
- * Detect phases based on issue labels (like /assess logic)
43
+ * Detect phases based on issue labels (like /assess logic).
44
+ *
45
+ * Label phase mapping now lives in `PhaseDefinition.detect.labels`. Only
46
+ * the *insertion position* of detected phases remains baked in here, because
47
+ * pipeline ordering depends on the phase's role (security-review goes after
48
+ * spec; test goes before qa).
41
49
  */
42
50
  export declare function detectPhasesFromLabels(labels: string[]): {
43
51
  phases: Phase[];
@@ -56,7 +64,12 @@ export declare function parseRecommendedWorkflow(output: string): {
56
64
  qualityLoop: boolean;
57
65
  } | null;
58
66
  /**
59
- * Check if an issue has UI-related labels
67
+ * Check if an issue has UI-related labels.
68
+ *
69
+ * Sources the label list from the `test` phase's `detect.labels` entry in
70
+ * the registry — same data as `detectPhasesFromLabels` consults, just
71
+ * exposed as a boolean for callers that only need the yes/no answer
72
+ * (e.g. test phase insertion in `determinePhasesForIssue`).
60
73
  */
61
74
  export declare function hasUILabels(labels: string[]): boolean;
62
75
  /**
@@ -7,43 +7,62 @@
7
7
  *
8
8
  * @module phase-mapper
9
9
  */
10
+ import { phaseRegistry } from "./phase-registry.js";
10
11
  /**
11
- * UI-related labels that trigger automatic test phase
12
- */
13
- export const UI_LABELS = ["ui", "frontend", "admin", "web", "browser"];
14
- /**
15
- * Bug-related labels (used by downstream metadata consumers)
12
+ * Bug-related labels (used by downstream metadata consumers).
13
+ *
14
+ * Issue-type metadata NOT phase-trigger rules. The registry-driven
15
+ * `detectPhasesFromLabels` below does not consult this list. It stays
16
+ * here because `batch-executor.ts` and other modules read it for
17
+ * `issueType` propagation and similar non-phase concerns.
16
18
  */
17
19
  export const BUG_LABELS = ["bug", "fix", "hotfix", "patch"];
18
20
  /**
19
- * Documentation labels (used for issueType propagation and downstream metadata)
21
+ * Documentation labels (used for issueType propagation and downstream metadata).
22
+ *
23
+ * Issue-type metadata — NOT phase-trigger rules. See BUG_LABELS comment.
20
24
  */
21
25
  export const DOCS_LABELS = ["docs", "documentation", "readme"];
22
26
  /**
23
- * Complex labels that enable quality loop
27
+ * Complex labels that enable quality loop.
28
+ *
29
+ * Quality-loop trigger — NOT a phase-trigger rule (does not add the loop
30
+ * *phase*; only flips the `qualityLoop` flag on the run config). Kept
31
+ * out of the phase registry by design.
24
32
  */
25
33
  export const COMPLEX_LABELS = ["complex", "refactor", "breaking", "major"];
26
34
  /**
27
- * Security-related labels that trigger security-review phase
35
+ * Look up label-based detect rules from the registry, returning the set
36
+ * of phases whose `detect.labels` intersect the issue's labels. Comparison
37
+ * is case-insensitive (labels lowercased at the call site).
28
38
  */
29
- export const SECURITY_LABELS = [
30
- "security",
31
- "auth",
32
- "authentication",
33
- "permissions",
34
- "admin",
35
- ];
39
+ function detectPhasesFromRegistry(lowerLabels) {
40
+ const matched = new Set();
41
+ for (const def of phaseRegistry.list()) {
42
+ const triggers = def.detect?.labels;
43
+ if (!triggers || triggers.length === 0)
44
+ continue;
45
+ const hit = triggers.some((t) => lowerLabels.includes(t.toLowerCase()));
46
+ if (hit)
47
+ matched.add(def.name);
48
+ }
49
+ return matched;
50
+ }
36
51
  /**
37
- * Detect phases based on issue labels (like /assess logic)
52
+ * Detect phases based on issue labels (like /assess logic).
53
+ *
54
+ * Label → phase mapping now lives in `PhaseDefinition.detect.labels`. Only
55
+ * the *insertion position* of detected phases remains baked in here, because
56
+ * pipeline ordering depends on the phase's role (security-review goes after
57
+ * spec; test goes before qa).
38
58
  */
39
59
  export function detectPhasesFromLabels(labels) {
40
60
  const lowerLabels = labels.map((l) => l.toLowerCase());
41
- // Check for UI labels add test phase
42
- const isUI = lowerLabels.some((label) => UI_LABELS.some((uiLabel) => label === uiLabel));
43
- // Check for complex labels → enable quality loop
61
+ // Quality loop is a registry-independent label trigger (see COMPLEX_LABELS).
44
62
  const isComplex = lowerLabels.some((label) => COMPLEX_LABELS.some((complexLabel) => label === complexLabel));
45
- // Check for security labels → add security-review phase
46
- const isSecurity = lowerLabels.some((label) => SECURITY_LABELS.some((secLabel) => label === secLabel));
63
+ const matched = detectPhasesFromRegistry(lowerLabels);
64
+ const isUI = matched.has("test");
65
+ const isSecurity = matched.has("security-review");
47
66
  // Build phase list — spec is always included by default (#533).
48
67
  // Bug/docs labels no longer short-circuit spec; downstream consumers
49
68
  // (e.g. `issueType: "docs"` propagation) still use DOCS_LABELS for
@@ -78,18 +97,10 @@ export function parseRecommendedWorkflow(output) {
78
97
  .split(/\s*→\s*|\s*->\s*|\s*,\s*/)
79
98
  .map((p) => p.trim().toLowerCase())
80
99
  .filter((p) => p.length > 0);
81
- // Validate and convert to Phase type
100
+ // Validate against the registry accepts any registered phase.
82
101
  const validPhases = [];
83
102
  for (const name of phaseNames) {
84
- if ([
85
- "spec",
86
- "security-review",
87
- "testgen",
88
- "exec",
89
- "test",
90
- "qa",
91
- "loop",
92
- ].includes(name)) {
103
+ if (phaseRegistry.has(name)) {
93
104
  validPhases.push(name);
94
105
  }
95
106
  }
@@ -104,10 +115,21 @@ export function parseRecommendedWorkflow(output) {
104
115
  return { phases: validPhases, qualityLoop };
105
116
  }
106
117
  /**
107
- * Check if an issue has UI-related labels
118
+ * Check if an issue has UI-related labels.
119
+ *
120
+ * Sources the label list from the `test` phase's `detect.labels` entry in
121
+ * the registry — same data as `detectPhasesFromLabels` consults, just
122
+ * exposed as a boolean for callers that only need the yes/no answer
123
+ * (e.g. test phase insertion in `determinePhasesForIssue`).
108
124
  */
109
125
  export function hasUILabels(labels) {
110
- return labels.some((label) => UI_LABELS.some((uiLabel) => label.toLowerCase() === uiLabel));
126
+ const testTriggers = phaseRegistry.has("test")
127
+ ? (phaseRegistry.get("test").detect?.labels ?? [])
128
+ : [];
129
+ if (testTriggers.length === 0)
130
+ return false;
131
+ const lowered = new Set(testTriggers.map((t) => t.toLowerCase()));
132
+ return labels.some((label) => lowered.has(label.toLowerCase()));
111
133
  }
112
134
  /**
113
135
  * Determine phases to run based on options and issue labels