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
@@ -57,6 +57,23 @@ export interface AgentSettings {
57
57
  */
58
58
  isolateParallel: boolean;
59
59
  }
60
+ /**
61
+ * The Agent SDK's closed reasoning-effort enum (#914). Single source of
62
+ * truth — reused by `PhasePolicySchema`'s zod validation below and by
63
+ * `cli-flags.ts:parsePhaseSpecFlag` for the `--efforts` CLI boundary, so the
64
+ * two validation points cannot drift apart on which values are accepted.
65
+ */
66
+ export declare const EFFORT_LEVELS: readonly ["low", "medium", "high", "xhigh", "max"];
67
+ /**
68
+ * A single phase's `model`/`effort` override for the claude-code driver
69
+ * (#914). See `RunSettings.phases`.
70
+ */
71
+ export interface PhasePolicy {
72
+ /** Model alias/ID, passed through unvalidated to the Agent SDK. */
73
+ model?: string;
74
+ /** Reasoning effort — validated against the SDK's closed enum. */
75
+ effort?: (typeof EFFORT_LEVELS)[number];
76
+ }
60
77
  /**
61
78
  * Aider-specific settings for the aider agent driver.
62
79
  */
@@ -100,11 +117,35 @@ export interface RunSettings {
100
117
  defaultBase?: string;
101
118
  /**
102
119
  * Enable MCP servers in headless mode.
103
- * 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.
104
125
  * When false or --no-mcp flag is used, MCPs are disabled.
105
126
  * Default: true
106
127
  */
107
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[];
108
149
  /**
109
150
  * Enable automatic retry with MCP fallback.
110
151
  * When true (default), failed phases are retried with MCP disabled.
@@ -166,6 +207,30 @@ export interface RunSettings {
166
207
  * Default: true.
167
208
  */
168
209
  relay?: boolean;
210
+ /**
211
+ * Per-phase `model`/`effort` overrides for the claude-code driver (#914),
212
+ * keyed by phase name. Absent by default — zero behavior change until
213
+ * opted in. Overridable per-invocation with `--models`/`--efforts`
214
+ * (CLI > settings > absent, resolved by `resolvePhasePolicies` in
215
+ * `config-resolver.ts`).
216
+ */
217
+ phases?: Record<string, PhasePolicy>;
218
+ /**
219
+ * Evidence-based effort escalation on quality-loop retries (#915). Default
220
+ * `false` — raising effort raises token spend, which is the user's call.
221
+ * Overridable per-invocation with `--escalate-effort` (CLI > settings >
222
+ * default). See `effort-escalation.ts` for the resolver.
223
+ */
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;
169
234
  }
170
235
  /**
171
236
  * Scope assessment threshold configuration
@@ -304,6 +369,25 @@ export declare const AgentSettingsSchema: z.ZodObject<{
304
369
  }>>;
305
370
  isolateParallel: z.ZodDefault<z.ZodBoolean>;
306
371
  }, z.core.$strip>;
372
+ /**
373
+ * Zod schema for a single phase's model/effort override (#914).
374
+ *
375
+ * Model aliases/IDs pass through unvalidated — they churn independently of
376
+ * sequant releases, and the Agent SDK's `query()` call errors clearly on a
377
+ * bad one. Effort validates against the SDK's closed enum at settings-parse
378
+ * time since that set is stable and a typo here would otherwise silently
379
+ * fall through to the SDK default.
380
+ */
381
+ export declare const PhasePolicySchema: z.ZodObject<{
382
+ model: z.ZodOptional<z.ZodString>;
383
+ effort: z.ZodOptional<z.ZodEnum<{
384
+ low: "low";
385
+ medium: "medium";
386
+ high: "high";
387
+ xhigh: "xhigh";
388
+ max: "max";
389
+ }>>;
390
+ }, z.core.$strip>;
307
391
  /** Zod schema for RunSettings */
