sequant 2.11.0 → 2.13.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 (75) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +47 -3
  4. package/dist/dashboard/server.js +4 -0
  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/README.md +9 -0
  8. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +331 -12
  9. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +7 -18
  10. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +5 -1
  11. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +62 -8
  12. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +187 -28
  13. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +127 -23
  14. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +130 -13
  15. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +306 -8
  16. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +79 -0
  17. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +42 -20
  18. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +14 -1
  19. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  20. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +23 -6
  21. package/dist/src/commands/doctor.js +20 -18
  22. package/dist/src/commands/ready.js +7 -1
  23. package/dist/src/commands/status.js +4 -0
  24. package/dist/src/lib/ac-linter.js +26 -0
  25. package/dist/src/lib/ac-parser.d.ts +40 -0
  26. package/dist/src/lib/ac-parser.js +202 -16
  27. package/dist/src/lib/markdown-fence.d.ts +24 -0
  28. package/dist/src/lib/markdown-fence.js +51 -0
  29. package/dist/src/lib/mcp-config.d.ts +24 -0
  30. package/dist/src/lib/mcp-config.js +51 -0
  31. package/dist/src/lib/scope/analyzer.d.ts +4 -0
  32. package/dist/src/lib/scope/analyzer.js +7 -1
  33. package/dist/src/lib/settings.d.ts +73 -14
  34. package/dist/src/lib/settings.js +45 -3
  35. package/dist/src/lib/system.d.ts +7 -3
  36. package/dist/src/lib/system.js +7 -3
  37. package/dist/src/lib/test-tautology-detector.js +50 -3
  38. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  39. package/dist/src/lib/workflow/batch-executor.js +81 -6
  40. package/dist/src/lib/workflow/config-resolver.d.ts +30 -2
  41. package/dist/src/lib/workflow/config-resolver.js +59 -2
  42. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +14 -0
  43. package/dist/src/lib/workflow/drivers/claude-code.js +36 -4
  44. package/dist/src/lib/workflow/metrics-schema.d.ts +10 -1
  45. package/dist/src/lib/workflow/metrics-schema.js +13 -1
  46. package/dist/src/lib/workflow/metrics-writer.d.ts +3 -1
  47. package/dist/src/lib/workflow/mutation-marker.d.ts +86 -0
  48. package/dist/src/lib/workflow/mutation-marker.js +97 -0
  49. package/dist/src/lib/workflow/phase-executor.d.ts +17 -0
  50. package/dist/src/lib/workflow/phase-executor.js +60 -6
  51. package/dist/src/lib/workflow/qa-gaps-marker.d.ts +38 -0
  52. package/dist/src/lib/workflow/qa-gaps-marker.js +66 -0
  53. package/dist/src/lib/workflow/ready-gate.d.ts +25 -1
  54. package/dist/src/lib/workflow/ready-gate.js +81 -11
  55. package/dist/src/lib/workflow/reconcile.js +4 -2
  56. package/dist/src/lib/workflow/run-log-schema.d.ts +120 -0
  57. package/dist/src/lib/workflow/run-log-schema.js +40 -0
  58. package/dist/src/lib/workflow/run-orchestrator.d.ts +18 -0
  59. package/dist/src/lib/workflow/run-orchestrator.js +38 -2
  60. package/dist/src/lib/workflow/state-cleanup.d.ts +4 -4
  61. package/dist/src/lib/workflow/state-cleanup.js +9 -5
  62. package/dist/src/lib/workflow/state-schema.d.ts +10 -1
  63. package/dist/src/lib/workflow/state-schema.js +13 -1
  64. package/dist/src/lib/workflow/types.d.ts +20 -0
  65. package/dist/src/mcp/tools/run.js +10 -1
  66. package/package.json +13 -12
  67. package/templates/hooks/pre-tool.sh +108 -17
  68. package/templates/memory/constitution.md +112 -45
  69. package/templates/skills/exec/SKILL.md +1 -1
  70. package/templates/skills/fullsolve/SKILL.md +62 -9
  71. package/templates/skills/loop/SKILL.md +71 -12
  72. package/templates/skills/merger/SKILL.md +32 -3
  73. package/templates/skills/qa/SKILL.md +247 -2
  74. package/templates/skills/spec/SKILL.md +11 -5
  75. package/templates/skills/test/SKILL.md +1 -1
