sequant 2.3.0 → 2.4.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 (54) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +8 -5
  4. package/dist/bin/cli.js +46 -4
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/prompt.d.ts +7 -0
  8. package/dist/src/commands/prompt.js +101 -7
  9. package/dist/src/commands/run-progress.d.ts +11 -1
  10. package/dist/src/commands/run-progress.js +20 -3
  11. package/dist/src/commands/run.js +12 -2
  12. package/dist/src/commands/watch.d.ts +2 -0
  13. package/dist/src/commands/watch.js +67 -3
  14. package/dist/src/lib/assess-collision-detect.js +1 -1
  15. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
  16. package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
  17. package/dist/src/lib/cli-ui/run-renderer.js +231 -14
  18. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  19. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  20. package/dist/src/lib/merge-check/types.js +1 -1
  21. package/dist/src/lib/relay/archive.js +6 -0
  22. package/dist/src/lib/relay/types.d.ts +2 -0
  23. package/dist/src/lib/relay/types.js +9 -0
  24. package/dist/src/lib/workflow/batch-executor.js +34 -18
  25. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  26. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  27. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  28. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  29. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  30. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  31. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  32. package/dist/src/lib/workflow/event-emitter.js +102 -0
  33. package/dist/src/lib/workflow/notice.d.ts +32 -0
  34. package/dist/src/lib/workflow/notice.js +38 -0
  35. package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
  36. package/dist/src/lib/workflow/phase-executor.js +88 -115
  37. package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
  38. package/dist/src/lib/workflow/phase-mapper.js +55 -33
  39. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  40. package/dist/src/lib/workflow/phase-registry.js +233 -0
  41. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  42. package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
  43. package/dist/src/lib/workflow/run-orchestrator.js +125 -11
  44. package/dist/src/lib/workflow/state-manager.d.ts +19 -1
  45. package/dist/src/lib/workflow/state-manager.js +27 -1
  46. package/dist/src/lib/workflow/state-schema.d.ts +20 -35
  47. package/dist/src/lib/workflow/state-schema.js +28 -3
  48. package/dist/src/lib/workflow/types.d.ts +65 -15
  49. package/dist/src/lib/workflow/types.js +18 -13
  50. package/package.json +5 -4
  51. package/templates/hooks/post-tool.sh +81 -0
  52. package/templates/skills/assess/SKILL.md +28 -28
  53. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  54. 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;
@@ -377,6 +329,10 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
377
329
  stdoutTail: agentResult.stdoutTail,
378
330
  exitCode: agentResult.exitCode,
379
331
  };
332
+ const resume = {
333
+ sessionId: agentResult.sessionId,
334
+ resumeHandle: agentResult.resumeHandle,
335
+ };
380
336
  if (phase === "qa") {
381
337
  const verdict = agentResult.output
382
338
  ? parseQaVerdict(agentResult.output)
@@ -392,7 +348,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
392
348
  success: false,
393
349
  durationSeconds,
394
350
  error: `QA verdict: ${verdict}`,
395
- sessionId: agentResult.sessionId,
351
+ ...resume,
396
352
  output: agentResult.output,
397
353
  verdict,
398
354
  summary,
@@ -406,7 +362,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
406
362
  success: false,
407
363
  durationSeconds,
408
364
  error: "QA completed without a parseable verdict",
409
- sessionId: agentResult.sessionId,
365
+ ...resume,
410
366
  output: agentResult.output,
411
367
  summary,
412
368
  ...tails,
@@ -416,7 +372,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
416
372
  phase,
417
373
  success: true,
418
374
  durationSeconds,
419
- sessionId: agentResult.sessionId,
375
+ ...resume,
420
376
  output: agentResult.output,
421
377
  verdict,
422
378
  summary,
@@ -430,7 +386,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
430
386
  success: false,
431
387
  durationSeconds,
432
388
  error: "exec produced no changes (no commits, no uncommitted work)",
433
- sessionId: agentResult.sessionId,
389
+ ...resume,
434
390
  output: agentResult.output,
435
391
  ...tails,
436
392
  };
@@ -439,7 +395,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
439
395
  phase,
440
396
  success: true,
441
397
  durationSeconds,
442
- sessionId: agentResult.sessionId,
398
+ ...resume,
443
399
  output: agentResult.output,
444
400
  ...tails,
445
401
  };
@@ -453,8 +409,14 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
453
409
  * @internal Exported for testing only
454
410
  */
455
411
  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));
412
+ const definition = phaseRegistry.get(phase);
413
+ // Non-claude drivers consult driverOverrides[<driver>] first; fall back to
414
+ // the default promptTemplate when no override is registered for the driver.
415
+ const driverPrompt = agent && agent !== "claude-code"
416
+ ? definition.driverOverrides?.[agent]?.promptTemplate
417
+ : undefined;
418
+ const template = driverPrompt ?? definition.promptTemplate;
419
+ let basePrompt = template.replace(/\{issue\}/g, String(issueNumber));
458
420
  // Append phase-specific context (e.g., QA findings for loop phase)
