sequant 2.2.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 (156) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +81 -5
  4. package/dist/bin/cli.js +140 -13
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/doctor.d.ts +25 -0
  8. package/dist/src/commands/doctor.js +36 -1
  9. package/dist/src/commands/locks.d.ts +67 -0
  10. package/dist/src/commands/locks.js +290 -0
  11. package/dist/src/commands/merge.js +11 -0
  12. package/dist/src/commands/prompt.d.ts +46 -0
  13. package/dist/src/commands/prompt.js +273 -0
  14. package/dist/src/commands/run-display.d.ts +11 -2
  15. package/dist/src/commands/run-display.js +62 -28
  16. package/dist/src/commands/run-progress.d.ts +42 -0
  17. package/dist/src/commands/run-progress.js +93 -0
  18. package/dist/src/commands/run.js +90 -18
  19. package/dist/src/commands/stats.d.ts +2 -0
  20. package/dist/src/commands/stats.js +94 -8
  21. package/dist/src/commands/status.js +12 -0
  22. package/dist/src/commands/watch.d.ts +18 -0
  23. package/dist/src/commands/watch.js +211 -0
  24. package/dist/src/lib/ac-linter.d.ts +1 -1
  25. package/dist/src/lib/ac-linter.js +81 -0
  26. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  27. package/dist/src/lib/assess-collision-detect.js +217 -0
  28. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  29. package/dist/src/lib/assess-comment-parser.js +124 -2
  30. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  31. package/dist/src/lib/cli-ui/format.js +34 -0
  32. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +220 -0
  33. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  34. package/dist/src/lib/cli-ui/run-renderer.d.ts +265 -0
  35. package/dist/src/lib/cli-ui/run-renderer.js +1390 -0
  36. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  37. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  38. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  39. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  40. package/dist/src/lib/locks/index.d.ts +7 -0
  41. package/dist/src/lib/locks/index.js +5 -0
  42. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  43. package/dist/src/lib/locks/lock-manager.js +433 -0
  44. package/dist/src/lib/locks/types.d.ts +59 -0
  45. package/dist/src/lib/locks/types.js +31 -0
  46. package/dist/src/lib/merge-check/types.js +1 -1
  47. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  48. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  49. package/dist/src/lib/relay/activation.d.ts +60 -0
  50. package/dist/src/lib/relay/activation.js +122 -0
  51. package/dist/src/lib/relay/archive.d.ts +34 -0
  52. package/dist/src/lib/relay/archive.js +112 -0
  53. package/dist/src/lib/relay/frame.d.ts +20 -0
  54. package/dist/src/lib/relay/frame.js +76 -0
  55. package/dist/src/lib/relay/index.d.ts +13 -0
  56. package/dist/src/lib/relay/index.js +13 -0
  57. package/dist/src/lib/relay/paths.d.ts +43 -0
  58. package/dist/src/lib/relay/paths.js +59 -0
  59. package/dist/src/lib/relay/pid.d.ts +34 -0
  60. package/dist/src/lib/relay/pid.js +72 -0
  61. package/dist/src/lib/relay/reader.d.ts +35 -0
  62. package/dist/src/lib/relay/reader.js +115 -0
  63. package/dist/src/lib/relay/types.d.ts +70 -0
  64. package/dist/src/lib/relay/types.js +85 -0
  65. package/dist/src/lib/relay/writer.d.ts +48 -0
  66. package/dist/src/lib/relay/writer.js +113 -0
  67. package/dist/src/lib/settings.d.ts +31 -1
  68. package/dist/src/lib/settings.js +18 -3
  69. package/dist/src/lib/version-check.d.ts +60 -5
  70. package/dist/src/lib/version-check.js +97 -9
  71. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  72. package/dist/src/lib/workflow/batch-executor.js +274 -185
  73. package/dist/src/lib/workflow/config-resolver.js +4 -0
  74. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  75. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  76. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  77. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  78. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  79. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  80. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  81. package/dist/src/lib/workflow/event-emitter.js +102 -0
  82. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  83. package/dist/src/lib/workflow/heartbeat.js +194 -0
  84. package/dist/src/lib/workflow/notice.d.ts +32 -0
  85. package/dist/src/lib/workflow/notice.js +38 -0
  86. package/dist/src/lib/workflow/phase-executor.d.ts +58 -16
  87. package/dist/src/lib/workflow/phase-executor.js +244 -130
  88. package/dist/src/lib/workflow/phase-mapper.d.ts +27 -13
  89. package/dist/src/lib/workflow/phase-mapper.js +70 -51
  90. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  91. package/dist/src/lib/workflow/phase-registry.js +233 -0
  92. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  93. package/dist/src/lib/workflow/platforms/github.js +20 -3
  94. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  95. package/dist/src/lib/workflow/pr-status.js +41 -9
  96. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  97. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  98. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  99. package/dist/src/lib/workflow/run-orchestrator.d.ts +70 -1
  100. package/dist/src/lib/workflow/run-orchestrator.js +464 -25
  101. package/dist/src/lib/workflow/run-reflect.js +1 -1
  102. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  103. package/dist/src/lib/workflow/run-state.js +14 -0
  104. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  105. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  106. package/dist/src/lib/workflow/state-manager.d.ts +31 -2
  107. package/dist/src/lib/workflow/state-manager.js +64 -1
  108. package/dist/src/lib/workflow/state-schema.d.ts +82 -35
  109. package/dist/src/lib/workflow/state-schema.js +63 -4
  110. package/dist/src/lib/workflow/types.d.ts +139 -16
  111. package/dist/src/lib/workflow/types.js +18 -13
  112. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  113. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  114. package/dist/src/mcp/tools/run.d.ts +44 -0
  115. package/dist/src/mcp/tools/run.js +104 -13
  116. package/dist/src/ui/tui/App.d.ts +14 -0
  117. package/dist/src/ui/tui/App.js +41 -0
  118. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  119. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  120. package/dist/src/ui/tui/Header.d.ts +6 -0
  121. package/dist/src/ui/tui/Header.js +15 -0
  122. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  123. package/dist/src/ui/tui/IssueBox.js +68 -0
  124. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  125. package/dist/src/ui/tui/Spinner.js +18 -0
  126. package/dist/src/ui/tui/index.d.ts +15 -0
  127. package/dist/src/ui/tui/index.js +29 -0
  128. package/dist/src/ui/tui/theme.d.ts +29 -0
  129. package/dist/src/ui/tui/theme.js +52 -0
  130. package/dist/src/ui/tui/truncate.d.ts +11 -0
  131. package/dist/src/ui/tui/truncate.js +31 -0
  132. package/package.json +14 -6
  133. package/templates/agents/sequant-explorer.md +1 -0
  134. package/templates/agents/sequant-qa-checker.md +2 -1
  135. package/templates/agents/sequant-testgen.md +1 -0
  136. package/templates/hooks/post-tool.sh +92 -0
  137. package/templates/hooks/pre-tool.sh +18 -9
  138. package/templates/hooks/relay-check.sh +107 -0
  139. package/templates/relay/frame.txt +11 -0
  140. package/templates/scripts/cleanup-worktree.sh +25 -3
  141. package/templates/scripts/new-feature.sh +6 -0
  142. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  143. package/templates/skills/_shared/references/subagent-types.md +21 -8
  144. package/templates/skills/assess/SKILL.md +122 -68
  145. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  146. package/templates/skills/docs/SKILL.md +141 -22
  147. package/templates/skills/exec/SKILL.md +10 -8
  148. package/templates/skills/fullsolve/SKILL.md +79 -5
  149. package/templates/skills/loop/SKILL.md +28 -0
  150. package/templates/skills/merger/SKILL.md +621 -0
  151. package/templates/skills/qa/SKILL.md +727 -8
  152. package/templates/skills/setup/SKILL.md +12 -6
  153. package/templates/skills/spec/SKILL.md +52 -0
  154. package/templates/skills/spec/references/parallel-groups.md +7 -0
  155. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  156. package/templates/skills/testgen/SKILL.md +24 -17
