sequant 2.10.0 → 2.12.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 (108) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +19 -2
  4. package/dist/bin/cli.js +47 -2
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +331 -12
  8. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +7 -18
  9. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +5 -1
  10. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +62 -8
  11. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +187 -28
  12. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +127 -23
  13. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +130 -13
  14. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +306 -8
  15. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +79 -0
  16. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +40 -20
  17. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +14 -1
  18. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  19. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +23 -6
  20. package/dist/src/commands/doctor.js +20 -18
  21. package/dist/src/commands/locks.d.ts +20 -1
  22. package/dist/src/commands/locks.js +206 -4
  23. package/dist/src/commands/ready.d.ts +6 -0
  24. package/dist/src/commands/ready.js +19 -1
  25. package/dist/src/commands/run-display.js +1 -0
  26. package/dist/src/commands/worktree.d.ts +31 -0
  27. package/dist/src/commands/worktree.js +95 -0
  28. package/dist/src/lib/ac-linter.js +26 -0
  29. package/dist/src/lib/ac-parser.d.ts +40 -0
  30. package/dist/src/lib/ac-parser.js +202 -16
  31. package/dist/src/lib/cli-flags.d.ts +23 -0
  32. package/dist/src/lib/cli-flags.js +43 -0
  33. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +2 -0
  34. package/dist/src/lib/cli-ui/run-renderer.js +7 -1
  35. package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
  36. package/dist/src/lib/locks/checkout-lock.js +389 -0
  37. package/dist/src/lib/locks/index.d.ts +6 -3
  38. package/dist/src/lib/locks/index.js +4 -2
  39. package/dist/src/lib/locks/lock-manager.d.ts +81 -1
  40. package/dist/src/lib/locks/lock-manager.js +230 -5
  41. package/dist/src/lib/locks/types.d.ts +72 -0
  42. package/dist/src/lib/locks/types.js +28 -0
  43. package/dist/src/lib/markdown-fence.d.ts +24 -0
  44. package/dist/src/lib/markdown-fence.js +51 -0
  45. package/dist/src/lib/mcp-config.d.ts +24 -0
  46. package/dist/src/lib/mcp-config.js +51 -0
  47. package/dist/src/lib/scope/analyzer.d.ts +4 -0
  48. package/dist/src/lib/scope/analyzer.js +7 -1
  49. package/dist/src/lib/settings.d.ts +111 -1
  50. package/dist/src/lib/settings.js +59 -0
  51. package/dist/src/lib/system.d.ts +7 -3
  52. package/dist/src/lib/system.js +7 -3
  53. package/dist/src/lib/test-tautology-detector.d.ts +4 -3
  54. package/dist/src/lib/test-tautology-detector.js +147 -40
  55. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  56. package/dist/src/lib/workflow/batch-executor.js +154 -23
  57. package/dist/src/lib/workflow/config-resolver.d.ts +25 -0
  58. package/dist/src/lib/workflow/config-resolver.js +90 -0
  59. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +22 -0
  60. package/dist/src/lib/workflow/drivers/claude-code.js +14 -3
  61. package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
  62. package/dist/src/lib/workflow/effort-escalation.js +82 -0
  63. package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
  64. package/dist/src/lib/workflow/error-classifier.js +4 -0
  65. package/dist/src/lib/workflow/log-writer.d.ts +10 -1
  66. package/dist/src/lib/workflow/log-writer.js +20 -0
  67. package/dist/src/lib/workflow/metrics-schema.d.ts +49 -6
  68. package/dist/src/lib/workflow/metrics-schema.js +33 -0
  69. package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
  70. package/dist/src/lib/workflow/mutation-marker.d.ts +86 -0
  71. package/dist/src/lib/workflow/mutation-marker.js +97 -0
  72. package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
  73. package/dist/src/lib/workflow/phase-detection.js +5 -1
  74. package/dist/src/lib/workflow/phase-executor.d.ts +17 -0
  75. package/dist/src/lib/workflow/phase-executor.js +60 -4
  76. package/dist/src/lib/workflow/qa-gaps-marker.d.ts +38 -0
  77. package/dist/src/lib/workflow/qa-gaps-marker.js +66 -0
  78. package/dist/src/lib/workflow/ready-gate.d.ts +53 -1
  79. package/dist/src/lib/workflow/ready-gate.js +105 -14
  80. package/dist/src/lib/workflow/run-log-schema.d.ts +175 -0
  81. package/dist/src/lib/workflow/run-log-schema.js +71 -1
  82. package/dist/src/lib/workflow/run-orchestrator.js +27 -0
  83. package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
  84. package/dist/src/lib/workflow/spec-recommendation.js +142 -0
  85. package/dist/src/lib/workflow/state-schema.d.ts +5 -1
  86. package/dist/src/lib/workflow/state-schema.js +8 -1
  87. package/dist/src/lib/workflow/types.d.ts +78 -0
  88. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  89. package/dist/src/lib/workflow/worktree-manager.js +9 -1
  90. package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
  91. package/dist/src/lib/workflow/worktree-resolver.js +126 -0
  92. package/package.json +4 -3
  93. package/templates/hooks/pre-tool.sh +331 -12
  94. package/templates/scripts/cleanup-worktree.sh +36 -15
  95. package/templates/scripts/new-feature.sh +25 -19
  96. package/templates/skills/_shared/references/subagent-types.md +7 -18
  97. package/templates/skills/assess/SKILL.md +5 -1
  98. package/templates/skills/exec/SKILL.md +62 -8
  99. package/templates/skills/fullsolve/SKILL.md +187 -28
  100. package/templates/skills/loop/SKILL.md +127 -23
  101. package/templates/skills/merger/SKILL.md +130 -13
  102. package/templates/skills/qa/SKILL.md +306 -8
  103. package/templates/skills/release/SKILL.md +79 -0
  104. package/templates/skills/spec/SKILL.md +40 -20
  105. package/templates/skills/spec/references/recommended-workflow.md +14 -1
  106. package/templates/skills/test/SKILL.md +1 -1
  107. package/templates/skills/testgen/SKILL.md +23 -6
  108. package/templates/agents/sequant-explorer.md +0 -24