308
392
  export declare const RunSettingsSchema: z.ZodObject<{
309
393
  logJson: z.ZodDefault<z.ZodBoolean>;
@@ -322,6 +406,7 @@ export declare const RunSettingsSchema: z.ZodObject<{
322
406
  }, z.core.$strip>>;
323
407
  defaultBase: z.ZodOptional<z.ZodString>;
324
408
  mcp: z.ZodDefault<z.ZodBoolean>;
409
+ mcpAllowlist: z.ZodOptional<z.ZodArray<z.ZodString>>;
325
410
  retry: z.ZodDefault<z.ZodBoolean>;
326
411
  autoWaitMinutes: z.ZodDefault<z.ZodNumber>;
327
412
  staleBranchThreshold: z.ZodDefault<z.ZodNumber>;
@@ -335,6 +420,18 @@ export declare const RunSettingsSchema: z.ZodObject<{
335
420
  extraArgs: z.ZodOptional<z.ZodArray<z.ZodString>>;
336
421
  }, z.core.$strip>>;
337
422
  relay: z.ZodDefault<z.ZodBoolean>;
423
+ phases: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
424
+ model: z.ZodOptional<z.ZodString>;
425
+ effort: z.ZodOptional<z.ZodEnum<{
426
+ low: "low";
427
+ medium: "medium";
428
+ high: "high";
429
+ xhigh: "xhigh";
430
+ max: "max";
431
+ }>>;
432
+ }, z.core.$strip>>>;
433
+ effortEscalation: z.ZodDefault<z.ZodBoolean>;
434
+ autoMerge: z.ZodDefault<z.ZodBoolean>;
338
435
  }, z.core.$strip>;
339
436
  /** Zod schema for ScopeThreshold (base — fields required, no defaults) */
340
437
  export declare const ScopeThresholdSchema: z.ZodObject<{
@@ -415,6 +512,7 @@ export declare const SettingsSchema: z.ZodObject<{
415
512
  }, z.core.$strip>>;
416
513
  defaultBase: z.ZodOptional<z.ZodString>;
417
514
  mcp: z.ZodDefault<z.ZodBoolean>;
515
+ mcpAllowlist: z.ZodOptional<z.ZodArray<z.ZodString>>;
418
516
  retry: z.ZodDefault<z.ZodBoolean>;
419
517
  autoWaitMinutes: z.ZodDefault<z.ZodNumber>;
420
518
  staleBranchThreshold: z.ZodDefault<z.ZodNumber>;
@@ -428,6 +526,18 @@ export declare const SettingsSchema: z.ZodObject<{
428
526
  extraArgs: z.ZodOptional<z.ZodArray<z.ZodString>>;
429
527
  }, z.core.$strip>>;
430
528
  relay: z.ZodDefault<z.ZodBoolean>;
529
+ phases: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
530
+ model: z.ZodOptional<z.ZodString>;
531
+ effort: z.ZodOptional<z.ZodEnum<{
532
+ low: "low";
533
+ medium: "medium";
534
+ high: "high";
535
+ xhigh: "xhigh";
536
+ max: "max";
537
+ }>>;
538
+ }, z.core.$strip>>>;
539
+ effortEscalation: z.ZodDefault<z.ZodBoolean>;
540
+ autoMerge: z.ZodDefault<z.ZodBoolean>;
431
541
  }, z.core.$strip>>;