459
421
  if (promptContext) {
460
422
  basePrompt += `\n\n---\n\n${promptContext}`;
@@ -471,14 +433,14 @@ export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
471
433
  /**
472
434
  * Execute a single phase for an issue using the configured AgentDriver.
473
435
  */
474
- async function executePhase(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner) {
436
+ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner) {
475
437
  const startTime = Date.now();
476
438
  const prompt = await getPhasePrompt(phase, issueNumber, config.agent, config.promptContext);
477
439
  if (config.dryRun) {
478
440
  // Dry run - show the prompt that would be sent, then return
479
441
  if (config.verbose) {
480
- console.log(chalk.gray(` Would execute: /${phase} ${issueNumber}`));
481
- console.log(chalk.gray(` Prompt: ${prompt}`));
442
+ bracketedConsoleLog(spinner, chalk.gray(` Would execute: /${phase} ${issueNumber}`));
443
+ bracketedConsoleLog(spinner, chalk.gray(` Prompt: ${prompt}`));
482
444
  }
483
445
  return {
484
446
  phase,
@@ -488,13 +450,13 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
488
450
  };
489
451
  }
490
452
  if (config.verbose) {
491
- console.log(chalk.gray(` Prompt: ${prompt}`));
492
- if (worktreePath && ISOLATED_PHASES.includes(phase)) {
493
- console.log(chalk.gray(` Worktree: ${worktreePath}`));
453
+ bracketedConsoleLog(spinner, chalk.gray(` Prompt: ${prompt}`));
454
+ if (worktreePath && phaseRequiresWorktree(phase)) {
455
+ bracketedConsoleLog(spinner, chalk.gray(` Worktree: ${worktreePath}`));
494
456
  }
495
457
  }
496
458
  // Determine working directory and environment
497
- const shouldUseWorktree = worktreePath && ISOLATED_PHASES.includes(phase);
459
+ const shouldUseWorktree = worktreePath && phaseRequiresWorktree(phase);
498
460
  const cwd = shouldUseWorktree ? worktreePath : process.cwd();
499
461
  // Resolve file context for file-oriented drivers (e.g., Aider --file)
500
462
  let files;
@@ -599,12 +561,19 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
599
561
  }, ACTIVITY_THROTTLE_MS)
600
562
  : undefined;
601
563
  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;
564
+ // Resolve driver before the resume check eligibility is now driver-owned
565
+ // (#674). Each driver's `canResume(handle, cwd)` enforces its own contract:
566
+ // Claude Code requires byte-equal cwd match (session storage is
567
+ // cwd-namespaced); Aider declines all resume (no session concept); Codex
568
+ // (when added in #497) folds in AGENTS.md parity. Replacing the prior
569
+ // `sessionId && !worktreePath` heuristic also unblocks same-worktree resume
570
+ // across phases.
571
+ const driver = getDriver(config.agent, {
572
+ aiderSettings: config.aiderSettings,
573
+ });
574
+ const eligibleHandle = resumeHandle && driver.canResume(resumeHandle, cwd)
575
+ ? resumeHandle
576
+ : undefined;
608
577
  // Build AgentExecutionConfig for the driver
609
578
  const agentConfig = {
610
579
  cwd,
@@ -613,7 +582,8 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
613
582
  phaseTimeout: config.phaseTimeout,
614
583
  verbose: config.verbose,
615
584
  mcp: config.mcp,
616
- sessionId: canResume ? sessionId : undefined,
585
+ resumeHandle: eligibleHandle,
586
+ sessionId: eligibleHandle?.token,
617
587
  files,
618
588
  onOutput: config.verbose || reportActivity
619
589
  ? (text) => {
@@ -622,6 +592,7 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
622
592
  spinner?.pause();
623
593
  verboseStreamingActive = true;
624
594
  }
595
+ // eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
625
596
  process.stdout.write(chalk.gray(text));
626
597
  }
627
598
  reportActivity?.(text);
@@ -633,14 +604,11 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
633
604
  spinner?.pause();
634
605
  verboseStreamingActive = true;
635
606
  }
607
+ // eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
636
608
  process.stderr.write(chalk.red(data));
637
609
  }
638
610
  : undefined,
639
611
  };
640
- // Resolve driver from config or default
641
- const driver = getDriver(config.agent, {
642
- aiderSettings: config.aiderSettings,
643
- });
644
612
  const agentResult = await driver.executePhase(prompt, agentConfig);
645
613
  // Cancel any pending trailing activity fire — phase is done; the
646
614
  // orchestrator's stale-phase guard would no-op a late call anyway, but
@@ -665,6 +633,7 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
665
633
  durationSeconds,
666
634
  error: agentResult.error,
667
635
  sessionId: agentResult.sessionId,
636
+ resumeHandle: agentResult.resumeHandle,
668
637
  stderrTail: agentResult.stderrTail,