@@ -14,7 +14,7 @@ import { withEscalatedEffort } from "./effort-escalation.js";
14
14
  import { classifyError, errorTypeToCategory, } from "./error-classifier.js";
15
15
  import { getGitDiffStats, getCommitHash, resolveDiffBase, } from "./git-diff-utils.js";
16
16
  import { createCheckpointCommit, rebaseBeforePR, createPR, readCacheMetrics, filterResumedPhases, } from "./worktree-manager.js";
17
- import { AUTO_WAIT_BUFFER_MS, createAutoWaitLedger, executePhaseWithRetry, hasExecChanges, isWindowExhaustedRateLimit, } from "./phase-executor.js";
17
+ import { AUTO_WAIT_BUFFER_MS, createAutoWaitLedger, executePhaseWithRetry, hasExecChanges, isWindowExhaustedRateLimit, selectFixableGaps, } from "./phase-executor.js";
18
18
  import { BillingError, RateLimitError, resetsAtToMs } from "../errors.js";
19
19
  import { parseBodyDependencyMarkers } from "./dependency-markers.js";
20
20
  import { determinePhasesForIssue, DOCS_LABELS } from "./phase-mapper.js";
@@ -135,8 +135,11 @@ export function buildLoopContext(failedResult) {
135
135
  if (failedResult.verdict) {
136
136
  parts.push(`QA Verdict: ${failedResult.verdict}`);
137
137
  }
138
- if (failedResult.summary?.gaps?.length) {
139
- 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")}`);
140
143
  }
141
144
  if (failedResult.summary?.suggestions?.length) {
142
145
  parts.push(`Suggestions:\n${failedResult.summary.suggestions.map((s) => `- ${s}`).join("\n")}`);
@@ -564,6 +567,50 @@ async function recordWindowHaltState(stateManager, issueNumber, phase, result) {
564
567
  // State tracking errors shouldn't stop execution
565
568
  }
566
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
+ }
567
614
  /**
568
615
  * Run the post-QA ready gate (#817) for a single issue at the run path's
569
616
  * post-success / pre-PR seam.
@@ -594,6 +641,8 @@ async function runReadyGateForIssue(args) {
594
641
  const getSettingsFn = args.getSettingsFn ?? getSettings;
595
642
  const fetchBody = args.fetchBody ??
596
643
  ((n) => new GitHubProvider().fetchIssueBodySync(String(n)));
644
+ const postComment = args.postComment ??
645
+ ((n, body) => new GitHubProvider().postComment(String(n), body));
597
646
  try {
598
647
  const settings = await getSettingsFn();
599
648
  const policy = settings.ready.policy;
@@ -620,6 +669,8 @@ async function runReadyGateForIssue(args) {
620
669
  verbose: config.verbose,
621
670
  runPhase,
622
671
  onProgress,
672
+ // #937 AC-4: persist the final gap report as an issue comment.
673
+ postReport: (body) => postComment(issueNumber, body),
623
674
  });
624
675
  log(result.ready
625
676
  ? chalk.green(` ✓ Ready gate: ${result.reason} — awaiting human merge (never merged)`)
@@ -636,7 +687,7 @@ async function runReadyGateForIssue(args) {
636
687
  }
637
688
  export async function runIssueWithLogging(ctx) {
638
689
  // Destructure context for use throughout the function
639
- 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;
640
691
  const worktreePath = worktree?.path;
641
692
  const branch = worktree?.branch;
642
693
  const chainMode = chain?.enabled;
@@ -1103,6 +1154,23 @@ export async function runIssueWithLogging(ctx) {
1103
1154
  /* progress errors must not halt */
1104
1155
  }
1105
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
+ }
1106
1174
  // Log phase result with observability data (AC-1, AC-2, AC-3, AC-7)