@@ -8,82 +8,26 @@
8
8
  * is agent-agnostic.
9
9
  */
10
10
  import chalk from "chalk";
11
- import { execSync } from "child_process";
11
+ import { execSync, execFileSync } from "child_process";
12
12
  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
@@ -93,15 +37,67 @@ const ISOLATED_PHASES = [
93
37
  const COLD_START_THRESHOLD_SECONDS = 60;
94
38
  const COLD_START_MAX_RETRIES = 2;
95
39
  /**
96
- * Spec-specific retry configuration.
40
+ * Leading + trailing throttle. Fires the wrapped callback immediately on the
41
+ * first call, drops subsequent calls that arrive inside `intervalMs` but
42
+ * remembers the latest payload, and fires one final "trailing" call with that
43
+ * latest payload after the window closes. Used to bridge the agent driver's
44
+ * fine-grained `onOutput` stream (#543) to the TUI's `nowLine` without
45
+ * either burning the 10 Hz snapshot budget on every chunk or losing the last
46
+ * useful chunk before the agent goes idle.
47
+ *
48
+ * `cancel()` clears the pending timer + payload — call after the consuming
49
+ * phase finishes so a residual trailing fire doesn't outlive its phase
50
+ * context. (The orchestrator's stale-phase guard catches it anyway, but
51
+ * cleanup avoids holding even a no-op timer.)
52
+ *
53
+ * @internal Exported for testing only.
54
+ */
55
+ export function createThrottledReporter(fn, intervalMs) {
56
+ let timer = null;
57
+ let pending = null;
58
+ const report = (text) => {
59
+ if (timer) {
60
+ // Inside the throttle window — stash the latest payload for the
61
+ // trailing fire and drop this call.
62
+ pending = text;
63
+ return;
64
+ }
65
+ fn(text);
66
+ timer = setTimeout(() => {
67
+ const trailing = pending;
68
+ pending = null;
69
+ timer = null;
70
+ if (trailing !== null)
71
+ report(trailing);
72
+ }, intervalMs);
73
+ timer.unref?.();
74
+ };
75
+ const cancel = () => {
76
+ if (timer)
77
+ clearTimeout(timer);
78
+ timer = null;
79
+ pending = null;
80
+ };
81
+ return { report, cancel };
82
+ }
83
+ /**
84
+ * Spec-specific retry configuration. Sourced from the phase registry's
85
+ * `retryStrategy` field — `phase-registry.ts` is the source of truth.
86
+ *
97
87
  * Spec failures have a higher failure rate (~8.6%) than other phases due to
98
88
  * transient GitHub API issues and rate limits. One extra retry with backoff
99
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.
100
95
  */
96
+ const SPEC_RETRY_STRATEGY = phaseRegistry.get("spec").retryStrategy;
101
97
  /** @internal Exported for testing only */
102
- export const SPEC_RETRY_BACKOFF_MS = 5000;
98
+ export const SPEC_RETRY_BACKOFF_MS = SPEC_RETRY_STRATEGY?.backoffMs ?? 5000;
103
99
  /** @internal Exported for testing only */
104
- export const SPEC_EXTRA_RETRIES = 1;
100
+ export const SPEC_EXTRA_RETRIES = SPEC_RETRY_STRATEGY?.extraRetries ?? 1;
105
101
  export function parseQaVerdict(output) {
106
102
  if (!output)
107
103
  return null;
@@ -218,29 +214,83 @@ export function formatDuration(seconds) {
218
214
  const secs = seconds % 60;
219
215
  return `${mins}m ${secs.toFixed(0)}s`;
220
216
  }
217
+ /**
218
+ * Resolve the base ref the zero-diff guard should compare against for
219
+ * this worktree.
220
+ *
221
+ * Reads `branch.<current>.sequantBase` — written by `scripts/new-feature.sh`
222
+ * when a worktree is created with `--base <branch>`. Returns `origin/<base>`
223
+ * (prepending `origin/` only when the recorded value does not already
224
+ * reference a remote). Falls back to `"origin/main"` on missing config,
225
+ * missing branch, or any git error — preserves the pre-#537 behavior
226
+ * for worktrees that predate this change or are managed outside
227
+ * `new-feature.sh`.
228
+ *
229
+ * Uses `execFileSync` (not `execSync`) so argv is passed directly to
230
+ * `execve` without shell interpretation — the recorded value originates
231
+ * from the user-supplied `--base` CLI flag, and shell-interpolating it
232
+ * would open a shell-injection vector. With `execFileSync`, a malicious
233
+ * value is at worst treated as an invalid revspec by git (triggering
234
+ * the fail-open path), never executed as shell.
235
+ *
236
+ * @internal Exported for testing only.
237
+ */
238
+ export function resolveBaseRef(cwd) {
239
+ const fallback = "origin/main";
240
+ let branch;
241
+ try {
242
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
243
+ cwd,
244
+ stdio: "pipe",
245
+ })
246
+ .toString()
247
+ .trim();
248
+ }
249
+ catch {
250
+ return fallback;
251
+ }
252
+ // Guard against multi-line output (paranoid — should never happen) and
253
+ // the detached-HEAD case where we have no recorded base to look up.
254
+ if (!branch || branch === "HEAD" || branch.includes("\n"))
255
+ return fallback;
256
+ let recorded;
257
+ try {
258
+ recorded = execFileSync("git", ["config", "--get", `branch.${branch}.sequantBase`], { cwd, stdio: "pipe" })
259
+ .toString()
260
+ .trim();
261
+ }
262
+ catch {
263
+ return fallback;
264
+ }
265
+ if (!recorded || recorded.includes("\n"))
266
+ return fallback;
267
+ return recorded.startsWith("origin/") ? recorded : `origin/${recorded}`;
268
+ }
221
269
  /**
222
270
  * Check whether the exec phase produced any changes in the worktree.
223
- * Returns true if HEAD has commits unique to it relative to origin/main
224
- * OR uncommitted work is present.
271
+ * Returns true if HEAD has commits unique to it relative to the resolved
272
+ * base ref (see {@link resolveBaseRef}) OR uncommitted work is present.
225
273
  *
226
- * Uses `git rev-list --count origin/main..HEAD` (commits reachable from HEAD
227
- * but not origin/main) instead of `git diff origin/main..HEAD`, because the
228
- * two-dot diff also fires in reverse when origin/main has advanced past HEAD
274
+ * Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD
275
+ * but not the base) instead of `git diff <base>..HEAD`, because the
276
+ * two-dot diff also fires in reverse when the base has advanced past HEAD
229
277
  * — on stale branches that would falsely report "has commits" even when the
230
278
  * exec phase produced nothing, reintroducing the bug #534 is fixing.
231
279
  *
280
+ * The base ref defaults to `origin/main` but is overridden to the worktree's
281
+ * recorded base (see #537) so zero-diff execs are still detected on
282
+ * custom-base worktrees (e.g. those created with `--base feature/epic`).
283
+ *
232
284
  * Fails open (returns true) on git errors — a missing origin ref is better
233
285
  * diagnosed as a real zero-diff run than as a false phase failure.
234
286
  *
235
287
  * @internal Exported for testing only.
236
288
  */
