sequant 2.2.0 → 2.3.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 (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +73 -0
  4. package/dist/bin/cli.js +94 -9
  5. package/dist/src/commands/doctor.d.ts +25 -0
  6. package/dist/src/commands/doctor.js +36 -1
  7. package/dist/src/commands/locks.d.ts +67 -0
  8. package/dist/src/commands/locks.js +290 -0
  9. package/dist/src/commands/merge.js +11 -0
  10. package/dist/src/commands/prompt.d.ts +39 -0
  11. package/dist/src/commands/prompt.js +179 -0
  12. package/dist/src/commands/run-display.d.ts +11 -2
  13. package/dist/src/commands/run-display.js +62 -28
  14. package/dist/src/commands/run-progress.d.ts +32 -0
  15. package/dist/src/commands/run-progress.js +76 -0
  16. package/dist/src/commands/run.js +80 -18
  17. package/dist/src/commands/stats.d.ts +2 -0
  18. package/dist/src/commands/stats.js +94 -8
  19. package/dist/src/commands/status.js +12 -0
  20. package/dist/src/commands/watch.d.ts +16 -0
  21. package/dist/src/commands/watch.js +147 -0
  22. package/dist/src/lib/ac-linter.d.ts +1 -1
  23. package/dist/src/lib/ac-linter.js +81 -0
  24. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  25. package/dist/src/lib/assess-collision-detect.js +217 -0
  26. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  27. package/dist/src/lib/assess-comment-parser.js +124 -2
  28. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  29. package/dist/src/lib/cli-ui/format.js +34 -0
  30. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
  31. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  32. package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
  33. package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
  34. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  35. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  36. package/dist/src/lib/locks/index.d.ts +7 -0
  37. package/dist/src/lib/locks/index.js +5 -0
  38. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  39. package/dist/src/lib/locks/lock-manager.js +433 -0
  40. package/dist/src/lib/locks/types.d.ts +59 -0
  41. package/dist/src/lib/locks/types.js +31 -0
  42. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  43. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  44. package/dist/src/lib/relay/activation.d.ts +60 -0
  45. package/dist/src/lib/relay/activation.js +122 -0
  46. package/dist/src/lib/relay/archive.d.ts +34 -0
  47. package/dist/src/lib/relay/archive.js +106 -0
  48. package/dist/src/lib/relay/frame.d.ts +20 -0
  49. package/dist/src/lib/relay/frame.js +76 -0
  50. package/dist/src/lib/relay/index.d.ts +13 -0
  51. package/dist/src/lib/relay/index.js +13 -0
  52. package/dist/src/lib/relay/paths.d.ts +43 -0
  53. package/dist/src/lib/relay/paths.js +59 -0
  54. package/dist/src/lib/relay/pid.d.ts +34 -0
  55. package/dist/src/lib/relay/pid.js +72 -0
  56. package/dist/src/lib/relay/reader.d.ts +35 -0
  57. package/dist/src/lib/relay/reader.js +115 -0
  58. package/dist/src/lib/relay/types.d.ts +68 -0
  59. package/dist/src/lib/relay/types.js +76 -0
  60. package/dist/src/lib/relay/writer.d.ts +48 -0
  61. package/dist/src/lib/relay/writer.js +113 -0
  62. package/dist/src/lib/settings.d.ts +31 -1
  63. package/dist/src/lib/settings.js +18 -3
  64. package/dist/src/lib/version-check.d.ts +60 -5
  65. package/dist/src/lib/version-check.js +97 -9
  66. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  67. package/dist/src/lib/workflow/batch-executor.js +248 -175
  68. package/dist/src/lib/workflow/config-resolver.js +4 -0
  69. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  70. package/dist/src/lib/workflow/heartbeat.js +194 -0
  71. package/dist/src/lib/workflow/phase-executor.d.ts +62 -8
  72. package/dist/src/lib/workflow/phase-executor.js +157 -16
  73. package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
  74. package/dist/src/lib/workflow/phase-mapper.js +17 -20
  75. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  76. package/dist/src/lib/workflow/platforms/github.js +20 -3
  77. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  78. package/dist/src/lib/workflow/pr-status.js +41 -9
  79. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  80. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  81. package/dist/src/lib/workflow/run-orchestrator.d.ts +39 -0
  82. package/dist/src/lib/workflow/run-orchestrator.js +340 -15
  83. package/dist/src/lib/workflow/run-reflect.js +1 -1
  84. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  85. package/dist/src/lib/workflow/run-state.js +14 -0
  86. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  87. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  88. package/dist/src/lib/workflow/state-manager.d.ts +12 -1
  89. package/dist/src/lib/workflow/state-manager.js +37 -0
  90. package/dist/src/lib/workflow/state-schema.d.ts +62 -0
  91. package/dist/src/lib/workflow/state-schema.js +35 -1
  92. package/dist/src/lib/workflow/types.d.ts +74 -1
  93. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  94. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  95. package/dist/src/mcp/tools/run.d.ts +44 -0
  96. package/dist/src/mcp/tools/run.js +104 -13
  97. package/dist/src/ui/tui/App.d.ts +14 -0
  98. package/dist/src/ui/tui/App.js +41 -0
  99. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  100. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  101. package/dist/src/ui/tui/Header.d.ts +6 -0
  102. package/dist/src/ui/tui/Header.js +15 -0
  103. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  104. package/dist/src/ui/tui/IssueBox.js +68 -0
  105. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  106. package/dist/src/ui/tui/Spinner.js +18 -0
  107. package/dist/src/ui/tui/index.d.ts +15 -0
  108. package/dist/src/ui/tui/index.js +29 -0
  109. package/dist/src/ui/tui/theme.d.ts +29 -0
  110. package/dist/src/ui/tui/theme.js +52 -0
  111. package/dist/src/ui/tui/truncate.d.ts +11 -0
  112. package/dist/src/ui/tui/truncate.js +31 -0
  113. package/package.json +10 -3
  114. package/templates/agents/sequant-explorer.md +1 -0
  115. package/templates/agents/sequant-qa-checker.md +2 -1
  116. package/templates/agents/sequant-testgen.md +1 -0
  117. package/templates/hooks/post-tool.sh +11 -0
  118. package/templates/hooks/pre-tool.sh +18 -9
  119. package/templates/hooks/relay-check.sh +107 -0
  120. package/templates/relay/frame.txt +11 -0
  121. package/templates/scripts/cleanup-worktree.sh +25 -3
  122. package/templates/scripts/new-feature.sh +6 -0
  123. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  124. package/templates/skills/_shared/references/subagent-types.md +21 -8
  125. package/templates/skills/assess/SKILL.md +103 -49
  126. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  127. package/templates/skills/docs/SKILL.md +141 -22
  128. package/templates/skills/exec/SKILL.md +10 -8
  129. package/templates/skills/fullsolve/SKILL.md +79 -5
  130. package/templates/skills/loop/SKILL.md +28 -0
  131. package/templates/skills/merger/SKILL.md +621 -0
  132. package/templates/skills/qa/SKILL.md +727 -8
  133. package/templates/skills/setup/SKILL.md +6 -0
  134. package/templates/skills/spec/SKILL.md +52 -0
  135. package/templates/skills/spec/references/parallel-groups.md +7 -0
  136. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  137. package/templates/skills/testgen/SKILL.md +24 -17
@@ -0,0 +1,71 @@
1
+ import type { ShutdownManager } from "../shutdown.js";
2
+ export interface LivenessHeartbeatOptions {
3
+ /** Polling cadence for heartbeat ticks. Default: 30_000ms */
4
+ pollIntervalMs?: number;
5
+ /** Stall threshold (mtime gap) before warning fires. Default: 5min */
6
+ stallThresholdMs?: number;
7
+ /** Liveness file whose mtime is the activity proxy. Default: .sequant/state.json */
8
+ livenessFile?: string;
9
+ /** When false, no timer is started (heartbeat fully suppressed). Default: true */
10
+ enabled?: boolean;
11
+ /**
12
+ * Per-phase timeout in seconds, used for the "phase timeout in N" suffix
13
+ * on stall warnings. When omitted, suffix is dropped.
14
+ */
15
+ phaseTimeoutSeconds?: number;
16
+ /** Optional ShutdownManager for graceful cleanup */
17
+ shutdownManager?: ShutdownManager;
18
+ /** Override TTY detection (testing). Default: process.stdout.isTTY */
19
+ isTTY?: boolean;
20
+ /** Override clock (testing). Default: Date.now */
21
+ now?: () => number;
22
+ /** Override stdout writer (testing). Default: process.stdout.write */
23
+ stdoutWrite?: (s: string) => void;
24
+ /** Override stderr writer (testing). Default: process.stderr.write */
25
+ stderrWrite?: (s: string) => void;
26
+ }
27
+ interface PhaseKey {
28
+ issueNumber: number;
29
+ phase: string;
30
+ }
31
+ export declare class LivenessHeartbeat {
32
+ private readonly pollIntervalMs;
33
+ private readonly stallThresholdMs;
34
+ private readonly livenessFile;
35
+ private readonly enabled;
36
+ private readonly phaseTimeoutSeconds?;
37
+ private readonly shutdownManager?;
38
+ private readonly tty;
39
+ private readonly now;
40
+ private readonly stdoutWrite;
41
+ private readonly stderrWrite;
42
+ private readonly phases;
43
+ private timer;
44
+ private stopped;
45
+ private cleanupRegistered;
46
+ constructor(options?: LivenessHeartbeatOptions);
47
+ /**
48
+ * Begin tracking a phase. Starts the shared poll timer on first call.
49
+ * No-op if `enabled === false`.
50
+ */
51
+ start(entry: PhaseKey & {
52
+ startedAt: number;
53
+ }): void;
54
+ /**
55
+ * Stop tracking a specific phase. When the last phase is removed, the timer
56
+ * is cleared so no orphaned polls remain.
57
+ */
58
+ stop(key?: PhaseKey): void;
59
+ /**
60
+ * Dispose all tracked phases and clear the timer. Idempotent.
61
+ */
62
+ dispose(): void;
63
+ /** Test hook: drive a poll synchronously without waiting on real timers. */
64
+ tickNow(): void;
65
+ private tick;
66
+ private writeHeartbeat;
67
+ private writeStallWarning;
68
+ }
69
+ /** Convenience factory mirroring `phaseSpinner()`. */
70
+ export declare function livenessHeartbeat(options?: LivenessHeartbeatOptions): LivenessHeartbeat;
71
+ export {};
@@ -0,0 +1,194 @@
1
+ /**
2
+ * LivenessHeartbeat — `-q` mode liveness signal + stall warning.
3
+ *
4
+ * Surfaces a per-phase liveness line (TTY only) and a one-shot stall warning
5
+ * (TTY and non-TTY) so users can distinguish "agent working" from "process hung"
6
+ * without inspecting `state.json` or `ps`/`lsof`.
7
+ *
8
+ * Liveness source: mtime of `.sequant/state.json` — written 3-10x per phase by
9
+ * `StateManager.saveState()`. Zero new infrastructure.
10
+ *
11
+ * @see Issue #574
12
+ */
13
+ import * as fs from "fs";
14
+ import { formatElapsedTime } from "../cli-ui/format.js";
15
+ const DEFAULT_POLL_INTERVAL_MS = 30_000;
16
+ const DEFAULT_STALL_THRESHOLD_MS = 5 * 60_000;
17
+ const DEFAULT_LIVENESS_FILE = ".sequant/state.json";
18
+ const CLEANUP_NAME = "liveness-heartbeat";
19
+ function keyFor(k) {
20
+ return `${k.issueNumber}:${k.phase}`;
21
+ }
22
+ /**
23
+ * Format the stall window's elapsed seconds. Floors to whole seconds.
24
+ */
25
+ function formatStall(seconds) {
26
+ return formatElapsedTime(Math.floor(seconds));
27
+ }
28
+ export class LivenessHeartbeat {
29
+ pollIntervalMs;
30
+ stallThresholdMs;
31
+ livenessFile;
32
+ enabled;
33
+ phaseTimeoutSeconds;
34
+ shutdownManager;
35
+ tty;
36
+ now;
37
+ stdoutWrite;
38
+ stderrWrite;
39
+ phases = new Map();
40
+ timer = null;
41
+ stopped = false;
42
+ cleanupRegistered = false;
43
+ constructor(options = {}) {
44
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
45
+ this.stallThresholdMs =
46
+ options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
47
+ this.livenessFile = options.livenessFile ?? DEFAULT_LIVENESS_FILE;
48
+ this.enabled = options.enabled ?? true;
49
+ this.phaseTimeoutSeconds = options.phaseTimeoutSeconds;
50
+ this.shutdownManager = options.shutdownManager;
51
+ this.tty = options.isTTY ?? Boolean(process.stdout.isTTY);
52
+ this.now = options.now ?? Date.now;
53
+ this.stdoutWrite =
54
+ options.stdoutWrite ?? ((s) => void process.stdout.write(s));
55
+ this.stderrWrite =
56
+ options.stderrWrite ?? ((s) => void process.stderr.write(s));
57
+ }
58
+ /**
59
+ * Begin tracking a phase. Starts the shared poll timer on first call.
60
+ * No-op if `enabled === false`.
61
+ */
62
+ start(entry) {
63
+ if (!this.enabled || this.stopped)
64
+ return;
65
+ const key = keyFor(entry);
66
+ this.phases.set(key, {
67
+ issueNumber: entry.issueNumber,
68
+ phase: entry.phase,
69
+ startedAt: entry.startedAt,
70
+ warningFired: false,
71
+ });
72
+ if (this.timer === null) {
73
+ this.timer = setInterval(() => this.tick(), this.pollIntervalMs);
74
+ // Don't keep the event loop alive solely for the heartbeat.
75
+ if (typeof this.timer.unref === "function")
76
+ this.timer.unref();
77
+ }
78
+ if (this.shutdownManager && !this.cleanupRegistered) {
79
+ this.shutdownManager.registerCleanup(CLEANUP_NAME, async () => {
80
+ this.dispose();
81
+ });
82
+ this.cleanupRegistered = true;
83
+ }
84
+ }
85
+ /**
86
+ * Stop tracking a specific phase. When the last phase is removed, the timer
87
+ * is cleared so no orphaned polls remain.
88
+ */
89
+ stop(key) {
90
+ if (key) {
91
+ this.phases.delete(keyFor(key));
92
+ }
93
+ else {
94
+ this.phases.clear();
95
+ }
96
+ // Clear the timer when no phases remain so we don't poll uselessly between
97
+ // phases. We do NOT call dispose() here — that would set `stopped = true`
98
+ // and silently no-op the next start() in a sequential single-issue run
99
+ // (spec → stop → exec). Terminal teardown is dispose() called from the
100
+ // run.ts `finally` block.
101
+ if (this.phases.size === 0 && this.timer !== null) {
102
+ clearInterval(this.timer);
103
+ this.timer = null;
104
+ }
105
+ }
106
+ /**
107
+ * Dispose all tracked phases and clear the timer. Idempotent.
108
+ */
109
+ dispose() {
110
+ this.stopped = true;
111
+ this.phases.clear();
112
+ if (this.timer !== null) {
113
+ clearInterval(this.timer);
114
+ this.timer = null;
115
+ }
116
+ if (this.shutdownManager && this.cleanupRegistered) {
117
+ this.shutdownManager.unregisterCleanup(CLEANUP_NAME);
118
+ this.cleanupRegistered = false;
119
+ }
120
+ }
121
+ /** Test hook: drive a poll synchronously without waiting on real timers. */
122
+ tickNow() {
123
+ this.tick();
124
+ }
125
+ tick() {
126
+ if (this.stopped || this.phases.size === 0)
127
+ return;
128
+ const now = this.now();
129
+ for (const entry of this.phases.values()) {
130
+ let mtimeMs;
131
+ try {
132
+ const stat = fs.statSync(this.livenessFile);
133
+ mtimeMs = stat.mtimeMs;
134
+ }
135
+ catch {
136
+ // ENOENT (state.json not yet written) or EACCES — treat as no signal.
137
+ // Skip both heartbeat and stall logic this tick.
138
+ mtimeMs = null;
139
+ }
140
+ if (mtimeMs === null)
141
+ continue;
142
+ const sinceActivityMs = Math.max(0, now - mtimeMs);
143
+ const elapsedSinceStartMs = Math.max(0, now - entry.startedAt);
144
+ // AC-1: TTY heartbeat line — rewrite via \r.
145
+ if (this.tty) {
146
+ this.writeHeartbeat(entry, elapsedSinceStartMs, sinceActivityMs);
147
+ }
148
+ // AC-2: One-shot stall warning (TTY and non-TTY).
149
+ if (sinceActivityMs >= this.stallThresholdMs) {
150
+ if (!entry.warningFired) {
151
+ this.writeStallWarning(entry, sinceActivityMs, elapsedSinceStartMs);
152
+ entry.warningFired = true;
153
+ }
154
+ }
155
+ else if (entry.warningFired) {
156
+ // Activity resumed — reset for the next stall window.
157
+ entry.warningFired = false;
158
+ }
159
+ }
160
+ }
161
+ writeHeartbeat(entry, elapsedSinceStartMs, sinceActivityMs) {
162
+ if (this.stopped)
163
+ return;
164
+ const elapsed = formatElapsedTime(Math.floor(elapsedSinceStartMs / 1000));
165
+ const sinceActivity = formatElapsedTime(Math.floor(sinceActivityMs / 1000));
166
+ // \r rewrites current line; \x1b[K clears the rest in case the new line is
167
+ // shorter than the old one.
168
+ const line = `\r ▸ #${entry.issueNumber} ${entry.phase} (${elapsed} elapsed, last log update ${sinceActivity} ago)`;
169
+ this.stdoutWrite(line);
170
+ }
171
+ writeStallWarning(entry, sinceActivityMs, elapsedSinceStartMs) {
172
+ if (this.stopped)
173
+ return;
174
+ const stallStr = formatStall(sinceActivityMs / 1000);
175
+ let suffix = "";
176
+ if (this.phaseTimeoutSeconds !== undefined) {
177
+ const remaining = this.phaseTimeoutSeconds - elapsedSinceStartMs / 1000;
178
+ if (remaining > 0) {
179
+ suffix = ` (phase timeout in ${formatElapsedTime(Math.floor(remaining))})`;
180
+ }
181
+ }
182
+ // Prefix \r\x1b[K to clear any in-flight TTY heartbeat on this line, then
183
+ // emit the warning on its own line. Non-TTY mode prints leading control
184
+ // chars too — they are inert when not interpreted, and matter for TTY co-
185
+ // existence with the heartbeat rewrite.
186
+ const prefix = this.tty ? "\r" : "";
187
+ const line = `${prefix} ⚠ #${entry.issueNumber} ${entry.phase} no log activity for ${stallStr}${suffix}\n`;
188
+ this.stderrWrite(line);
189
+ }
190
+ }
191
+ /** Convenience factory mirroring `phaseSpinner()`. */
192
+ export function livenessHeartbeat(options) {
193
+ return new LivenessHeartbeat(options);
194
+ }
@@ -8,10 +8,38 @@
8
8
  * is agent-agnostic.
9
9
  */
10
10
  import { ShutdownManager } from "../shutdown.js";
11
- import { PhaseSpinner } from "../phase-spinner.js";
11
+ /**
12
+ * Lifecycle hook for pausing the run renderer's live zone while verbose
13
+ * Claude streaming writes through stdout, then resuming after the agent
14
+ * call completes. Replaces the legacy `PhaseSpinner` argument (#618).
15
+ */
16
+ export interface PhasePauseHandle {
17
+ pause(): void;
18
+ resume(): void;
19
+ }
12
20
  import { Phase, ExecutionConfig, PhaseResult, QaVerdict } from "./types.js";
13
21
  import type { QaSummary } from "./run-log-schema.js";
14
22
  import type { AgentPhaseResult } from "./drivers/index.js";
23
+ /**
24
+ * Leading + trailing throttle. Fires the wrapped callback immediately on the
25
+ * first call, drops subsequent calls that arrive inside `intervalMs` but
26
+ * remembers the latest payload, and fires one final "trailing" call with that
27
+ * latest payload after the window closes. Used to bridge the agent driver's
28
+ * fine-grained `onOutput` stream (#543) to the TUI's `nowLine` without
29
+ * either burning the 10 Hz snapshot budget on every chunk or losing the last
30
+ * useful chunk before the agent goes idle.
31
+ *
32
+ * `cancel()` clears the pending timer + payload — call after the consuming
33
+ * phase finishes so a residual trailing fire doesn't outlive its phase
34
+ * context. (The orchestrator's stale-phase guard catches it anyway, but
35
+ * cleanup avoids holding even a no-op timer.)
36
+ *
37
+ * @internal Exported for testing only.
38
+ */
39
+ export declare function createThrottledReporter(fn: (text: string) => void, intervalMs: number): {
40
+ report(text: string): void;
41
+ cancel(): void;
42
+ };
15
43
  /**
16
44
  * Spec-specific retry configuration.
17
45
  * Spec failures have a higher failure rate (~8.6%) than other phases due to
@@ -41,17 +69,43 @@ export declare function parseQaSummary(output: string): QaSummary | null;
41
69
  * Format duration in human-readable format
42
70
  */
43
71
  export declare function formatDuration(seconds: number): string;
72
+ /**
73
+ * Resolve the base ref the zero-diff guard should compare against for
74
+ * this worktree.
75
+ *
76
+ * Reads `branch.<current>.sequantBase` — written by `scripts/new-feature.sh`
77
+ * when a worktree is created with `--base <branch>`. Returns `origin/<base>`
78
+ * (prepending `origin/` only when the recorded value does not already
79
+ * reference a remote). Falls back to `"origin/main"` on missing config,
80
+ * missing branch, or any git error — preserves the pre-#537 behavior
81
+ * for worktrees that predate this change or are managed outside
82
+ * `new-feature.sh`.
83
+ *
84
+ * Uses `execFileSync` (not `execSync`) so argv is passed directly to
85
+ * `execve` without shell interpretation — the recorded value originates
86
+ * from the user-supplied `--base` CLI flag, and shell-interpolating it
87
+ * would open a shell-injection vector. With `execFileSync`, a malicious
88
+ * value is at worst treated as an invalid revspec by git (triggering
89
+ * the fail-open path), never executed as shell.
90
+ *
91
+ * @internal Exported for testing only.
92
+ */
93
+ export declare function resolveBaseRef(cwd: string): string;
44
94
  /**
45
95
  * Check whether the exec phase produced any changes in the worktree.
46
- * Returns true if HEAD has commits unique to it relative to origin/main
47
- * OR uncommitted work is present.
96
+ * Returns true if HEAD has commits unique to it relative to the resolved
97
+ * base ref (see {@link resolveBaseRef}) OR uncommitted work is present.
48
98
  *
49
- * Uses `git rev-list --count origin/main..HEAD` (commits reachable from HEAD
50
- * but not origin/main) instead of `git diff origin/main..HEAD`, because the
51
- * two-dot diff also fires in reverse when origin/main has advanced past HEAD
99
+ * Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD
100
+ * but not the base) instead of `git diff <base>..HEAD`, because the
101
+ * two-dot diff also fires in reverse when the base has advanced past HEAD
52
102
  * — on stale branches that would falsely report "has commits" even when the
53
103
  * exec phase produced nothing, reintroducing the bug #534 is fixing.
54
104
  *
105
+ * The base ref defaults to `origin/main` but is overridden to the worktree's
106
+ * recorded base (see #537) so zero-diff execs are still detected on
107
+ * custom-base worktrees (e.g. those created with `--base feature/epic`).
108
+ *
55
109
  * Fails open (returns true) on git errors — a missing origin ref is better
56
110
  * diagnosed as a real zero-diff run than as a false phase failure.
57
111
  *
@@ -83,7 +137,7 @@ export declare function getPhasePrompt(phase: Phase, issueNumber: number, agent?
83
137
  /**
84
138
  * Execute a single phase for an issue using the configured AgentDriver.
85
139
  */
86
- declare function executePhase(issueNumber: number, phase: Phase, config: ExecutionConfig, sessionId?: string, worktreePath?: string, shutdownManager?: ShutdownManager, spinner?: PhaseSpinner): Promise<PhaseResult & {
140
+ declare function executePhase(issueNumber: number, phase: Phase, config: ExecutionConfig, sessionId?: string, worktreePath?: string, shutdownManager?: ShutdownManager, spinner?: PhasePauseHandle): Promise<PhaseResult & {
87
141
  sessionId?: string;
88
142
  }>;
89
143
  /**
@@ -100,7 +154,7 @@ declare function executePhase(issueNumber: number, phase: Phase, config: Executi
100
154
  /**
101
155
  * @internal Exported for testing only
102
156
  */
103
- export declare function executePhaseWithRetry(issueNumber: number, phase: Phase, config: ExecutionConfig, sessionId?: string, worktreePath?: string, shutdownManager?: ShutdownManager, spinner?: PhaseSpinner,
157
+ export declare function executePhaseWithRetry(issueNumber: number, phase: Phase, config: ExecutionConfig, sessionId?: string, worktreePath?: string, shutdownManager?: ShutdownManager, spinner?: PhasePauseHandle,
104
158
  /** @internal Injected for testing — defaults to module-level executePhase */
105
159
  executePhaseFn?: typeof executePhase,
106
160
  /** @internal Injected for testing — defaults to setTimeout-based delay */
@@ -8,7 +8,7 @@
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";
@@ -92,6 +92,50 @@ const ISOLATED_PHASES = [
92
92
  */
93
93
  const COLD_START_THRESHOLD_SECONDS = 60;
94
94
  const COLD_START_MAX_RETRIES = 2;
95
+ /**
96
+ * Leading + trailing throttle. Fires the wrapped callback immediately on the
97
+ * first call, drops subsequent calls that arrive inside `intervalMs` but
98
+ * remembers the latest payload, and fires one final "trailing" call with that
99
+ * latest payload after the window closes. Used to bridge the agent driver's
100
+ * fine-grained `onOutput` stream (#543) to the TUI's `nowLine` without
101
+ * either burning the 10 Hz snapshot budget on every chunk or losing the last
102
+ * useful chunk before the agent goes idle.
103
+ *
104
+ * `cancel()` clears the pending timer + payload — call after the consuming
105
+ * phase finishes so a residual trailing fire doesn't outlive its phase
106
+ * context. (The orchestrator's stale-phase guard catches it anyway, but
107
+ * cleanup avoids holding even a no-op timer.)
108
+ *
109
+ * @internal Exported for testing only.
110
+ */
111
+ export function createThrottledReporter(fn, intervalMs) {
112
+ let timer = null;
113
+ let pending = null;
114
+ const report = (text) => {
115
+ if (timer) {
116
+ // Inside the throttle window — stash the latest payload for the
117
+ // trailing fire and drop this call.
118
+ pending = text;
119
+ return;
120
+ }
121
+ fn(text);
122
+ timer = setTimeout(() => {
123
+ const trailing = pending;
124
+ pending = null;
125
+ timer = null;
126
+ if (trailing !== null)
127
+ report(trailing);
128
+ }, intervalMs);
129
+ timer.unref?.();
130
+ };
131
+ const cancel = () => {
132
+ if (timer)
133
+ clearTimeout(timer);
134
+ timer = null;
135
+ pending = null;
136
+ };
137
+ return { report, cancel };
138
+ }
95
139
  /**
96
140
  * Spec-specific retry configuration.
97
141
  * Spec failures have a higher failure rate (~8.6%) than other phases due to
@@ -218,29 +262,83 @@ export function formatDuration(seconds) {
218
262
  const secs = seconds % 60;
219
263
  return `${mins}m ${secs.toFixed(0)}s`;
220
264
  }
265
+ /**
266
+ * Resolve the base ref the zero-diff guard should compare against for
267
+ * this worktree.
268
+ *
269
+ * Reads `branch.<current>.sequantBase` — written by `scripts/new-feature.sh`
270
+ * when a worktree is created with `--base <branch>`. Returns `origin/<base>`
271
+ * (prepending `origin/` only when the recorded value does not already
272
+ * reference a remote). Falls back to `"origin/main"` on missing config,
273
+ * missing branch, or any git error — preserves the pre-#537 behavior
274
+ * for worktrees that predate this change or are managed outside
275
+ * `new-feature.sh`.
276
+ *
277
+ * Uses `execFileSync` (not `execSync`) so argv is passed directly to
278
+ * `execve` without shell interpretation — the recorded value originates
279
+ * from the user-supplied `--base` CLI flag, and shell-interpolating it
280
+ * would open a shell-injection vector. With `execFileSync`, a malicious
281
+ * value is at worst treated as an invalid revspec by git (triggering
282
+ * the fail-open path), never executed as shell.
283
+ *
284
+ * @internal Exported for testing only.
285
+ */
286
+ export function resolveBaseRef(cwd) {
287
+ const fallback = "origin/main";
288
+ let branch;
289
+ try {
290
+ branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
291
+ cwd,
292
+ stdio: "pipe",
293
+ })
294
+ .toString()
295
+ .trim();
296
+ }
297
+ catch {
298
+ return fallback;
299
+ }
300
+ // Guard against multi-line output (paranoid — should never happen) and
301
+ // the detached-HEAD case where we have no recorded base to look up.
302
+ if (!branch || branch === "HEAD" || branch.includes("\n"))
303
+ return fallback;
304
+ let recorded;
305
+ try {
306
+ recorded = execFileSync("git", ["config", "--get", `branch.${branch}.sequantBase`], { cwd, stdio: "pipe" })
307
+ .toString()
308
+ .trim();
309
+ }
310
+ catch {
311
+ return fallback;
312
+ }
313
+ if (!recorded || recorded.includes("\n"))
314
+ return fallback;
315
+ return recorded.startsWith("origin/") ? recorded : `origin/${recorded}`;
316
+ }
221
317
  /**
222
318
  * 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.
319
+ * Returns true if HEAD has commits unique to it relative to the resolved
320
+ * base ref (see {@link resolveBaseRef}) OR uncommitted work is present.
225
321
  *
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
322
+ * Uses `git rev-list --count <base>..HEAD` (commits reachable from HEAD
323
+ * but not the base) instead of `git diff <base>..HEAD`, because the
324
+ * two-dot diff also fires in reverse when the base has advanced past HEAD
229
325
  * — on stale branches that would falsely report "has commits" even when the
230
326
  * exec phase produced nothing, reintroducing the bug #534 is fixing.
231
327
  *
328
+ * The base ref defaults to `origin/main` but is overridden to the worktree's
329
+ * recorded base (see #537) so zero-diff execs are still detected on
330
+ * custom-base worktrees (e.g. those created with `--base feature/epic`).
331
+ *
232
332
  * Fails open (returns true) on git errors — a missing origin ref is better
233
333
  * diagnosed as a real zero-diff run than as a false phase failure.
234
334
  *
235
335
  * @internal Exported for testing only.
236
336
  */
237
337
  export function hasExecChanges(cwd) {
338
+ const baseRef = resolveBaseRef(cwd);
238
339
  let commitsAhead;
239
340
  try {
240
- const count = execSync("git rev-list --count origin/main..HEAD", {
241
- cwd,
242
- stdio: "pipe",
243
- })
341
+ const count = execFileSync("git", ["rev-list", "--count", `${baseRef}..HEAD`], { cwd, stdio: "pipe" })
244
342
  .toString()
245
343
  .trim();
246
344
  commitsAhead = Number.parseInt(count, 10) > 0;
@@ -251,7 +349,10 @@ export function hasExecChanges(cwd) {
251
349
  if (commitsAhead)
252
350
  return true;
253
351
  try {
254
- const porcelain = execSync("git status --porcelain", { cwd, stdio: "pipe" })
352
+ const porcelain = execFileSync("git", ["status", "--porcelain"], {
353
+ cwd,
354
+ stdio: "pipe",
355
+ })
255
356
  .toString()
256
357
  .trim();
257
358
  return porcelain.length > 0;
@@ -460,11 +561,44 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
460
561
  if (config.isolateParallel) {
461
562
  env.SEQUANT_ISOLATE_PARALLEL = "true";
462
563
  }
564
+ // Activate interactive relay (#383) unless explicitly disabled.
565
+ // `relay-check.sh` (sourced from post-tool.sh) reads this env var on every
566
+ // tool call. Disabled by default in non-interactive scenarios — controlled
567
+ // via `settings.run.relay` (true by default).
568
+ if (config.relayEnabled) {
569
+ env.SEQUANT_RELAY = "true";
570
+ try {
571
+ const { resolveBundledFramePath } = await import("../relay/activation.js");
572
+ const framePath = resolveBundledFramePath();
573
+ if (framePath)
574
+ env.SEQUANT_RELAY_FRAME = framePath;
575
+ }
576
+ catch {
577
+ /* relay module unavailable — fall back to bash's search heuristic. */
578
+ }
579
+ }
463
580
  // Track whether we're actively streaming verbose output
464
581
  // Pausing spinner once per streaming session prevents truncation from rapid pause/resume cycles
465
582
  // (Issue #283: ora's stop() clears the current line, which can truncate output when
466
583
  // pause/resume is called for every chunk in rapid succession)
467
584
  let verboseStreamingActive = false;
585
+ // Activity ping throttle (#543): the agent driver streams text in many small
586
+ // chunks; the TUI only polls at 10 Hz. Coalesce to ≤2 calls per ~100ms
587
+ // window (leading + trailing) so we don't burn the poll budget on snapshot
588
+ // churn but still surface the latest chunk before the agent goes idle.
589
+ const ACTIVITY_THROTTLE_MS = 100;
590
+ const onActivity = config.onActivity;
591
+ const throttle = onActivity
592
+ ? createThrottledReporter((text) => {
593
+ try {
594
+ onActivity(text);
595
+ }
596
+ catch {
597
+ // Activity reporting must never disrupt the run.
598
+ }
599
+ }, ACTIVITY_THROTTLE_MS)
600
+ : undefined;
601
+ const reportActivity = throttle ? throttle.report : undefined;
468
602
  // Safety: never resume a session when worktree isolation is active.
469
603
  // Even if THIS phase doesn't use the worktree, a previous phase may have
470
604
  // created the session there. Resuming from a different cwd crashes the SDK
@@ -481,13 +615,16 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
481
615
  mcp: config.mcp,
482
616
  sessionId: canResume ? sessionId : undefined,
483
617
  files,
484
- onOutput: config.verbose
618
+ onOutput: config.verbose || reportActivity
485
619
  ? (text) => {
486
- if (!verboseStreamingActive) {
487
- spinner?.pause();
488
- verboseStreamingActive = true;
620
+ if (config.verbose) {
621
+ if (!verboseStreamingActive) {
622
+ spinner?.pause();
623
+ verboseStreamingActive = true;
624
+ }
625
+ process.stdout.write(chalk.gray(text));
489
626
  }
490
- process.stdout.write(chalk.gray(text));
627
+ reportActivity?.(text);
491
628
  }
492
629
  : undefined,
493
630
  onStderr: config.verbose
@@ -505,6 +642,10 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
505
642
  aiderSettings: config.aiderSettings,
506
643
  });
507
644
  const agentResult = await driver.executePhase(prompt, agentConfig);
645
+ // Cancel any pending trailing activity fire — phase is done; the
646
+ // orchestrator's stale-phase guard would no-op a late call anyway, but
647
+ // clearing the timer is cheaper than letting it elapse.
648
+ throttle?.cancel();
508
649
  // Resume spinner after execution completes (if we paused it)
509
650
  if (verboseStreamingActive) {
510
651
  spinner?.resume();
@@ -14,17 +14,18 @@ import type { Phase } from "./types.js";
14
14
  */
15
15
  interface PhaseMapperOptions {
16
16
  testgen?: boolean;
17
+ securityReview?: boolean;
17
18
  }
18
19
  /**
19
20
  * UI-related labels that trigger automatic test phase
20
21
  */
21
22
  export declare const UI_LABELS: string[];
22
23
  /**
23
- * Bug-related labels that skip spec phase
24
+ * Bug-related labels (used by downstream metadata consumers)
24
25
  */
25
26
  export declare const BUG_LABELS: string[];
26
27
  /**
27
- * Documentation labels that skip spec phase
28
+ * Documentation labels (used for issueType propagation and downstream metadata)
28
29
  */
29
30
  export declare const DOCS_LABELS: string[];
30
31
  /**