1107
1175
  if (logWriter) {
1108
1176
  // Resolve the diff base once (#878): worktrees branch from
@@ -1222,10 +1290,14 @@ export async function runIssueWithLogging(ctx) {
1222
1290
  // Build enriched config for loop phase with QA context (#488).
1223
1291
  // Pass verdict, failed ACs, and error directly so the /loop skill
1224
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);
1225
1297
  const loopConfig = {
1226
1298
  ...issueConfig,
1227
1299
  lastVerdict: result.verdict ?? undefined,
1228
- failedAcs: result.summary?.gaps?.join("; ") ?? undefined,
1300
+ failedAcs: fixableGaps.length ? fixableGaps.join("; ") : undefined,
1229
1301
  promptContext: buildLoopContext(result),
1230
1302
  };
1231
1303
  const loopStartTime = new Date();
@@ -1335,10 +1407,13 @@ export async function runIssueWithLogging(ctx) {
1335
1407
  // Hoisted out of the `if (stateManager)` block below because the checkpoint
1336
1408
  // warning also has to name this status, and naming the wrong one is exactly
1337
1409
  // the #837 inaccuracy being fixed here.
1410
+ const qaVerdict = phaseResults.find((p) => p.phase === "qa")?.verdict;
1338
1411
  const finalStatus = readyGateResult
1339
1412
  ? readyGateResult.issueStatus
1340
1413
  : success
1341
- ? "ready_for_merge"
1414
+ ? qaVerdict === "NEEDS_VERIFICATION"
1415
+ ? "awaiting_verification"
1416
+ : "ready_for_merge"
1342
1417
  : "in_progress";
1343
1418
  if (stateManager) {
1344
1419
  try {
@@ -7,7 +7,7 @@
7
7
  * @module
8
8
  */
9
9
  import { type ExecutionConfig, type RunOptions } from "./types.js";
10
- import type { SequantSettings } from "../settings.js";
10
+ import type { SequantSettings, ModelRoles } from "../settings.js";
11
11
  /**
12
12
  * Layers for config resolution.
13
13
  * Each field is optional — only defined values participate in merging.
@@ -72,6 +72,19 @@ export declare function positiveOr(value: number | undefined, fallback: number):
72
72
  export interface PhasePolicy {
73
73
  model?: string;
74
74
  effort?: string;
75
+ /**
76
+ * The original `role:<name>` string before resolution (#975). Set only when
77
+ * a `role:` prefix was used; absent for raw model strings. Allows metrics to
78
+ * record both what the user configured and what was actually dispatched.
79
+ */
80
+ requestedModel?: string;
81
+ /**
82
+ * Concrete model ID from `modelUsage` after phase execution (#975). Not
83
+ * present on the static config — populated by `enrichPhasePoliciesFromResults`
84
+ * just before the run is written to metrics. Absent for phases that did not
85
+ * execute or drivers that do not report `modelUsage`.
86
+ */
87
+ resolvedModel?: string;
75
88
  }
76
89
  /**
77
90
  * Parse a `--models`/`--efforts` CLI spec into a phase → value map.
@@ -83,6 +96,21 @@ export interface PhasePolicy {
83
96
  * malformed spec must never silently resolve to "nothing configured".
84
97
  */
85
98
  export declare function parsePhaseSpec(spec: string, phaseNames: string[]): Record<string, string>;
99
+ /**
100
+ * Resolve a model-or-role reference to a concrete model string (#975).
101
+ *
102
+ * - No `role:` prefix → returns verbatim (backward compat, AC-3).
103
+ * - `role:<name>` → looks up `<name>` in `modelRoles`.
104
+ * - Missing entry → throws, naming the role and available keys (AC-2).
105
+ * - String value → desugars to `{ "claude-code": value }`, picks by `activeDriver`.
106
+ * - Object value → picks by `activeDriver`; missing driver key → throws.
107
+ * - A role that resolves to a string for a different driver throws (AC-2).
108
+ *
109
+ * @param roleOrString - The model string from config/CLI (may have `role:` prefix).
110
+ * @param modelRoles - The resolved `run.modelRoles` map.
111
+ * @param activeDriver - Driver registry name (default: `"claude-code"`).
112
+ */
113
+ export declare function resolveRoleToModel(roleOrString: string, modelRoles?: ModelRoles, activeDriver?: string): string;
86
114
  /**
87
115
  * Resolve per-phase model/effort policies with CLI > settings > absent
88
116
  * precedence.
@@ -92,7 +120,7 @@ export declare function parsePhaseSpec(spec: string, phaseNames: string[]): Reco
92
120
  * two `phaseTimeout` producers did in #833 — see `positiveOr`'s doc comment
93
121
  * for that history.
94
122
  */
95
- export declare function resolvePhasePolicies(cliModels: string | undefined, cliEfforts: string | undefined, settingsPhases: Record<string, PhasePolicy> | undefined, phaseNames: string[]): Record<string, PhasePolicy>;
123
+ export declare function resolvePhasePolicies(cliModels: string | undefined, cliEfforts: string | undefined, settingsPhases: Record<string, PhasePolicy> | undefined, phaseNames: string[], modelRoles?: ModelRoles, activeDriver?: string): Record<string, PhasePolicy>;
96
124
  /**
97
125
  * Build an ExecutionConfig from merged RunOptions and settings.
98
126
  * Extracts the phase-timeout, MCP, retry, and mode resolution logic
@@ -7,6 +7,7 @@
7
7
  * @module
8
8
  */
9
9
  import { DEFAULT_CONFIG, DEFAULT_PHASES, } from "./types.js";
10
+ import { DEFAULT_MODEL_ROLES } from "../settings.js";
10
11
  import { getEnvConfig } from "./batch-executor.js";
11
12
  import { getPhaseNames } from "./phase-registry.js";
12
13
  /**
@@ -213,6 +214,46 @@ function applyPhaseSpec(target, parsed, field, phaseNames) {
213
214
  target[phase] = { ...target[phase], [field]: value };
214
215
  }
215
216
  }
217
+ /**
218
+ * Resolve a model-or-role reference to a concrete model string (#975).
219
+ *
220
+ * - No `role:` prefix → returns verbatim (backward compat, AC-3).
221
+ * - `role:<name>` → looks up `<name>` in `modelRoles`.
222
+ * - Missing entry → throws, naming the role and available keys (AC-2).
223
+ * - String value → desugars to `{ "claude-code": value }`, picks by `activeDriver`.
224
+ * - Object value → picks by `activeDriver`; missing driver key → throws.
225
+ * - A role that resolves to a string for a different driver throws (AC-2).
226
+ *
227
+ * @param roleOrString - The model string from config/CLI (may have `role:` prefix).
228
+ * @param modelRoles - The resolved `run.modelRoles` map.
229
+ * @param activeDriver - Driver registry name (default: `"claude-code"`).
230
+ */
231
+ export function resolveRoleToModel(roleOrString, modelRoles = DEFAULT_MODEL_ROLES, activeDriver = "claude-code") {
232
+ if (!roleOrString.startsWith("role:")) {
233
+ return roleOrString;
234
+ }
235
+ const roleName = roleOrString.slice("role:".length);
236
+ if (!roleName) {
237
+ throw new Error(`Invalid role reference "role:" — role name is empty. Available roles: ${Object.keys(modelRoles).join(", ")}.`);
238
+ }
239
+ const entry = modelRoles[roleName];
240
+ if (entry === undefined) {
241
+ throw new Error(`Role "${roleName}" is not defined in run.modelRoles. Available roles: ${Object.keys(modelRoles).join(", ")}.`);
242
+ }
243
+ if (typeof entry === "string") {
244
+ // String shorthand desugars to { "claude-code": value } — only for claude-code.
245
+ if (activeDriver !== "claude-code") {
246
+ throw new Error(`Role "${roleName}" uses a string shorthand (claude-code only) but the active driver is "${activeDriver}". Use an object map to define per-driver models.`);
247
+ }
248
+ return entry;
249
+ }
250
+ // Object map: pick by driver.
251
+ const resolved = entry[activeDriver];
252
+ if (resolved === undefined) {
253
+ throw new Error(`Role "${roleName}" has no entry for driver "${activeDriver}". Available drivers in this role: ${Object.keys(entry).join(", ")}.`);
254
+ }
255
+ return resolved;
256
+ }
216
257
  /**
217
258
  * Resolve per-phase model/effort policies with CLI > settings > absent
218
259
  * precedence.
@@ -222,7 +263,7 @@ function applyPhaseSpec(target, parsed, field, phaseNames) {
222
263
  * two `phaseTimeout` producers did in #833 — see `positiveOr`'s doc comment
223
264
  * for that history.
224
265
  */
225
- export function resolvePhasePolicies(cliModels, cliEfforts, settingsPhases, phaseNames) {
266
+ export function resolvePhasePolicies(cliModels, cliEfforts, settingsPhases, phaseNames, modelRoles, activeDriver) {
226
267
  const result = {};
227
268
  // Layer 1 (lowest): settings.run.phases. Skip any phase name settings
228
269
  // validation already didn't recognize — that's surfaced as a settings
@@ -243,6 +284,20 @@ export function resolvePhasePolicies(cliModels, cliEfforts, settingsPhases, phas
243
284
  if (cliEfforts) {
244
285
  applyPhaseSpec(result, parsePhaseSpec(cliEfforts, phaseNames), "effort", phaseNames);
245
286
  }
287
+ // Role resolution (#975): resolve any `role:<name>` model references to
288
+ // concrete model strings. Raw strings (no `role:` prefix) pass through
289
+ // verbatim — this is AC-3 backward compat.
290
+ if (modelRoles) {
291
+ for (const [phase, policy] of Object.entries(result)) {
292
+ if (policy.model && policy.model.startsWith("role:")) {
293
+ result[phase] = {
294
+ ...policy,
295
+ requestedModel: policy.model, // capture pre-resolution value (AC-4)
296
+ model: resolveRoleToModel(policy.model, modelRoles, activeDriver),
297
+ };
298
+ }
299
+ }
300
+ }
246
301
  return result;
247
302
  }
248
303
  /**
@@ -285,6 +340,7 @@ export function buildExecutionConfig(mergedOptions, settings, issueCount) {
285
340
  maxIterations: positiveOr(mergedOptions.maxIterations, positiveOr(settings.run.maxIterations, DEFAULT_CONFIG.maxIterations)),
286
341
  noSmartTests: mergedOptions.noSmartTests ?? false,
287
342
  mcp: mcpEnabled,
343
+ mcpAllowlist: settings.run.mcpAllowlist,
288
344
  retry: retryEnabled,
289
345
  // #804: default 0 (off) — the whole regression contract for auto-wait is
290
346
  // that an unset flag leaves the #761/#799 halt path untouched.
@@ -303,7 +359,8 @@ export function buildExecutionConfig(mergedOptions, settings, issueCount) {
303
359
  // #914: CLI > settings > absent, via the shared resolver both
304
360
  // ExecutionConfig producers call (see `resolvePhasePolicies`'s doc
305
361
  // comment for the #833 drift this guards against).
306
- phasePolicies: resolvePhasePolicies(mergedOptions.models, mergedOptions.efforts, settings.run.phases, getPhaseNames()),
362
+ // #975: pass modelRoles + active driver so `role:` prefixes resolve.
363
+ phasePolicies: resolvePhasePolicies(mergedOptions.models, mergedOptions.efforts, settings.run.phases, getPhaseNames(), settings.run.modelRoles, settings.run.agent ?? "claude-code"),
307
364
  // #915: CLI > settings > default `false` — mirrors the `readyGate`
308
365
  // precedent above. Both `ExecutionConfig` producers (here and
309
366
  // `ready-gate.ts:buildPhaseConfig`) resolve this the same way (#833).
@@ -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
  *
@@ -102,6 +109,13 @@ export interface AgentPhaseResult {
102
109
  stdoutTail?: string[];
103
110
  /** Process exit code (undefined for SDK-based drivers) (#447) */
104
111
  exitCode?: number;
112
+ /**
113
+ * Per-model usage totals from the SDK result (#975). Keys are concrete model
114
+ * IDs (e.g. `"claude-sonnet-5"`); first key is the resolved model string used
115
+ * for this phase. Only set by SDK-based drivers (claude-code); undefined for
116
+ * subprocess drivers (aider).
117
+ */
118
+ modelUsage?: Record<string, unknown>;
105
119
  }
106
120
  /**
107
121
  * Interface that all agent backends must implement.
@@ -5,8 +5,8 @@
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";
9
- import { RateLimitError, BillingError, createRateLimitError, isWaitableWindow, isRateLimitFailureInfo, } from "../../errors.js";
8
+ import { getPhaseMcpServersConfig } from "../../mcp-config.js";
9
+ import { ApiError, RateLimitError, BillingError, createRateLimitError, isWaitableWindow, isRateLimitFailureInfo, } from "../../errors.js";
10
10
  import { RingBuffer } from "../ring-buffer.js";
11
11
  export class ClaudeCodeDriver {
12
12
  name = "claude-code";
@@ -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: {
@@ -167,6 +173,31 @@ export class ClaudeCodeDriver {
167
173
  const structuredError = this.buildStructuredError(rateLimitInfo, assistantError, apiRetryError);
168
174
  if (resultMessage) {
169
175
  if (resultMessage.subtype === "success") {
176
+ // The SDK emits subtype "success" with is_error:true when the turn
177
+ // ends on an API error (e.g. unrecognized model). Treat it as a
178
+ // phase failure so the caller gets a real error, not a zero-work
179
+ // success. See #973 / SDK docs §SDKResultMessage.
180
+ if (resultMessage.is_error) {
181
+ const metadata = {};
182
+ if (resultMessage.api_error_status != null) {
183
+ metadata.statusCode = resultMessage.api_error_status;
184
+ metadata.api_error_status = resultMessage.api_error_status;
185
+ }
186
+ if (resultMessage.terminal_reason != null) {
187
+ metadata.terminal_reason = resultMessage.terminal_reason;
188
+ }
189
+ const apiError = new ApiError(resultMessage.result, metadata);
190
+ return {
191
+ success: false,
192
+ output: capturedOutput,
193
+ sessionId: resultSessionId,
194
+ resumeHandle,
195
+ error: resultMessage.result,
196
+ structuredError: apiError,
197
+ stderrTail: stderrBuffer.getLines(),
198
+ stdoutTail: stdoutBuffer.getLines(),
199
+ };
200
+ }
170
201
  return {
171
202
  success: true,
172
203
  output: capturedOutput,
@@ -174,6 +205,7 @@ export class ClaudeCodeDriver {
174
205
  resumeHandle,
175
206
  stderrTail: stderrBuffer.getLines(),
176
207
  stdoutTail: stdoutBuffer.getLines(),
208
+ modelUsage: resultMessage.modelUsage,
177
209
  };
178
210
  }
179
211
  // Turn-cap is a soft, recoverable outcome, not a hard failure: the
@@ -116,6 +116,8 @@ export declare const MetricRunSchema: z.ZodObject<{
116
116
  phasePolicies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
117
117
  model: z.ZodOptional<z.ZodString>;
118
118
  effort: z.ZodOptional<z.ZodString>;
119
+ requestedModel: z.ZodOptional<z.ZodString>;
120
+ resolvedModel: z.ZodOptional<z.ZodString>;
119
121
  }, z.core.$strip>>>;
120
122
  effortEscalations: z.ZodOptional<z.ZodArray<z.ZodObject<{
121
123
  phase: z.ZodString;
@@ -176,6 +178,8 @@ export declare const MetricsSchema: z.ZodObject<{
176
178
  phasePolicies: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
177
179
  model: z.ZodOptional<z.ZodString>;
178
180
  effort: z.ZodOptional<z.ZodString>;
181
+ requestedModel: z.ZodOptional<z.ZodString>;
182
+ resolvedModel: z.ZodOptional<z.ZodString>;
179
183
  }, z.core.$strip>>>;
180
184
  effortEscalations: z.ZodOptional<z.ZodArray<z.ZodObject<{
181
185
  phase: z.ZodString;
@@ -215,15 +219,20 @@ export declare function createMetricRun(options: {
215
219
  flags?: string[];
216
220
  failureCategory?: FailureCategory;
217
221
  /**
218
- * Resolved per-phase model/effort overrides (#914), keyed by phase name.
222
+ * Resolved per-phase model/effort overrides (#914/#975), keyed by phase name.
219
223
  * Pass only the phases that actually had a configured override — a phase
220
224
  * that inherited the CLI default should not appear here at all. See
221
225
  * `resolvePhasePolicies` in `config-resolver.ts`, which already produces
222
226
  * a map shaped this way.
227
+ *
228
+ * `requestedModel` and `resolvedModel` are #975 additions — pass when available
229
+ * to enable cross-time benchmark comparisons as the model roster evolves.
223
230
  */
224
231
  phasePolicies?: Record<string, {
225
232
  model?: string;
226
233
  effort?: string;
234
+ requestedModel?: string;
235
+ resolvedModel?: string;
227
236
  }>;
228
237
  /**
229
238
  * Effort escalations applied during this run (#915), one entry per
@@ -103,9 +103,21 @@ export const MetricRunSchema = z.object({
103
103
  * undefined fields. Enum/alias strings only, consistent with this
104
104
  * schema's no-file-paths/no-content privacy contract. Optional and
105
105
  * additive — absent on records written before this field existed.
106
+ *
107
+ * `requestedModel` and `resolvedModel` are added in #975 to record the
108
+ * role string (pre-resolution) and the concrete model ID from `modelUsage`
109
+ * (post-execution) respectively — enabling cross-time benchmark comparisons
110
+ * as the model roster moves under aliases.
106
111
  */
107
112
  phasePolicies: z
108
- .record(z.string(), z.object({ model: z.string().optional(), effort: z.string().optional() }))
113
+ .record(z.string(), z.object({
114
+ model: z.string().optional(),
115
+ effort: z.string().optional(),
116
+ /** The role string or raw model string as configured (pre-resolution, #975). */
117
+ requestedModel: z.string().optional(),
118
+ /** The concrete model ID from `modelUsage` after execution (#975). */
119
+ resolvedModel: z.string().optional(),
120
+ }))
109
121
  .optional(),
110
122
  /**
111
123
  * Effort escalations applied during this run (#915), one entry per
@@ -70,10 +70,12 @@ export declare class MetricsWriter {
70
70
  model?: string;
71
71
  flags?: string[];
72
72
  failureCategory?: FailureCategory;
73
- /** Resolved per-phase model/effort overrides (#914). See `createMetricRun`. */
73
+ /** Resolved per-phase model/effort overrides (#914/#975). See `createMetricRun`. */
74
74
  phasePolicies?: Record<string, {
75
75
  model?: string;
76
76
  effort?: string;
77
+ requestedModel?: string;
78
+ resolvedModel?: string;
77
79
  }>;
78
80
  /** Effort escalations applied during this run (#915). See `createMetricRun`. */
79
81
  effortEscalations?: Array<{
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Parseable mutation-verification markers for `/qa` §6i (#939).
3
+ *
4
+ * CLAUDE.md's testing rule ("Gate tests ship with a recorded mutation
5
+ * result") was honor-system prose — nothing parsed or checked the recorded
6
+ * result, so compliance was invisible (see #830, #834's "prose only, and
7
+ * therefore unenforceable" defect class). This module promotes that record
8
+ * to a parseable PR-body marker:
9
+ *
10
+ * ```
11
+ * <!-- SEQUANT_MUTATION: {"ac":"AC-3","mutation":"removed payload fixture
12
+ * block","failedTest":"injection.test.ts > rejects payload"} -->
13
+ * ```
14
+ *
15
+ * Same durable-marker idiom as `SEQUANT_SPEC` (spec-recommendation.ts) and
16
+ * `SEQUANT_PHASE` (phase-detection.ts): a flat-JSON HTML comment, matched
17
+ * with the `{[^}]+}` regex family. That regex stops at the FIRST `}`, so the
18
+ * payload must stay flat — a nested object would truncate mid-JSON and fail
19
+ * to parse. Unlike those two markers (one recommendation / one status per
20
+ * comment, latest-wins), a PR body carries one `SEQUANT_MUTATION` marker per
21
+ * AC, so all markers are collected, not just the latest.
22
+ */
23
+ import { z } from "zod";
24
+ declare const MutationMarkerSchema: z.ZodObject<{
25
+ ac: z.ZodString;
26
+ mutation: z.ZodString;
27
+ failedTest: z.ZodString;
28
+ }, z.core.$strip>;
29
+ export type MutationMarker = z.infer<typeof MutationMarkerSchema>;
30
+ export type MutationMarkerClassification = "valid" | "test_not_in_diff";
31
+ export interface ClassifiedMutationMarker extends MutationMarker {
32
+ classification: MutationMarkerClassification;
33
+ }
34
+ /**
35
+ * Parse every `SEQUANT_MUTATION` marker from a PR body.
36
+ *
37
+ * Markers inside fenced code blocks or inline code (e.g. a doc example
38
+ * showing the marker format) are ignored, matching `parsePhaseMarkers`.
39
+ * Malformed JSON or schema-invalid entries are skipped silently rather than
40
+ * thrown — one bad marker must not take down the rest of the PR body's
41
+ * markers.
42
+ *
43
+ * Called with just `prBody`, this only parses. Given a second argument —
44
+ * the diff's test-file paths — it also **rejects** markers naming a test
45
+ * absent from the diff, attaching a `classification` to each result rather
46
+ * than silently dropping the rejected ones (a fabricated marker is worse
47
+ * than a missing one; it must be visible, not discarded). The two-argument
48
+ * form is what makes this function itself the rejecting entry point AC-3
49
+ * names, while the one-argument form stays free of a git-diff dependency
50
+ * for parser-only tests.
51
+ *
52
+ * @param prBody - The full PR body text
53
+ * @param diffTestFiles - When provided, test file paths present in the PR's
54
+ * diff; each returned marker is classified against them
55
+ * @returns Every valid marker found, in document order — classified when
56
+ * `diffTestFiles` is provided
57
+ */
58
+ export declare function parseMutationMarkers(prBody: string): MutationMarker[];
59
+ export declare function parseMutationMarkers(prBody: string, diffTestFiles: string[]): ClassifiedMutationMarker[];
60
+ /**
61
+ * Reduce a marker list to one-per-AC, the later marker winning.
62
+ *
63
+ * A re-run's marker (e.g. after amending the PR body) supersedes the
64
+ * earlier one for the same AC — the same "latest wins" idiom
65
+ * `resolveSpecRecommendation` uses for `SEQUANT_SPEC`.
66
+ *
67
+ * @param markers - Markers as returned by {@link parseMutationMarkers}
68
+ * @returns Map keyed by AC id, one marker per key
69
+ */
70
+ export declare function latestMutationMarkerPerAc(markers: MutationMarker[]): Map<string, MutationMarker>;
71
+ /**
72
+ * Classify a marker's `failedTest` against the diff's actual test files.
73
+ *
74
+ * A fabricated marker (naming a test that doesn't exist in the diff) is
75
+ * worse than a missing one — it claims verification that never happened.
76
+ * `failedTest` follows the `<file> > <test name>` shape `/qa` §6i renders
77
+ * in its output table (e.g. `injection.test.ts > rejects payload`); only
78
+ * the file segment is checked, since the suite/test-name portion after
79
+ * ` > ` isn't independently verifiable without executing the test.
80
+ *
81
+ * @param marker - A single parsed marker
82
+ * @param diffTestFiles - Test file paths present in the PR's diff
83
+ * @returns `"valid"` when the named file is in the diff, `"test_not_in_diff"` otherwise
84
+ */
85
+ export declare function classifyMutationMarker(marker: MutationMarker, diffTestFiles: string[]): MutationMarkerClassification;
86
+ export {};