237
289
  export function hasExecChanges(cwd) {
290
+ const baseRef = resolveBaseRef(cwd);
238
291
  let commitsAhead;
239
292
  try {
240
- const count = execSync("git rev-list --count origin/main..HEAD", {
241
- cwd,
242
- stdio: "pipe",
243
- })
293
+ const count = execFileSync("git", ["rev-list", "--count", `${baseRef}..HEAD`], { cwd, stdio: "pipe" })
244
294
  .toString()
245
295
  .trim();
246
296
  commitsAhead = Number.parseInt(count, 10) > 0;
@@ -251,7 +301,10 @@ export function hasExecChanges(cwd) {
251
301
  if (commitsAhead)
252
302
  return true;
253
303
  try {
254
- const porcelain = execSync("git status --porcelain", { cwd, stdio: "pipe" })
304
+ const porcelain = execFileSync("git", ["status", "--porcelain"], {
305
+ cwd,
306
+ stdio: "pipe",
307
+ })
255
308
  .toString()
256
309
  .trim();
257
310
  return porcelain.length > 0;
@@ -276,6 +329,10 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
276
329
  stdoutTail: agentResult.stdoutTail,
277
330
  exitCode: agentResult.exitCode,
278
331
  };
332
+ const resume = {
333
+ sessionId: agentResult.sessionId,
334
+ resumeHandle: agentResult.resumeHandle,
335
+ };
279
336
  if (phase === "qa") {
280
337
  const verdict = agentResult.output
281
338
  ? parseQaVerdict(agentResult.output)
@@ -291,7 +348,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
291
348
  success: false,
292
349
  durationSeconds,
293
350
  error: `QA verdict: ${verdict}`,
294
- sessionId: agentResult.sessionId,
351
+ ...resume,
295
352
  output: agentResult.output,
296
353
  verdict,
297
354
  summary,
@@ -305,7 +362,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
305
362
  success: false,
306
363
  durationSeconds,
307
364
  error: "QA completed without a parseable verdict",
308
- sessionId: agentResult.sessionId,
365
+ ...resume,
309
366
  output: agentResult.output,
310
367
  summary,
311
368
  ...tails,
@@ -315,7 +372,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
315
372
  phase,
316
373
  success: true,
317
374
  durationSeconds,
318
- sessionId: agentResult.sessionId,
375
+ ...resume,
319
376
  output: agentResult.output,
320
377
  verdict,
321
378
  summary,
@@ -329,7 +386,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
329
386
  success: false,
330
387
  durationSeconds,
331
388
  error: "exec produced no changes (no commits, no uncommitted work)",
332
- sessionId: agentResult.sessionId,
389
+ ...resume,
333
390
  output: agentResult.output,
334
391
  ...tails,
335
392
  };
@@ -338,7 +395,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
338
395
  phase,
339
396
  success: true,
340
397
  durationSeconds,
341
- sessionId: agentResult.sessionId,
398
+ ...resume,
342
399
  output: agentResult.output,
343
400
  ...tails,
344
401
  };
@@ -352,8 +409,14 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
352
409
  * @internal Exported for testing only
353
410
  */
354
411
  export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
355
- const prompts = agent && agent !== "claude-code" ? AIDER_PHASE_PROMPTS : PHASE_PROMPTS;
356
- 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));
357
420
  // Append phase-specific context (e.g., QA findings for loop phase)
