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
@@ -0,0 +1,97 @@
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
+ import { stripMarkdownCode } from "./phase-detection.js";
25
+ /** Regex to extract mutation-verification marker JSON from HTML comments. */
26
+ const MUTATION_MARKER_REGEX = /<!-- SEQUANT_MUTATION: (\{[^}]+\}) -->/g;
27
+ const MutationMarkerSchema = z.object({
28
+ ac: z.string().min(1),
29
+ mutation: z.string().min(1),
30
+ failedTest: z.string().min(1),
31
+ });
32
+ function parseMarkersRaw(prBody) {
33
+ const markers = [];
34
+ const stripped = stripMarkdownCode(prBody);
35
+ MUTATION_MARKER_REGEX.lastIndex = 0;
36
+ let match;
37
+ while ((match = MUTATION_MARKER_REGEX.exec(stripped)) !== null) {
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(match[1]);
41
+ }
42
+ catch {
43
+ continue;
44
+ }
45
+ const result = MutationMarkerSchema.safeParse(parsed);
46
+ if (result.success) {
47
+ markers.push(result.data);
48
+ }
49
+ }
50
+ return markers;
51
+ }
52
+ export function parseMutationMarkers(prBody, diffTestFiles) {
53
+ const markers = parseMarkersRaw(prBody);
54
+ if (diffTestFiles === undefined) {
55
+ return markers;
56
+ }
57
+ return markers.map((marker) => ({
58
+ ...marker,
59
+ classification: classifyMutationMarker(marker, diffTestFiles),
60
+ }));
61
+ }
62
+ /**
63
+ * Reduce a marker list to one-per-AC, the later marker winning.
64
+ *
65
+ * A re-run's marker (e.g. after amending the PR body) supersedes the
66
+ * earlier one for the same AC — the same "latest wins" idiom
67
+ * `resolveSpecRecommendation` uses for `SEQUANT_SPEC`.
68
+ *
69
+ * @param markers - Markers as returned by {@link parseMutationMarkers}
70
+ * @returns Map keyed by AC id, one marker per key
71
+ */
72
+ export function latestMutationMarkerPerAc(markers) {
73
+ const byAc = new Map();
74
+ for (const marker of markers) {
75
+ byAc.set(marker.ac, marker);
76
+ }
77
+ return byAc;
78
+ }
79
+ /**
80
+ * Classify a marker's `failedTest` against the diff's actual test files.
81
+ *
82
+ * A fabricated marker (naming a test that doesn't exist in the diff) is
83
+ * worse than a missing one — it claims verification that never happened.
84
+ * `failedTest` follows the `<file> > <test name>` shape `/qa` §6i renders
85
+ * in its output table (e.g. `injection.test.ts > rejects payload`); only
86
+ * the file segment is checked, since the suite/test-name portion after
87
+ * ` > ` isn't independently verifiable without executing the test.
88
+ *
89
+ * @param marker - A single parsed marker
90
+ * @param diffTestFiles - Test file paths present in the PR's diff
91
+ * @returns `"valid"` when the named file is in the diff, `"test_not_in_diff"` otherwise
92
+ */
93
+ export function classifyMutationMarker(marker, diffTestFiles) {
94
+ const namedFile = marker.failedTest.split(">")[0]?.trim() ?? "";
95
+ const found = diffTestFiles.some((file) => file === namedFile || file.endsWith(`/${namedFile}`));
96
+ return found ? "valid" : "test_not_in_diff";
97
+ }
@@ -225,6 +225,15 @@ export declare function endedWithoutVerdict(output: string | undefined): boolean
225
225
  * @internal Exported for testing only
226
226
  */
227
227
  export declare function parseQaSummary(output: string): QaSummary | null;
228
+ /**
229
+ * Select the gap descriptions from a QA summary appropriate to feed a fix
230
+ * loop (#937 AC-3): every gap, except one whose `SEQUANT_QA_GAPS` finding is
231
+ * explicitly marked `document` or `pause_for_human` — those are QA-real but
232
+ * not code-fixable (quality/polish, or a decision the loop can't make on its
233
+ * own). A gap with no matching finding (the legacy prose-scrape path) is
234
+ * always included, unchanged from pre-#937 behavior.
235
+ */
236
+ export declare function selectFixableGaps(summary: QaSummary | null | undefined): string[];
228
237
  /**
229
238
  * Format duration in human-readable format
230
239
  */