@@ -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, selectFixableGaps, } 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";
@@ -133,8 +135,11 @@ export function buildLoopContext(failedResult) {
133
135
  if (failedResult.verdict) {
134
136
  parts.push(`QA Verdict: ${failedResult.verdict}`);
135
137
  }
136
- if (failedResult.summary?.gaps?.length) {
137
- parts.push(`QA Gaps:\n${failedResult.summary.gaps.map((gap) => `- ${gap}`).join("\n")}`);
138
+ // #937 AC-3: exclude findings marked `document`/`pause_for_human` — those
139
+ // are QA-real but not something a fix loop should chase.
140
+ const fixableGaps = selectFixableGaps(failedResult.summary);
141
+ if (fixableGaps.length) {
142
+ parts.push(`QA Gaps:\n${fixableGaps.map((gap) => `- ${gap}`).join("\n")}`);
138
143
  }
139
144
  if (failedResult.summary?.suggestions?.length) {
140
145
  parts.push(`Suggestions:\n${failedResult.summary.suggestions.map((s) => `- ${s}`).join("\n")}`);
@@ -562,6 +567,50 @@ async function recordWindowHaltState(stateManager, issueNumber, phase, result) {
562
567
  // State tracking errors shouldn't stop execution
563
568
  }
564
569
  }
570
+ /**
571
+ * Build the comment body for a standard-qa-phase verdict post (#964).
572
+ * Includes AC coverage and any gaps/suggestions from the parsed `QaSummary`,
573
+ * plus a machine marker so a future dedup pass has an anchor.
574
+ * @internal Exported for testing.
575
+ */
576
+ export function buildQaVerdictComment(verdict, summary, commitHash, iteration) {
577
+ const lines = [`## QA Verdict: ${verdict}`];
578
+ if (summary) {
579
+ lines.push("", `AC coverage: ${summary.acMet}/${summary.acTotal} met`);
580
+ if (summary.gaps.length > 0) {
581
+ lines.push("", "**Gaps:**", ...summary.gaps.map((g) => `- ${g}`));
582
+ }
583
+ if (summary.suggestions.length > 0) {
584
+ lines.push("", "**Suggestions:**", ...summary.suggestions.map((s) => `- ${s}`));
585
+ }
586
+ }
587
+ lines.push("", `<!-- SEQUANT_QA_VERDICT: ${JSON.stringify({
588
+ verdict,
589
+ commit: commitHash ?? null,
590
+ iteration,
591
+ })} -->`);
592
+ return lines.join("\n");
593
+ }
594
+ /**
595
+ * Post the qa-verdict comment for a standard (non-ready-gate) qa phase under
596
+ * orchestrated `sequant run` (#964). This is the channel `qa/SKILL.md` §9
597
+ * promises ("orchestrator handles aggregated summary") but batch-executor
598
+ * never backed — a re-run producing a fresh, different verdict left a stale,
599
+ * contradicted comment as the only externally-visible one.
600
+ *
601
+ * Best-effort: a post failure is caught and logged, never fails the run —
602
+ * mirrors {@link runReadyGateForIssue}'s `postReport` contract (#937 AC-4).
603
+ * @internal Exported for testing.
604
+ */
605
+ export async function postQaVerdictComment(issueNumber, verdict, summary, commitHash, iteration, log, postComment = (n, body) => new GitHubProvider().postComment(String(n), body)) {
606
+ try {
607
+ const body = buildQaVerdictComment(verdict, summary, commitHash, iteration);
608
+ await postComment(issueNumber, body);
609
+ }
610
+ catch (err) {
611
+ log(chalk.yellow(` ! Failed to post QA verdict comment: ${err}`));
612
+ }
613
+ }
565
614
  /**
566
615
  * Run the post-QA ready gate (#817) for a single issue at the run path's
567
616
  * post-success / pre-PR seam.
@@ -592,6 +641,8 @@ async function runReadyGateForIssue(args) {
592
641
  const getSettingsFn = args.getSettingsFn ?? getSettings;
593
642
  const fetchBody = args.fetchBody ??
594
643
  ((n) => new GitHubProvider().fetchIssueBodySync(String(n)));
644
+ const postComment = args.postComment ??
645
+ ((n, body) => new GitHubProvider().postComment(String(n), body));
595
646
  try {
596
647
  const settings = await getSettingsFn();
597
648
  const policy = settings.ready.policy;
@@ -618,6 +669,8 @@ async function runReadyGateForIssue(args) {
618
669
  verbose: config.verbose,
619
670
  runPhase,
620
671
  onProgress,
672
+ // #937 AC-4: persist the final gap report as an issue comment.
673
+ postReport: (body) => postComment(issueNumber, body),
621
674
  });
622
675
  log(result.ready
623
676
  ? chalk.green(` ✓ Ready gate: ${result.reason} — awaiting human merge (never merged)`)
@@ -634,7 +687,7 @@ async function runReadyGateForIssue(args) {
634
687
  }
635
688
  export async function runIssueWithLogging(ctx) {
636
689
  // Destructure context for use throughout the function
637
- const { issueNumber, config, options, title: issueTitle, labels, services: { logWriter, stateManager, shutdownManager }, worktree, chain, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, } = ctx;
690
+ const { issueNumber, config, options, title: issueTitle, labels, services: { logWriter, stateManager, shutdownManager }, worktree, chain, packageManager, baseBranch, onProgress, onPhasePlan, phasePauseHandle, postComment: injectedPostComment, } = ctx;
638
691
  const worktreePath = worktree?.path;
639
692
  const branch = worktree?.branch;
640
693
  const chainMode = chain?.enabled;
@@ -876,22 +929,35 @@ export async function runIssueWithLogging(ctx) {
876
929
  failureCategory: deriveFailureCategory(phaseResults),
877
930
  };
878
931
  }
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)" : ""}`));
932
+ // Resolve the spec→run phase recommendation through the ordered chain
933
+ // comment-marker comment-prose → chat-text → label-fallback (#921).
934
+ // Chat-text parsing alone is nondeterministic: the spec agent's plan
935
+ // comment is the durable artifact, but the old code only ever looked at
936
+ // ephemeral chat output, silently dropping recommended phases (e.g.
937
+ // testgen) whenever the agent posted the plan via a body file (#814).
938
+ const resolved = resolveSpecRecommendation({
939
+ chatOutput: specResult.output ?? "",
940
+ issueNumber,
941
+ labels,
942
+ });
943
+ // `resolveSpecRecommendation` already excludes "spec" regardless of
944
+ // which step in the chain produced the result.
945
+ phases = resolved.phases;
946
+ detectedQualityLoop = resolved.qualityLoop;
947
+ if (logWriter) {
948
+ logWriter.setSpecRecommendation({ source: resolved.source, phases, qualityLoop: detectedQualityLoop }, issueNumber);
949
+ }
950
+ if (resolved.source === "marker") {
951
+ log(chalk.gray(` Spec recommends (marker): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
952
+ }
953
+ else if (resolved.source === "comment-prose") {
954
+ log(chalk.gray(` Spec recommends (comment): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
955
+ }
956
+ else if (resolved.source === "chat") {
957
+ log(chalk.gray(` Spec recommends (chat): ${phases.join(" → ")}${detectedQualityLoop ? " (quality loop)" : ""}`));
888
958
  }
889
959
  else {
890
- // Fall back to label-based detection
891
960
  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
961
  log(chalk.gray(` Fallback: ${phases.join(" → ")}`));
896
962
  }
897
963
  }
@@ -1010,8 +1076,22 @@ export async function runIssueWithLogging(ctx) {
1010
1076
  // State tracking errors shouldn't stop execution
1011
1077
  }
1012
1078
  }
1079
+ // #915: iteration > 1 is the outer quality-loop's retry signal — the
1080
+ // same condition that triggers the "Quality loop iteration" log line
1081
+ // above. This loop re-runs the WHOLE `phases` list on every iteration
1082
+ // (it does not resume from the specific phase that failed), so the
1083
+ // escalation is per RETRIED ITERATION, not per specific-phase-that-
1084
+ // previously-failed: every phase dispatched while iteration > 1
1085
+ // escalates, including one that already succeeded on iteration 1 (e.g.
1086
+ // exec re-running alongside a retried qa). `withEscalatedEffort` is a
1087
+ // no-op (returns the input config by reference) whenever escalation is
1088
+ // off or this is the first attempt.
1089
+ const { config: dispatchConfig, record: escalationRecord } = withEscalatedEffort(withActivityHook(issueConfig, issueNumber, phase, onProgress, makeWaitTransition(phase)), phase, iteration > 1);
1090
+ if (escalationRecord && config.verbose) {
1091
+ log(chalk.gray(` effort: ${escalationRecord.base} → ${escalationRecord.escalated} (loop retry)`));
1092
+ }
1013
1093
  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
1094
+ const result = await executePhaseWithRetry(issueNumber, phase, dispatchConfig, resumeHandle, worktreePath, shutdownManager, phasePauseHandle, undefined, // executePhaseFn — use the default
1015
1095
  undefined, // delayFn — use the default
1016
1096
  autoWaitLedger);
1017
1097
  const phaseEndTime = new Date();
@@ -1027,7 +1107,15 @@ export async function runIssueWithLogging(ctx) {
1027
1107
  }
1028
1108
  }
1029
1109
  }
1030
- phaseResults.push(result);
1110
+ phaseResults.push(escalationRecord
1111
+ ? {
1112
+ ...result,
1113
+ escalatedEffort: {
1114
+ base: escalationRecord.base,
1115
+ escalated: escalationRecord.escalated,
1116
+ },
1117
+ }
1118
+ : result);
1031
1119
  // Emit completion/failure progress event (AC-8)
1032
1120
  const phaseDurationSec = Math.round((phaseEndTime.getTime() - phaseStartTime.getTime()) / 1000);
1033
1121
  if (result.success) {
@@ -1066,6 +1154,23 @@ export async function runIssueWithLogging(ctx) {
1066
1154
  /* progress errors must not halt */
1067
1155
  }
1068
1156
  }
