sequant 2.11.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 (61) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +13 -0
  4. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  5. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +331 -12
  7. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +7 -18
  8. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +5 -1
  9. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +62 -8
  10. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +187 -28
  11. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +127 -23
  12. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +130 -13
  13. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +306 -8
  14. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +79 -0
  15. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +40 -20
  16. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +14 -1
  17. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  18. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +23 -6
  19. package/dist/src/commands/doctor.js +20 -18
  20. package/dist/src/commands/ready.js +4 -0
  21. package/dist/src/lib/ac-linter.js +26 -0
  22. package/dist/src/lib/ac-parser.d.ts +40 -0
  23. package/dist/src/lib/ac-parser.js +202 -16
  24. package/dist/src/lib/markdown-fence.d.ts +24 -0
  25. package/dist/src/lib/markdown-fence.js +51 -0
  26. package/dist/src/lib/mcp-config.d.ts +24 -0
  27. package/dist/src/lib/mcp-config.js +51 -0
  28. package/dist/src/lib/scope/analyzer.d.ts +4 -0
  29. package/dist/src/lib/scope/analyzer.js +7 -1
  30. package/dist/src/lib/settings.d.ts +38 -1
  31. package/dist/src/lib/settings.js +14 -0
  32. package/dist/src/lib/system.d.ts +7 -3
  33. package/dist/src/lib/system.js +7 -3
  34. package/dist/src/lib/test-tautology-detector.js +50 -3
  35. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  36. package/dist/src/lib/workflow/batch-executor.js +77 -5
  37. package/dist/src/lib/workflow/config-resolver.js +1 -0
  38. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  39. package/dist/src/lib/workflow/drivers/claude-code.js +9 -3
  40. package/dist/src/lib/workflow/mutation-marker.d.ts +86 -0
  41. package/dist/src/lib/workflow/mutation-marker.js +97 -0
  42. package/dist/src/lib/workflow/phase-executor.d.ts +17 -0
  43. package/dist/src/lib/workflow/phase-executor.js +50 -4
  44. package/dist/src/lib/workflow/qa-gaps-marker.d.ts +38 -0
  45. package/dist/src/lib/workflow/qa-gaps-marker.js +66 -0
  46. package/dist/src/lib/workflow/ready-gate.d.ts +25 -1
  47. package/dist/src/lib/workflow/ready-gate.js +81 -11
  48. package/dist/src/lib/workflow/run-log-schema.d.ts +120 -0
  49. package/dist/src/lib/workflow/run-log-schema.js +40 -0
  50. package/dist/src/lib/workflow/state-schema.d.ts +5 -1
  51. package/dist/src/lib/workflow/state-schema.js +8 -1
  52. package/dist/src/lib/workflow/types.d.ts +14 -0
  53. package/package.json +2 -2
  54. package/templates/hooks/pre-tool.sh +108 -17
  55. package/templates/skills/exec/SKILL.md +1 -1
  56. package/templates/skills/fullsolve/SKILL.md +62 -9
  57. package/templates/skills/loop/SKILL.md +71 -12
  58. package/templates/skills/merger/SKILL.md +32 -3
  59. package/templates/skills/qa/SKILL.md +247 -2
  60. package/templates/skills/spec/SKILL.md +9 -5
  61. package/templates/skills/test/SKILL.md +1 -1
@@ -8,6 +8,7 @@ import * as fs from "fs";
8
8
  import * as os from "os";
9
9
  import * as path from "path";
10
10
  import { getVersion } from "./version.js";
11
+ import { getMcpServersConfig } from "./system.js";
11
12
  /** Path to the project-level MCP config file used by Claude Code */
12
13
  export const PROJECT_MCP_JSON = ".mcp.json";
