sequant 2.10.0 → 2.11.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/plugin.json +1 -1
- package/README.md +6 -2
- package/dist/bin/cli.js +47 -2
- package/dist/src/commands/locks.d.ts +20 -1
- package/dist/src/commands/locks.js +206 -4
- package/dist/src/commands/ready.d.ts +6 -0
- package/dist/src/commands/ready.js +15 -1
- package/dist/src/commands/run-display.js +1 -0
- package/dist/src/commands/worktree.d.ts +31 -0
- package/dist/src/commands/worktree.js +95 -0
- package/dist/src/lib/cli-flags.d.ts +23 -0
- package/dist/src/lib/cli-flags.js +43 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +2 -0
- package/dist/src/lib/cli-ui/run-renderer.js +7 -1
- package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
- package/dist/src/lib/locks/checkout-lock.js +389 -0
- package/dist/src/lib/locks/index.d.ts +6 -3
- package/dist/src/lib/locks/index.js +4 -2
- package/dist/src/lib/locks/lock-manager.d.ts +81 -1
- package/dist/src/lib/locks/lock-manager.js +230 -5
- package/dist/src/lib/locks/types.d.ts +72 -0
- package/dist/src/lib/locks/types.js +28 -0
- package/dist/src/lib/settings.d.ts +73 -0
- package/dist/src/lib/settings.js +45 -0
- package/dist/src/lib/test-tautology-detector.d.ts +4 -3
- package/dist/src/lib/test-tautology-detector.js +101 -41
- package/dist/src/lib/workflow/batch-executor.js +78 -19
- package/dist/src/lib/workflow/config-resolver.d.ts +25 -0
- package/dist/src/lib/workflow/config-resolver.js +89 -0
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +15 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +5 -0
- package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
- package/dist/src/lib/workflow/effort-escalation.js +82 -0
- package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
- package/dist/src/lib/workflow/error-classifier.js +4 -0
- package/dist/src/lib/workflow/log-writer.d.ts +10 -1
- package/dist/src/lib/workflow/log-writer.js +20 -0
- package/dist/src/lib/workflow/metrics-schema.d.ts +49 -6
- package/dist/src/lib/workflow/metrics-schema.js +33 -0
- package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
- package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
- package/dist/src/lib/workflow/phase-detection.js +5 -1
- package/dist/src/lib/workflow/phase-executor.js +10 -0
- package/dist/src/lib/workflow/ready-gate.d.ts +28 -0
- package/dist/src/lib/workflow/ready-gate.js +24 -3
- package/dist/src/lib/workflow/run-log-schema.d.ts +55 -0
- package/dist/src/lib/workflow/run-log-schema.js +31 -1
- package/dist/src/lib/workflow/run-orchestrator.js +27 -0
- package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
- package/dist/src/lib/workflow/spec-recommendation.js +142 -0
- package/dist/src/lib/workflow/types.d.ts +64 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
- package/dist/src/lib/workflow/worktree-manager.js +9 -1
- package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
- package/dist/src/lib/workflow/worktree-resolver.js +126 -0
- package/package.json +3 -2
- package/templates/hooks/pre-tool.sh +228 -0
- package/templates/scripts/cleanup-worktree.sh +36 -15
- package/templates/scripts/new-feature.sh +25 -19
- package/templates/skills/_shared/references/subagent-types.md +7 -18
- package/templates/skills/assess/SKILL.md +5 -1
- package/templates/skills/exec/SKILL.md +61 -7
- package/templates/skills/fullsolve/SKILL.md +127 -21
- package/templates/skills/loop/SKILL.md +56 -11
- package/templates/skills/merger/SKILL.md +98 -10
- package/templates/skills/qa/SKILL.md +59 -6
- package/templates/skills/release/SKILL.md +79 -0
- package/templates/skills/spec/SKILL.md +31 -15
- package/templates/skills/spec/references/recommended-workflow.md +14 -1
- package/templates/skills/testgen/SKILL.md +23 -6
- package/templates/agents/sequant-explorer.md +0 -24
|
@@ -104,12 +104,13 @@ export declare function extractTestBlocks(content: string): Array<{
|
|
|
104
104
|
* when it references an imported production function, directly spawns the
|
|
105
105
|
* project's build output, or calls a helper that (transitively) does so.
|
|
106
106
|
*
|
|
107
|
-
* @param
|
|
108
|
-
*
|
|
107
|
+
* @param productionHandles Names of describe/module-scope helpers that reach
|
|
108
|
+
* production — by spawning the project's executable code or by calling an
|
|
109
|
+
* imported production function (see {@link collectProductionHandles}).
|
|
109
110
|
* @param buildOutputVars Variable names bound to a build-output path (see
|
|
110
111
|
* {@link collectBuildOutputVars}).
|
|
111
112
|
*/
|
|
112
|
-
export declare function testBlockCallsProductionCode(body: string, importedFunctions: ImportedFunction[],
|
|
113
|
+
export declare function testBlockCallsProductionCode(body: string, importedFunctions: ImportedFunction[], productionHandles?: string[], buildOutputVars?: string[]): boolean;
|
|
113
114
|
/**
|
|
114
115
|
* Check if a file opts out of tautology detection via pragma comment.
|
|
115
116
|
*
|
|
@@ -369,20 +369,39 @@ const SPAWN_PATTERN = /(?:\b(?:execFileSync|spawnSync|execSync|execFile)\s*\(|(?
|
|
|
369
369
|
*/
|
|
370
370
|
const BUILD_OUTPUT_PATTERN = /\bdist\//;
|
|
371
371
|
/**
|
|
372
|
-
*
|
|
372
|
+
* Non-compiled production code this project ships and executes: the hook
|
|
373
|
+
* scripts and the `scripts/` + `templates/scripts/` trees (#906).
|
|
374
|
+
*
|
|
375
|
+
* `dist/` alone was too narrow. `checkout-lock.integration.test.ts` drives the
|
|
376
|
+
* real `.claude/hooks/pre-tool.sh` as a subprocess — that hook IS the
|
|
377
|
+
* enforcement half of the feature under test — yet every block in the file
|
|
378
|
+
* read as tautological because the path is not under `dist/`.
|
|
379
|
+
*/
|
|
380
|
+
const PROJECT_SCRIPT_PATTERN = /\b(?:hooks\/[\w.-]+\.sh|scripts\/[\w./-]+)/;
|
|
381
|
+
/**
|
|
382
|
+
* Collect names of variables bound to a path into the project's own executable
|
|
383
|
+
* code, e.g.
|
|
373
384
|
* const cliPath = resolve(projectRoot, "dist/bin/cli.js");
|
|
374
|
-
*
|
|
375
|
-
*
|
|
385
|
+
* const HOOK = join(REPO_ROOT, ".claude/hooks/pre-tool.sh");
|
|
386
|
+
* captures `cliPath` / `HOOK`. Tests almost always spawn via such a handle
|
|
387
|
+
* rather than an inline string, so these names stand in for the literal path.
|
|
376
388
|
*
|
|
377
389
|
* The right-hand side is statement-bounded (`[^;]`) so a match cannot bleed
|
|
378
|
-
* across declarations, and must
|
|
390
|
+
* across declarations, and must reach one of the two markers. Spawning a
|
|
391
|
+
* *system* binary (`git`, `bash` with a temp fixture) matches neither, which
|
|
392
|
+
* is the intended exclusion — those are not this project's code.
|
|
379
393
|
*/
|
|
380
394
|
function collectBuildOutputVars(content) {
|
|
381
395
|
const names = new Set();
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
396
|
+
const patterns = [
|
|
397
|
+
/(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?\bdist\//g,
|
|
398
|
+
/(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?\b(?:hooks\/[\w.-]+\.sh|scripts\/[\w./-]+)/g,
|
|
399
|
+
];
|
|
400
|
+
for (const pattern of patterns) {
|
|
401
|
+
let match;
|
|
402
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
403
|
+
names.add(match[1]);
|
|
404
|
+
}
|
|
386
405
|
}
|
|
387
406
|
return [...names];
|
|
388
407
|
}
|
|
@@ -391,7 +410,7 @@ function collectBuildOutputVars(content) {
|
|
|
391
410
|
* `dist/` marker or one of the collected build-path variable names.
|
|
392
411
|
*/
|
|
393
412
|
function referencesBuildOutput(body, buildOutputVars) {
|
|
394
|
-
if (BUILD_OUTPUT_PATTERN.test(body)) {
|
|
413
|
+
if (BUILD_OUTPUT_PATTERN.test(body) || PROJECT_SCRIPT_PATTERN.test(body)) {
|
|
395
414
|
return true;
|
|
396
415
|
}
|
|
397
416
|
return buildOutputVars.some((name) => referenceMatcher(name).test(body));
|
|
@@ -406,45 +425,84 @@ function spawnsBuildOutput(body, buildOutputVars) {
|
|
|
406
425
|
return (SPAWN_PATTERN.test(body) && referencesBuildOutput(body, buildOutputVars));
|
|
407
426
|
}
|
|
408
427
|
/**
|
|
409
|
-
* Extract module/describe-scope helper definitions
|
|
410
|
-
*
|
|
428
|
+
* Extract module/describe-scope helper definitions as { name, body } pairs.
|
|
429
|
+
*
|
|
430
|
+
* Two shapes, because both are idiomatic and a detector that saw only one
|
|
431
|
+
* produced large-scale false positives (#906): a test calling a helper the
|
|
432
|
+
* detector cannot see reads as import-less, hence tautological. Measured on
|
|
433
|
+
* `checkout-lock.integration.test.ts` (helpers written as `function`
|
|
434
|
+
* declarations): 17 of 19 blocks flagged, every one of them real.
|
|
411
435
|
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
* nested parens) to keep the scan from running away across the file. Only
|
|
416
|
-
* block-bodied arrows are collected; the subprocess integration tests this
|
|
417
|
-
* targets all use them.
|
|
436
|
+
* Params are matched with `[^()]*` (no nested parens) to keep the scan from
|
|
437
|
+
* running away across the file. Expression-bodied arrows are skipped — they
|
|
438
|
+
* have no `{` body to extract.
|
|
418
439
|
*/
|
|
419
440
|
function extractHelperDefinitions(content) {
|
|
420
441
|
const helpers = [];
|
|
421
|
-
|
|
442
|
+
// Arrow consts anchor on `=> {`, so the body brace is unambiguous.
|
|
443
|
+
const arrowPattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^()]*\)\s*(?::[^=]*?)?=>\s*\{/g;
|
|
422
444
|
let match;
|
|
423
|
-
while ((match =
|
|
424
|
-
if (isInsideString(content, match.index))
|
|
445
|
+
while ((match = arrowPattern.exec(content)) !== null) {
|
|
446
|
+
if (isInsideString(content, match.index))
|
|
425
447
|
continue;
|
|
426
|
-
}
|
|
427
|
-
const name = match[1];
|
|
428
|
-
// The final `{` of the match is the function body's opening brace.
|
|
429
448
|
const braceIndex = match.index + match[0].length - 1;
|
|
430
|
-
|
|
431
|
-
|
|
449
|
+
helpers.push({
|
|
450
|
+
name: match[1],
|
|
451
|
+
body: extractBlockBody(content.substring(braceIndex)),
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// Declarations have no `=>` marker, and the return-type annotation may open
|
|
455
|
+
// a brace group of its own:
|
|
456
|
+
// function runHook(o): { status: number; stderr: string } { ... }
|
|
457
|
+
// so the body is NOT simply the first `{` after the parameters. Do not try
|
|
458
|
+
// to express that in the regex — a greedy annotation subpattern silently ran
|
|
459
|
+
// past the body and anchored on the NEXT declaration's brace, yielding a
|
|
460
|
+
// "helper" whose body was somebody else's code (caught by the object
|
|
461
|
+
// return-type test below). Match only to the closing paren, then walk: take
|
|
462
|
+
// the first brace group; if another `{` follows it, that group was the
|
|
463
|
+
// return type and the body is the next one.
|
|
464
|
+
const declPattern = /(?:async\s+)?function\s*\*?\s*(\w+)\s*\([^()]*\)/g;
|
|
465
|
+
while ((match = declPattern.exec(content)) !== null) {
|
|
466
|
+
if (isInsideString(content, match.index))
|
|
467
|
+
continue;
|
|
468
|
+
const rest = content.substring(match.index + match[0].length);
|
|
469
|
+
const firstBrace = rest.indexOf("{");
|
|
470
|
+
if (firstBrace === -1)
|
|
471
|
+
continue;
|
|
472
|
+
// Anything between `)` and the first `{` must be an annotation, not code.
|
|
473
|
+
if (/[;)=]/.test(rest.substring(0, firstBrace)))
|
|
474
|
+
continue;
|
|
475
|
+
let body = extractBlockBody(rest.substring(firstBrace));
|
|
476
|
+
const after = rest.substring(firstBrace + body.length);
|
|
477
|
+
if (/^\s*\{/.test(after)) {
|
|
478
|
+
body = extractBlockBody(after);
|
|
479
|
+
}
|
|
480
|
+
helpers.push({ name: match[1], body });
|
|
432
481
|
}
|
|
433
482
|
return helpers;
|
|
434
483
|
}
|
|
435
484
|
/**
|
|
436
|
-
* Collect the names of helper functions that
|
|
437
|
-
* indirection transitively: a helper that calls an already-known
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
485
|
+
* Collect the names of helper functions that reach production code, resolving
|
|
486
|
+
* indirection transitively: a helper that calls an already-known handle is
|
|
487
|
+
* itself a handle. This lets a test that only calls `expectFlagAccepted(...)`
|
|
488
|
+
* (which calls `runInUninitializedDir`, which spawns the CLI) count as
|
|
489
|
+
* exercising production code.
|
|
490
|
+
*
|
|
491
|
+
* A helper qualifies two ways:
|
|
492
|
+
* - it spawns the project's own executable code (subprocess integration
|
|
493
|
+
* tests), or
|
|
494
|
+
* - it references an imported production function (#906). `makeLock()`
|
|
495
|
+
* returning `new CheckoutLock({...})` is production code by any reading,
|
|
496
|
+
* but seeding on spawns alone missed it, so every test that built its
|
|
497
|
+
* subject through a factory read as tautological.
|
|
441
498
|
*/
|
|
442
|
-
function
|
|
499
|
+
function collectProductionHandles(content, buildOutputVars, importedFunctions = []) {
|
|
443
500
|
const helpers = extractHelperDefinitions(content);
|
|
444
501
|
const handles = new Set();
|
|
445
|
-
// Seed: helpers that directly
|
|
502
|
+
// Seed: helpers that directly reach production.
|
|
446
503
|
for (const helper of helpers) {
|
|
447
|
-
if (spawnsBuildOutput(helper.body, buildOutputVars)
|
|
504
|
+
if (spawnsBuildOutput(helper.body, buildOutputVars) ||
|
|
505
|
+
importedFunctions.some((fn) => referenceMatcher(fn.name).test(helper.body))) {
|
|
448
506
|
handles.add(helper.name);
|
|
449
507
|
}
|
|
450
508
|
}
|
|
@@ -472,12 +530,13 @@ function collectSpawnHandles(content, buildOutputVars) {
|
|
|
472
530
|
* when it references an imported production function, directly spawns the
|
|
473
531
|
* project's build output, or calls a helper that (transitively) does so.
|
|
474
532
|
*
|
|
475
|
-
* @param
|
|
476
|
-
*
|
|
533
|
+
* @param productionHandles Names of describe/module-scope helpers that reach
|
|
534
|
+
* production — by spawning the project's executable code or by calling an
|
|
535
|
+
* imported production function (see {@link collectProductionHandles}).
|
|
477
536
|
* @param buildOutputVars Variable names bound to a build-output path (see
|
|
478
537
|
* {@link collectBuildOutputVars}).
|
|
479
538
|
*/
|
|
480
|
-
export function testBlockCallsProductionCode(body, importedFunctions,
|
|
539
|
+
export function testBlockCallsProductionCode(body, importedFunctions, productionHandles = [], buildOutputVars = []) {
|
|
481
540
|
// 1. References an imported production function.
|
|
482
541
|
for (const fn of importedFunctions) {
|
|
483
542
|
if (referenceMatcher(fn.name).test(body)) {
|
|
@@ -490,9 +549,10 @@ export function testBlockCallsProductionCode(body, importedFunctions, spawnHandl
|
|
|
490
549
|
if (spawnsBuildOutput(body, buildOutputVars)) {
|
|
491
550
|
return true;
|
|
492
551
|
}
|
|
493
|
-
// 3. Calls a describe/module-scope helper that (transitively)
|
|
494
|
-
//
|
|
495
|
-
|
|
552
|
+
// 3. Calls a describe/module-scope helper that (transitively) reaches
|
|
553
|
+
// production — spawns the project's executable code, or calls an imported
|
|
554
|
+
// production function (#906). Covers arrow-const and `function` helpers.
|
|
555
|
+
for (const handle of productionHandles) {
|
|
496
556
|
if (referenceMatcher(handle).test(body)) {
|
|
497
557
|
return true;
|
|
498
558
|
}
|
|
@@ -529,13 +589,13 @@ export function analyzeTestFile(content, filePath) {
|
|
|
529
589
|
try {
|
|
530
590
|
const importedFunctions = extractImports(content);
|
|
531
591
|
const buildOutputVars = collectBuildOutputVars(content);
|
|
532
|
-
const
|
|
592
|
+
const productionHandles = collectProductionHandles(content, buildOutputVars, importedFunctions);
|
|
533
593
|
const testBlocks = extractTestBlocks(content);
|
|
534
594
|
const analyzedBlocks = testBlocks.map((block) => ({
|
|
535
595
|
description: block.description,
|
|
536
596
|
lineNumber: block.lineNumber,
|
|
537
597
|
style: block.style,
|
|
538
|
-
isTautological: !testBlockCallsProductionCode(block.body, importedFunctions,
|
|
598
|
+
isTautological: !testBlockCallsProductionCode(block.body, importedFunctions, productionHandles, buildOutputVars),
|
|
539
599
|
}));
|
|
540
600
|
const tautologicalCount = analyzedBlocks.filter((b) => b.isTautological).length;
|
|
541
601
|
const totalTests = analyzedBlocks.length;
|
|
@@ -10,13 +10,15 @@
|
|
|
10
10
|
import chalk from "chalk";
|
|
11
11
|
import { spawnSync } from "child_process";
|
|
12
12
|
import { createPhaseLogFromTiming } from "./log-writer.js";
|
|
13
|
+
import { withEscalatedEffort } from "./effort-escalation.js";
|
|
13
14
|
import { classifyError, errorTypeToCategory, } from "./error-classifier.js";
|
|
14
15
|
import { getGitDiffStats, getCommitHash, resolveDiffBase, } from "./git-diff-utils.js";
|
|
15
16
|
import { createCheckpointCommit, rebaseBeforePR, createPR, readCacheMetrics, filterResumedPhases, } from "./worktree-manager.js";
|
|
16
|
-
import { AUTO_WAIT_BUFFER_MS, createAutoWaitLedger, executePhaseWithRetry, isWindowExhaustedRateLimit, } from "./phase-executor.js";
|
|
17
|
+
import { AUTO_WAIT_BUFFER_MS, createAutoWaitLedger, executePhaseWithRetry, hasExecChanges, isWindowExhaustedRateLimit, } from "./phase-executor.js";
|
|
17
18
|
import { BillingError, RateLimitError, resetsAtToMs } from "../errors.js";
|
|
18
19
|
import { parseBodyDependencyMarkers } from "./dependency-markers.js";
|
|
19
|
-
import {
|
|
20
|
+
import { determinePhasesForIssue, DOCS_LABELS } from "./phase-mapper.js";
|
|
21
|
+
import { resolveSpecRecommendation } from "./spec-recommendation.js";
|
|
20
22
|
import { activateRelay, deactivateRelay, } from "../relay/activation.js";
|
|
21
23
|
import { getSettings } from "../settings.js";
|
|
22
24
|
import { GitHubProvider } from "./platforms/github.js";
|
|
@@ -876,22 +878,35 @@ export async function runIssueWithLogging(ctx) {
|
|
|
876
878
|
failureCategory: deriveFailureCategory(phaseResults),
|
|
877
879
|
};
|
|
878
880
|
}
|
|
879
|
-
//
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
881
|
+
// Resolve the spec→run phase recommendation through the ordered chain
|
|
882
|
+
// comment-marker → comment-prose → chat-text → label-fallback (#921).
|
|
883
|
+
// Chat-text parsing alone is nondeterministic: the spec agent's plan
|
|
884
|
+
// comment is the durable artifact, but the old code only ever looked at
|
|
885
|
+
// ephemeral chat output, silently dropping recommended phases (e.g.
|
|
886
|
+
// testgen) whenever the agent posted the plan via a body file (#814).
|
|
887
|
+
const resolved = resolveSpecRecommendation({
|
|
888
|
+
chatOutput: specResult.output ?? "",
|
|
889
|
+
issueNumber,
|
|
890
|
+
labels,
|
|
891
|
+
});
|
|
892
|
+
// `resolveSpecRecommendation` already excludes "spec" regardless of
|
|
893
|
+
// which step in the chain produced the result.
|
|
894
|
+
phases = resolved.phases;
|
|
895
|
+
detectedQualityLoop = resolved.qualityLoop;
|
|
896
|
+
if (logWriter) {
|
|
897
|
+
logWriter.setSpecRecommendation({ source: resolved.source, phases, qualityLoop: detectedQualityLoop }, issueNumber);
|
|
898
|
+
}
|
|
899
|
+
if (resolved.source === "marker") {
|
|
900
|
+
log(chalk.gray(` Spec recommends (marker): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
|
|
901
|
+
}
|
|
902
|
+
else if (resolved.source === "comment-prose") {
|
|
903
|
+
log(chalk.gray(` Spec recommends (comment): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
|
|
904
|
+
}
|
|
905
|
+
else if (resolved.source === "chat") {
|
|
906
|
+
log(chalk.gray(` Spec recommends (chat): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
|
|
888
907
|
}
|
|
889
908
|
else {
|
|
890
|
-
// Fall back to label-based detection
|
|
891
909
|
log(chalk.yellow(` Could not parse spec recommendation, using label-based detection`));
|
|
892
|
-
const detected = detectPhasesFromLabels(labels);
|
|
893
|
-
phases = detected.phases.filter((p) => p !== "spec");
|
|
894
|
-
detectedQualityLoop = detected.qualityLoop;
|
|
895
910
|
log(chalk.gray(` Fallback: ${phases.join(" → ")}`));
|
|
896
911
|
}
|
|
897
912
|
}
|
|
@@ -1010,8 +1025,22 @@ export async function runIssueWithLogging(ctx) {
|
|
|
1010
1025
|
// State tracking errors shouldn't stop execution
|
|
1011
1026
|
}
|
|
1012
1027
|
}
|
|
1028
|
+
// #915: iteration > 1 is the outer quality-loop's retry signal — the
|
|
1029
|
+
// same condition that triggers the "Quality loop iteration" log line
|
|
1030
|
+
// above. This loop re-runs the WHOLE `phases` list on every iteration
|
|
1031
|
+
// (it does not resume from the specific phase that failed), so the
|
|
1032
|
+
// escalation is per RETRIED ITERATION, not per specific-phase-that-
|
|
1033
|
+
// previously-failed: every phase dispatched while iteration > 1
|
|
1034
|
+
// escalates, including one that already succeeded on iteration 1 (e.g.
|
|
1035
|
+
// exec re-running alongside a retried qa). `withEscalatedEffort` is a
|
|
1036
|
+
// no-op (returns the input config by reference) whenever escalation is
|
|
1037
|
+
// off or this is the first attempt.
|
|
1038
|
+
const { config: dispatchConfig, record: escalationRecord } = withEscalatedEffort(withActivityHook(issueConfig, issueNumber, phase, onProgress, makeWaitTransition(phase)), phase, iteration > 1);
|
|
1039
|
+
if (escalationRecord && config.verbose) {
|
|
1040
|
+
log(chalk.gray(` effort: ${escalationRecord.base} → ${escalationRecord.escalated} (loop retry)`));
|
|
1041
|
+
}
|
|
1013
1042
|
const phaseStartTime = new Date();
|
|
1014
|
-
const result = await executePhaseWithRetry(issueNumber, phase,
|
|
1043
|
+
const result = await executePhaseWithRetry(issueNumber, phase, dispatchConfig, resumeHandle, worktreePath, shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
|
|
1015
1044
|
undefined, // delayFn — use the default
|
|
1016
1045
|
autoWaitLedger);
|
|
1017
1046
|
const phaseEndTime = new Date();
|
|
@@ -1027,7 +1056,15 @@ export async function runIssueWithLogging(ctx) {
|
|
|
1027
1056
|
}
|
|
1028
1057
|
}
|
|
1029
1058
|
}
|
|
1030
|
-
phaseResults.push(
|
|
1059
|
+
phaseResults.push(escalationRecord
|
|
1060
|
+
? {
|
|
1061
|
+
...result,
|
|
1062
|
+
escalatedEffort: {
|
|
1063
|
+
base: escalationRecord.base,
|
|
1064
|
+
escalated: escalationRecord.escalated,
|
|
1065
|
+
},
|
|
1066
|
+
}
|
|
1067
|
+
: result);
|
|
1031
1068
|
// Emit completion/failure progress event (AC-8)
|
|
1032
1069
|
const phaseDurationSec = Math.round((phaseEndTime.getTime() - phaseStartTime.getTime()) / 1000);
|
|
1033
1070
|
if (result.success) {
|
|
@@ -1356,7 +1393,23 @@ export async function runIssueWithLogging(ctx) {
|
|
|
1356
1393
|
// warning and leave the issue at `success`. Recorded here and folded into the
|
|
1357
1394
|
// returned `success` below.
|
|
1358
1395
|
let prCreationError;
|
|
1359
|
-
const
|
|
1396
|
+
const wouldCreatePR = success && worktreePath && branch && !options.noPr;
|
|
1397
|
+
// #920: a phase-restricted run (e.g. `--phases spec`) provisions a worktree
|
|
1398
|
+
// and branch unconditionally of which phases ran, so a clean spec-only pass
|
|
1399
|
+
// satisfies every conjunct above with zero commits — `gh pr create` then
|
|
1400
|
+
// fails with "No commits between main and …" and the run reports failed
|
|
1401
|
+
// for work that landed exactly where it was supposed to (the plan comment).
|
|
1402
|
+
// Gate on the evidence (commits ahead of base) rather than the phase list:
|
|
1403
|
+
// `hasExecChanges` fails open (`unknown` counts as "has changes") so a git
|
|
1404
|
+
// error here falls through to today's attempt-PR behavior, and it resolves
|
|
1405
|
+
// the base the same #537-aware way `classifyExecChanges` already does for
|
|
1406
|
+
// the exec zero-diff guard.
|
|
1407
|
+
let prSkippedReason;
|
|
1408
|
+
if (wouldCreatePR && !hasExecChanges(worktreePath)) {
|
|
1409
|
+
prSkippedReason = `no commits ahead of ${baseBranch ?? "main"} (no implementing phase ran)`;
|
|
1410
|
+
log(chalk.gray(` ℹ️ PR skipped — ${prSkippedReason}`));
|
|
1411
|
+
}
|
|
1412
|
+
const shouldCreatePR = wouldCreatePR && !prSkippedReason;
|
|
1360
1413
|
if (shouldCreatePR) {
|
|
1361
1414
|
// #605: under --stacked, target predecessor branch (only for non-first,
|
|
1362
1415
|
// non-last issues). Last PR keeps `main` so partial progress can land.
|
|
@@ -1424,10 +1477,16 @@ export async function runIssueWithLogging(ctx) {
|
|
|
1424
1477
|
prNumber,
|
|
1425
1478
|
prUrl,
|
|
1426
1479
|
prCreationError,
|
|
1480
|
+
prSkippedReason,
|
|
1427
1481
|
checkpointFailed,
|
|
1428
1482
|
failureCategory: overallSuccess
|
|
1429
1483
|
? undefined
|
|
1430
|
-
:
|
|
1484
|
+
: // #920: a PR-creation failure has no failed phase for
|
|
1485
|
+
// `deriveFailureCategory` to classify — fall back to the dedicated
|
|
1486
|
+
// category so the metrics residual from #879 (empty failureCategory
|
|
1487
|
+
// on a PR-only failure) doesn't reopen for this failure path.
|
|
1488
|
+
(deriveFailureCategory(phaseResults) ??
|
|
1489
|
+
(prCreationError ? "pr_creation" : undefined)),
|
|
1431
1490
|
// #817: present only when `--ready-gate` ran the gate; the summary renders
|
|
1432
1491
|
// its terminal reason (AC-6).
|
|
1433
1492
|
readyGate: readyGateResult,
|
|
@@ -68,6 +68,31 @@ export declare function resolveRunOptions(cliOptions: RunOptions, settings: Sequ
|
|
|
68
68
|
* ```
|
|
69
69
|
*/
|
|
70
70
|
export declare function positiveOr(value: number | undefined, fallback: number): number;
|
|
71
|
+
/** A single phase's resolved model/effort override (#914). */
|
|
72
|
+
export interface PhasePolicy {
|
|
73
|
+
model?: string;
|
|
74
|
+
effort?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Parse a `--models`/`--efforts` CLI spec into a phase → value map.
|
|
78
|
+
*
|
|
79
|
+
* Grammar: a bare value (`"sonnet"`) applies to every phase and resolves to
|
|
80
|
+
* `{"*": "sonnet"}`; a comma list of `phase=value` pairs (`"spec=fable,exec=sonnet"`)
|
|
81
|
+
* resolves per phase. Mixing the two forms, an empty phase/value, or an
|
|
82
|
+
* unrecognized phase name all fail fast — this is the CLI boundary, so a
|
|
83
|
+
* malformed spec must never silently resolve to "nothing configured".
|
|
84
|
+
*/
|
|
85
|
+
export declare function parsePhaseSpec(spec: string, phaseNames: string[]): Record<string, string>;
|
|
86
|
+
/**
|
|
87
|
+
* Resolve per-phase model/effort policies with CLI > settings > absent
|
|
88
|
+
* precedence.
|
|
89
|
+
*
|
|
90
|
+
* This is the single resolver both `buildExecutionConfig` (here) and
|
|
91
|
+
* `ready-gate.ts:buildPhaseConfig` call, so they cannot drift the way the
|
|
92
|
+
* two `phaseTimeout` producers did in #833 — see `positiveOr`'s doc comment
|
|
93
|
+
* for that history.
|
|
94
|
+
*/
|
|
95
|
+
export declare function resolvePhasePolicies(cliModels: string | undefined, cliEfforts: string | undefined, settingsPhases: Record<string, PhasePolicy> | undefined, phaseNames: string[]): Record<string, PhasePolicy>;
|
|
71
96
|
/**
|
|
72
97
|
* Build an ExecutionConfig from merged RunOptions and settings.
|
|
73
98
|
* Extracts the phase-timeout, MCP, retry, and mode resolution logic
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { DEFAULT_CONFIG, DEFAULT_PHASES, } from "./types.js";
|
|
10
10
|
import { getEnvConfig } from "./batch-executor.js";
|
|
11
|
+
import { getPhaseNames } from "./phase-registry.js";
|
|
11
12
|
/**
|
|
12
13
|
* Coerce an env-var string to the type of the default value.
|
|
13
14
|
* Returns the string as-is if no default exists for type inference.
|
|
@@ -164,6 +165,86 @@ export function positiveOr(value, fallback) {
|
|
|
164
165
|
? value
|
|
165
166
|
: fallback;
|
|
166
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Parse a `--models`/`--efforts` CLI spec into a phase → value map.
|
|
170
|
+
*
|
|
171
|
+
* Grammar: a bare value (`"sonnet"`) applies to every phase and resolves to
|
|
172
|
+
* `{"*": "sonnet"}`; a comma list of `phase=value` pairs (`"spec=fable,exec=sonnet"`)
|
|
173
|
+
* resolves per phase. Mixing the two forms, an empty phase/value, or an
|
|
174
|
+
* unrecognized phase name all fail fast — this is the CLI boundary, so a
|
|
175
|
+
* malformed spec must never silently resolve to "nothing configured".
|
|
176
|
+
*/
|
|
177
|
+
export function parsePhaseSpec(spec, phaseNames) {
|
|
178
|
+
const trimmed = spec.trim();
|
|
179
|
+
if (!trimmed) {
|
|
180
|
+
throw new Error("Malformed spec: value is empty.");
|
|
181
|
+
}
|
|
182
|
+
if (!trimmed.includes("=")) {
|
|
183
|
+
return { "*": trimmed };
|
|
184
|
+
}
|
|
185
|
+
const result = {};
|
|
186
|
+
for (const segment of trimmed.split(",")) {
|
|
187
|
+
const eq = segment.indexOf("=");
|
|
188
|
+
if (eq === -1) {
|
|
189
|
+
throw new Error(`Malformed spec segment '${segment}' — expected 'phase=value' (cannot mix a bare value with phase=value pairs).`);
|
|
190
|
+
}
|
|
191
|
+
const phase = segment.slice(0, eq).trim();
|
|
192
|
+
const value = segment.slice(eq + 1).trim();
|
|
193
|
+
if (!phase || !value) {
|
|
194
|
+
throw new Error(`Malformed spec segment '${segment}' — both phase and value are required.`);
|
|
195
|
+
}
|
|
196
|
+
if (!phaseNames.includes(phase)) {
|
|
197
|
+
throw new Error(`Unknown phase '${phase}'. Available phases: ${phaseNames.join(", ")}.`);
|
|
198
|
+
}
|
|
199
|
+
result[phase] = value;
|
|
200
|
+
}
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
/** Apply a parsed phase-spec map onto a policy accumulator for one field. */
|
|
204
|
+
function applyPhaseSpec(target, parsed, field, phaseNames) {
|
|
205
|
+
const wildcard = parsed["*"];
|
|
206
|
+
if (wildcard !== undefined) {
|
|
207
|
+
for (const phase of phaseNames) {
|
|
208
|
+
target[phase] = { ...target[phase], [field]: wildcard };
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
for (const [phase, value] of Object.entries(parsed)) {
|
|
213
|
+
target[phase] = { ...target[phase], [field]: value };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Resolve per-phase model/effort policies with CLI > settings > absent
|
|
218
|
+
* precedence.
|
|
219
|
+
*
|
|
220
|
+
* This is the single resolver both `buildExecutionConfig` (here) and
|
|
221
|
+
* `ready-gate.ts:buildPhaseConfig` call, so they cannot drift the way the
|
|
222
|
+
* two `phaseTimeout` producers did in #833 — see `positiveOr`'s doc comment
|
|
223
|
+
* for that history.
|
|
224
|
+
*/
|
|
225
|
+
export function resolvePhasePolicies(cliModels, cliEfforts, settingsPhases, phaseNames) {
|
|
226
|
+
const result = {};
|
|
227
|
+
// Layer 1 (lowest): settings.run.phases. Skip any phase name settings
|
|
228
|
+
// validation already didn't recognize — that's surfaced as a settings
|
|
229
|
+
// warning at load time (AC-1), not a resolver-time failure.
|
|
230
|
+
if (settingsPhases) {
|
|
231
|
+
for (const [phase, policy] of Object.entries(settingsPhases)) {
|
|
232
|
+
if (!phaseNames.includes(phase))
|
|
233
|
+
continue;
|
|
234
|
+
result[phase] = { ...policy };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Layer 2 (highest): CLI --models/--efforts. Malformed specs throw here —
|
|
238
|
+
// callers at the CLI boundary (cli-flags.ts) turn that into a fail-fast
|
|
239
|
+
// InvalidArgumentError.
|
|
240
|
+
if (cliModels) {
|
|
241
|
+
applyPhaseSpec(result, parsePhaseSpec(cliModels, phaseNames), "model", phaseNames);
|
|
242
|
+
}
|
|
243
|
+
if (cliEfforts) {
|
|
244
|
+
applyPhaseSpec(result, parsePhaseSpec(cliEfforts, phaseNames), "effort", phaseNames);
|
|
245
|
+
}
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
167
248
|
/**
|
|
168
249
|
* Build an ExecutionConfig from merged RunOptions and settings.
|
|
169
250
|
* Extracts the phase-timeout, MCP, retry, and mode resolution logic
|
|
@@ -219,5 +300,13 @@ export function buildExecutionConfig(mergedOptions, settings, issueCount) {
|
|
|
219
300
|
// load-bearing wire the #795 inert-flag class guards against — the flag is
|
|
220
301
|
// useless if it stops reaching the executor here.
|
|
221
302
|
readyGate: mergedOptions.readyGate ?? false,
|
|
303
|
+
// #914: CLI > settings > absent, via the shared resolver both
|
|
304
|
+
// ExecutionConfig producers call (see `resolvePhasePolicies`'s doc
|
|
305
|
+
// comment for the #833 drift this guards against).
|
|
306
|
+
phasePolicies: resolvePhasePolicies(mergedOptions.models, mergedOptions.efforts, settings.run.phases, getPhaseNames()),
|
|
307
|
+
// #915: CLI > settings > default `false` — mirrors the `readyGate`
|
|
308
|
+
// precedent above. Both `ExecutionConfig` producers (here and
|
|
309
|
+
// `ready-gate.ts:buildPhaseConfig`) resolve this the same way (#833).
|
|
310
|
+
effortEscalation: mergedOptions.escalateEffort ?? settings.run.effortEscalation ?? false,
|
|
222
311
|
};
|
|
223
312
|
}
|
|
@@ -49,6 +49,21 @@ export interface AgentExecutionConfig {
|
|
|
49
49
|
onStderr?: (text: string) => void;
|
|
50
50
|
/** Relevant files for the phase (used by file-oriented drivers like Aider) */
|
|
51
51
|
files?: string[];
|
|
52
|
+
/**
|
|
53
|
+
* Claude model to use for this phase (#914). Forwarded verbatim to the
|
|
54
|
+
* Agent SDK `query()` options by ClaudeCodeDriver; ignored by drivers
|
|
55
|
+
* without a model concept (Aider uses its own `AiderSettings.model`).
|
|
56
|
+
* Absent by default — the SDK falls back to the CLI default model.
|
|
57
|
+
*/
|
|
58
|
+
model?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Reasoning effort for this phase (#914). Forwarded verbatim to the Agent
|
|
61
|
+
* SDK `query()` options by ClaudeCodeDriver; ignored by drivers without an
|
|
62
|
+
* effort concept. Absent by default — the SDK defaults to `high`. Matches
|
|
63
|
+
* the SDK's own closed `EffortLevel` enum, not a bare `string`, so a value
|
|
64
|
+
* that reaches this field type-checks against `query()`'s options.
|
|
65
|
+
*/
|
|
66
|
+
effort?: "low" | "medium" | "high" | "xhigh" | "max";
|
|
52
67
|
}
|
|
53
68
|
/**
|
|
54
69
|
* Result returned by an agent after executing a phase.
|
|
@@ -96,6 +96,11 @@ export class ClaudeCodeDriver {
|
|
|
96
96
|
...(resumeToken ? { resume: resumeToken } : {}),
|
|
97
97
|
env: config.env,
|
|
98
98
|
...(mcpServers ? { mcpServers } : {}),
|
|
99
|
+
// #914: per-phase model/effort override. Omitted entirely when
|
|
100
|
+
// unset (not `undefined`-valued) so the SDK's own default
|
|
101
|
+
// resolution is untouched — see the AC-3 key-presence test.
|
|
102
|
+
...(config.model ? { model: config.model } : {}),
|
|
103
|
+
...(config.effort ? { effort: config.effort } : {}),
|
|
99
104
|
stderr: (data) => {
|
|
100
105
|
capturedStderr += data;
|
|
101
106
|
// Split on newlines and push each line to the ring buffer
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence-based effort escalation on quality-loop retries (#915).
|
|
3
|
+
*
|
|
4
|
+
* Sequant already detects several "this attempt is a retry" moments: the
|
|
5
|
+
* outer quality-loop re-entering a phase (`batch-executor.ts`) and the
|
|
6
|
+
* `sequant ready` QA-pass loop re-running `qa`/`loop` (`ready-gate.ts`).
|
|
7
|
+
* Escalation raises the phase's reasoning effort one tier for exactly that
|
|
8
|
+
* retried execution when the workflow observed a prior attempt fail — never
|
|
9
|
+
* speculatively, and never more than one tier per retry (see AC-6).
|
|
10
|
+
*
|
|
11
|
+
* Deliberately its own module rather than living beside `resolvePhasePolicies`
|
|
12
|
+
* in `config-resolver.ts`: `config-resolver.ts` imports `getEnvConfig` from
|
|
13
|
+
* `batch-executor.ts`, and `batch-executor.ts` is one of this module's
|
|
14
|
+
* dispatch-time callers, so co-locating here avoids introducing that cycle.
|
|
15
|
+
*/
|
|
16
|
+
import { EFFORT_LEVELS } from "../settings.js";
|
|
17
|
+
import type { ExecutionConfig, Phase } from "./types.js";
|
|
18
|
+
/**
|
|
19
|
+
* Base effort assumed for a phase with no configured `effort` override, when
|
|
20
|
+
* escalation needs a starting point to step up from (AC-5). `phase-executor.ts`
|
|
21
|
+
* omits the `effort` key entirely in that case (#914) so the Agent SDK's own
|
|
22
|
+
* default applies — #914 deliberately never encoded what that default is,
|
|
23
|
+
* since "omitted" is not the same claim as "equals X".
|
|
24
|
+
*
|
|
25
|
+
* Verified against `@anthropic-ai/claude-agent-sdk`'s own `query()` Options
|
|
26
|
+
* type (`sdk.d.ts`): `effort?: EffortLevel` is documented inline as
|
|
27
|
+
* `'high' — Deep reasoning (default)`. That is the SDK's default for the
|
|
28
|
+
* exact call sequant makes (raw `query()`, not the Claude Code CLI product —
|
|
29
|
+
* whose own `xhigh` default is a caller choice on top of this SDK, not the
|
|
30
|
+
* SDK's own default), so this constant is not a guess.
|
|
31
|
+
*/
|
|
32
|
+
export declare const DEFAULT_ESCALATION_BASE: (typeof EFFORT_LEVELS)[number];
|
|
33
|
+
/**
|
|
34
|
+
* Pure ladder step: one tier above `base` on `EFFORT_LEVELS`, capped at the
|
|
35
|
+
* top (`max`). Returns `base` unchanged whenever `enabled` is false or this
|
|
36
|
+
* isn't a retry — the disabled/first-attempt path must be indistinguishable
|
|
37
|
+
* from #914 with escalation never having existed (AC-2).
|
|
38
|
+
*
|
|
39
|
+
* Always escalates from the phase's CONFIGURED base, never from a previously
|
|
40
|
+
* escalated value — callers must not accumulate escalation across iterations
|
|
41
|
+
* (AC-6): base `high` on the 3rd loop iteration is `xhigh`, not `max`.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveEscalatedEffort(base: string | undefined, isRetry: boolean, enabled: boolean): string | undefined;
|
|
44
|
+
/** One escalated execution, for observability (run metrics + verbose output). */
|
|
45
|
+
export interface EscalationRecord {
|
|
46
|
+
phase: Phase;
|
|
47
|
+
base: string;
|
|
48
|
+
escalated: string;
|
|
49
|
+
}
|
|
50
|
+
export interface EscalationOutcome {
|
|
51
|
+
/**
|
|
52
|
+
* The config to dispatch with. Identical by reference to the input `config`
|
|
53
|
+
* whenever nothing escalated — so a shared `ExecutionConfig` object is never
|
|
54
|
+
* mutated and an escalation never leaks into a phase execution it wasn't
|
|
55
|
+
* computed for (AC-7).
|
|
56
|
+
*/
|
|
57
|
+
config: ExecutionConfig;
|
|
58
|
+
/** Present only when this dispatch actually escalated. */
|
|
59
|
+
record?: EscalationRecord;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Apply escalation to ONE phase's execution, for THIS dispatch only.
|
|
63
|
+
*
|
|
64
|
+
* This is deliberately a per-execution decision made at the dispatch site,
|
|
65
|
+
* not a value baked into `ExecutionConfig` at build time: `buildExecutionConfig`
|
|
66
|
+
* / `buildPhaseConfig` run once per run/gate, not once per phase execution, so
|
|
67
|
+
* a static escalated value would leak across every phase in the chain and
|
|
68
|
+
* violate AC-7. The three retry-dispatch sites (batch-executor.ts's quality
|
|
69
|
+
* loop, ready-gate.ts's QA-pass loop `qa`/`loop` dispatch) call this function
|
|
70
|
+
* — and only this function — so they cannot drift on the cap/one-tier rules
|
|
71
|
+
* in AC-6 (see resolveEscalatedEffort's doc comment).
|
|
72
|
+
*/
|
|
73
|
+
export declare function withEscalatedEffort(config: ExecutionConfig, phase: Phase, isRetry: boolean): EscalationOutcome;
|