1157
+ // #964: post the verdict from a standard (non-ready-gate) qa phase.
1158
+ // qa/SKILL.md §9 promises "the orchestrator handles aggregated summary"
1159
+ // under SEQUANT_ORCHESTRATOR, but nothing backed that promise — a
1160
+ // re-run producing a fresh, different verdict left the stale prior
1161
+ // comment as the only externally-visible one. Gating on
1162
+ // `result.success && result.verdict` also excludes turn-capped and
1163
+ // unparseable-verdict phases (AC-4) without extra bookkeeping, since
1164
+ // both already flow through the `else` branch above.
1165
+ if (phase === "qa" && result.success && result.verdict) {
1166
+ const verdictDiffBase = worktreePath
1167
+ ? resolveDiffBase(worktreePath, baseBranch ?? "main")
1168
+ : undefined;
1169
+ const verdictCommitHash = worktreePath && verdictDiffBase
1170
+ ? getCommitHash(worktreePath, verdictDiffBase)
1171
+ : undefined;
1172
+ await postQaVerdictComment(issueNumber, result.verdict, result.summary, verdictCommitHash, iteration, log, injectedPostComment);
1173
+ }
1069
1174
  // Log phase result with observability data (AC-1, AC-2, AC-3, AC-7)
1070
1175
  if (logWriter) {
1071
1176
  // Resolve the diff base once (#878): worktrees branch from
@@ -1185,10 +1290,14 @@ export async function runIssueWithLogging(ctx) {
1185
1290
  // Build enriched config for loop phase with QA context (#488).
1186
1291
  // Pass verdict, failed ACs, and error directly so the /loop skill
1187
1292
  // doesn't need to reconstruct context from GitHub comments.
1293
+ // #937 AC-3: exclude `document`/`pause_for_human`-tagged findings
1294
+ // from what the loop is told to fix (same filter as ready-gate's
1295
+ // fixableGaps).
1296
+ const fixableGaps = selectFixableGaps(result.summary);
1188
1297
  const loopConfig = {
1189
1298
  ...issueConfig,
1190
1299
  lastVerdict: result.verdict ?? undefined,
1191
- failedAcs: result.summary?.gaps?.join("; ") ?? undefined,
1300
+ failedAcs: fixableGaps.length ? fixableGaps.join("; ") : undefined,
1192
1301
  promptContext: buildLoopContext(result),
1193
1302
  };
1194
1303
  const loopStartTime = new Date();
@@ -1356,7 +1465,23 @@ export async function runIssueWithLogging(ctx) {
1356
1465
  // warning and leave the issue at `success`. Recorded here and folded into the
1357
1466
  // returned `success` below.
1358
1467
  let prCreationError;
1359
- const shouldCreatePR = success && worktreePath && branch && !options.noPr;
1468
+ const wouldCreatePR = success && worktreePath && branch && !options.noPr;
1469
+ // #920: a phase-restricted run (e.g. `--phases spec`) provisions a worktree
1470
+ // and branch unconditionally of which phases ran, so a clean spec-only pass
1471
+ // satisfies every conjunct above with zero commits — `gh pr create` then
1472
+ // fails with "No commits between main and …" and the run reports failed
1473
+ // for work that landed exactly where it was supposed to (the plan comment).
1474
+ // Gate on the evidence (commits ahead of base) rather than the phase list:
1475
+ // `hasExecChanges` fails open (`unknown` counts as "has changes") so a git
1476
+ // error here falls through to today's attempt-PR behavior, and it resolves
1477
+ // the base the same #537-aware way `classifyExecChanges` already does for
1478
+ // the exec zero-diff guard.
1479
+ let prSkippedReason;
1480
+ if (wouldCreatePR && !hasExecChanges(worktreePath)) {
1481
+ prSkippedReason = `no commits ahead of ${baseBranch ?? "main"} (no implementing phase ran)`;
1482
+ log(chalk.gray(` ℹ️ PR skipped — ${prSkippedReason}`));
1483
+ }
1484
+ const shouldCreatePR = wouldCreatePR && !prSkippedReason;
1360
1485
  if (shouldCreatePR) {
1361
1486
  // #605: under --stacked, target predecessor branch (only for non-first,
1362
1487
  // non-last issues). Last PR keeps `main` so partial progress can land.
@@ -1424,10 +1549,16 @@ export async function runIssueWithLogging(ctx) {
1424
1549
  prNumber,
1425
1550
  prUrl,
1426
1551
  prCreationError,
1552
+ prSkippedReason,
1427
1553
  checkpointFailed,
1428
1554
  failureCategory: overallSuccess
1429
1555
  ? undefined
1430
- : deriveFailureCategory(phaseResults),
1556
+ : // #920: a PR-creation failure has no failed phase for
1557
+ // `deriveFailureCategory` to classify — fall back to the dedicated
1558
+ // category so the metrics residual from #879 (empty failureCategory
1559
+ // on a PR-only failure) doesn't reopen for this failure path.
1560
+ (deriveFailureCategory(phaseResults) ??
1561
+ (prCreationError ? "pr_creation" : undefined)),
1431
1562
  // #817: present only when `--ready-gate` ran the gate; the summary renders
1432
1563
  // its terminal reason (AC-6).
1433
1564
  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
@@ -204,6 +285,7 @@ export function buildExecutionConfig(mergedOptions, settings, issueCount) {
204
285
  maxIterations: positiveOr(mergedOptions.maxIterations, positiveOr(settings.run.maxIterations, DEFAULT_CONFIG.maxIterations)),
205
286
  noSmartTests: mergedOptions.noSmartTests ?? false,
206
287
  mcp: mcpEnabled,
288
+ mcpAllowlist: settings.run.mcpAllowlist,
207
289
  retry: retryEnabled,
208
290
  // #804: default 0 (off) — the whole regression contract for auto-wait is
209
291
  // that an unset flag leaves the #761/#799 halt path untouched.
@@ -219,5 +301,13 @@ export function buildExecutionConfig(mergedOptions, settings, issueCount) {
219
301
  // load-bearing wire the #795 inert-flag class guards against — the flag is
220
302
  // useless if it stops reaching the executor here.
221
303
  readyGate: mergedOptions.readyGate ?? false,
304
+ // #914: CLI > settings > absent, via the shared resolver both
305
+ // ExecutionConfig producers call (see `resolvePhasePolicies`'s doc
306
+ // comment for the #833 drift this guards against).
307
+ phasePolicies: resolvePhasePolicies(mergedOptions.models, mergedOptions.efforts, settings.run.phases, getPhaseNames()),
308
+ // #915: CLI > settings > default `false` — mirrors the `readyGate`
309
+ // precedent above. Both `ExecutionConfig` producers (here and
310
+ // `ready-gate.ts:buildPhaseConfig`) resolve this the same way (#833).
311
+ effortEscalation: mergedOptions.escalateEffort ?? settings.run.effortEscalation ?? false,
222
312
  };
223
313
  }
@@ -32,6 +32,13 @@ export interface AgentExecutionConfig {
32
32
  phaseTimeout: number;
33
33
  verbose: boolean;
34
34
  mcp: boolean;
35
+ /**
36
+ * Claude Desktop MCP server names explicitly opted in to pass through to
37
+ * this phase, despite `mcp`'s default exclusion (#936). Forwarded
38
+ * verbatim to `getPhaseMcpServersConfig`'s `desktopAllowlist` by
39
+ * ClaudeCodeDriver; ignored by drivers without an MCP concept.
40
+ */
41
+ mcpAllowlist?: string[];
35
42
  /**
36
43
  * Resume a previous session (driver-specific; ignored if unsupported).
37
44
  *
@@ -49,6 +56,21 @@ export interface AgentExecutionConfig {
49
56
  onStderr?: (text: string) => void;
50
57
  /** Relevant files for the phase (used by file-oriented drivers like Aider) */
51
58
  files?: string[];
59
+ /**
60
+ * Claude model to use for this phase (#914). Forwarded verbatim to the
61
+ * Agent SDK `query()` options by ClaudeCodeDriver; ignored by drivers
62
+ * without a model concept (Aider uses its own `AiderSettings.model`).
63
+ * Absent by default — the SDK falls back to the CLI default model.
64
+ */
65
+ model?: string;
66
+ /**
67
+ * Reasoning effort for this phase (#914). Forwarded verbatim to the Agent
68
+ * SDK `query()` options by ClaudeCodeDriver; ignored by drivers without an
69
+ * effort concept. Absent by default — the SDK defaults to `high`. Matches
70
+ * the SDK's own closed `EffortLevel` enum, not a bare `string`, so a value
71
+ * that reaches this field type-checks against `query()`'s options.
72
+ */
73
+ effort?: "low" | "medium" | "high" | "xhigh" | "max";
52
74
  }
53
75
  /**
54
76
  * Result returned by an agent after executing a phase.
@@ -5,7 +5,7 @@
5
5
  * orchestration layer should import the SDK directly.
6
6
  */
7
7
  import { query } from "@anthropic-ai/claude-agent-sdk";
8
- import { getMcpServersConfig } from "../../system.js";
8
+ import { getPhaseMcpServersConfig } from "../../mcp-config.js";
9
9
  import { RateLimitError, BillingError, createRateLimitError, isWaitableWindow, isRateLimitFailureInfo, } from "../../errors.js";
10
10
  import { RingBuffer } from "../ring-buffer.js";
11
11
  export class ClaudeCodeDriver {
@@ -80,8 +80,14 @@ export class ClaudeCodeDriver {
80
80
  resumeToken = undefined;
81
81
  }
82
82
  try {
83
- // Get MCP servers config if enabled
84
- const mcpServers = config.mcp ? getMcpServersConfig() : undefined;
83
+ // Get MCP servers config if enabled — allowlisted, not passed through
84
+ // from Claude Desktop config (#936), except for servers explicitly
85
+ // named in config.mcpAllowlist (settings.run.mcpAllowlist).
86
+ const mcpServers = config.mcp
87
+ ? getPhaseMcpServersConfig(config.cwd, {
88
+ desktopAllowlist: config.mcpAllowlist,
89
+ })
90
+ : undefined;
85
91
  const queryInstance = query({
86
92
  prompt,
87
93
  options: {
@@ -96,6 +102,11 @@ export class ClaudeCodeDriver {
96
102
  ...(resumeToken ? { resume: resumeToken } : {}),
97
103
  env: config.env,
98
104
  ...(mcpServers ? { mcpServers } : {}),
105
+ // #914: per-phase model/effort override. Omitted entirely when
106
+ // unset (not `undefined`-valued) so the SDK's own default
107
+ // resolution is untouched — see the AC-3 key-presence test.
108
+ ...(config.model ? { model: config.model } : {}),
109
+ ...(config.effort ? { effort: config.effort } : {}),
99
110
  stderr: (data) => {
100
111
  capturedStderr += data;
101
112
  // 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;
@@ -0,0 +1,82 @@
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
+ /**
18
+ * Base effort assumed for a phase with no configured `effort` override, when
19
+ * escalation needs a starting point to step up from (AC-5). `phase-executor.ts`
20
+ * omits the `effort` key entirely in that case (#914) so the Agent SDK's own
21
+ * default applies — #914 deliberately never encoded what that default is,
22
+ * since "omitted" is not the same claim as "equals X".
23
+ *
24
+ * Verified against `@anthropic-ai/claude-agent-sdk`'s own `query()` Options
25
+ * type (`sdk.d.ts`): `effort?: EffortLevel` is documented inline as
26
+ * `'high' — Deep reasoning (default)`. That is the SDK's default for the
27
+ * exact call sequant makes (raw `query()`, not the Claude Code CLI product —
28
+ * whose own `xhigh` default is a caller choice on top of this SDK, not the
29
+ * SDK's own default), so this constant is not a guess.
30
+ */
31
+ export const DEFAULT_ESCALATION_BASE = "high";
32
+ /**
33
+ * Pure ladder step: one tier above `base` on `EFFORT_LEVELS`, capped at the
34
+ * top (`max`). Returns `base` unchanged whenever `enabled` is false or this
35
+ * isn't a retry — the disabled/first-attempt path must be indistinguishable
36
+ * from #914 with escalation never having existed (AC-2).
37
+ *
38
+ * Always escalates from the phase's CONFIGURED base, never from a previously
39
+ * escalated value — callers must not accumulate escalation across iterations
40
+ * (AC-6): base `high` on the 3rd loop iteration is `xhigh`, not `max`.
41
+ */
42
+ export function resolveEscalatedEffort(base, isRetry, enabled) {
43
+ if (!enabled || !isRetry)
44
+ return base;
45
+ const effectiveBase = (base ??
46
+ DEFAULT_ESCALATION_BASE);
47
+ const baseIdx = EFFORT_LEVELS.indexOf(effectiveBase);
48
+ const resolvedIdx = baseIdx === -1 ? EFFORT_LEVELS.indexOf(DEFAULT_ESCALATION_BASE) : baseIdx;
49
+ const nextIdx = Math.min(resolvedIdx + 1, EFFORT_LEVELS.length - 1);
50
+ return EFFORT_LEVELS[nextIdx];
51
+ }
52
+ /**
53
+ * Apply escalation to ONE phase's execution, for THIS dispatch only.
54
+ *
55
+ * This is deliberately a per-execution decision made at the dispatch site,
56
+ * not a value baked into `ExecutionConfig` at build time: `buildExecutionConfig`
57
+ * / `buildPhaseConfig` run once per run/gate, not once per phase execution, so
58
+ * a static escalated value would leak across every phase in the chain and
59
+ * violate AC-7. The three retry-dispatch sites (batch-executor.ts's quality
60
+ * loop, ready-gate.ts's QA-pass loop `qa`/`loop` dispatch) call this function
61
+ * — and only this function — so they cannot drift on the cap/one-tier rules
62
+ * in AC-6 (see resolveEscalatedEffort's doc comment).
63
+ */
64
+ export function withEscalatedEffort(config, phase, isRetry) {
65
+ if (!config.effortEscalation || !isRetry)
66
+ return { config };
67
+ const currentPolicy = config.phasePolicies?.[phase];
68
+ const base = currentPolicy?.effort;
69
+ const escalated = resolveEscalatedEffort(base, isRetry, true);
70
+ if (!escalated || escalated === base)
71
+ return { config };
72
+ return {
73
+ config: {
74
+ ...config,
75
+ phasePolicies: {
76
+ ...config.phasePolicies,
77
+ [phase]: { ...currentPolicy, effort: escalated },
78
+ },
79
+ },
80
+ record: { phase, base: base ?? DEFAULT_ESCALATION_BASE, escalated },
81
+ };
82
+ }