13
14
  /**
@@ -59,6 +60,56 @@ export function getSequantMcpConfig(options) {
59
60
  }
60
61
  return config;
61
62
  }
63
+ /**
64
+ * Build the MCP server set for an autonomous phase agent (#936).
65
+ *
66
+ * Phase agents are a different trust domain from the interactive Claude
67
+ * Desktop app: they run unattended, and Claude Desktop configs cannot use
68
+ * `${VAR}` references, so they hold literal secrets that the SDK would
69
+ * otherwise serialize verbatim into the child process's `--mcp-config`
70
+ * argv. This builder allowlists instead of passing through — it unions the
71
+ * project's own `.mcp.json` (secret-free by convention, committed to git)
72
+ * with a guaranteed sequant server entry, and never reads
73
+ * `claude_desktop_config.json` **unless** a server name is explicitly
74
+ * listed in `opts.desktopAllowlist` (from `settings.run.mcpAllowlist`) —
75
+ * the deliberate per-server opt-in for a desktop-only server. A name not
76
+ * present in the desktop config is silently ignored.
77
+ *
78
+ * @param cwd - Directory to resolve `.mcp.json` from (the phase worktree)
79
+ * @param opts.desktopAllowlist - Exact `mcpServers` keys to pass through
80
+ * from Claude Desktop config, despite the default exclusion
81
+ * @returns MCP server configurations for the phase agent
82
+ */
83
+ export function getPhaseMcpServersConfig(cwd, opts) {
84
+ const mcpJsonPath = path.resolve(cwd ?? ".", PROJECT_MCP_JSON);
85
+ let servers = {};
86
+ try {
87
+ const content = fs.readFileSync(mcpJsonPath, "utf-8");
88
+ const config = JSON.parse(content);
89
+ if (config.mcpServers &&
90
+ typeof config.mcpServers === "object" &&
91
+ !Array.isArray(config.mcpServers)) {
92
+ servers = config.mcpServers;
93
+ }
94
+ }
95
+ catch {
96
+ // .mcp.json doesn't exist or is invalid — sequant entry still applies
97
+ }
98
+ const allowedDesktopServers = {};
99
+ if (opts?.desktopAllowlist && opts.desktopAllowlist.length > 0) {
100
+ const desktopServers = getMcpServersConfig() ?? {};
101
+ for (const name of opts.desktopAllowlist) {
102
+ if (desktopServers[name]) {
103
+ allowedDesktopServers[name] = desktopServers[name];
104
+ }
105
+ }
106
+ }
107
+ return {
108
+ ...allowedDesktopServers,
109
+ ...servers,
110
+ sequant: getSequantMcpConfig(),
111
+ };
112
+ }
62
113
  /**
63
114
  * Detect which MCP-compatible clients are installed
64
115
  */
