sequant 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +8 -5
- package/dist/bin/cli.js +46 -4
- package/dist/src/commands/abort.d.ts +36 -0
- package/dist/src/commands/abort.js +138 -0
- package/dist/src/commands/prompt.d.ts +7 -0
- package/dist/src/commands/prompt.js +101 -7
- package/dist/src/commands/run-progress.d.ts +11 -1
- package/dist/src/commands/run-progress.js +20 -3
- package/dist/src/commands/run.js +12 -2
- package/dist/src/commands/watch.d.ts +2 -0
- package/dist/src/commands/watch.js +67 -3
- package/dist/src/lib/assess-collision-detect.js +1 -1
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +39 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +27 -1
- package/dist/src/lib/cli-ui/run-renderer.js +231 -14
- package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
- package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
- package/dist/src/lib/merge-check/types.js +1 -1
- package/dist/src/lib/relay/archive.js +6 -0
- package/dist/src/lib/relay/types.d.ts +2 -0
- package/dist/src/lib/relay/types.js +9 -0
- package/dist/src/lib/workflow/batch-executor.js +34 -18
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
- package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
- package/dist/src/lib/workflow/drivers/aider.js +9 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
- package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
- package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
- package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
- package/dist/src/lib/workflow/event-emitter.js +102 -0
- package/dist/src/lib/workflow/notice.d.ts +32 -0
- package/dist/src/lib/workflow/notice.js +38 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +9 -21
- package/dist/src/lib/workflow/phase-executor.js +88 -115
- package/dist/src/lib/workflow/phase-mapper.d.ts +26 -13
- package/dist/src/lib/workflow/phase-mapper.js +55 -33
- package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
- package/dist/src/lib/workflow/phase-registry.js +233 -0
- package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
- package/dist/src/lib/workflow/run-orchestrator.d.ts +32 -2
- package/dist/src/lib/workflow/run-orchestrator.js +125 -11
- package/dist/src/lib/workflow/state-manager.d.ts +19 -1
- package/dist/src/lib/workflow/state-manager.js +27 -1
- package/dist/src/lib/workflow/state-schema.d.ts +20 -35
- package/dist/src/lib/workflow/state-schema.js +28 -3
- package/dist/src/lib/workflow/types.d.ts +65 -15
- package/dist/src/lib/workflow/types.js +18 -13
- package/package.json +5 -4
- package/templates/hooks/post-tool.sh +81 -0
- package/templates/skills/assess/SKILL.md +28 -28
- package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
- package/templates/skills/setup/SKILL.md +6 -6
|
@@ -13,77 +13,21 @@ import { readAgentsMd } from "../agents-md.js";
|
|
|
13
13
|
import { getDriver } from "./drivers/index.js";
|
|
14
14
|
import { classifyError } from "./error-classifier.js";
|
|
15
15
|
import { ApiError } from "../errors.js";
|
|
16
|
+
import { phaseRegistry } from "./phase-registry.js";
|
|
17
|
+
import { bracketedConsoleLog } from "./notice.js";
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"qa",
|
|
85
|
-
"loop",
|
|
86
|
-
];
|
|
26
|
+
function phaseRequiresWorktree(phase) {
|
|
27
|
+
return phaseRegistry.has(phase)
|
|
28
|
+
? phaseRegistry.get(phase).requiresWorktree
|
|
29
|
+
: false;
|
|
30
|
+
}
|
|
87
31
|
/**
|
|
88
32
|
* Cold-start retry threshold in seconds.
|
|
89
33
|
* Failures under this duration are likely Claude Code subprocess initialization
|
|
@@ -137,15 +81,23 @@ export function createThrottledReporter(fn, intervalMs) {
|
|
|
137
81
|
return { report, cancel };
|
|
138
82
|
}
|
|
139
83
|
/**
|
|
140
|
-
* Spec-specific retry configuration.
|
|
84
|
+
* Spec-specific retry configuration. Sourced from the phase registry's
|
|
85
|
+
* `retryStrategy` field — `phase-registry.ts` is the source of truth.
|
|
86
|
+
*
|
|
141
87
|
* Spec failures have a higher failure rate (~8.6%) than other phases due to
|
|
142
88
|
* transient GitHub API issues and rate limits. One extra retry with backoff
|
|
143
89
|
* recovers most of these without user intervention.
|
|
90
|
+
*
|
|
91
|
+
* Fallback literals (5000 / 1) match the legacy hardcoded values and only
|
|
92
|
+
* fire if the spec registration is removed or its `retryStrategy` is unset,
|
|
93
|
+
* which would be a misconfiguration. Tests pin these at 5000 / 1, so any
|
|
94
|
+
* drift surfaces immediately.
|
|
144
95
|
*/
|
|
96
|
+
const SPEC_RETRY_STRATEGY = phaseRegistry.get("spec").retryStrategy;
|
|
145
97
|
/** @internal Exported for testing only */
|
|
146
|
-
export const SPEC_RETRY_BACKOFF_MS = 5000;
|
|
98
|
+
export const SPEC_RETRY_BACKOFF_MS = SPEC_RETRY_STRATEGY?.backoffMs ?? 5000;
|
|
147
99
|
/** @internal Exported for testing only */
|
|
148
|
-
export const SPEC_EXTRA_RETRIES = 1;
|
|
100
|
+
export const SPEC_EXTRA_RETRIES = SPEC_RETRY_STRATEGY?.extraRetries ?? 1;
|
|
149
101
|
export function parseQaVerdict(output) {
|
|
150
102
|
if (!output)
|
|
151
103
|
return null;
|
|
@@ -377,6 +329,10 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
377
329
|
stdoutTail: agentResult.stdoutTail,
|
|
378
330
|
exitCode: agentResult.exitCode,
|
|
379
331
|
};
|
|
332
|
+
const resume = {
|
|
333
|
+
sessionId: agentResult.sessionId,
|
|
334
|
+
resumeHandle: agentResult.resumeHandle,
|
|
335
|
+
};
|
|
380
336
|
if (phase === "qa") {
|
|
381
337
|
const verdict = agentResult.output
|
|
382
338
|
? parseQaVerdict(agentResult.output)
|
|
@@ -392,7 +348,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
392
348
|
success: false,
|
|
393
349
|
durationSeconds,
|
|
394
350
|
error: `QA verdict: ${verdict}`,
|
|
395
|
-
|
|
351
|
+
...resume,
|
|
396
352
|
output: agentResult.output,
|
|
397
353
|
verdict,
|
|
398
354
|
summary,
|
|
@@ -406,7 +362,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
406
362
|
success: false,
|
|
407
363
|
durationSeconds,
|
|
408
364
|
error: "QA completed without a parseable verdict",
|
|
409
|
-
|
|
365
|
+
...resume,
|
|
410
366
|
output: agentResult.output,
|
|
411
367
|
summary,
|
|
412
368
|
...tails,
|
|
@@ -416,7 +372,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
416
372
|
phase,
|
|
417
373
|
success: true,
|
|
418
374
|
durationSeconds,
|
|
419
|
-
|
|
375
|
+
...resume,
|
|
420
376
|
output: agentResult.output,
|
|
421
377
|
verdict,
|
|
422
378
|
summary,
|
|
@@ -430,7 +386,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
430
386
|
success: false,
|
|
431
387
|
durationSeconds,
|
|
432
388
|
error: "exec produced no changes (no commits, no uncommitted work)",
|
|
433
|
-
|
|
389
|
+
...resume,
|
|
434
390
|
output: agentResult.output,
|
|
435
391
|
...tails,
|
|
436
392
|
};
|
|
@@ -439,7 +395,7 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
439
395
|
phase,
|
|
440
396
|
success: true,
|
|
441
397
|
durationSeconds,
|
|
442
|
-
|
|
398
|
+
...resume,
|
|
443
399
|
output: agentResult.output,
|
|
444
400
|
...tails,
|
|
445
401
|
};
|
|
@@ -453,8 +409,14 @@ export function mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds
|
|
|
453
409
|
* @internal Exported for testing only
|
|
454
410
|
*/
|
|
455
411
|
export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
|
|
456
|
-
const
|
|
457
|
-
|
|
412
|
+
const definition = phaseRegistry.get(phase);
|
|
413
|
+
// Non-claude drivers consult driverOverrides[<driver>] first; fall back to
|
|
414
|
+
// the default promptTemplate when no override is registered for the driver.
|
|
415
|
+
const driverPrompt = agent && agent !== "claude-code"
|
|
416
|
+
? definition.driverOverrides?.[agent]?.promptTemplate
|
|
417
|
+
: undefined;
|
|
418
|
+
const template = driverPrompt ?? definition.promptTemplate;
|
|
419
|
+
let basePrompt = template.replace(/\{issue\}/g, String(issueNumber));
|
|
458
420
|
// Append phase-specific context (e.g., QA findings for loop phase)
|
|
459
421
|
if (promptContext) {
|
|
460
422
|
basePrompt += `\n\n---\n\n${promptContext}`;
|
|
@@ -471,14 +433,14 @@ export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
|
|
|
471
433
|
/**
|
|
472
434
|
* Execute a single phase for an issue using the configured AgentDriver.
|
|
473
435
|
*/
|
|
474
|
-
async function executePhase(issueNumber, phase, config,
|
|
436
|
+
async function executePhase(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner) {
|
|
475
437
|
const startTime = Date.now();
|
|
476
438
|
const prompt = await getPhasePrompt(phase, issueNumber, config.agent, config.promptContext);
|
|
477
439
|
if (config.dryRun) {
|
|
478
440
|
// Dry run - show the prompt that would be sent, then return
|
|
479
441
|
if (config.verbose) {
|
|
480
|
-
|
|
481
|
-
|
|
442
|
+
bracketedConsoleLog(spinner, chalk.gray(` Would execute: /${phase} ${issueNumber}`));
|
|
443
|
+
bracketedConsoleLog(spinner, chalk.gray(` Prompt: ${prompt}`));
|
|
482
444
|
}
|
|
483
445
|
return {
|
|
484
446
|
phase,
|
|
@@ -488,13 +450,13 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
488
450
|
};
|
|
489
451
|
}
|
|
490
452
|
if (config.verbose) {
|
|
491
|
-
|
|
492
|
-
if (worktreePath &&
|
|
493
|
-
|
|
453
|
+
bracketedConsoleLog(spinner, chalk.gray(` Prompt: ${prompt}`));
|
|
454
|
+
if (worktreePath && phaseRequiresWorktree(phase)) {
|
|
455
|
+
bracketedConsoleLog(spinner, chalk.gray(` Worktree: ${worktreePath}`));
|
|
494
456
|
}
|
|
495
457
|
}
|
|
496
458
|
// Determine working directory and environment
|
|
497
|
-
const shouldUseWorktree = worktreePath &&
|
|
459
|
+
const shouldUseWorktree = worktreePath && phaseRequiresWorktree(phase);
|
|
498
460
|
const cwd = shouldUseWorktree ? worktreePath : process.cwd();
|
|
499
461
|
// Resolve file context for file-oriented drivers (e.g., Aider --file)
|
|
500
462
|
let files;
|
|
@@ -599,12 +561,19 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
599
561
|
}, ACTIVITY_THROTTLE_MS)
|
|
600
562
|
: undefined;
|
|
601
563
|
const reportActivity = throttle ? throttle.report : undefined;
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
564
|
+
// Resolve driver before the resume check — eligibility is now driver-owned
|
|
565
|
+
// (#674). Each driver's `canResume(handle, cwd)` enforces its own contract:
|
|
566
|
+
// Claude Code requires byte-equal cwd match (session storage is
|
|
567
|
+
// cwd-namespaced); Aider declines all resume (no session concept); Codex
|
|
568
|
+
// (when added in #497) folds in AGENTS.md parity. Replacing the prior
|
|
569
|
+
// `sessionId && !worktreePath` heuristic also unblocks same-worktree resume
|
|
570
|
+
// across phases.
|
|
571
|
+
const driver = getDriver(config.agent, {
|
|
572
|
+
aiderSettings: config.aiderSettings,
|
|
573
|
+
});
|
|
574
|
+
const eligibleHandle = resumeHandle && driver.canResume(resumeHandle, cwd)
|
|
575
|
+
? resumeHandle
|
|
576
|
+
: undefined;
|
|
608
577
|
// Build AgentExecutionConfig for the driver
|
|
609
578
|
const agentConfig = {
|
|
610
579
|
cwd,
|
|
@@ -613,7 +582,8 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
613
582
|
phaseTimeout: config.phaseTimeout,
|
|
614
583
|
verbose: config.verbose,
|
|
615
584
|
mcp: config.mcp,
|
|
616
|
-
|
|
585
|
+
resumeHandle: eligibleHandle,
|
|
586
|
+
sessionId: eligibleHandle?.token,
|
|
617
587
|
files,
|
|
618
588
|
onOutput: config.verbose || reportActivity
|
|
619
589
|
? (text) => {
|
|
@@ -622,6 +592,7 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
622
592
|
spinner?.pause();
|
|
623
593
|
verboseStreamingActive = true;
|
|
624
594
|
}
|
|
595
|
+
// eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
|
|
625
596
|
process.stdout.write(chalk.gray(text));
|
|
626
597
|
}
|
|
627
598
|
reportActivity?.(text);
|
|
@@ -633,14 +604,11 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
633
604
|
spinner?.pause();
|
|
634
605
|
verboseStreamingActive = true;
|
|
635
606
|
}
|
|
607
|
+
// eslint-disable-next-line no-restricted-syntax -- spinner is paused above; verbose subprocess streaming bypasses log-update intentionally.
|
|
636
608
|
process.stderr.write(chalk.red(data));
|
|
637
609
|
}
|
|
638
610
|
: undefined,
|
|
639
611
|
};
|
|
640
|
-
// Resolve driver from config or default
|
|
641
|
-
const driver = getDriver(config.agent, {
|
|
642
|
-
aiderSettings: config.aiderSettings,
|
|
643
|
-
});
|
|
644
612
|
const agentResult = await driver.executePhase(prompt, agentConfig);
|
|
645
613
|
// Cancel any pending trailing activity fire — phase is done; the
|
|
646
614
|
// orchestrator's stale-phase guard would no-op a late call anyway, but
|
|
@@ -665,6 +633,7 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
665
633
|
durationSeconds,
|
|
666
634
|
error: agentResult.error,
|
|
667
635
|
sessionId: agentResult.sessionId,
|
|
636
|
+
resumeHandle: agentResult.resumeHandle,
|
|
668
637
|
stderrTail: agentResult.stderrTail,
|
|
669
638
|
stdoutTail: agentResult.stdoutTail,
|
|
670
639
|
exitCode: agentResult.exitCode,
|
|
@@ -684,24 +653,28 @@ async function executePhase(issueNumber, phase, config, sessionId, worktreePath,
|
|
|
684
653
|
/**
|
|
685
654
|
* @internal Exported for testing only
|
|
686
655
|
*/
|
|
687
|
-
export async function executePhaseWithRetry(issueNumber, phase, config,
|
|
656
|
+
export async function executePhaseWithRetry(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner,
|
|
688
657
|
/** @internal Injected for testing — defaults to module-level executePhase */
|
|
689
658
|
executePhaseFn = executePhase,
|
|
690
659
|
/** @internal Injected for testing — defaults to setTimeout-based delay */
|
|
691
660
|
delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
692
661
|
// Skip retry logic if explicitly disabled
|
|
693
662
|
if (config.retry === false) {
|
|
694
|
-
return executePhaseFn(issueNumber, phase, config,
|
|
695
|
-
}
|
|
696
|
-
// Skip cold-start retries for
|
|
697
|
-
//
|
|
698
|
-
// Failures at 47-51s are genuine skill failures, not cold-start
|
|
699
|
-
// Without this guard, 2 cold-start retries + 1 MCP fallback = 3 wasted
|
|
700
|
-
|
|
663
|
+
return executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
664
|
+
}
|
|
665
|
+
// Skip cold-start retries for phases registered with `retryStrategy.maxRetries: 0`.
|
|
666
|
+
// `loop` is the canonical user (#488) — it's always a re-run after a failed QA,
|
|
667
|
+
// never a first boot. Failures at 47-51s are genuine skill failures, not cold-start
|
|
668
|
+
// issues. Without this guard, 2 cold-start retries + 1 MCP fallback = 3 wasted
|
|
669
|
+
// spawns per loop. Sourcing the decision from the registry makes the rule
|
|
670
|
+
// data-driven — any future phase registered with `maxRetries: 0` inherits the
|
|
671
|
+
// same behavior without a code change here.
|
|
672
|
+
const skipColdStartRetry = phaseRegistry.has(phase) &&
|
|
673
|
+
phaseRegistry.get(phase).retryStrategy?.maxRetries === 0;
|
|
701
674
|
let lastResult;
|
|
702
675
|
if (skipColdStartRetry) {
|
|
703
676
|
// Single attempt — no cold-start retry loop
|
|
704
|
-
lastResult = await executePhaseFn(issueNumber, phase, config,
|
|
677
|
+
lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
705
678
|
if (lastResult.success) {
|
|
706
679
|
return lastResult;
|
|
707
680
|
}
|
|
@@ -709,7 +682,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
709
682
|
else {
|
|
710
683
|
// Phase 1: Cold-start retry attempts (with MCP enabled if configured)
|
|
711
684
|
for (let attempt = 0; attempt <= COLD_START_MAX_RETRIES; attempt++) {
|
|
712
|
-
lastResult = await executePhaseFn(issueNumber, phase, config,
|
|
685
|
+
lastResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
713
686
|
const duration = lastResult.durationSeconds ?? 0;
|
|
714
687
|
// Success → return immediately
|
|
715
688
|
if (lastResult.success) {
|
|
@@ -725,7 +698,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
725
698
|
const label = typedError instanceof ApiError
|
|
726
699
|
? `API error (status ${typedError.metadata.statusCode ?? "unknown"})`
|
|
727
700
|
: typedError.name;
|
|
728
|
-
|
|
701
|
+
bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Retryable error: ${label}, retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
|
|
729
702
|
}
|
|
730
703
|
continue;
|
|
731
704
|
}
|
|
@@ -737,7 +710,7 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
737
710
|
// Cold-start failure detected — retry
|
|
738
711
|
if (attempt < COLD_START_MAX_RETRIES) {
|
|
739
712
|
if (config.verbose) {
|
|
740
|
-
|
|
713
|
+
bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Cold-start failure detected (${duration.toFixed(1)}s), retrying... (attempt ${attempt + 2}/${COLD_START_MAX_RETRIES + 1})`));
|
|
741
714
|
}
|
|
742
715
|
}
|
|
743
716
|
}
|
|
@@ -748,15 +721,15 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
748
721
|
// This handles npx-based MCP servers that fail on first run due to cold-cache issues.
|
|
749
722
|
// Skip for `loop` phase — MCP is never the cause of loop failures (#488).
|
|
750
723
|
if (config.mcp && !lastResult.success && !skipColdStartRetry) {
|
|
751
|
-
|
|
724
|
+
bracketedConsoleLog(spinner, chalk.yellow(`\n ! Phase failed with MCP enabled, retrying without MCP...`));
|
|
752
725
|
// Create config copy with MCP disabled
|
|
753
726
|
const configWithoutMcp = {
|
|
754
727
|
...config,
|
|
755
728
|
mcp: false,
|
|
756
729
|
};
|
|
757
|
-
const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp,
|
|
730
|
+
const retryResult = await executePhaseFn(issueNumber, phase, configWithoutMcp, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
758
731
|
if (retryResult.success) {
|
|
759
|
-
|
|
732
|
+
bracketedConsoleLog(spinner, chalk.green(` ✓ Phase succeeded without MCP (MCP cold-start issue detected)`));
|
|
760
733
|
return retryResult;
|
|
761
734
|
}
|
|
762
735
|
// Update lastResult for Phase 3 (spec retry)
|
|
@@ -773,11 +746,11 @@ delayFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms))) {
|
|
|
773
746
|
// than other phases (~8.6%), so one extra retry with backoff recovers most cases.
|
|
774
747
|
if (phase === "spec" && !lastResult.success) {
|
|
775
748
|
for (let i = 0; i < SPEC_EXTRA_RETRIES; i++) {
|
|
776
|
-
|
|
749
|
+
bracketedConsoleLog(spinner, chalk.yellow(`\n ⟳ Spec phase failed, retrying with ${SPEC_RETRY_BACKOFF_MS}ms backoff... (spec retry ${i + 1}/${SPEC_EXTRA_RETRIES})`));
|
|
777
750
|
await delayFn(SPEC_RETRY_BACKOFF_MS);
|
|
778
|
-
const specRetryResult = await executePhaseFn(issueNumber, phase, config,
|
|
751
|
+
const specRetryResult = await executePhaseFn(issueNumber, phase, config, resumeHandle, worktreePath, shutdownManager, spinner);
|
|
779
752
|
if (specRetryResult.success) {
|
|
780
|
-
|
|
753
|
+
bracketedConsoleLog(spinner, chalk.green(` ✓ Spec phase succeeded on retry`));
|
|
781
754
|
return specRetryResult;
|
|
782
755
|
}
|
|
783
756
|
lastResult = specRetryResult;
|
|
@@ -17,27 +17,35 @@ interface PhaseMapperOptions {
|
|
|
17
17
|
securityReview?: boolean;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*
|
|
20
|
+
* Bug-related labels (used by downstream metadata consumers).
|
|
21
|
+
*
|
|
22
|
+
* Issue-type metadata — NOT phase-trigger rules. The registry-driven
|
|
23
|
+
* `detectPhasesFromLabels` below does not consult this list. It stays
|
|
24
|
+
* here because `batch-executor.ts` and other modules read it for
|
|
25
|
+
* `issueType` propagation and similar non-phase concerns.
|
|
25
26
|
*/
|
|
26
27
|
export declare const BUG_LABELS: string[];
|
|
27
28
|
/**
|
|
28
|
-
* Documentation labels (used for issueType propagation and downstream metadata)
|
|
29
|
+
* Documentation labels (used for issueType propagation and downstream metadata).
|
|
30
|
+
*
|
|
31
|
+
* Issue-type metadata — NOT phase-trigger rules. See BUG_LABELS comment.
|
|
29
32
|
*/
|
|
30
33
|
export declare const DOCS_LABELS: string[];
|
|
31
34
|
/**
|
|
32
|
-
* Complex labels that enable quality loop
|
|
35
|
+
* Complex labels that enable quality loop.
|
|
36
|
+
*
|
|
37
|
+
* Quality-loop trigger — NOT a phase-trigger rule (does not add the loop
|
|
38
|
+
* *phase*; only flips the `qualityLoop` flag on the run config). Kept
|
|
39
|
+
* out of the phase registry by design.
|
|
33
40
|
*/
|
|
34
41
|
export declare const COMPLEX_LABELS: string[];
|
|
35
42
|
/**
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
*
|
|
43
|
+
* Detect phases based on issue labels (like /assess logic).
|
|
44
|
+
*
|
|
45
|
+
* Label → phase mapping now lives in `PhaseDefinition.detect.labels`. Only
|
|
46
|
+
* the *insertion position* of detected phases remains baked in here, because
|
|
47
|
+
* pipeline ordering depends on the phase's role (security-review goes after
|
|
48
|
+
* spec; test goes before qa).
|
|
41
49
|
*/
|
|
42
50
|
export declare function detectPhasesFromLabels(labels: string[]): {
|
|
43
51
|
phases: Phase[];
|
|
@@ -56,7 +64,12 @@ export declare function parseRecommendedWorkflow(output: string): {
|
|
|
56
64
|
qualityLoop: boolean;
|
|
57
65
|
} | null;
|
|
58
66
|
/**
|
|
59
|
-
* Check if an issue has UI-related labels
|
|
67
|
+
* Check if an issue has UI-related labels.
|
|
68
|
+
*
|
|
69
|
+
* Sources the label list from the `test` phase's `detect.labels` entry in
|
|
70
|
+
* the registry — same data as `detectPhasesFromLabels` consults, just
|
|
71
|
+
* exposed as a boolean for callers that only need the yes/no answer
|
|
72
|
+
* (e.g. test phase insertion in `determinePhasesForIssue`).
|
|
60
73
|
*/
|
|
61
74
|
export declare function hasUILabels(labels: string[]): boolean;
|
|
62
75
|
/**
|
|
@@ -7,43 +7,62 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module phase-mapper
|
|
9
9
|
*/
|
|
10
|
+
import { phaseRegistry } from "./phase-registry.js";
|
|
10
11
|
/**
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
*
|
|
12
|
+
* Bug-related labels (used by downstream metadata consumers).
|
|
13
|
+
*
|
|
14
|
+
* Issue-type metadata — NOT phase-trigger rules. The registry-driven
|
|
15
|
+
* `detectPhasesFromLabels` below does not consult this list. It stays
|
|
16
|
+
* here because `batch-executor.ts` and other modules read it for
|
|
17
|
+
* `issueType` propagation and similar non-phase concerns.
|
|
16
18
|
*/
|
|
17
19
|
export const BUG_LABELS = ["bug", "fix", "hotfix", "patch"];
|
|
18
20
|
/**
|
|
19
|
-
* Documentation labels (used for issueType propagation and downstream metadata)
|
|
21
|
+
* Documentation labels (used for issueType propagation and downstream metadata).
|
|
22
|
+
*
|
|
23
|
+
* Issue-type metadata — NOT phase-trigger rules. See BUG_LABELS comment.
|
|
20
24
|
*/
|
|
21
25
|
export const DOCS_LABELS = ["docs", "documentation", "readme"];
|
|
22
26
|
/**
|
|
23
|
-
* Complex labels that enable quality loop
|
|
27
|
+
* Complex labels that enable quality loop.
|
|
28
|
+
*
|
|
29
|
+
* Quality-loop trigger — NOT a phase-trigger rule (does not add the loop
|
|
30
|
+
* *phase*; only flips the `qualityLoop` flag on the run config). Kept
|
|
31
|
+
* out of the phase registry by design.
|
|
24
32
|
*/
|
|
25
33
|
export const COMPLEX_LABELS = ["complex", "refactor", "breaking", "major"];
|
|
26
34
|
/**
|
|
27
|
-
*
|
|
35
|
+
* Look up label-based detect rules from the registry, returning the set
|
|
36
|
+
* of phases whose `detect.labels` intersect the issue's labels. Comparison
|
|
37
|
+
* is case-insensitive (labels lowercased at the call site).
|
|
28
38
|
*/
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
function detectPhasesFromRegistry(lowerLabels) {
|
|
40
|
+
const matched = new Set();
|
|
41
|
+
for (const def of phaseRegistry.list()) {
|
|
42
|
+
const triggers = def.detect?.labels;
|
|
43
|
+
if (!triggers || triggers.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
const hit = triggers.some((t) => lowerLabels.includes(t.toLowerCase()));
|
|
46
|
+
if (hit)
|
|
47
|
+
matched.add(def.name);
|
|
48
|
+
}
|
|
49
|
+
return matched;
|
|
50
|
+
}
|
|
36
51
|
/**
|
|
37
|
-
* Detect phases based on issue labels (like /assess logic)
|
|
52
|
+
* Detect phases based on issue labels (like /assess logic).
|
|
53
|
+
*
|
|
54
|
+
* Label → phase mapping now lives in `PhaseDefinition.detect.labels`. Only
|
|
55
|
+
* the *insertion position* of detected phases remains baked in here, because
|
|
56
|
+
* pipeline ordering depends on the phase's role (security-review goes after
|
|
57
|
+
* spec; test goes before qa).
|
|
38
58
|
*/
|
|
39
59
|
export function detectPhasesFromLabels(labels) {
|
|
40
60
|
const lowerLabels = labels.map((l) => l.toLowerCase());
|
|
41
|
-
//
|
|
42
|
-
const isUI = lowerLabels.some((label) => UI_LABELS.some((uiLabel) => label === uiLabel));
|
|
43
|
-
// Check for complex labels → enable quality loop
|
|
61
|
+
// Quality loop is a registry-independent label trigger (see COMPLEX_LABELS).
|
|
44
62
|
const isComplex = lowerLabels.some((label) => COMPLEX_LABELS.some((complexLabel) => label === complexLabel));
|
|
45
|
-
|
|
46
|
-
const
|
|
63
|
+
const matched = detectPhasesFromRegistry(lowerLabels);
|
|
64
|
+
const isUI = matched.has("test");
|
|
65
|
+
const isSecurity = matched.has("security-review");
|
|
47
66
|
// Build phase list — spec is always included by default (#533).
|
|
48
67
|
// Bug/docs labels no longer short-circuit spec; downstream consumers
|
|
49
68
|
// (e.g. `issueType: "docs"` propagation) still use DOCS_LABELS for
|
|
@@ -78,18 +97,10 @@ export function parseRecommendedWorkflow(output) {
|
|
|
78
97
|
.split(/\s*→\s*|\s*->\s*|\s*,\s*/)
|
|
79
98
|
.map((p) => p.trim().toLowerCase())
|
|
80
99
|
.filter((p) => p.length > 0);
|
|
81
|
-
// Validate
|
|
100
|
+
// Validate against the registry — accepts any registered phase.
|
|
82
101
|
const validPhases = [];
|
|
83
102
|
for (const name of phaseNames) {
|
|
84
|
-
if (
|
|
85
|
-
"spec",
|
|
86
|
-
"security-review",
|
|
87
|
-
"testgen",
|
|
88
|
-
"exec",
|
|
89
|
-
"test",
|
|
90
|
-
"qa",
|
|
91
|
-
"loop",
|
|
92
|
-
].includes(name)) {
|
|
103
|
+
if (phaseRegistry.has(name)) {
|
|
93
104
|
validPhases.push(name);
|
|
94
105
|
}
|
|
95
106
|
}
|
|
@@ -104,10 +115,21 @@ export function parseRecommendedWorkflow(output) {
|
|
|
104
115
|
return { phases: validPhases, qualityLoop };
|
|
105
116
|
}
|
|
106
117
|
/**
|
|
107
|
-
* Check if an issue has UI-related labels
|
|
118
|
+
* Check if an issue has UI-related labels.
|
|
119
|
+
*
|
|
120
|
+
* Sources the label list from the `test` phase's `detect.labels` entry in
|
|
121
|
+
* the registry — same data as `detectPhasesFromLabels` consults, just
|
|
122
|
+
* exposed as a boolean for callers that only need the yes/no answer
|
|
123
|
+
* (e.g. test phase insertion in `determinePhasesForIssue`).
|
|
108
124
|
*/
|
|
109
125
|
export function hasUILabels(labels) {
|
|
110
|
-
|
|
126
|
+
const testTriggers = phaseRegistry.has("test")
|
|
127
|
+
? (phaseRegistry.get("test").detect?.labels ?? [])
|
|
128
|
+
: [];
|
|
129
|
+
if (testTriggers.length === 0)
|
|
130
|
+
return false;
|
|
131
|
+
const lowered = new Set(testTriggers.map((t) => t.toLowerCase()));
|
|
132
|
+
return labels.some((label) => lowered.has(label.toLowerCase()));
|
|
111
133
|
}
|
|
112
134
|
/**
|
|
113
135
|
* Determine phases to run based on options and issue labels
|