358
421
  if (promptContext) {
359
422
  basePrompt += `\n\n---\n\n${promptContext}`;
@@ -370,14 +433,14 @@ export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
370
433
  /**
371
434
  * Execute a single phase for an issue using the configured AgentDriver.
372
435
  */
373
- async function executePhase(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner) {
436
+ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner) {
374
437
  const startTime = Date.now();
375
438
  const prompt = await getPhasePrompt(phase, issueNumber, config.agent, config.promptContext);
376
439
  if (config.dryRun) {
377
440
  // Dry run - show the prompt that would be sent, then return
378
441
  if (config.verbose) {
379
- console.log(chalk.gray(` Would execute: /${phase} ${issueNumber}`));
380
- console.log(chalk.gray(` Prompt: ${prompt}`));
442
+ bracketedConsoleLog(spinner, chalk.gray(` Would execute: /${phase} ${issueNumber}`));
443
+ bracketedConsoleLog(spinner, chalk.gray(` Prompt: ${prompt}`));
381
444
  }
382
445
  return {
383
446
  phase,
@@ -387,13 +450,13 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
387
450
  };
388
451
  }
389
452
  if (config.verbose) {
390
- console.log(chalk.gray(` Prompt: ${prompt}`));
391
- if (worktreePath && ISOLATED_PHASES.includes(phase)) {
392
- 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}`));
393
456
  }
394
457
  }
395
458
  // Determine working directory and environment
396
- const shouldUseWorktree = worktreePath && ISOLATED_PHASES.includes(phase);
459
+ const shouldUseWorktree = worktreePath && phaseRequiresWorktree(phase);
397
460
  const cwd = shouldUseWorktree ? worktreePath : process.cwd();
398
461
  // Resolve file context for file-oriented drivers (e.g., Aider --file)
399
462
  let files;
@@ -460,17 +523,57 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
460
523
  if (config.isolateParallel) {
461
524
  env.SEQUANT_ISOLATE_PARALLEL = "true";
462
525
  }
526
+ // Activate interactive relay (#383) unless explicitly disabled.
527
+ // `relay-check.sh` (sourced from post-tool.sh) reads this env var on every
528
+ // tool call. Disabled by default in non-interactive scenarios — controlled
529
+ // via `settings.run.relay` (true by default).
530
+ if (config.relayEnabled) {
531
+ env.SEQUANT_RELAY = "true";
532
+ try {
533
+ const { resolveBundledFramePath } = await import("../relay/activation.js");
534
+ const framePath = resolveBundledFramePath();
535
+ if (framePath)
536
+ env.SEQUANT_RELAY_FRAME = framePath;
537
+ }
538
+ catch {
539
+ /* relay module unavailable — fall back to bash's search heuristic. */
540
+ }
541
+ }
463
542
  // Track whether we're actively streaming verbose output
464
543
  // Pausing spinner once per streaming session prevents truncation from rapid pause/resume cycles
465
544
  // (Issue #283: ora's stop() clears the current line, which can truncate output when
466
545
  // pause/resume is called for every chunk in rapid succession)
467
546
  let verboseStreamingActive = false;
468
- // Safety: never resume a session when worktree isolation is active.
469
- // Even if THIS phase doesn't use the worktree, a previous phase may have
470
- // created the session there. Resuming from a different cwd crashes the SDK
471
- // (exit code 1). ISOLATED_PHASES prevents this by design, but this guard
472
- // catches edge cases (e.g. a new phase added without updating ISOLATED_PHASES).
473
- const canResume = sessionId && !worktreePath;
547
+ // Activity ping throttle (#543): the agent driver streams text in many small
548
+ // chunks; the TUI only polls at 10 Hz. Coalesce to ≤2 calls per ~100ms
549
+ // window (leading + trailing) so we don't burn the poll budget on snapshot
550
+ // churn but still surface the latest chunk before the agent goes idle.
551
+ const ACTIVITY_THROTTLE_MS = 100;
552
+ const onActivity = config.onActivity;
553
+ const throttle = onActivity
554
+ ? createThrottledReporter((text) => {
555
+ try {
556
+ onActivity(text);
557
+ }
558
+ catch {
559
+ // Activity reporting must never disrupt the run.
560
+ }
561
+ }, ACTIVITY_THROTTLE_MS)
562
+ : undefined;
563
+ const reportActivity = throttle ? throttle.report : undefined;
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;
474
577
  // Build AgentExecutionConfig for the driver
475
578
  const agentConfig = {
476
579
  cwd,
@@ -479,15 +582,20 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
479
582
  phaseTimeout: config.phaseTimeout,
480
583
  verbose: config.verbose,
481
584
  mcp: config.mcp,
482
- sessionId: canResume ? sessionId : undefined,
585
+ resumeHandle: eligibleHandle,
586
+ sessionId: eligibleHandle?.token,
483
587
  files,
484
- onOutput: config.verbose
588
+ onOutput: config.verbose || reportActivity
485
589
  ? (text) => {
486
- if (!verboseStreamingActive) {
487
- spinner?.pause();
488
- verboseStreamingActive = true;
590
+ if (config.verbose) {
591
+ if (!verboseStreamingActive) {
592
+ spinner?.pause();
593
+ verboseStreamingActive = true;
594
+ }
595
+ // eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
596
+ process.stdout.write(chalk.gray(text));
489
597
  }
490
- process.stdout.write(chalk.gray(text));
598
+ reportActivity?.(text);
491
599
  }
492
600
  : undefined,
493
601
  onStderr: config.verbose
@@ -496,15 +604,16 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
496
604
  spinner?.pause();
497
605
  verboseStreamingActive = true;
498
606
  }
607
+ // eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
499
608
  process.stderr.write(chalk.red(data));
500
609
  }
501
610
  : undefined,
502
611
  };
503
- // Resolve driver from config or default
504
- const driver = getDriver(config.agent, {
505
- aiderSettings: config.aiderSettings,
506
- });
507
612
  const agentResult = await driver.executePhase(prompt, agentConfig);
613
+ // Cancel any pending trailing activity fire — phase is done; the
614
+ // orchestrator's stale-phase guard would no-op a late call anyway, but
615
+ // clearing the timer is cheaper than letting it elapse.
616
+ throttle?.cancel();
508
617
  // Resume spinner after execution completes (if we paused it)
509
618
  if (verboseStreamingActive) {
510
619
  spinner?.resume();
@@ -524,6 +633,7 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
524
633
  durationSeconds,
525
634
  error: agentResult.error,
526
635
  sessionId: agentResult.sessionId,
636
+ resumeHandle: agentResult.resumeHandle,
527
637
  stderrTail: agentResult.stderrTail,
528
638
  stdoutTail: agentResult.stdoutTail,
529
639
  exitCode: agentResult.exitCode,
@@ -543,24 +653,28 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
543
653
  /**
544
654
  * @internal Exported for testing only
545
655
  */
546
- export async function executePhaseWithRetry(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner,
656
+ export async function executePhaseWithRetry(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner,
547
657
  /** @internal Injected for testing — defaults to module-level executePhase */
548
658
  executePhaseFn = executePhase,
549
659
  /** @internal Injected for testing — defaults to setTimeout-based delay */
550
660
  delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
551
661
  // Skip retry logic if explicitly disabled
552
662
  if (config.retry === false) {
553
- return executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
663
+ return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
554
664
  }
555
- // Skip cold-start retries for `loop` phase (#488).
556
- // Loop is always a re-run after a failed QA — never a first boot.
557
- // Failures at 47-51s are genuine skill failures, not cold-start issues.
558
- // Without this guard, 2 cold-start retries + 1 MCP fallback = 3 wasted spawns per loop.
559
- const skipColdStartRetry = phase === "loop";
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;
560
674
  let lastResult;
561
675
  if (skipColdStartRetry) {
562
676
  // Single attempt — no cold-start retry loop
563
- lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
677
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
564
678
  if (lastResult.success) {
565
679
  return lastResult;
566
680
  }
@@ -568,7 +682,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
568
682
  else {
569
683
  // Phase 1: Cold-start retry attempts (with MCP enabled if configured)
570
684
  for (let attempt = 0; attempt <= COLD_START_MAX_RETRIES; attempt++) {
571
- lastResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
685
+ lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
572
686
  const duration = lastResult.durationSeconds ?? 0;
573
687
  // Success → return immediately
574
688
  if (lastResult.success) {
@@ -584,7 +698,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
584
698
  const label = typedError instanceof ApiError
585
699
  ? `API error (status ${typedError.metadata.statusCode ?? "unknown"})`
586
700
  : typedError.name;
587
- 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})`));
588
702
  }