@@ -341,6 +350,14 @@ export declare function mapAgentFailureToPhaseResult(phase: Phase, agentResult:
341
350
  sessionId?: string;
342
351
  resumeHandle?: ResumeHandle;
343
352
  };
353
+ /**
354
+ * Marks the embedded `promptContext` block appended below so a phase's own
355
+ * skill (e.g. `/loop`) can detect it deterministically instead of pattern-
356
+ * matching prose that varies per producer (`ready-gate.ts` and
357
+ * `batch-executor.ts` build differently-worded context for the same slot).
358
+ * See #960.
359
+ */
360
+ export declare const PROMPT_CONTEXT_SENTINEL = "SEQUANT_PROMPT_CONTEXT";
344
361
  /**
345
362
  * Get the prompt for a phase with the issue number substituted.
346
363
  * Selects self-contained prompts for non-Claude agents.
@@ -10,6 +10,7 @@
10
10
  import chalk from "chalk";
11
11
  import { execFileSync } from "child_process";
12
12
  import { resolveDiffBase } from "./git-diff-utils.js";
13
+ import { parseQaGapsMarker } from "./qa-gaps-marker.js";
13
14
  import { readAgentsMd } from "../agents-md.js";
14
15
  import { getDriver } from "./drivers/index.js";
15
16
  import { classifyError } from "./error-classifier.js";
@@ -499,9 +500,43 @@ export function parseQaSummary(output) {
499
500
  }
500
501
  if (acTotal === 0)
501
502
  return null;
502
- const gaps = parseListSection(output, /\*\*(?:Issues|Gaps)/);
503
+ const proseGaps = parseListSection(output, /\*\*(?:Issues|Gaps)/);
503
504
  const suggestions = parseListSection(output, /\*\*Suggestions/);
504
- return { acMet, acTotal, gaps, suggestions };
505
+ // #937: prefer the structured marker, but UNION with the prose scrape
506
+ // rather than replace it — a marker-carrying comment can still contain a
507
+ // gap the model couldn't fit into the six categories (the fallback rule),
508
+ // and that prose-only finding must not be silently dropped (AC-5).
509
+ const findings = parseQaGapsMarker(output) ?? undefined;
510
+ const seen = new Set();
511
+ const gaps = [];
512
+ for (const gap of [
513
+ ...(findings?.map((f) => f.description) ?? []),
514
+ ...proseGaps,
515
+ ]) {
516
+ const key = gap.trim().toLowerCase();
517
+ if (seen.has(key))
518
+ continue;
519
+ seen.add(key);
520
+ gaps.push(gap);
521
+ }
522
+ return { acMet, acTotal, gaps, suggestions, ...(findings && { findings }) };
523
+ }
524
+ /**
525
+ * Select the gap descriptions from a QA summary appropriate to feed a fix
526
+ * loop (#937 AC-3): every gap, except one whose `SEQUANT_QA_GAPS` finding is
527
+ * explicitly marked `document` or `pause_for_human` — those are QA-real but
528
+ * not code-fixable (quality/polish, or a decision the loop can't make on its
529
+ * own). A gap with no matching finding (the legacy prose-scrape path) is
530
+ * always included, unchanged from pre-#937 behavior.
531
+ */
532
+ export function selectFixableGaps(summary) {
533
+ if (!summary)
534
+ return [];
535
+ const excluded = new Set((summary.findings ?? [])
536
+ .filter((f) => f.recommendedAction === "document" ||
537
+ f.recommendedAction === "pause_for_human")
538
+ .map((f) => f.description.trim().toLowerCase()));
539
+ return summary.gaps.filter((g) => !excluded.has(g.trim().toLowerCase()));
505
540
  }