432
542
  agents: z.ZodDefault<z.ZodObject<{
433
543
  parallel: z.ZodDefault<z.ZodBoolean>;
@@ -13,10 +13,18 @@
13
13
  import { readFile, writeFile, fileExists, ensureDir } from "./fs.js";
14
14
  import { dirname } from "path";
15
15
  import { z } from "zod";
16
+ import { getPhaseNames } from "./workflow/phase-registry.js";
16
17
  /** Path to project-level settings file */
17
18
  export const SETTINGS_PATH = ".sequant/settings.json";
18
19
  /** Current settings schema version */
19
20
  export const SETTINGS_VERSION = "1.0";
21
+ /**
22
+ * The Agent SDK's closed reasoning-effort enum (#914). Single source of
23
+ * truth — reused by `PhasePolicySchema`'s zod validation below and by
24
+ * `cli-flags.ts:parsePhaseSpecFlag` for the `--efforts` CLI boundary, so the
25
+ * two validation points cannot drift apart on which values are accepted.
26
+ */
27
+ export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
20
28
  // ─── Zod Schemas (AC-1, AC-5) ────────────────────────────────────────────────
21
29
  /** Zod schema for RotationSettings */
22
30
  export const RotationSettingsSchema = z.object({
@@ -36,6 +44,19 @@ export const AgentSettingsSchema = z.object({
36
44
  model: z.enum(["haiku", "sonnet", "opus"]).default("haiku"),
37
45
  isolateParallel: z.boolean().default(false),
38
46
  });
47
+ /**
48
+ * Zod schema for a single phase's model/effort override (#914).
49
+ *
50
+ * Model aliases/IDs pass through unvalidated — they churn independently of
51
+ * sequant releases, and the Agent SDK's `query()` call errors clearly on a
52
+ * bad one. Effort validates against the SDK's closed enum at settings-parse
53
+ * time since that set is stable and a typo here would otherwise silently
54
+ * fall through to the SDK default.
55
+ */
56
+ export const PhasePolicySchema = z.object({
57
+ model: z.string().optional(),
58
+ effort: z.enum(EFFORT_LEVELS).optional(),
59
+ });
39
60
  /** Zod schema for RunSettings */
40
61
  export const RunSettingsSchema = z.object({
41
62
  logJson: z.boolean().default(true),
@@ -57,6 +78,7 @@ export const RunSettingsSchema = z.object({
57
78
  rotation: RotationSettingsSchema.default(() => RotationSettingsSchema.parse({})),
58
79
  defaultBase: z.string().optional(),
59
80
  mcp: z.boolean().default(true),
81
+ mcpAllowlist: z.array(z.string()).optional(),
60
82
  retry: z.boolean().default(true),
61
83
  autoWaitMinutes: z.number().min(0).default(0),
62
84
  staleBranchThreshold: z.number().default(5),
@@ -66,6 +88,29 @@ export const RunSettingsSchema = z.object({
66
88
  agent: z.string().optional(),
67
89
  aider: AiderSettingsSchema.optional(),
68
90
  relay: z.boolean().default(true),
91
+ /**
92
+ * Per-phase `model`/`effort` overrides for the claude-code driver (#914).
93
+ * Absent by default — zero behavior change until opted in. Keyed by phase
94
+ * name (validated against `getPhaseNames()` via `KNOWN_KEYS["run.phases"]`
95
+ * as a non-fatal warning, not a schema-level rejection — a typo'd phase
96
+ * name here should not crash a run the way an invalid `effort` enum does).
97
+ */
98
+ phases: z.record(z.string(), PhasePolicySchema).optional(),
99
+ /**
100
+ * Evidence-based effort escalation on quality-loop retries (#915). Default
101
+ * `false` — raising effort raises token spend, which is the user's call.
102
+ * When enabled, a retried phase execution (loop iteration ≥ 2, or a
103
+ * `sequant ready` QA-pass loop re-run) resolves one effort tier above its
104
+ * configured/inherited base for that execution only. See
105
+ * `effort-escalation.ts` for the resolver.
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),
69
114
  });
70
115
  /** Zod schema for ScopeThreshold (base — fields required, no defaults) */
71
116
  export const ScopeThresholdSchema = z.object({
@@ -178,6 +223,7 @@ const KNOWN_KEYS = {
178
223
  "rotation",
179
224
  "defaultBase",
180
225
  "mcp",
226
+ "mcpAllowlist",
181
227
  "retry",
182
228
  "staleBranchThreshold",
183
229
  "resolvedIssueTTL",
@@ -186,7 +232,13 @@ const KNOWN_KEYS = {
186
232
  "agent",
187
233
  "aider",
188
234
  "relay",
235
+ "phases",
189
236
  ]),
237
+ // #914: keyed by real phase name so a typo (`run.phases.exce`) warns
238
+ // instead of silently resolving to nothing. Computed from the registry
239
+ // rather than hardcoded so a new phase registration doesn't need a
240
+ // matching edit here.
241
+ "run.phases": new Set(getPhaseNames()),
190
242
  agents: new Set(["parallel", "model", "isolateParallel"]),
191
243
  scopeAssessment: new Set([
192
244
  "enabled",
@@ -406,6 +458,8 @@ export const DEFAULT_SETTINGS = {
406
458
  staleBranchThreshold: 5, // Block QA/test if feature is >5 commits behind main
407
459
  resolvedIssueTTL: 7, // Auto-prune resolved issues after 7 days
408
460
  relay: true, // Enable interactive relay (#383) by default
461
+ effortEscalation: false, // #915: off by default — raises token spend
462
+ autoMerge: false, // #958: off by default — preserves the human merge gate
409
463
  },
410
464
  agents: DEFAULT_AGENT_SETTINGS,
411
465
  scopeAssessment: DEFAULT_SCOPE_ASSESSMENT_SETTINGS,
@@ -536,6 +590,10 @@ export function generateSettingsJsonc(settings) {
536
590
  lines.push(` "smartTests": ${JSON.stringify(settings.run.smartTests)},`);
537
591
  lines.push(` // Enable MCP servers in headless mode`);
538
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
+ }
539
597
  lines.push(` // Enable automatic retry with MCP fallback`);
540
598
  lines.push(` "retry": ${JSON.stringify(settings.run.retry)},`);
541
599
  lines.push(` // Commits behind main before warning`);
@@ -653,6 +711,7 @@ Generated by \`sequant init\`. See defaults below.
653
711
  | \`smartTests\` | boolean | \`true\` | Enable smart test detection |
654
712
  | \`defaultBase\` | string | — | Default base branch for worktree creation |
655
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) |
656
715
  | \`retry\` | boolean | \`true\` | Enable automatic retry with MCP fallback |
657
716
  | \`staleBranchThreshold\` | number | \`5\` | Commits behind main before warning |
658
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
  */
@@ -104,12 +104,13 @@ export declare function extractTestBlocks(content: string): Array<{
104
104
  * when it references an imported production function, directly spawns the
105
105
  * project's build output, or calls a helper that (transitively) does so.
106
106
  *
107
- * @param spawnHandles Names of describe/module-scope helpers that spawn the
108
- * build output (see {@link collectSpawnHandles}).
107
+ * @param productionHandles Names of describe/module-scope helpers that reach
108
+ * production by spawning the project's executable code or by calling an
109
+ * imported production function (see {@link collectProductionHandles}).
109
110
  * @param buildOutputVars Variable names bound to a build-output path (see
110
111
  * {@link collectBuildOutputVars}).
111
112
  */
112
- export declare function testBlockCallsProductionCode(body: string, importedFunctions: ImportedFunction[], spawnHandles?: string[], buildOutputVars?: string[]): boolean;
113
+ export declare function testBlockCallsProductionCode(body: string, importedFunctions: ImportedFunction[], productionHandles?: string[], buildOutputVars?: string[]): boolean;
113
114
  /**
114
115
  * Check if a file opts out of tautology detection via pragma comment.
115
116
  *
@@ -369,20 +369,85 @@ const SPAWN_PATTERN = /(?:\b(?:execFileSync|spawnSync|execSync|execFile)\s*\(|(?
369
369
  */
370
370
  const BUILD_OUTPUT_PATTERN = /\bdist\//;
371
371
  /**
372
- * Collect names of variables bound to a build-output path, e.g.
372
+ * Non-compiled production code this project ships and executes: the hook
373
+ * scripts and the `scripts/` + `templates/scripts/` trees (#906).
374
+ *
375
+ * `dist/` alone was too narrow. `checkout-lock.integration.test.ts` drives the
376
+ * real `.claude/hooks/pre-tool.sh` as a subprocess — that hook IS the
377
+ * enforcement half of the feature under test — yet every block in the file
378
+ * read as tautological because the path is not under `dist/`.
379
+ */
380
+ const PROJECT_SCRIPT_PATTERN = /\b(?:hooks\/[\w.-]+\.sh|scripts\/[\w./-]+)/;
381
+ /**
382
+ * Collect names of variables bound to a path into the project's own executable
383
+ * code, e.g.
373
384
  * const cliPath = resolve(projectRoot, "dist/bin/cli.js");
374
- * captures `cliPath`. Tests almost always spawn via such a handle rather than
375
- * an inline string, so these names stand in for the literal build path.
385
+ * const HOOK = join(REPO_ROOT, ".claude/hooks/pre-tool.sh");
386
+ * captures `cliPath` / `HOOK`. Tests almost always spawn via such a handle
387
+ * rather than an inline string, so these names stand in for the literal path.
376
388
  *
377
389
  * The right-hand side is statement-bounded (`[^;]`) so a match cannot bleed
378
- * across declarations, and must contain the `dist/` marker.
390
+ * across declarations, and must reach one of the two markers. Spawning a
391
+ * *system* binary (`git`, `bash` with a temp fixture) matches neither, which
392
+ * is the intended exclusion — those are not this project's code.
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).
379
401
  */
380
402
  function collectBuildOutputVars(content) {
381
403
  const names = new Set();
382
- const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*[^;]*?\bdist\//g;
404
+ const patterns = [
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,
407
+ ];
408
+ for (const pattern of patterns) {
409
+ let match;
410
+ while ((match = pattern.exec(content)) !== null) {
411
+ names.add(match[1]);
412
+ }
413
+ }
414
+ return [...names];
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;
383
437
  let match;
384
438
  while ((match = pattern.exec(content)) !== null) {
385
- names.add(match[1]);
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
+ }
386
451
  }
387
452
  return [...names];
388
453
  }
@@ -391,7 +456,7 @@ function collectBuildOutputVars(content) {
391
456
  * `dist/` marker or one of the collected build-path variable names.
392
457
  */
393
458
  function referencesBuildOutput(body, buildOutputVars) {
394
- if (BUILD_OUTPUT_PATTERN.test(body)) {
459
+ if (BUILD_OUTPUT_PATTERN.test(body) || PROJECT_SCRIPT_PATTERN.test(body)) {
395
460
  return true;
396
461
  }
397
462
  return buildOutputVars.some((name) => referenceMatcher(name).test(body));
@@ -406,45 +471,84 @@ function spawnsBuildOutput(body, buildOutputVars) {
406
471
  return (SPAWN_PATTERN.test(body) && referencesBuildOutput(body, buildOutputVars));
407
472
  }
408
473
  /**
409
- * Extract module/describe-scope helper definitions (named block-bodied arrow
410
- * consts) as { name, body } pairs.
474
+ * Extract module/describe-scope helper definitions as { name, body } pairs.
411
475
  *
412
- * Anchors on `=> {` so a return-type object annotation
413
- * const run = (): { stdout: string } => { ... }
414
- * is not mistaken for the function body. Params are matched with `[^()]*` (no
415
- * nested parens) to keep the scan from running away across the file. Only
416
- * block-bodied arrows are collected; the subprocess integration tests this
417
- * targets all use them.
476
+ * Two shapes, because both are idiomatic and a detector that saw only one
477
+ * produced large-scale false positives (#906): a test calling a helper the
478
+ * detector cannot see reads as import-less, hence tautological. Measured on
479
+ * `checkout-lock.integration.test.ts` (helpers written as `function`
480
+ * declarations): 17 of 19 blocks flagged, every one of them real.
481
+ *
482
+ * Params are matched with `[^()]*` (no nested parens) to keep the scan from
483
+ * running away across the file. Expression-bodied arrows are skipped — they
484
+ * have no `{` body to extract.
418
485
  */
419
486
  function extractHelperDefinitions(content) {
420
487
  const helpers = [];
421
- const pattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^()]*\)\s*(?::[^=]*?)?=>\s*\{/g;
488
+ // Arrow consts anchor on `=> {`, so the body brace is unambiguous.
489
+ const arrowPattern = /(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?\([^()]*\)\s*(?::[^=]*?)?=>\s*\{/g;
422
490
  let match;
423
- while ((match = pattern.exec(content)) !== null) {
424
- if (isInsideString(content, match.index)) {
491
+ while ((match = arrowPattern.exec(content)) !== null) {
492
+ if (isInsideString(content, match.index))
425
493
  continue;
426
- }
427
- const name = match[1];
428
- // The final `{` of the match is the function body's opening brace.
429
494
  const braceIndex = match.index + match[0].length - 1;
430
- const body = extractBlockBody(content.substring(braceIndex));
431
- helpers.push({ name, body });
495
+ helpers.push({
496
+ name: match[1],
497
+ body: extractBlockBody(content.substring(braceIndex)),
498
+ });
499
+ }
500
+ // Declarations have no `=>` marker, and the return-type annotation may open
501
+ // a brace group of its own:
502
+ // function runHook(o): { status: number; stderr: string } { ... }
503
+ // so the body is NOT simply the first `{` after the parameters. Do not try
504
+ // to express that in the regex — a greedy annotation subpattern silently ran
505
+ // past the body and anchored on the NEXT declaration's brace, yielding a
506
+ // "helper" whose body was somebody else's code (caught by the object
507
+ // return-type test below). Match only to the closing paren, then walk: take
508
+ // the first brace group; if another `{` follows it, that group was the
509
+ // return type and the body is the next one.
510
+ const declPattern = /(?:async\s+)?function\s*\*?\s*(\w+)\s*\([^()]*\)/g;
511
+ while ((match = declPattern.exec(content)) !== null) {
512
+ if (isInsideString(content, match.index))
513
+ continue;
514
+ const rest = content.substring(match.index + match[0].length);
515
+ const firstBrace = rest.indexOf("{");
516
+ if (firstBrace === -1)
517
+ continue;
518
+ // Anything between `)` and the first `{` must be an annotation, not code.
519
+ if (/[;)=]/.test(rest.substring(0, firstBrace)))
520
+ continue;
521
+ let body = extractBlockBody(rest.substring(firstBrace));
522
+ const after = rest.substring(firstBrace + body.length);
523
+ if (/^\s*\{/.test(after)) {
524
+ body = extractBlockBody(after);
525
+ }
526
+ helpers.push({ name: match[1], body });
432
527
  }
433
528
  return helpers;
434
529
  }
435
530
  /**
436
- * Collect the names of helper functions that spawn the build output, resolving
437
- * indirection transitively: a helper that calls an already-known spawn helper
438
- * is itself a spawn helper. This lets a test that only calls
439
- * `expectFlagAccepted(...)` (which calls `runInUninitializedDir`, which spawns
440
- * the CLI) count as exercising production code.
531
+ * Collect the names of helper functions that reach production code, resolving
532
+ * indirection transitively: a helper that calls an already-known handle is
533
+ * itself a handle. This lets a test that only calls `expectFlagAccepted(...)`
534
+ * (which calls `runInUninitializedDir`, which spawns the CLI) count as
535
+ * exercising production code.
536
+ *
537
+ * A helper qualifies two ways:
538
+ * - it spawns the project's own executable code (subprocess integration
539
+ * tests), or
540
+ * - it references an imported production function (#906). `makeLock()`
541
+ * returning `new CheckoutLock({...})` is production code by any reading,
542
+ * but seeding on spawns alone missed it, so every test that built its
543
+ * subject through a factory read as tautological.
441
544
  */
442
- function collectSpawnHandles(content, buildOutputVars) {
545
+ function collectProductionHandles(content, buildOutputVars, importedFunctions = []) {
443
546
  const helpers = extractHelperDefinitions(content);
444
547
  const handles = new Set();
445
- // Seed: helpers that directly spawn the build output.
548
+ // Seed: helpers that directly reach production.
446
549
  for (const helper of helpers) {
447
- if (spawnsBuildOutput(helper.body, buildOutputVars)) {
550
+ if (spawnsBuildOutput(helper.body, buildOutputVars) ||
551
+ importedFunctions.some((fn) => referenceMatcher(fn.name).test(helper.body))) {
448
552
  handles.add(helper.name);
449
553
  }
450
554
  }
@@ -472,12 +576,13 @@ function collectSpawnHandles(content, buildOutputVars) {
472
576
  * when it references an imported production function, directly spawns the
473
577
  * project's build output, or calls a helper that (transitively) does so.
474
578
  *
475
- * @param spawnHandles Names of describe/module-scope helpers that spawn the
476
- * build output (see {@link collectSpawnHandles}).
579
+ * @param productionHandles Names of describe/module-scope helpers that reach
580
+ * production by spawning the project's executable code or by calling an
581
+ * imported production function (see {@link collectProductionHandles}).
477
582
  * @param buildOutputVars Variable names bound to a build-output path (see
478
583
  * {@link collectBuildOutputVars}).
479
584
  */
480
- export function testBlockCallsProductionCode(body, importedFunctions, spawnHandles = [], buildOutputVars = []) {
585
+ export function testBlockCallsProductionCode(body, importedFunctions, productionHandles = [], buildOutputVars = []) {
481
586
  // 1. References an imported production function.
482
587
  for (const fn of importedFunctions) {
483
588
  if (referenceMatcher(fn.name).test(body)) {
@@ -490,9 +595,10 @@ export function testBlockCallsProductionCode(body, importedFunctions, spawnHandl
490
595
  if (spawnsBuildOutput(body, buildOutputVars)) {
491
596
  return true;
492
597
  }
493
- // 3. Calls a describe/module-scope helper that (transitively) spawns the
494
- // build output.
495
- for (const handle of spawnHandles) {
598
+ // 3. Calls a describe/module-scope helper that (transitively) reaches
599
+ // production — spawns the project's executable code, or calls an imported
600
+ // production function (#906). Covers arrow-const and `function` helpers.
601
+ for (const handle of productionHandles) {
496
602
  if (referenceMatcher(handle).test(body)) {
497
603
  return true;
498
604
  }
@@ -528,14 +634,15 @@ export function analyzeTestFile(content, filePath) {
528
634
  }
529
635
  try {
530
636
  const importedFunctions = extractImports(content);
531
- const buildOutputVars = collectBuildOutputVars(content);
532
- const spawnHandles = collectSpawnHandles(content, buildOutputVars);
637
+ const declaredBuildOutputVars = collectBuildOutputVars(content);
638
+ const buildOutputVars = declaredBuildOutputVars.concat(collectDescribeEachParams(content, declaredBuildOutputVars));
639
+ const productionHandles = collectProductionHandles(content, buildOutputVars, importedFunctions);
533
640
  const testBlocks = extractTestBlocks(content);
534
641
  const analyzedBlocks = testBlocks.map((block) => ({
535
642
  description: block.description,
536
643
  lineNumber: block.lineNumber,
537
644
  style: block.style,
538
- isTautological: !testBlockCallsProductionCode(block.body, importedFunctions, spawnHandles, buildOutputVars),
645
+ isTautological: !testBlockCallsProductionCode(block.body, importedFunctions, productionHandles, buildOutputVars),
539
646
  }));
540
647
  const tautologicalCount = analyzedBlocks.filter((b) => b.isTautological).length;
541
648
  const totalTests = analyzedBlocks.length;
@@ -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>;