669
638
  stdoutTail: agentResult.stdoutTail,
670
639
  exitCode: agentResult.exitCode,
@@ -684,24 +653,28 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
684
653
  /**
685
654
  * @internal Exported for testing only
686
655
  */
687
- export async function executePhaseWithRetry(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner,
656
+ export async function executePhaseWithRetry(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner,
688
657
  /** @internal Injected for testing — defaults to module-level executePhase */
689
658
  executePhaseFn = executePhase,
690
659
  /** @internal Injected for testing — defaults to setTimeout-based delay */
691
660
  delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
692
661
  // Skip retry logic if explicitly disabled
693
662
  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";
663
+ return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
664
+ }
665
+ // Skip cold-start retries for phases registered with `retryStrategy.maxRetries: 0`.
666
+ // `loop` is the canonical user (#488) — it's always a re-run after a failed QA,
667
+ // never a first boot. Failures at 47-51s are genuine skill failures, not cold-start
668
+ // issues. Without this guard, 2 cold-start retries + 1 MCP fallback = 3 wasted
669
+ // spawns per loop. Sourcing the decision from the registry makes the rule
670
+ // data-driven — any future phase registered with `maxRetries: 0` inherits the
671
+ // same behavior without a code change here.
672
+ const skipColdStartRetry = phaseRegistry.has(phase) &&
673
+ phaseRegistry.get(phase).retryStrategy?.maxRetries === 0;
701
674
  let lastResult;
702
675
  if (skipColdStartRetry) {
703
676
  // Single attempt — no cold-start retry loop
704
- lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
677
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
705
678
  if (lastResult.success) {
706
679
  return lastResult;
707
680
  }
@@ -709,7 +682,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
709
682
  else {
710
683
  // Phase 1: Cold-start retry attempts (with MCP enabled if configured)
711
684
  for (let attempt = 0; attempt <= COLD_START_MAX_RETRIES; attempt++) {
712
- lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
685
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
713
686
  const duration = lastResult.durationSeconds ?? 0;
714
687
  // Success → return immediately
715
688
  if (lastResult.success) {
@@ -725,7 +698,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
725
698
  const label = typedError instanceof ApiError
726
699
  ? `API error (status ${typedError.metadata.statusCode ?? "unknown"})`
727
700
  : typedError.name;
728
- console.log(chalk.yellow(`\n ⟳ Retryable error: ${label}, retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
701
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Retryable error: ${label}, retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
729
702
  }
730
703
  continue;
731
704
  }
@@ -737,7 +710,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
737
710
  // Cold-start failure detected — retry
738
711
  if (attempt < COLD_START_MAX_RETRIES) {
739
712
  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})`));
713
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Cold-start failure detected (${duration.toFixed(1)}s), retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
741
714
  }
742
715
  }
743
716
  }
@@ -748,15 +721,15 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
748
721
  // This handles npx-based MCP servers that fail on first run due to cold-cache issues.
749
722
  // Skip for `loop` phase — MCP is never the cause of loop failures (#488).
750
723
  if (config.mcp && !lastResult.success && !skipColdStartRetry) {
751
- console.log(chalk.yellow(`\n ! Phase failed with MCP enabled, retrying without MCP...`));
724
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ! Phase failed with MCP enabled, retrying without MCP...`));
752
725
  // Create config copy with MCP disabled
753
726
  const configWithoutMcp = {
754
727
  ...config,
755
728
  mcp: false,
756
729
  };
757
- const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, sessionId, worktreePath, shutdownManager, spinner);
730
+ const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, resumeHandle, worktreePath, shutdownManager, spinner);
758
731
  if (retryResult.success) {
759
- console.log(chalk.green(` ✓ Phase succeeded without MCP (MCP cold-start issue detected)`));
732
+ bracketedConsoleLog(spinner, chalk.green(` ✓ Phase succeeded without MCP (MCP cold-start issue detected)`));
760
733
  return retryResult;
761
734
  }
762
735
  // Update lastResult for Phase 3 (spec retry)
@@ -773,11 +746,11 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
773
746
  // than other phases (~8.6%), so one extra retry with backoff recovers most cases.
774
747
  if (phase === "spec" && !lastResult.success) {
775
748
  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})`));
749
+ bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Spec phase failed, retrying with ${SPEC_RETRY_BACKOFF_MS}ms backoff... (spec retry ${i + 1}/${SPEC_EXTRA_RETRIES})`));
777
750
  await delayFn(SPEC_RETRY_BACKOFF_MS);
778
- const specRetryResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
751
+ const specRetryResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
779
752
  if (specRetryResult.success) {
780
- console.log(chalk.green(` ✓ Spec phase succeeded on retry`));
753
+ bracketedConsoleLog(spinner, chalk.green(` ✓ Spec phase succeeded on retry`));
781
754
  return specRetryResult;
782
755
  }
783
756
  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