506
541
  /**
507
542
  * Parse a markdown bullet list section, filtering out "None" variants.
@@ -850,6 +885,14 @@ export function mapAgentFailureToPhaseResult(phase, agentResult, durationSeconds
850
885
  exitCode: agentResult.exitCode,
851
886
  };
852
887
  }
888
+ /**
889
+ * Marks the embedded `promptContext` block appended below so a phase's own
890
+ * skill (e.g. `/loop`) can detect it deterministically instead of pattern-
891
+ * matching prose that varies per producer (`ready-gate.ts` and
892
+ * `batch-executor.ts` build differently-worded context for the same slot).
893
+ * See #960.
894
+ */
895
+ export const PROMPT_CONTEXT_SENTINEL = "SEQUANT_PROMPT_CONTEXT";
853
896
  /**
854
897
  * Get the prompt for a phase with the issue number substituted.
855
898
  * Selects self-contained prompts for non-Claude agents.
@@ -867,9 +910,11 @@ export async function getPhasePrompt(phase, issueNumber, agent, promptContext) {
867
910
  : undefined;
868
911
  const template = driverPrompt ?? definition.promptTemplate;
869
912
  let basePrompt = template.replace(/\{issue\}/g, String(issueNumber));
870
- // Append phase-specific context (e.g., QA findings for loop phase)
913
+ // Append phase-specific context (e.g., QA findings for loop phase),
914
+ // wrapped in a sentinel so the phase's own skill (e.g. /loop) can detect
915
+ // embedded orchestrator context without re-fetching it from GitHub (#960).
871
916
  if (promptContext) {
872
- basePrompt += `\n\n---\n\n${promptContext}`;
917
+ basePrompt += `\n\n---\n\n<!-- ${PROMPT_CONTEXT_SENTINEL} -->\n${promptContext}\n<!-- /${PROMPT_CONTEXT_SENTINEL} -->`;
873
918
  }
874
919
  // Include AGENTS.md content in the prompt context for non-Claude agent compatibility.
875
920
  // Claude reads CLAUDE.md natively, but other agents (Aider, Codex, Gemini CLI)
@@ -1046,6 +1091,7 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
1046
1091
  phaseTimeout: config.phaseTimeout,
1047
1092
  verbose: config.verbose,
1048
1093
  mcp: config.mcp,
1094
+ mcpAllowlist: config.mcpAllowlist,
1049
1095
  resumeHandle: eligibleHandle,
1050
1096
  sessionId: eligibleHandle?.token,
1051
1097
  files,
@@ -1092,10 +1138,18 @@ async function executePhase(issueNumber, phase, config, resumeHandle, worktreePa
1092
1138
  shutdownManager.removeAbortController(abortController);
1093
1139
  }
1094
1140
  const durationSeconds = (Date.now() - startTime) / 1000;
1141
+ // #975: extract the concrete model ID from the driver's modelUsage map.
1142
+ // First key is the primary model dispatched; undefined for drivers that
1143
+ // don't populate modelUsage (aider, subprocess paths).
1144
+ const resolvedModel = agentResult.modelUsage
1145
+ ? Object.keys(agentResult.modelUsage)[0]
1146
+ : undefined;
1095
1147
  if (agentResult.success) {
1096
- return mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds, cwd);
1148
+ const result = mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds, cwd);
1149
+ return resolvedModel ? { ...result, resolvedModel } : result;
1097
1150
  }
1098
- return mapAgentFailureToPhaseResult(phase, agentResult, durationSeconds);
1151
+ const result = mapAgentFailureToPhaseResult(phase, agentResult, durationSeconds);
1152
+ return resolvedModel ? { ...result, resolvedModel } : result;
1099
1153
  }
1100
1154
  /**
1101
1155
  * Execute a phase with automatic retry for cold-start failures and MCP fallback.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Parseable structured gap-finding markers for `/qa` (#937).
3
+ *
4
+ * `parseQaSummary`'s prose scrape (`parseListSection` against a
5
+ * `**Issues:**`/`**Gaps**` header) is lossy — it misses AC-table NOT_MET
6
+ * rows, §6d Adversarial Re-Read findings, and §5 Risk Assessment. QA output
7
+ * templates now end with a structured marker:
8
+ *
9
+ * ```
10
+ * <!-- SEQUANT_QA_GAPS: {"findings":[{"category":"test_gap","evidence":"...",
11
+ * "description":"...","recommendedAction":"fix_now"}]} -->
12
+ * ```
13
+ *
14
+ * Same durable-marker idiom as `SEQUANT_SPEC` (spec-recommendation.ts),
15
+ * `SEQUANT_PHASE` (phase-detection.ts), and `SEQUANT_MUTATION`
16
+ * (mutation-marker.ts) — but unlike those three, this marker's payload is
17
+ * an array of objects, not a flat record, so it cannot reuse their
18
+ * `{[^}]+}` regex (that stops at the FIRST `}`, truncating mid-JSON on any
19
+ * nested object). Instead this matches lazily up to the closing `-->`,
20
+ * which the emitting template guarantees appears on the same line as the
21
+ * marker (single-line JSON, no `-->` inside string values).
22
+ */
23
+ import { type GapFinding } from "./run-log-schema.js";
24
+ /**
25
+ * Parse every `SEQUANT_QA_GAPS` marker from a QA comment/output and return
26
+ * the findings from the last valid one (latest-wins, matching
27
+ * `resolveSpecRecommendation`'s idiom for `SEQUANT_SPEC`).
28
+ *
29
+ * Markers inside fenced code blocks or inline code (e.g. a doc example
30
+ * showing the marker format) are ignored. Malformed JSON or a
31
+ * schema-invalid payload is skipped rather than thrown — an unparseable
32
+ * marker degrades to "no marker found", not a crash.
33
+ *
34
+ * @param output - QA comment body or agent output text
35
+ * @returns The findings array from the last valid marker, or `null` if no
36
+ * valid marker was found
37
+ */
38
+ export declare function parseQaGapsMarker(output: string): GapFinding[] | null;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Parseable structured gap-finding markers for `/qa` (#937).
3
+ *
4
+ * `parseQaSummary`'s prose scrape (`parseListSection` against a
5
+ * `**Issues:**`/`**Gaps**` header) is lossy — it misses AC-table NOT_MET
6
+ * rows, §6d Adversarial Re-Read findings, and §5 Risk Assessment. QA output
7
+ * templates now end with a structured marker:
8
+ *
9
+ * ```
10
+ * <!-- SEQUANT_QA_GAPS: {"findings":[{"category":"test_gap","evidence":"...",
11
+ * "description":"...","recommendedAction":"fix_now"}]} -->
12
+ * ```
13
+ *
14
+ * Same durable-marker idiom as `SEQUANT_SPEC` (spec-recommendation.ts),
15
+ * `SEQUANT_PHASE` (phase-detection.ts), and `SEQUANT_MUTATION`
16
+ * (mutation-marker.ts) — but unlike those three, this marker's payload is
17
+ * an array of objects, not a flat record, so it cannot reuse their
18
+ * `{[^}]+}` regex (that stops at the FIRST `}`, truncating mid-JSON on any
19
+ * nested object). Instead this matches lazily up to the closing `-->`,
20
+ * which the emitting template guarantees appears on the same line as the
21
+ * marker (single-line JSON, no `-->` inside string values).
22
+ */
23
+ import { z } from "zod";
24
+ import { GapFindingSchema } from "./run-log-schema.js";
25
+ import { stripMarkdownCode } from "./phase-detection.js";
26
+ /** Regex to extract the SEQUANT_QA_GAPS marker JSON from an HTML comment. */
27
+ const QA_GAPS_MARKER_REGEX = /<!-- SEQUANT_QA_GAPS: (\{[\s\S]*?\}) -->/g;
28
+ const QaGapsMarkerPayloadSchema = z.object({
29
+ findings: z.array(GapFindingSchema),
30
+ });
31
+ /**
32
+ * Parse every `SEQUANT_QA_GAPS` marker from a QA comment/output and return
33
+ * the findings from the last valid one (latest-wins, matching
34
+ * `resolveSpecRecommendation`'s idiom for `SEQUANT_SPEC`).
35
+ *
36
+ * Markers inside fenced code blocks or inline code (e.g. a doc example
37
+ * showing the marker format) are ignored. Malformed JSON or a
38
+ * schema-invalid payload is skipped rather than thrown — an unparseable
39
+ * marker degrades to "no marker found", not a crash.
40
+ *
41
+ * @param output - QA comment body or agent output text
42
+ * @returns The findings array from the last valid marker, or `null` if no
43
+ * valid marker was found
44
+ */
45
+ export function parseQaGapsMarker(output) {
46
+ if (!output)
47
+ return null;
48
+ const stripped = stripMarkdownCode(output);
49
+ QA_GAPS_MARKER_REGEX.lastIndex = 0;
50
+ let latest = null;
51
+ let match;
52
+ while ((match = QA_GAPS_MARKER_REGEX.exec(stripped)) !== null) {
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(match[1]);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ const result = QaGapsMarkerPayloadSchema.safeParse(parsed);
61
+ if (result.success) {
62
+ latest = result.data.findings;
63
+ }
64
+ }
65
+ return latest;
66
+ }
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import type { ExecutionConfig, PhaseResult, ProgressCallback } from "./types.js";
25
25
  import { type EscalationRecord } from "./effort-escalation.js";
