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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +47 -3
- package/dist/dashboard/server.js +4 -0
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
- package/dist/marketplace/external_plugins/sequant/README.md +9 -0
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +331 -12
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +7 -18
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +5 -1
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +62 -8
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +187 -28
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +127 -23
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +130 -13
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +306 -8
- package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +79 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +42 -20
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +14 -1
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +23 -6
- package/dist/src/commands/doctor.js +20 -18
- package/dist/src/commands/ready.js +7 -1
- package/dist/src/commands/status.js +4 -0
- package/dist/src/lib/ac-linter.js +26 -0
- package/dist/src/lib/ac-parser.d.ts +40 -0
- package/dist/src/lib/ac-parser.js +202 -16
- package/dist/src/lib/markdown-fence.d.ts +24 -0
- package/dist/src/lib/markdown-fence.js +51 -0
- package/dist/src/lib/mcp-config.d.ts +24 -0
- package/dist/src/lib/mcp-config.js +51 -0
- package/dist/src/lib/scope/analyzer.d.ts +4 -0
- package/dist/src/lib/scope/analyzer.js +7 -1
- package/dist/src/lib/settings.d.ts +73 -14
- package/dist/src/lib/settings.js +45 -3
- package/dist/src/lib/system.d.ts +7 -3
- package/dist/src/lib/system.js +7 -3
- package/dist/src/lib/test-tautology-detector.js +50 -3
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +81 -6
- package/dist/src/lib/workflow/config-resolver.d.ts +30 -2
- package/dist/src/lib/workflow/config-resolver.js +59 -2
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +14 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +36 -4
- package/dist/src/lib/workflow/metrics-schema.d.ts +10 -1
- package/dist/src/lib/workflow/metrics-schema.js +13 -1
- package/dist/src/lib/workflow/metrics-writer.d.ts +3 -1
- package/dist/src/lib/workflow/mutation-marker.d.ts +86 -0
- package/dist/src/lib/workflow/mutation-marker.js +97 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +17 -0
- package/dist/src/lib/workflow/phase-executor.js +60 -6
- package/dist/src/lib/workflow/qa-gaps-marker.d.ts +38 -0
- package/dist/src/lib/workflow/qa-gaps-marker.js +66 -0
- package/dist/src/lib/workflow/ready-gate.d.ts +25 -1
- package/dist/src/lib/workflow/ready-gate.js +81 -11
- package/dist/src/lib/workflow/reconcile.js +4 -2
- package/dist/src/lib/workflow/run-log-schema.d.ts +120 -0
- package/dist/src/lib/workflow/run-log-schema.js +40 -0
- package/dist/src/lib/workflow/run-orchestrator.d.ts +18 -0
- package/dist/src/lib/workflow/run-orchestrator.js +38 -2
- package/dist/src/lib/workflow/state-cleanup.d.ts +4 -4
- package/dist/src/lib/workflow/state-cleanup.js +9 -5
- package/dist/src/lib/workflow/state-schema.d.ts +10 -1
- package/dist/src/lib/workflow/state-schema.js +13 -1
- package/dist/src/lib/workflow/types.d.ts +20 -0
- package/dist/src/mcp/tools/run.js +10 -1
- package/package.json +13 -12
- package/templates/hooks/pre-tool.sh +108 -17
- package/templates/memory/constitution.md +112 -45
- package/templates/skills/exec/SKILL.md +1 -1
- package/templates/skills/fullsolve/SKILL.md +62 -9
- package/templates/skills/loop/SKILL.md +71 -12
- package/templates/skills/merger/SKILL.md +32 -3
- package/templates/skills/qa/SKILL.md +247 -2
- package/templates/skills/spec/SKILL.md +11 -5
- 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
|
|
503
|
+
const proseGaps = parseListSection(output, /\*\*(?:Issues|Gaps)/);
|
|
503
504
|
const suggestions = parseListSection(output, /\*\*Suggestions/);
|
|
504
|
-
|
|
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
|
-
|
|
1148
|
+
const result = mapAgentSuccessToPhaseResult(phase, agentResult, durationSeconds, cwd);
|
|
1149
|
+
return resolvedModel ? { ...result, resolvedModel } : result;
|
|
1097
1150
|
}
|
|
1098
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
233
|
-
|
|
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} --
|
|
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} --
|
|
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) {
|