@@ -73,6 +73,10 @@ export declare function detectFeatures(criteria: AcceptanceCriterion[], title: s
73
73
  *
74
74
  * Looks for a "Non-Goals" or "Out of Scope" section with checkbox items.
75
75
  *
76
+ * Fenced code blocks are excluded before matching (#947 sibling) — a
77
+ * Non-Goals example shown inside a fence (e.g. illustrating checkbox
78
+ * syntax) must not itself be read as a real non-goal item.
79
+ *
76
80
  * @param issueBody - Full issue body markdown
77
81
  * @returns Non-goals extraction result
78
82
  */
@@ -16,6 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  import { DEFAULT_SCOPE_CONFIG } from "./types.js";
19
+ import { stripFencedLines } from "../markdown-fence.js";
19
20
  /**
20
21
  * Keywords for clustering AC items by functional area
21
22
  */
@@ -247,14 +248,19 @@ export function detectFeatures(criteria, title) {
247
248
  *
248
249
  * Looks for a "Non-Goals" or "Out of Scope" section with checkbox items.
249
250
  *
251
+ * Fenced code blocks are excluded before matching (#947 sibling) — a
252
+ * Non-Goals example shown inside a fence (e.g. illustrating checkbox
253
+ * syntax) must not itself be read as a real non-goal item.
254
+ *
250
255
  * @param issueBody - Full issue body markdown
251
256
  * @returns Non-goals extraction result
252
257
  */
253
258
  export function parseNonGoals(issueBody) {
254
259
  const items = [];
260
+ const strippedBody = stripFencedLines(issueBody);
255
261
  // Find Non-Goals section (case-insensitive)
256
262
  const sectionPattern = /##\s*(?:Non[- ]?Goals|Out\s+of\s+Scope|Scope\s+Boundaries)\s*\n([\s\S]*?)(?=\n##|\n---|$)/i;
257
- const sectionMatch = issueBody.match(sectionPattern);
263
+ const sectionMatch = strippedBody.match(sectionPattern);
258
264
  if (!sectionMatch) {
259
265
  return {
260
266
  items: [],
@@ -117,11 +117,35 @@ export interface RunSettings {
117
117
  defaultBase?: string;
118
118
  /**
119
119
  * Enable MCP servers in headless mode.
120
- * When true, reads MCP config from Claude Desktop and passes to SDK.
120
+ * When true, injects the sequant MCP server plus any servers declared in
121
+ * the project's own `.mcp.json` — never a passthrough of the user's Claude
122
+ * Desktop config, which is a different trust domain and may carry literal
123
+ * secrets (#936), unless a server is explicitly named in `mcpAllowlist`
124
+ * below.
121
125
  * When false or --no-mcp flag is used, MCPs are disabled.
122
126
  * Default: true
123
127
  */
124
128
  mcp: boolean;
129
+ /**
130
+ * Explicit per-server opt-in to pass specific Claude Desktop MCP servers
131
+ * through to phase execution (#936).
132
+ *
133
+ * `mcp` above never reads Claude Desktop config by default — phase agents
134
+ * get the sequant server plus the project's own `.mcp.json` only. This is
135
+ * the deliberate escape hatch for a server that exists only in Claude
136
+ * Desktop config (e.g. never committed to git): list its exact
137
+ * `mcpServers` key here and phase agents additionally receive it. A name
138
+ * not present in the desktop config is silently ignored — this is a
139
+ * filter, not a requirement. Unset or empty (default): no desktop servers
140
+ * pass through, matching `mcp`'s secure-by-default behavior.
141
+ *
142
+ * ⚠️ Desktop Claude configs cannot use `${VAR}` references, so a server you
143
+ * allowlist here may carry a literal secret that reaches the phase
144
+ * process's argv (the SDK serializes `mcpServers` into `--mcp-config`).
145
+ * Only allowlist a server with no credential, or one whose credential you
146
+ * accept exposing to phase agents and to `ps`.
147
+ */
148
+ mcpAllowlist?: string[];
125
149
  /**
126
150
  * Enable automatic retry with MCP fallback.
127
151
  * When true (default), failed phases are retried with MCP disabled.
@@ -198,6 +222,15 @@ export interface RunSettings {
198
222
  * default). See `effort-escalation.ts` for the resolver.
199
223
  */
200
224
  effortEscalation: boolean;
225
+ /**
226
+ * Whether `/fullsolve`'s Phase 5.3 merges the PR automatically once QA
227
+ * passes (#958). Default `false` — the workflow stops at PR creation +
228
+ * final summary, preserving the human merge gate kept by #817–#819.
229
+ * Overridable per-invocation with `--auto-merge`. Read directly by the
230
+ * `/fullsolve` skill prose (not by any runtime code path — `sequant run`
231
+ * never merges regardless of this setting).
232
+ */
233
+ autoMerge: boolean;
201
234
  }
202
235
  /**
203
236
  * Scope assessment threshold configuration
@@ -373,6 +406,7 @@ export declare const RunSettingsSchema: z.ZodObject<{
373
406
  }, z.core.$strip>>;
374
407
  defaultBase: z.ZodOptional<z.ZodString>;
375
408
  mcp: z.ZodDefault<z.ZodBoolean>;
409
+ mcpAllowlist: z.ZodOptional<z.ZodArray<z.ZodString>>;
376
410
  retry: z.ZodDefault<z.ZodBoolean>;
377
411
  autoWaitMinutes: z.ZodDefault<z.ZodNumber>;
378
412
  staleBranchThreshold: z.ZodDefault<z.ZodNumber>;
@@ -397,6 +431,7 @@ export declare const RunSettingsSchema: z.ZodObject<{
397
431
  }>>;
398
432
  }, z.core.$strip>>>;
399
433
  effortEscalation: z.ZodDefault<z.ZodBoolean>;
434
+ autoMerge: z.ZodDefault<z.ZodBoolean>;
400
435
  }, z.core.$strip>;
401
436
  /** Zod schema for ScopeThreshold (base — fields required, no defaults) */
402
437
  export declare const ScopeThresholdSchema: z.ZodObject<{
@@ -477,6 +512,7 @@ export declare const SettingsSchema: z.ZodObject<{
477
512
  }, z.core.$strip>>;
478
513
  defaultBase: z.ZodOptional<z.ZodString>;
479
514
  mcp: z.ZodDefault<z.ZodBoolean>;
515
+ mcpAllowlist: z.ZodOptional<z.ZodArray<z.ZodString>>;
480
516
  retry: z.ZodDefault<z.ZodBoolean>;
481
517
  autoWaitMinutes: z.ZodDefault<z.ZodNumber>;
482
518
  staleBranchThreshold: z.ZodDefault<z.ZodNumber>;
@@ -501,6 +537,7 @@ export declare const SettingsSchema: z.ZodObject<{
501
537
  }>>;
502
538
  }, z.core.$strip>>>;
503
539
  effortEscalation: z.ZodDefault<z.ZodBoolean>;
540
+ autoMerge: z.ZodDefault<z.ZodBoolean>;
504
541
  }, z.core.$strip>>;
505
542
  agents: z.ZodDefault<z.ZodObject<{
506
543
  parallel: z.ZodDefault<z.ZodBoolean>;
@@ -78,6 +78,7 @@ export const RunSettingsSchema = z.object({
78
78
  rotation: RotationSettingsSchema.default(() => RotationSettingsSchema.parse({})),
79
79
  defaultBase: z.string().optional(),
80
80
  mcp: z.boolean().default(true),
81
+ mcpAllowlist: z.array(z.string()).optional(),
81
82
  retry: z.boolean().default(true),
82
83
  autoWaitMinutes: z.number().min(0).default(0),
83
84
  staleBranchThreshold: z.number().default(5),
@@ -104,6 +105,12 @@ export const RunSettingsSchema = z.object({
104
105
  * `effort-escalation.ts` for the resolver.
105
106
  */
106
107
  effortEscalation: z.boolean().default(false),
108
+ /**
109
+ * Whether `/fullsolve`'s Phase 5.3 merges the PR automatically once QA
110
+ * passes (#958). Default `false` — preserves the human merge gate kept by
111
+ * #817–#819. Overridable per-invocation with `--auto-merge`.
112
+ */
113
+ autoMerge: z.boolean().default(false),
107
114
  });
108
115
  /** Zod schema for ScopeThreshold (base — fields required, no defaults) */
109
116
  export const ScopeThresholdSchema = z.object({
@@ -216,6 +223,7 @@ const KNOWN_KEYS = {
216
223
  "rotation",
217
224
  "defaultBase",
218
225
  "mcp",
226
+ "mcpAllowlist",
219
227
  "retry",
220
228
  "staleBranchThreshold",
221
229
  "resolvedIssueTTL",
@@ -451,6 +459,7 @@ export const DEFAULT_SETTINGS = {
451
459
  resolvedIssueTTL: 7, // Auto-prune resolved issues after 7 days
452
460
  relay: true, // Enable interactive relay (#383) by default
453
461
  effortEscalation: false, // #915: off by default — raises token spend
462
+ autoMerge: false, // #958: off by default — preserves the human merge gate
454
463
  },
455
464
  agents: DEFAULT_AGENT_SETTINGS,
456
465
  scopeAssessment: DEFAULT_SCOPE_ASSESSMENT_SETTINGS,
@@ -581,6 +590,10 @@ export function generateSettingsJsonc(settings) {
581
590
  lines.push(` "smartTests": ${JSON.stringify(settings.run.smartTests)},`);
582
591
  lines.push(` // Enable MCP servers in headless mode`);
583
592
  lines.push(` "mcp": ${JSON.stringify(settings.run.mcp)},`);
593
+ if (settings.run.mcpAllowlist !== undefined) {
594
+ lines.push(` // Desktop MCP servers explicitly allowlisted for phases`);
595
+ lines.push(` "mcpAllowlist": ${JSON.stringify(settings.run.mcpAllowlist)},`);
596
+ }
584
597
  lines.push(` // Enable automatic retry with MCP fallback`);
585
598
  lines.push(` "retry": ${JSON.stringify(settings.run.retry)},`);
586
599
  lines.push(` // Commits behind main before warning`);
@@ -698,6 +711,7 @@ Generated by \`sequant init\`. See defaults below.
698
711
  | \`smartTests\` | boolean | \`true\` | Enable smart test detection |
699
712
  | \`defaultBase\` | string | — | Default base branch for worktree creation |
700
713
  | \`mcp\` | boolean | \`true\` | Enable MCP servers in headless mode |
714
+ | \`mcpAllowlist\` | string[] | — | Desktop MCP server names to pass through to phases despite \`mcp\`'s default exclusion (#936) |
701
715
  | \`retry\` | boolean | \`true\` | Enable automatic retry with MCP fallback |
702
716
  | \`staleBranchThreshold\` | number | \`5\` | Commits behind main before warning |
703
717
  | \`resolvedIssueTTL\` | number | \`7\` | Days before resolved issues auto-prune (0=never, -1=immediate) |
@@ -54,9 +54,13 @@ export interface McpServerConfig {
54
54
  /**
55
55
  * Get full MCP server configurations from Claude Desktop config
56
56
  *
57
- * Returns the complete mcpServers object suitable for passing to the
58
- * Claude Agent SDK query() options. Returns undefined if config doesn't
59
- * exist or is invalid (graceful degradation for AC-3).
57
+ * For interactive `doctor` diagnostics only reports what's configured for
58
+ * the human user. Never pass this to phase execution (#936): Claude Desktop
59
+ * is a different trust domain (interactive, human-supervised) than an
60
+ * autonomous phase agent, and desktop configs carry literal secrets since
61
+ * they cannot use `${VAR}` references. Phase agents use
62
+ * `getPhaseMcpServersConfig` from `mcp-config.ts` instead, which allowlists
63
+ * from the project's own `.mcp.json`.
60
64
  *
61
65
  * @returns MCP server configurations or undefined
62
66
  */
@@ -136,9 +136,13 @@ export function getConfiguredMcpServers() {
136
136
  /**
137
137
  * Get full MCP server configurations from Claude Desktop config
138
138
  *
139
- * Returns the complete mcpServers object suitable for passing to the
140
- * Claude Agent SDK query() options. Returns undefined if config doesn't
141
- * exist or is invalid (graceful degradation for AC-3).
139
+ * For interactive `doctor` diagnostics only reports what's configured for
140
+ * the human user. Never pass this to phase execution (#936): Claude Desktop
141
+ * is a different trust domain (interactive, human-supervised) than an
142
+ * autonomous phase agent, and desktop configs carry literal secrets since
143
+ * they cannot use `${VAR}` references. Phase agents use
144
+ * `getPhaseMcpServersConfig` from `mcp-config.ts` instead, which allowlists
145
+ * from the project's own `.mcp.json`.
142
146
  *
143
147
  * @returns MCP server configurations or undefined
144
148
  */
@@ -390,12 +390,20 @@ const PROJECT_SCRIPT_PATTERN = /\b(?:hooks\/[\w.-]+\.sh|scripts\/[\w./-]+)/;
390
390
  * across declarations, and must reach one of the two markers. Spawning a
391
391
  * *system* binary (`git`, `bash` with a temp fixture) matches neither, which
392
392
  * is the intended exclusion — those are not this project's code.
393
+ *
394
+ * An optional type annotation is allowed between the name and `=`, e.g.
395
+ * const HOOK_COPIES: Array<[label: string, path: string]> = [...];
396
+ * The annotation submatch (`[^=;]*`) stops at the first `=`, so an arrow-
397
+ * function-typed annotation (`const f: (x: string) => void = ...`) breaks
398
+ * the match at its `=>` instead of reaching the real assignment — accepted
399
+ * as a narrow miss; typed function-value declarations are not the shape this
400
+ * collector targets (path-bearing table/tuple declarations are).
393
401
  */
394
402
  function collectBuildOutputVars(content) {
395
403
  const names = new Set();
396
404
  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,
405
+ /(?:const|let|var)\s+(\w+)\s*(?::[^=;]*)?=\s*[^;]*?\bdist\//g,
406
+ /(?:const|let|var)\s+(\w+)\s*(?::[^=;]*)?=\s*[^;]*?\b(?:hooks\/[\w.-]+\.sh|scripts\/[\w./-]+)/g,
399
407
  ];
400
408
  for (const pattern of patterns) {
401
409
  let match;
@@ -405,6 +413,44 @@ function collectBuildOutputVars(content) {
405
413
  }
406
414
  return [...names];
407
415
  }
416
+ /**
417
+ * Collect callback parameter names bound to a build-output table's rows via
418
+ * `describe.each(X)("...", (a, b) => {...})`, when `X` is itself a
419
+ * build-output source: a var already collected by collectBuildOutputVars, or
420
+ * an inline array literal containing a build-output token directly.
421
+ *
422
+ * `describe.each` destructures each table row into positional callback
423
+ * params. A helper spawning `hookPath` — the param, not the table var — is
424
+ * exercising production code just as much as one spawning `HOOK_COPIES`
425
+ * directly; static analysis of the table alone misses it entirely. The
426
+ * string-title argument is matched by quote char (not `[^()]*`) because test
427
+ * titles routinely contain literal parens (e.g. `"... (#564) [%s]"`), which
428
+ * a paren-excluding class would truncate on.
429
+ *
430
+ * Bounded like the rest of this file's helper matchers: no nested parens in
431
+ * the table-var/params captures, so a callback with a destructured or
432
+ * default-valued param is skipped rather than mis-parsed.
433
+ */
434
+ function collectDescribeEachParams(content, buildOutputVars) {
435
+ const names = new Set();
436
+ const pattern = /describe\.each\(\s*([^()]*?)\s*\)\s*\(\s*(['"`])(?:(?!\2)[\s\S])*?\2\s*,\s*(?:async\s+)?\(([^()]*)\)\s*=>/g;
437
+ let match;
438
+ while ((match = pattern.exec(content)) !== null) {
439
+ const tableArg = match[1].trim();
440
+ const isKnownVar = /^\w+$/.test(tableArg) && buildOutputVars.includes(tableArg);
441
+ const isInlineSource = BUILD_OUTPUT_PATTERN.test(tableArg) ||
442
+ PROJECT_SCRIPT_PATTERN.test(tableArg);
443
+ if (!isKnownVar && !isInlineSource)
444
+ continue;
445
+ for (const param of match[3].split(",")) {
446
+ const name = param.trim().split(":")[0].trim();
447
+ if (/^\w+$/.test(name)) {
448
+ names.add(name);
449
+ }
450
+ }
451
+ }
452
+ return [...names];
453
+ }
408
454
  /**
409
455
  * Whether a code body references a build-output token: either the literal
410
456
  * `dist/` marker or one of the collected build-path variable names.
@@ -588,7 +634,8 @@ export function analyzeTestFile(content, filePath) {
588
634
  }
589
635
  try {
590
636
  const importedFunctions = extractImports(content);
591
- const buildOutputVars = collectBuildOutputVars(content);
637
+ const declaredBuildOutputVars = collectBuildOutputVars(content);
638
+ const buildOutputVars = declaredBuildOutputVars.concat(collectDescribeEachParams(content, declaredBuildOutputVars));
592
639
  const productionHandles = collectProductionHandles(content, buildOutputVars, importedFunctions);
593
640
  const testBlocks = extractTestBlocks(content);
594
641
  const analyzedBlocks = testBlocks.map((block) => ({
@@ -8,7 +8,7 @@
8
8
  * creation).
9
9
  */
10
10
  import { LogWriter } from "./log-writer.js";
11
- import { ExecutionConfig, PhaseResult, IssueResult, type RunOptions, type IssueExecutionContext, type BatchExecutionContext, type ProgressCallback } from "./types.js";
11
+ import { ExecutionConfig, PhaseResult, IssueResult, type RunOptions, type IssueExecutionContext, type BatchExecutionContext, type ProgressCallback, type QaVerdict, type QaSummary } from "./types.js";
12
12
  import { type ErrorCategory } from "./error-classifier.js";
13
13
  export type { RunOptions, ProgressCallback, IssueExecutionContext, BatchExecutionContext, } from "./types.js";
14
14
  /**
@@ -205,4 +205,23 @@ export declare function billingHaltReason(result: PhaseResult): string;
205
205
  * @internal Exported for testing
206
206
  */
207
207
  export declare function windowHaltResumeAtMs(result: PhaseResult): number | null;
208
+ /**
209
+ * Build the comment body for a standard-qa-phase verdict post (#964).
210
+ * Includes AC coverage and any gaps/suggestions from the parsed `QaSummary`,
211
+ * plus a machine marker so a future dedup pass has an anchor.
212
+ * @internal Exported for testing.
213
+ */
214
+ export declare function buildQaVerdictComment(verdict: QaVerdict, summary: QaSummary | undefined, commitHash: string | undefined, iteration: number): string;
215
+ /**
216
+ * Post the qa-verdict comment for a standard (non-ready-gate) qa phase under
217
+ * orchestrated `sequant run` (#964). This is the channel `qa/SKILL.md` §9
218
+ * promises ("orchestrator handles aggregated summary") but batch-executor
219
+ * never backed — a re-run producing a fresh, different verdict left a stale,
220
+ * contradicted comment as the only externally-visible one.
221
+ *
222
+ * Best-effort: a post failure is caught and logged, never fails the run —
223
+ * mirrors {@link runReadyGateForIssue}'s `postReport` contract (#937 AC-4).
224
+ * @internal Exported for testing.
225
+ */
226
+ export declare function postQaVerdictComment(issueNumber: number, verdict: QaVerdict, summary: QaSummary | undefined, commitHash: string | undefined, iteration: number, log: (msg: string) => void, postComment?: (issueNumber: number, body: string) => Promise<void>): Promise<void>;
208
227
  export declare function runIssueWithLogging(ctx: IssueExecutionContext): Promise<IssueResult>;
@@ -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();
@@ -285,6 +285,7 @@ export function buildExecutionConfig(mergedOptions, settings, issueCount) {
285
285
  maxIterations: positiveOr(mergedOptions.maxIterations, positiveOr(settings.run.maxIterations, DEFAULT_CONFIG.maxIterations)),
286
286
  noSmartTests: mergedOptions.noSmartTests ?? false,
287
287
  mcp: mcpEnabled,
288
+ mcpAllowlist: settings.run.mcpAllowlist,
288
289
  retry: retryEnabled,
289
290
  // #804: default 0 (off) — the whole regression contract for auto-wait is
290
291
  // that an unset flag leaves the #761/#799 halt path untouched.
@@ -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
  *
@@ -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: {