26
- import type { QaVerdict } from "./run-log-schema.js";
26
+ import type { QaVerdict, GapCategory, GapAction } from "./run-log-schema.js";
27
27
  import type { ReadyPolicy } from "../settings.js";
28
28
  import type { IssueStatus } from "./state-schema.js";
29
29
  import { type LoopProgressSnapshot } from "./qa-stagnation.js";
@@ -69,6 +69,15 @@ export interface ReadyGapItem {
69
69
  * mode these are explicitly report-only (never fed to the fix loop).
70
70
  */
71
71
  nonGoal: boolean;
72
+ /**
73
+ * Structured taxonomy fields from the `SEQUANT_QA_GAPS` marker (#937),
74
+ * present only when this gap's description matched a marker finding.
75
+ * A gap surfaced only via the legacy prose scrape carries none of these.
76
+ */
77
+ category?: GapCategory;
78
+ evidence?: string;
79
+ recommendedAction?: GapAction;
80
+ affectedAcs?: string[];
72
81
  }
73
82
  /** Structured outcome of a ready-gate run. */
74
83
  export interface ReadyResult {
@@ -117,6 +126,13 @@ export interface RunReadyGateOptions {
117
126
  phaseTimeout: number;
118
127
  /** Whether MCP servers are enabled for phase execution. */
119
128
  mcp: boolean;
129
+ /**
130
+ * Claude Desktop MCP server names explicitly opted in (#936). Callers
131
+ * (`commands/ready.ts`) resolve this from `settings.run.mcpAllowlist` —
132
+ * see the doc comment on `ExecutionConfig.mcpAllowlist` for why this
133
+ * producer cannot drift from `buildExecutionConfig`'s own assignment.
134
+ */
135
+ mcpAllowlist?: string[];
120
136
  verbose?: boolean;
121
137
  /** Injectable phase runner — defaults to the real executePhaseWithRetry wrapper. */
122
138
  runPhase: ReadyPhaseRunner;
@@ -155,6 +171,14 @@ export interface RunReadyGateOptions {
155
171
  * dispatch to decide whether that specific `qa`/`loop` call escalates.
156
172
  */
157
173
  effortEscalation?: boolean;
174
+ /**
175
+ * Persist the final gap report as an issue comment when the gate reaches
176
+ * a terminal state with a QA verdict (#937 AC-4) — callers wire this to
177
+ * `GitHubProvider.postComment`. Best-effort: a failure here is caught and
178
+ * swallowed, never failing the gate itself — `result.report` (returned to
179
+ * the caller either way) is the primary channel.
180
+ */
181
+ postReport?: (body: string) => Promise<void>;
158
182
  }
159
183
  /**
160
184
  * Pure exit predicate. Given a policy and a QA verdict, has the loop reached
@@ -138,11 +138,28 @@ export function parseNonGoals(issueBody) {
138
138
  }
139
139
  return items;
140
140
  }
141
- function classifyGaps(gaps, nonGoals) {
142
- return gaps.map((g) => ({
143
- description: g,
144
- nonGoal: gapTouchesNonGoals(g, nonGoals),
145
- }));
141
+ /**
142
+ * Classify each gap description for the report, enriching with structured
143
+ * taxonomy fields (#937) when the gap matches a `SEQUANT_QA_GAPS` marker
144
+ * finding. `findings` are matched to `gaps` by description (trimmed,
145
+ * case-folded) since `parseQaSummary` already unions marker findings into
146
+ * `gaps` in document order — every marker finding's description is present.
147
+ */
148
+ function classifyGaps(gaps, nonGoals, findings) {
149
+ const byDescription = new Map((findings ?? []).map((f) => [f.description.trim().toLowerCase(), f]));
150
+ return gaps.map((g) => {
151
+ const finding = byDescription.get(g.trim().toLowerCase());
152
+ return {
153
+ description: g,
154
+ nonGoal: finding?.nonGoal ?? gapTouchesNonGoals(g, nonGoals),
155
+ ...(finding && {
156
+ category: finding.category,
157
+ evidence: finding.evidence,
158
+ recommendedAction: finding.recommendedAction,
159
+ ...(finding.affectedAcs && { affectedAcs: finding.affectedAcs }),
160
+ }),
161
+ };
162
+ });
146
163
  }
147
164
  function defaultReadTokensUsed(worktreePath) {
148
165
  const dir = path.join(worktreePath, TOKEN_USAGE_DIR);
@@ -167,6 +184,7 @@ function buildPhaseConfig(opts, extra) {
167
184
  noSmartTests: false,
168
185
  dryRun: false,
169
186
  mcp: opts.mcp,
187
+ mcpAllowlist: opts.mcpAllowlist,
170
188
  retry: true,
171
189
  // #914: producer 2 (see the doc comment on RunReadyGateOptions.phasePolicies
172
190
  // for why this can't drift from buildExecutionConfig's own assignment).
@@ -229,14 +247,44 @@ export function formatReadyReport(result) {
229
247
  }
230
248
  else {
231
249
  for (const item of result.remaining) {
232
- const tag = item.nonGoal ? " _(Non-Goal — report-only)_" : "";
233
- lines.push(`- ${item.description}${tag}`);
250
+ const tags = [
251
+ item.category && item.recommendedAction
252
+ ? `\`${item.category} · ${item.recommendedAction}\``
253
+ : undefined,
254
+ item.nonGoal ? "_(Non-Goal — report-only)_" : undefined,
255
+ ].filter(Boolean);
256
+ const suffix = tags.length > 0 ? ` ${tags.join(" ")}` : "";
257
+ lines.push(`- ${item.description}${suffix}`);
234
258
  }
235
259
  }
236
260
  lines.push("");
237
261
  lines.push("> The human merge gate is intentional: `sequant ready` never merges. Review the gaps above, then merge manually when satisfied.");
262
+ lines.push("");
263
+ lines.push(formatReadyGapsMarker(result.remaining));
238
264
  return lines.join("\n");
239
265
  }
266
+ /**
267
+ * Render `remaining` as a `SEQUANT_QA_GAPS` marker so the persisted ready
268
+ * report (#937 AC-4) carries the same machine-readable channel `/qa` itself
269
+ * emits — only items that came from a structured finding (have a
270
+ * `category`) round-trip; legacy prose-only gaps are already in the prose
271
+ * list above and are not re-encoded here.
272
+ */
273
+ function formatReadyGapsMarker(remaining) {
274
+ const findings = remaining
275
+ .filter((g) => g.category !== undefined &&
276
+ g.evidence !== undefined &&
277
+ g.recommendedAction !== undefined)
278
+ .map((g) => ({
279
+ category: g.category,
280
+ evidence: g.evidence,
281
+ description: g.description,
282
+ recommendedAction: g.recommendedAction,
283
+ ...(g.affectedAcs && { affectedAcs: g.affectedAcs }),
284
+ ...(g.nonGoal && { nonGoal: g.nonGoal }),
285
+ }));
286
+ return `<!-- SEQUANT_QA_GAPS: ${JSON.stringify({ findings })} -->`;
287
+ }
240
288
  /**
241
289
  * Drive the policy-bounded `qa → loop → qa` ready gate.
242
290
  */
@@ -277,7 +325,7 @@ export async function runReadyGate(opts) {
277
325
  // #915: escalated (base, escalated) tiers, one entry per QA-pass dispatch
278
326
  // that actually escalated. Populated at the two dispatch sites below.
279
327
  const effortEscalations = [];
280
- const finish = (reason) => {
328
+ const finish = async (reason) => {
281
329
  const ready = reason === "AC_MET" || reason === "READY_FOR_MERGE";
282
330
  const issueStatus = ready
283
331
  ? "waiting_for_human_merge"
@@ -297,6 +345,22 @@ export async function runReadyGate(opts) {
297
345
  effortEscalations,
298
346
  };
299
347
  result.report = formatReadyReport(result);
348
+ // #937 AC-4: persist the gap report as an issue comment once the gate
349
+ // reaches a terminal state carrying a QA verdict. `finalVerdict === null`
350
+ // covers NO_IMPLEMENTATION/UNCOMMITTED_ONLY/NO_VERDICT and the guard
351
+ // TOKEN_BUDGET check before the first QA pass — none of those have a gap
352
+ // report worth persisting. Best-effort: a post failure must never fail
353
+ // the gate — `result.report` (returned either way) is the primary
354
+ // channel, same rationale as `sequant ready`'s existing state-persistence
355
+ // try/catch.
356
+ if (result.finalVerdict !== null && opts.postReport) {
357
+ try {
358
+ await opts.postReport(result.report);
359
+ }
360
+ catch {
361
+ // Non-fatal — see comment above.
362
+ }
363
+ }
300
364
  return result;
301
365
  };
302
366
  const budgetExceeded = () => typeof tokenBudget === "number" &&
@@ -338,7 +402,7 @@ export async function runReadyGate(opts) {
338
402
  }
339
403
  finalVerdict = verdict;
340
404
  const gaps = qaResult.summary?.gaps ?? [];
341
- remaining = classifyGaps(gaps, nonGoals);
405
+ remaining = classifyGaps(gaps, nonGoals, qaResult.summary?.findings);
342
406
  // Policy threshold reached → stop at the human merge gate.
343
407
  if (isAtThreshold(policy, verdict)) {
344
408
  return finish(verdict === "READY_FOR_MERGE" ? "READY_FOR_MERGE" : "AC_MET");
@@ -353,9 +417,15 @@ export async function runReadyGate(opts) {
353
417
  // Run one fix loop. In `ac` mode we only reach here on AC_NOT_MET, so the
354
418
  // gaps are AC gaps — feeding them via failedAcs keeps the loop scoped to
355
419
  // the AC boundary (quality gaps are never fixed under `ac`). Non-Goal-
356
- // touching findings are excluded from what we ask the loop to fix.
420
+ // touching findings are excluded from what we ask the loop to fix, as are
421
+ // `SEQUANT_QA_GAPS` findings explicitly marked `document` or
422
+ // `pause_for_human` (#937 AC-3) — a gap with no `recommendedAction` (the
423
+ // legacy prose-only path) is still treated as fixable, unchanged from
424
+ // pre-#937 behavior.
357
425
  const fixableGaps = remaining
358
- .filter((g) => !g.nonGoal)
426
+ .filter((g) => !g.nonGoal &&
427
+ g.recommendedAction !== "document" &&
428
+ g.recommendedAction !== "pause_for_human")
359
429
  .map((g) => g.description);
360
430
  const before = snapshotFn(worktreePath);
361
431
  // #915: iterations > 1 means this fix pass follows a QA pass that was
@@ -88,13 +88,15 @@ export function getNextActionHint(issue) {
88
88
  if (issue.currentPhase) {
89
89
  const failedPhase = Object.entries(issue.phases).find(([, ps]) => ps.status === "failed");
90
90
  if (failedPhase) {
91
- return `sequant run ${issue.number} --phase ${failedPhase[0]}`;
91
+ return `sequant run ${issue.number} --phases ${failedPhase[0]}`;
92
92
  }
93
93
  }
94
94
  return `sequant run ${issue.number}`;
95
95
  }
96
96
  case "waiting_for_qa_gate":
97
- return `sequant run ${issue.number} --phase qa`;
97
+ return `sequant run ${issue.number} --phases qa`;
98
+ case "awaiting_verification":
99
+ return `sequant run ${issue.number} --phases qa`;
98
100
  case "waiting_for_human_merge":
99
101
  // `sequant ready` certified the work; a human reviews + merges manually.
100
102
  if (issue.pr?.number) {