sequant 2.1.2 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +73 -0
- package/dist/bin/cli.js +95 -9
- package/dist/src/commands/doctor.d.ts +25 -0
- package/dist/src/commands/doctor.js +36 -1
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.js +118 -0
- package/dist/src/commands/locks.d.ts +67 -0
- package/dist/src/commands/locks.js +290 -0
- package/dist/src/commands/merge.js +11 -0
- package/dist/src/commands/prompt.d.ts +39 -0
- package/dist/src/commands/prompt.js +179 -0
- package/dist/src/commands/run-display.d.ts +26 -0
- package/dist/src/commands/run-display.js +150 -0
- package/dist/src/commands/run-progress.d.ts +32 -0
- package/dist/src/commands/run-progress.js +76 -0
- package/dist/src/commands/run.js +83 -73
- package/dist/src/commands/stats.d.ts +2 -0
- package/dist/src/commands/stats.js +94 -8
- package/dist/src/commands/status.js +27 -1
- package/dist/src/commands/watch.d.ts +16 -0
- package/dist/src/commands/watch.js +147 -0
- package/dist/src/lib/ac-linter.d.ts +1 -1
- package/dist/src/lib/ac-linter.js +81 -0
- package/dist/src/lib/assess-collision-detect.d.ts +91 -0
- package/dist/src/lib/assess-collision-detect.js +217 -0
- package/dist/src/lib/assess-comment-parser.d.ts +59 -1
- package/dist/src/lib/assess-comment-parser.js +124 -2
- package/dist/src/lib/cli-ui/format.d.ts +19 -0
- package/dist/src/lib/cli-ui/format.js +34 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
- package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
- package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
- package/dist/src/lib/locks/index.d.ts +7 -0
- package/dist/src/lib/locks/index.js +5 -0
- package/dist/src/lib/locks/lock-manager.d.ts +168 -0
- package/dist/src/lib/locks/lock-manager.js +433 -0
- package/dist/src/lib/locks/types.d.ts +59 -0
- package/dist/src/lib/locks/types.js +31 -0
- package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
- package/dist/src/lib/qa/markdown-only-ci.js +74 -0
- package/dist/src/lib/relay/activation.d.ts +60 -0
- package/dist/src/lib/relay/activation.js +122 -0
- package/dist/src/lib/relay/archive.d.ts +34 -0
- package/dist/src/lib/relay/archive.js +106 -0
- package/dist/src/lib/relay/frame.d.ts +20 -0
- package/dist/src/lib/relay/frame.js +76 -0
- package/dist/src/lib/relay/index.d.ts +13 -0
- package/dist/src/lib/relay/index.js +13 -0
- package/dist/src/lib/relay/paths.d.ts +43 -0
- package/dist/src/lib/relay/paths.js +59 -0
- package/dist/src/lib/relay/pid.d.ts +34 -0
- package/dist/src/lib/relay/pid.js +72 -0
- package/dist/src/lib/relay/reader.d.ts +35 -0
- package/dist/src/lib/relay/reader.js +115 -0
- package/dist/src/lib/relay/types.d.ts +68 -0
- package/dist/src/lib/relay/types.js +76 -0
- package/dist/src/lib/relay/writer.d.ts +48 -0
- package/dist/src/lib/relay/writer.js +113 -0
- package/dist/src/lib/settings.d.ts +31 -1
- package/dist/src/lib/settings.js +18 -3
- package/dist/src/lib/skill-version.d.ts +19 -0
- package/dist/src/lib/skill-version.js +68 -0
- package/dist/src/lib/templates.d.ts +1 -0
- package/dist/src/lib/templates.js +1 -1
- package/dist/src/lib/version-check.d.ts +60 -5
- package/dist/src/lib/version-check.js +97 -9
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +249 -176
- package/dist/src/lib/workflow/config-resolver.js +4 -0
- package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
- package/dist/src/lib/workflow/heartbeat.js +194 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +88 -3
- package/dist/src/lib/workflow/phase-executor.js +276 -52
- package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
- package/dist/src/lib/workflow/phase-mapper.js +17 -20
- package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
- package/dist/src/lib/workflow/platforms/github.js +20 -3
- package/dist/src/lib/workflow/pr-status.d.ts +18 -2
- package/dist/src/lib/workflow/pr-status.js +41 -9
- package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
- package/dist/src/lib/workflow/qa-stagnation.js +179 -0
- package/dist/src/lib/workflow/run-orchestrator.d.ts +76 -0
- package/dist/src/lib/workflow/run-orchestrator.js +382 -29
- package/dist/src/lib/workflow/run-reflect.js +1 -1
- package/dist/src/lib/workflow/run-state.d.ts +71 -0
- package/dist/src/lib/workflow/run-state.js +14 -0
- package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
- package/dist/src/lib/workflow/state-cleanup.js +17 -5
- package/dist/src/lib/workflow/state-manager.d.ts +12 -1
- package/dist/src/lib/workflow/state-manager.js +37 -0
- package/dist/src/lib/workflow/state-schema.d.ts +62 -0
- package/dist/src/lib/workflow/state-schema.js +35 -1
- package/dist/src/lib/workflow/types.d.ts +74 -1
- package/dist/src/lib/workflow/worktree-manager.d.ts +12 -4
- package/dist/src/lib/workflow/worktree-manager.js +76 -17
- package/dist/src/mcp/tools/run.d.ts +44 -0
- package/dist/src/mcp/tools/run.js +104 -13
- package/dist/src/ui/tui/App.d.ts +14 -0
- package/dist/src/ui/tui/App.js +41 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
- package/dist/src/ui/tui/ElapsedTimer.js +31 -0
- package/dist/src/ui/tui/Header.d.ts +6 -0
- package/dist/src/ui/tui/Header.js +15 -0
- package/dist/src/ui/tui/IssueBox.d.ts +16 -0
- package/dist/src/ui/tui/IssueBox.js +68 -0
- package/dist/src/ui/tui/Spinner.d.ts +9 -0
- package/dist/src/ui/tui/Spinner.js +18 -0
- package/dist/src/ui/tui/index.d.ts +15 -0
- package/dist/src/ui/tui/index.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +29 -0
- package/dist/src/ui/tui/theme.js +52 -0
- package/dist/src/ui/tui/truncate.d.ts +11 -0
- package/dist/src/ui/tui/truncate.js +31 -0
- package/package.json +10 -3
- package/templates/agents/sequant-explorer.md +1 -0
- package/templates/agents/sequant-qa-checker.md +2 -1
- package/templates/agents/sequant-testgen.md +1 -0
- package/templates/hooks/post-tool.sh +11 -0
- package/templates/hooks/pre-tool.sh +18 -9
- package/templates/hooks/relay-check.sh +107 -0
- package/templates/relay/frame.txt +11 -0
- package/templates/scripts/cleanup-worktree.sh +25 -3
- package/templates/scripts/new-feature.sh +6 -0
- package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/templates/skills/_shared/references/subagent-types.md +21 -8
- package/templates/skills/assess/SKILL.md +261 -94
- package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
- package/templates/skills/docs/SKILL.md +141 -22
- package/templates/skills/exec/SKILL.md +10 -49
- package/templates/skills/fullsolve/SKILL.md +80 -32
- package/templates/skills/loop/SKILL.md +28 -0
- package/templates/skills/merger/SKILL.md +621 -0
- package/templates/skills/qa/SKILL.md +746 -8
- package/templates/skills/qa/scripts/quality-checks.sh +47 -1
- package/templates/skills/setup/SKILL.md +6 -0
- package/templates/skills/spec/SKILL.md +217 -964
- package/templates/skills/spec/references/parallel-groups.md +7 -0
- package/templates/skills/spec/references/quality-checklist.md +75 -0
- package/templates/skills/spec/references/recommended-workflow.md +4 -2
- package/templates/skills/test/SKILL.md +0 -27
- package/templates/skills/testgen/SKILL.md +24 -44
|
@@ -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,6 +262,188 @@ 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
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Check whether the exec phase produced any changes in the worktree.
|
|
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.
|
|
321
|
+
*
|
|
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
|
|
325
|
+
* — on stale branches that would falsely report "has commits" even when the
|
|
326
|
+
* exec phase produced nothing, reintroducing the bug #534 is fixing.
|
|
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
|
+
*
|
|
332
|
+
* Fails open (returns true) on git errors — a missing origin ref is better
|
|
333
|
+
* diagnosed as a real zero-diff run than as a false phase failure.
|
|
334
|
+
*
|
|
335
|
+
* @internal Exported for testing only.
|
|
336
|
+
*/
|
|
337
|
+
export function hasExecChanges(cwd) {
|
|
338
|
+
const baseRef = resolveBaseRef(cwd);
|
|
339
|
+
let commitsAhead;
|
|
340
|
+
try {
|
|
341
|
+
const count = execFileSync("git", ["rev-list", "--count", `${baseRef}..HEAD`], { cwd, stdio: "pipe" })
|
|
342
|
+
.toString()
|
|
343
|
+
.trim();
|
|
344
|
+
commitsAhead = Number.parseInt(count, 10) > 0;
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
if (commitsAhead)
|
|
350
|
+
return true;
|
|
351
|
+
try {
|
|
352
|
+
const porcelain = execFileSync("git", ["status", "--porcelain"], {
|
|
353
|
+
cwd,
|
|
354
|
+
stdio: "pipe",
|
|
355
|
+
})
|
|
356
|
+
.toString()
|
|
357
|
+
.trim();
|
|
358
|
+
return porcelain.length > 0;
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Map a successful AgentPhaseResult to a PhaseResult, applying phase-specific
|
|
366
|
+
* guards that catch agent sessions which returned success without producing
|
|
367
|
+
* usable work (#534):
|
|
368
|
+
*
|
|
369
|
+
* - `qa`: fails when no parseable verdict is found (empty or malformed output).
|
|
370
|
+
* - `exec`: fails when no commits and no uncommitted changes exist.
|
|
371
|
+
*
|
|
372
|
+
* @internal Exported for testing only.
|
|
373
|
+
*/
|
|
374
|
+
export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds, cwd) {
|
|
375
|
+
const tails = {
|
|
376
|
+
stderrTail: agentResult.stderrTail,
|
|
377
|
+
stdoutTail: agentResult.stdoutTail,
|
|
378
|
+
exitCode: agentResult.exitCode,
|
|
379
|
+
};
|
|
380
|
+
if (phase === "qa") {
|
|
381
|
+
const verdict = agentResult.output
|
|
382
|
+
? parseQaVerdict(agentResult.output)
|
|
383
|
+
: null;
|
|
384
|
+
const summary = agentResult.output
|
|
385
|
+
? (parseQaSummary(agentResult.output) ?? undefined)
|
|
386
|
+
: undefined;
|
|
387
|
+
if (verdict &&
|
|
388
|
+
verdict !== "READY_FOR_MERGE" &&
|
|
389
|
+
verdict !== "NEEDS_VERIFICATION") {
|
|
390
|
+
return {
|
|
391
|
+
phase,
|
|
392
|
+
success: false,
|
|
393
|
+
durationSeconds,
|
|
394
|
+
error: `QA verdict: ${verdict}`,
|
|
395
|
+
sessionId: agentResult.sessionId,
|
|
396
|
+
output: agentResult.output,
|
|
397
|
+
verdict,
|
|
398
|
+
summary,
|
|
399
|
+
...tails,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
if (!verdict) {
|
|
403
|
+
// #534: a null verdict (empty or unparseable output) is not success.
|
|
404
|
+
return {
|
|
405
|
+
phase,
|
|
406
|
+
success: false,
|
|
407
|
+
durationSeconds,
|
|
408
|
+
error: "QA completed without a parseable verdict",
|
|
409
|
+
sessionId: agentResult.sessionId,
|
|
410
|
+
output: agentResult.output,
|
|
411
|
+
summary,
|
|
412
|
+
...tails,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
phase,
|
|
417
|
+
success: true,
|
|
418
|
+
durationSeconds,
|
|
419
|
+
sessionId: agentResult.sessionId,
|
|
420
|
+
output: agentResult.output,
|
|
421
|
+
verdict,
|
|
422
|
+
summary,
|
|
423
|
+
...tails,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (phase === "exec" && !hasExecChanges(cwd)) {
|
|
427
|
+
// #534: an exec phase that produced nothing is not success.
|
|
428
|
+
return {
|
|
429
|
+
phase,
|
|
430
|
+
success: false,
|
|
431
|
+
durationSeconds,
|
|
432
|
+
error: "exec produced no changes (no commits, no uncommitted work)",
|
|
433
|
+
sessionId: agentResult.sessionId,
|
|
434
|
+
output: agentResult.output,
|
|
435
|
+
...tails,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return {
|
|
439
|
+
phase,
|
|
440
|
+
success: true,
|
|
441
|
+
durationSeconds,
|
|
442
|
+
sessionId: agentResult.sessionId,
|
|
443
|
+
output: agentResult.output,
|
|
444
|
+
...tails,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
221
447
|
/**
|
|
222
448
|
* Get the prompt for a phase with the issue number substituted.
|
|
223
449
|
* Selects self-contained prompts for non-Claude agents.
|
|
@@ -335,11 +561,44 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
335
561
|
if (config.isolateParallel) {
|
|
336
562
|
env.SEQUANT_ISOLATE_PARALLEL = "true";
|
|
337
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
|
+
}
|
|
338
580
|
// Track whether we're actively streaming verbose output
|
|
339
581
|
// Pausing spinner once per streaming session prevents truncation from rapid pause/resume cycles
|
|
340
582
|
// (Issue #283: ora's stop() clears the current line, which can truncate output when
|
|
341
583
|
// pause/resume is called for every chunk in rapid succession)
|
|
342
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;
|
|
343
602
|
// Safety: never resume a session when worktree isolation is active.
|
|
344
603
|
// Even if THIS phase doesn't use the worktree, a previous phase may have
|
|
345
604
|
// created the session there. Resuming from a different cwd crashes the SDK
|
|
@@ -356,13 +615,16 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
356
615
|
mcp: config.mcp,
|
|
357
616
|
sessionId: canResume ? sessionId : undefined,
|
|
358
617
|
files,
|
|
359
|
-
onOutput: config.verbose
|
|
618
|
+
onOutput: config.verbose || reportActivity
|
|
360
619
|
? (text) => {
|
|
361
|
-
if (
|
|
362
|
-
|
|
363
|
-
|
|
620
|
+
if (config.verbose) {
|
|
621
|
+
if (!verboseStreamingActive) {
|
|
622
|
+
spinner?.pause();
|
|
623
|
+
verboseStreamingActive = true;
|
|
624
|
+
}
|
|
625
|
+
process.stdout.write(chalk.gray(text));
|
|
364
626
|
}
|
|
365
|
-
|
|
627
|
+
reportActivity?.(text);
|
|
366
628
|
}
|
|
367
629
|
: undefined,
|
|
368
630
|
onStderr: config.verbose
|
|
@@ -380,6 +642,10 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
380
642
|
aiderSettings: config.aiderSettings,
|
|
381
643
|
});
|
|
382
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();
|
|
383
649
|
// Resume spinner after execution completes (if we paused it)
|
|
384
650
|
if (verboseStreamingActive) {
|
|
385
651
|
spinner?.resume();
|
|
@@ -390,52 +656,8 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
390
656
|
shutdownManager.removeAbortController(abortController);
|
|
391
657
|
}
|
|
392
658
|
const durationSeconds = (Date.now() - startTime) / 1000;
|
|
393
|
-
// Map AgentPhaseResult to PhaseResult
|
|
394
|
-
const tails = {
|
|
395
|
-
stderrTail: agentResult.stderrTail,
|
|
396
|
-
stdoutTail: agentResult.stdoutTail,
|
|
397
|
-
exitCode: agentResult.exitCode,
|
|
398
|
-
};
|
|
399
659
|
if (agentResult.success) {
|
|
400
|
-
|
|
401
|
-
// Agent "success" just means the execution completed — we need to parse the verdict
|
|
402
|
-
if (phase === "qa" && agentResult.output) {
|
|
403
|
-
const verdict = parseQaVerdict(agentResult.output);
|
|
404
|
-
const summary = parseQaSummary(agentResult.output) ?? undefined;
|
|
405
|
-
if (verdict &&
|
|
406
|
-
verdict !== "READY_FOR_MERGE" &&
|
|
407
|
-
verdict !== "NEEDS_VERIFICATION") {
|
|
408
|
-
return {
|
|
409
|
-
phase,
|
|
410
|
-
success: false,
|
|
411
|
-
durationSeconds,
|
|
412
|
-
error: `QA verdict: ${verdict}`,
|
|
413
|
-
sessionId: agentResult.sessionId,
|
|
414
|
-
output: agentResult.output,
|
|
415
|
-
verdict,
|
|
416
|
-
summary,
|
|
417
|
-
...tails,
|
|
418
|
-
};
|
|
419
|
-
}
|
|
420
|
-
return {
|
|
421
|
-
phase,
|
|
422
|
-
success: true,
|
|
423
|
-
durationSeconds,
|
|
424
|
-
sessionId: agentResult.sessionId,
|
|
425
|
-
output: agentResult.output,
|
|
426
|
-
verdict: verdict ?? undefined,
|
|
427
|
-
summary,
|
|
428
|
-
...tails,
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
return {
|
|
432
|
-
phase,
|
|
433
|
-
success: true,
|
|
434
|
-
durationSeconds,
|
|
435
|
-
sessionId: agentResult.sessionId,
|
|
436
|
-
output: agentResult.output,
|
|
437
|
-
...tails,
|
|
438
|
-
};
|
|
660
|
+
return mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds, cwd);
|
|
439
661
|
}
|
|
440
662
|
return {
|
|
441
663
|
phase,
|
|
@@ -443,7 +665,9 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
443
665
|
durationSeconds,
|
|
444
666
|
error: agentResult.error,
|
|
445
667
|
sessionId: agentResult.sessionId,
|
|
446
|
-
|
|
668
|
+
stderrTail: agentResult.stderrTail,
|
|
669
|
+
stdoutTail: agentResult.stdoutTail,
|
|
670
|
+
exitCode: agentResult.exitCode,
|
|
447
671
|
};
|
|
448
672
|
}
|
|
449
673
|
/**
|
|
@@ -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
|
|
24
|
+
* Bug-related labels (used by downstream metadata consumers)
|
|
24
25
|
*/
|
|
25
26
|
export declare const BUG_LABELS: string[];
|
|
26
27
|
/**
|
|
27
|
-
* Documentation labels
|
|
28
|
+
* Documentation labels (used for issueType propagation and downstream metadata)
|
|
28
29
|
*/
|
|
29
30
|
export declare const DOCS_LABELS: string[];
|
|
30
31
|
/**
|
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export const UI_LABELS = ["ui", "frontend", "admin", "web", "browser"];
|
|
14
14
|
/**
|
|
15
|
-
* Bug-related labels
|
|
15
|
+
* Bug-related labels (used by downstream metadata consumers)
|
|
16
16
|
*/
|
|
17
17
|
export const BUG_LABELS = ["bug", "fix", "hotfix", "patch"];
|
|
18
18
|
/**
|
|
19
|
-
* Documentation labels
|
|
19
|
+
* Documentation labels (used for issueType propagation and downstream metadata)
|
|
20
20
|
*/
|
|
21
21
|
export const DOCS_LABELS = ["docs", "documentation", "readme"];
|
|
22
22
|
/**
|
|
@@ -38,30 +38,19 @@ export const SECURITY_LABELS = [
|
|
|
38
38
|
*/
|
|
39
39
|
export function detectPhasesFromLabels(labels) {
|
|
40
40
|
const lowerLabels = labels.map((l) => l.toLowerCase());
|
|
41
|
-
// Check for bug/fix labels → exec → qa (skip spec)
|
|
42
|
-
const isBugFix = lowerLabels.some((label) => BUG_LABELS.some((bugLabel) => label === bugLabel));
|
|
43
|
-
// Check for docs labels → exec → qa (skip spec)
|
|
44
|
-
const isDocs = lowerLabels.some((label) => DOCS_LABELS.some((docsLabel) => label === docsLabel));
|
|
45
41
|
// Check for UI labels → add test phase
|
|
46
42
|
const isUI = lowerLabels.some((label) => UI_LABELS.some((uiLabel) => label === uiLabel));
|
|
47
43
|
// Check for complex labels → enable quality loop
|
|
48
44
|
const isComplex = lowerLabels.some((label) => COMPLEX_LABELS.some((complexLabel) => label === complexLabel));
|
|
49
45
|
// Check for security labels → add security-review phase
|
|
50
46
|
const isSecurity = lowerLabels.some((label) => SECURITY_LABELS.some((secLabel) => label === secLabel));
|
|
51
|
-
// Build phase list
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// UI workflow: spec → exec → test → qa
|
|
59
|
-
phases = ["spec", "exec", "test", "qa"];
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
// Standard workflow: spec → exec → qa
|
|
63
|
-
phases = ["spec", "exec", "qa"];
|
|
64
|
-
}
|
|
47
|
+
// Build phase list — spec is always included by default (#533).
|
|
48
|
+
// Bug/docs labels no longer short-circuit spec; downstream consumers
|
|
49
|
+
// (e.g. `issueType: "docs"` propagation) still use DOCS_LABELS for
|
|
50
|
+
// metadata purposes, not for phase selection.
|
|
51
|
+
const phases = isUI
|
|
52
|
+
? ["spec", "exec", "test", "qa"]
|
|
53
|
+
: ["spec", "exec", "qa"];
|
|
65
54
|
// Add security-review phase after spec if security labels detected
|
|
66
55
|
if (isSecurity && phases.includes("spec")) {
|
|
67
56
|
const specIndex = phases.indexOf("spec");
|
|
@@ -132,6 +121,14 @@ export function determinePhasesForIssue(basePhases, labels, options) {
|
|
|
132
121
|
phases.splice(specIndex + 1, 0, "testgen");
|
|
133
122
|
}
|
|
134
123
|
}
|
|
124
|
+
// Add security-review phase after spec if requested.
|
|
125
|
+
// Idempotent vs label-based auto-detection in detectPhasesFromLabels.
|
|
126
|
+
if (options.securityReview && phases.includes("spec")) {
|
|
127
|
+
const specIndex = phases.indexOf("spec");
|
|
128
|
+
if (!phases.includes("security-review")) {
|
|
129
|
+
phases.splice(specIndex + 1, 0, "security-review");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
135
132
|
// Auto-detect UI issues and add test phase
|
|
136
133
|
if (hasUILabels(labels) && !phases.includes("test")) {
|
|
137
134
|
// Add test phase before qa if present, otherwise at the end
|
|
@@ -94,7 +94,7 @@ export declare class GitHubProvider implements PlatformProvider {
|
|
|
94
94
|
* Create a PR via `gh pr create` CLI, returning raw result.
|
|
95
95
|
* Used by worktree-manager.ts which needs access to stdout for URL extraction.
|
|
96
96
|
*/
|
|
97
|
-
createPRCliSync(title: string, body: string, head: string, cwd?: string): CreatePRCliResult;
|
|
97
|
+
createPRCliSync(title: string, body: string, head: string, cwd?: string, base?: string): CreatePRCliResult;
|
|
98
98
|
/**
|
|
99
99
|
* Batch fetch issue and PR status in a single GraphQL call.
|
|
100
100
|
* Returns a map keyed by issue/PR number.
|
|
@@ -137,8 +137,25 @@ export class GitHubProvider {
|
|
|
137
137
|
* Create a PR via `gh pr create` CLI, returning raw result.
|
|
138
138
|
* Used by worktree-manager.ts which needs access to stdout for URL extraction.
|
|
139
139
|
*/
|
|
140
|
-
createPRCliSync(title, body, head, cwd) {
|
|
141
|
-
const
|
|
140
|
+
createPRCliSync(title, body, head, cwd, base) {
|
|
141
|
+
const args = [
|
|
142
|
+
"pr",
|
|
143
|
+
"create",
|
|
144
|
+
"--title",
|
|
145
|
+
title,
|
|
146
|
+
"--body",
|
|
147
|
+
body,
|
|
148
|
+
"--head",
|
|
149
|
+
head,
|
|
150
|
+
];
|
|
151
|
+
if (base) {
|
|
152
|
+
args.push("--base", base);
|
|
153
|
+
}
|
|
154
|
+
const result = spawnSync("gh", args, {
|
|
155
|
+
stdio: "pipe",
|
|
156
|
+
cwd,
|
|
157
|
+
timeout: 30000,
|
|
158
|
+
});
|
|
142
159
|
return {
|
|
143
160
|
stdout: result.stdout?.toString() ?? "",
|
|
144
161
|
stderr: result.stderr?.toString() ?? "",
|
|
@@ -415,7 +432,7 @@ export class GitHubProvider {
|
|
|
415
432
|
});
|
|
416
433
|
}
|
|
417
434
|
async createPR(opts) {
|
|
418
|
-
const result = this.createPRCliSync(opts.title, opts.body, opts.head);
|
|
435
|
+
const result = this.createPRCliSync(opts.title, opts.body, opts.head, undefined, opts.base);
|
|
419
436
|
if (result.exitCode !== 0) {
|
|
420
437
|
const error = result.stderr.trim() || "Unknown error";
|
|
421
438
|
throw new Error(`gh pr create failed: ${error}`);
|
|
@@ -28,16 +28,32 @@ export declare function checkPRMergeStatus(prNumber: number): PRMergeStatus;
|
|
|
28
28
|
/**
|
|
29
29
|
* Check if a branch has been merged into a base branch using git
|
|
30
30
|
*
|
|
31
|
+
* "Merged" here means the branch was the source of an actual merge commit on
|
|
32
|
+
* the base branch — i.e., the branch tip appears as a non-first parent of some
|
|
33
|
+
* merge commit reachable from baseBranch. This deliberately excludes the case
|
|
34
|
+
* where the branch tip is just an ancestor of baseBranch with no commits ever
|
|
35
|
+
* added (e.g., a worktree branch created from main that was abandoned before
|
|
36
|
+
* any commits were made). Those branches are reachable from main but were
|
|
37
|
+
* never merged in any meaningful sense; the older `git branch --merged` check
|
|
38
|
+
* misclassified them as merged and caused subsequent runs to skip the still-
|
|
39
|
+
* open issue.
|
|
40
|
+
*
|
|
41
|
+
* Squash-merged branches do not satisfy this check (their tip is not on main
|
|
42
|
+
* after squash) — callers that need to detect squash merges should rely on
|
|
43
|
+
* commit-message detection (see {@link isIssueMergedIntoMain}'s `--grep` path)
|
|
44
|
+
* or a PR API check.
|
|
45
|
+
*
|
|
31
46
|
* @param branchName - The branch name to check (e.g., "feature/33-some-title")
|
|
32
47
|
* @param baseBranch - The base branch to check against (default: "main")
|
|
33
|
-
* @returns true if
|
|
48
|
+
* @returns true if a merge commit on baseBranch records branchName's tip as a
|
|
49
|
+
* non-first parent, false otherwise
|
|
34
50
|
*/
|
|
35
51
|
export declare function isBranchMergedIntoMain(branchName: string, baseBranch?: string): boolean;
|
|
36
52
|
/**
|
|
37
53
|
* Check if a feature branch for an issue is merged into a base branch
|
|
38
54
|
*
|
|
39
55
|
* Tries multiple detection methods:
|
|
40
|
-
* 1.
|
|
56
|
+
* 1. Find `feature/<N>-*` branches with `git branch -a` and check via {@link isBranchMergedIntoMain}
|
|
41
57
|
* 2. Check for merge commits mentioning the issue
|
|
42
58
|
*
|
|
43
59
|
* @param issueNumber - The issue number to check
|
|
@@ -31,22 +31,54 @@ export function checkPRMergeStatus(prNumber) {
|
|
|
31
31
|
/**
|
|
32
32
|
* Check if a branch has been merged into a base branch using git
|
|
33
33
|
*
|
|
34
|
+
* "Merged" here means the branch was the source of an actual merge commit on
|
|
35
|
+
* the base branch — i.e., the branch tip appears as a non-first parent of some
|
|
36
|
+
* merge commit reachable from baseBranch. This deliberately excludes the case
|
|
37
|
+
* where the branch tip is just an ancestor of baseBranch with no commits ever
|
|
38
|
+
* added (e.g., a worktree branch created from main that was abandoned before
|
|
39
|
+
* any commits were made). Those branches are reachable from main but were
|
|
40
|
+
* never merged in any meaningful sense; the older `git branch --merged` check
|
|
41
|
+
* misclassified them as merged and caused subsequent runs to skip the still-
|
|
42
|
+
* open issue.
|
|
43
|
+
*
|
|
44
|
+
* Squash-merged branches do not satisfy this check (their tip is not on main
|
|
45
|
+
* after squash) — callers that need to detect squash merges should rely on
|
|
46
|
+
* commit-message detection (see {@link isIssueMergedIntoMain}'s `--grep` path)
|
|
47
|
+
* or a PR API check.
|
|
48
|
+
*
|
|
34
49
|
* @param branchName - The branch name to check (e.g., "feature/33-some-title")
|
|
35
50
|
* @param baseBranch - The base branch to check against (default: "main")
|
|
36
|
-
* @returns true if
|
|
51
|
+
* @returns true if a merge commit on baseBranch records branchName's tip as a
|
|
52
|
+
* non-first parent, false otherwise
|
|
37
53
|
*/
|
|
38
54
|
export function isBranchMergedIntoMain(branchName, baseBranch = "main") {
|
|
39
55
|
try {
|
|
40
|
-
//
|
|
41
|
-
|
|
56
|
+
// Resolve the branch tip SHA. If the branch can't be resolved (deleted,
|
|
57
|
+
// typo'd, etc.), it can't be "merged" by any definition.
|
|
58
|
+
const tipResult = spawnSync("git", ["rev-parse", branchName], {
|
|
42
59
|
stdio: "pipe",
|
|
43
60
|
timeout: 10000,
|
|
44
61
|
});
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
if (tipResult.status !== 0)
|
|
63
|
+
return false;
|
|
64
|
+
const branchTip = tipResult.stdout.toString().trim();
|
|
65
|
+
if (!branchTip)
|
|
66
|
+
return false;
|
|
67
|
+
// Walk recent merge commits on baseBranch and check whether any records
|
|
68
|
+
// the branch tip as a non-first parent. The first parent of a merge
|
|
69
|
+
// commit is the prior tip of baseBranch; non-first parents are the
|
|
70
|
+
// sources being merged in.
|
|
71
|
+
const mergesResult = spawnSync("git", ["rev-list", "--merges", "--parents", "-200", baseBranch], { stdio: "pipe", timeout: 10000 });
|
|
72
|
+
if (mergesResult.status === 0 && mergesResult.stdout) {
|
|
73
|
+
const lines = mergesResult.stdout.toString().trim().split("\n");
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (!line)
|
|
76
|
+
continue;
|
|
77
|
+
const parts = line.split(" ");
|
|
78
|
+
if (parts.length > 2 && parts.slice(2).includes(branchTip)) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
50
82
|
}
|
|
51
83
|
}
|
|
52
84
|
catch {
|
|
@@ -58,7 +90,7 @@ export function isBranchMergedIntoMain(branchName, baseBranch = "main") {
|
|
|
58
90
|
* Check if a feature branch for an issue is merged into a base branch
|
|
59
91
|
*
|
|
60
92
|
* Tries multiple detection methods:
|
|
61
|
-
* 1.
|
|
93
|
+
* 1. Find `feature/<N>-*` branches with `git branch -a` and check via {@link isBranchMergedIntoMain}
|
|
62
94
|
* 2. Check for merge commits mentioning the issue
|
|
63
95
|
*
|
|
64
96
|
* @param issueNumber - The issue number to check
|