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.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +6 -2
  3. package/dist/bin/cli.js +47 -2
  4. package/dist/src/commands/locks.d.ts +20 -1
  5. package/dist/src/commands/locks.js +206 -4
  6. package/dist/src/commands/ready.d.ts +6 -0
  7. package/dist/src/commands/ready.js +15 -1
  8. package/dist/src/commands/run-display.js +1 -0
  9. package/dist/src/commands/worktree.d.ts +31 -0
  10. package/dist/src/commands/worktree.js +95 -0
  11. package/dist/src/lib/cli-flags.d.ts +23 -0
  12. package/dist/src/lib/cli-flags.js +43 -0
  13. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +2 -0
  14. package/dist/src/lib/cli-ui/run-renderer.js +7 -1
  15. package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
  16. package/dist/src/lib/locks/checkout-lock.js +389 -0
  17. package/dist/src/lib/locks/index.d.ts +6 -3
  18. package/dist/src/lib/locks/index.js +4 -2
  19. package/dist/src/lib/locks/lock-manager.d.ts +81 -1
  20. package/dist/src/lib/locks/lock-manager.js +230 -5
  21. package/dist/src/lib/locks/types.d.ts +72 -0
  22. package/dist/src/lib/locks/types.js +28 -0
  23. package/dist/src/lib/settings.d.ts +73 -0
  24. package/dist/src/lib/settings.js +45 -0
  25. package/dist/src/lib/test-tautology-detector.d.ts +4 -3
  26. package/dist/src/lib/test-tautology-detector.js +101 -41
  27. package/dist/src/lib/workflow/batch-executor.js +78 -19
  28. package/dist/src/lib/workflow/config-resolver.d.ts +25 -0
  29. package/dist/src/lib/workflow/config-resolver.js +89 -0
  30. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +15 -0
  31. package/dist/src/lib/workflow/drivers/claude-code.js +5 -0
  32. package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
  33. package/dist/src/lib/workflow/effort-escalation.js +82 -0
  34. package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
  35. package/dist/src/lib/workflow/error-classifier.js +4 -0
  36. package/dist/src/lib/workflow/log-writer.d.ts +10 -1
  37. package/dist/src/lib/workflow/log-writer.js +20 -0
  38. package/dist/src/lib/workflow/metrics-schema.d.ts +49 -6
  39. package/dist/src/lib/workflow/metrics-schema.js +33 -0
  40. package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
  41. package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
  42. package/dist/src/lib/workflow/phase-detection.js +5 -1
  43. package/dist/src/lib/workflow/phase-executor.js +10 -0
  44. package/dist/src/lib/workflow/ready-gate.d.ts +28 -0
  45. package/dist/src/lib/workflow/ready-gate.js +24 -3
  46. package/dist/src/lib/workflow/run-log-schema.d.ts +55 -0
  47. package/dist/src/lib/workflow/run-log-schema.js +31 -1
  48. package/dist/src/lib/workflow/run-orchestrator.js +27 -0
  49. package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
  50. package/dist/src/lib/workflow/spec-recommendation.js +142 -0
  51. package/dist/src/lib/workflow/types.d.ts +64 -0
  52. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  53. package/dist/src/lib/workflow/worktree-manager.js +9 -1
  54. package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
  55. package/dist/src/lib/workflow/worktree-resolver.js +126 -0
  56. package/package.json +3 -2
  57. package/templates/hooks/pre-tool.sh +228 -0
  58. package/templates/scripts/cleanup-worktree.sh +36 -15
  59. package/templates/scripts/new-feature.sh +25 -19
  60. package/templates/skills/_shared/references/subagent-types.md +7 -18
  61. package/templates/skills/assess/SKILL.md +5 -1
  62. package/templates/skills/exec/SKILL.md +61 -7
  63. package/templates/skills/fullsolve/SKILL.md +127 -21
  64. package/templates/skills/loop/SKILL.md +56 -11
  65. package/templates/skills/merger/SKILL.md +98 -10
  66. package/templates/skills/qa/SKILL.md +59 -6
  67. package/templates/skills/release/SKILL.md +79 -0
  68. package/templates/skills/spec/SKILL.md +31 -15
  69. package/templates/skills/spec/references/recommended-workflow.md +14 -1
  70. package/templates/skills/testgen/SKILL.md +23 -6
  71. 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 spawnHandles Names of describe/module-scope helpers that spawn the
108
- * build output (see {@link collectSpawnHandles}).
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[], spawnHandles?: string[], buildOutputVars?: string[]): boolean;
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
- * Collect names of variables bound to a build-output path, e.g.
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
- * captures `cliPath`. Tests almost always spawn via such a handle rather than
375
- * an inline string, so these names stand in for the literal build path.
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 contain the `dist/` marker.
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 pattern = /(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?\bdist\//g;
383
- let match;
384
- while ((match = pattern.exec(content)) !== null) {
385
- names.add(match[1]);
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 (named block-bodied arrow
410
- * consts) as { name, body } pairs.
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
- * Anchors on `=> {` so a return-type object annotation
413
- * const run = (): { stdout: string } => { ... }
414
- * is not mistaken for the function body. Params are matched with `[^()]*` (no
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
- const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^()]*\)\s*(?::[^=]*?)?=>\s*\{/g;
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 = pattern.exec(content)) !== null) {
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
- const body = extractBlockBody(content.substring(braceIndex));
431
- helpers.push({ name, body });
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 spawn the build output, resolving
437
- * indirection transitively: a helper that calls an already-known spawn helper
438
- * is itself a spawn helper. This lets a test that only calls
439
- * `expectFlagAccepted(...)` (which calls `runInUninitializedDir`, which spawns
440
- * the CLI) count as exercising production code.
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 collectSpawnHandles(content, buildOutputVars) {
499
+ function collectProductionHandles(content, buildOutputVars, importedFunctions = []) {
443
500
  const helpers = extractHelperDefinitions(content);
444
501
  const handles = new Set();
445
- // Seed: helpers that directly spawn the build output.
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 spawnHandles Names of describe/module-scope helpers that spawn the
476
- * build output (see {@link collectSpawnHandles}).
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, spawnHandles = [], buildOutputVars = []) {
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) spawns the
494
- // build output.
495
- for (const handle of spawnHandles) {
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 spawnHandles = collectSpawnHandles(content, buildOutputVars);
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, spawnHandles, buildOutputVars),
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 { detectPhasesFromLabels, parseRecommendedWorkflow, determinePhasesForIssue, DOCS_LABELS, } from "./phase-mapper.js";
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
- // Parse recommended workflow from spec output
880
- const parsedWorkflow = specResult.output
881
- ? parseRecommendedWorkflow(specResult.output)
882
- : null;
883
- if (parsedWorkflow) {
884
- // Remove spec from phases since we already ran it
885
- phases = parsedWorkflow.phases.filter((p) => p !== "spec");
886
- detectedQualityLoop = parsedWorkflow.qualityLoop;
887
- log(chalk.gray(` Spec recommends: ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
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, withActivityHook(issueConfig, issueNumber, phase, onProgress, makeWaitTransition(phase)), resumeHandle, worktreePath, shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
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(result);
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 shouldCreatePR = success && worktreePath && branch && !options.noPr;
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
- : deriveFailureCategory(phaseResults),
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;