589
703
  continue;
590
704
  }
@@ -596,7 +710,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
596
710
  // Cold-start failure detected — retry
597
711
  if (attempt < COLD_START_MAX_RETRIES) {
598
712
  if (config.verbose) {
599
- 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})`));
600
714
  }
601
715
  }
602
716
  }
@@ -607,15 +721,15 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
607
721
  // This handles npx-based MCP servers that fail on first run due to cold-cache issues.
608
722
  // Skip for `loop` phase — MCP is never the cause of loop failures (#488).
609
723
  if (config.mcp && !lastResult.success && !skipColdStartRetry) {
610
- 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...`));
611
725
  // Create config copy with MCP disabled
612
726
  const configWithoutMcp = {
613
727
  ...config,
614
728
  mcp: false,
615
729
  };
616
- const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, sessionId, worktreePath, shutdownManager, spinner);
730
+ const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, resumeHandle, worktreePath, shutdownManager, spinner);
617
731
  if (retryResult.success) {
618
- 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)`));
619
733
  return retryResult;
620
734
  }
621
735
  // Update lastResult for Phase 3 (spec retry)
@@ -632,11 +746,11 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
632
746
  // than other phases (~8.6%), so one extra retry with backoff recovers most cases.
633
747
  if (phase === "spec" && !lastResult.success) {
634
748
  for (let i = 0; i < SPEC_EXTRA_RETRIES; i++) {
635
- 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})`));
636
750
  await delayFn(SPEC_RETRY_BACKOFF_MS);
637
- const specRetryResult = await executePhaseFn(issueNumber, phase, config, sessionId, worktreePath, shutdownManager, spinner);
751
+ const specRetryResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
638
752
  if (specRetryResult.success) {
639
- console.log(chalk.green(` ✓ Spec phase succeeded on retry`));
753
+ bracketedConsoleLog(spinner, chalk.green(` ✓ Spec phase succeeded on retry`));
640
754
  return specRetryResult;
641
755
  }
642
756
  lastResult = specRetryResult;
@@ -14,29 +14,38 @@ import type { Phase } from "./types.js";
14
14
  */
15
15
  interface PhaseMapperOptions {
16
16
  testgen?: boolean;
17
+ securityReview?: boolean;
17
18
  }
18
19
  /**
19
- * UI-related labels that trigger automatic test phase
20
- */
21
- export declare const UI_LABELS: string[];
22
- /**
23
- * Bug-related labels that skip spec phase
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.
24
26
  */
25
27
  export declare const BUG_LABELS: string[];
26
28
  /**
27
- * Documentation labels that skip spec phase
29
+ * Documentation labels (used for issueType propagation and downstream metadata).
30
+ *
31
+ * Issue-type metadata — NOT phase-trigger rules. See BUG_LABELS comment.
28
32
  */
29
33
  export declare const DOCS_LABELS: string[];
30
34
  /**
31
- * 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.
32
40
  */
33
41
  export declare const COMPLEX_LABELS: string[];
34
42
  /**
35
- * Security-related labels that trigger security-review phase
36
- */
37
- export declare const SECURITY_LABELS: string[];
38
- /**
39
- * 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).
40
49
  */
41
50
  export declare function detectPhasesFromLabels(labels: string[]): {
42
51
  phases: Phase[];
@@ -55,7 +64,12 @@ export declare function parseRecommendedWorkflow(output: string): {
55
64
  qualityLoop: boolean;
56
65
  } | null;
57
66
  /**
58
- * 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`).
59
73
  */
60
74
  export declare function hasUILabels(labels: string[]): boolean;
61
75
  /**