sequant 2.4.0 → 2.5.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 (58) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +125 -163
  4. package/dist/bin/cli.js +13 -0
  5. package/dist/dashboard/server.js +1 -0
  6. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +2 -2
  7. package/dist/marketplace/external_plugins/sequant/README.md +6 -3
  8. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +92 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +18 -9
  10. package/dist/marketplace/external_plugins/sequant/hooks/relay-check.sh +107 -0
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/behavior-rule-detection.md +205 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +21 -8
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +302 -86
  14. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +109 -0
  15. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +141 -22
  16. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +83 -78
  17. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +377 -137
  18. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +28 -0
  19. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +621 -0
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +741 -232
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +47 -1
  22. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +12 -6
  23. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +217 -964
  24. package/dist/marketplace/external_plugins/sequant/skills/spec/references/parallel-groups.md +7 -0
  25. package/dist/marketplace/external_plugins/sequant/skills/spec/references/quality-checklist.md +75 -0
  26. package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +4 -2
  27. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +0 -27
  28. package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +24 -44
  29. package/dist/src/commands/ready-tui-adapter.d.ts +59 -0
  30. package/dist/src/commands/ready-tui-adapter.js +130 -0
  31. package/dist/src/commands/ready.d.ts +49 -0
  32. package/dist/src/commands/ready.js +243 -0
  33. package/dist/src/commands/status.js +4 -0
  34. package/dist/src/lib/cli-ui/run-renderer.d.ts +7 -1
  35. package/dist/src/lib/cli-ui/run-renderer.js +28 -28
  36. package/dist/src/lib/settings.d.ts +34 -0
  37. package/dist/src/lib/settings.js +23 -1
  38. package/dist/src/lib/workflow/phase-executor.js +17 -2
  39. package/dist/src/lib/workflow/platforms/github.d.ts +6 -0
  40. package/dist/src/lib/workflow/platforms/github.js +17 -0
  41. package/dist/src/lib/workflow/ready-gate.d.ts +155 -0
  42. package/dist/src/lib/workflow/ready-gate.js +374 -0
  43. package/dist/src/lib/workflow/reconcile.js +6 -0
  44. package/dist/src/lib/workflow/state-schema.d.ts +3 -0
  45. package/dist/src/lib/workflow/state-schema.js +1 -0
  46. package/dist/src/lib/workflow/types.d.ts +9 -0
  47. package/dist/src/ui/tui/App.js +8 -2
  48. package/dist/src/ui/tui/IssueBox.js +3 -4
  49. package/dist/src/ui/tui/index.d.ts +13 -4
  50. package/dist/src/ui/tui/index.js +19 -5
  51. package/dist/src/ui/tui/row-cap.d.ts +51 -0
  52. package/dist/src/ui/tui/row-cap.js +76 -0
  53. package/dist/src/ui/tui/teardown.d.ts +20 -0
  54. package/dist/src/ui/tui/teardown.js +29 -0
  55. package/dist/src/ui/tui/theme.d.ts +3 -0
  56. package/dist/src/ui/tui/theme.js +3 -0
  57. package/package.json +19 -8
  58. package/templates/skills/qa/SKILL.md +5 -2
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Ready gate engine (#683).
3
+ *
4
+ * Drives the `sequant ready <issue>` pipeline: a full-weight `qa → loop → qa`
5
+ * loop that reproduces the maintainer's manual fresh-session A+ pass
6
+ * deterministically, then STOPS at a human merge gate — it never merges.
7
+ *
8
+ * The loop's exit threshold is set by a **gate policy**:
9
+ *
10
+ * - `ac` (default): stop once no `AC_NOT_MET` verdict remains. Remaining
11
+ * quality/polish gaps are surfaced in the report but NOT auto-fixed. Findings
12
+ * that touch the issue's Non-Goals are report-only. Predictable, scope-
13
+ * respecting behavior for a team engineer with a fixed agenda.
14
+ * - `a-plus` (opt-in): loop toward `READY_FOR_MERGE`, auto-fixing quality gaps.
15
+ *
16
+ * Both policies are additionally bounded by `maxIterations`, an optional token
17
+ * budget, and the `LOOP_NO_DIFF` stagnation guard. The #534 class (zero-diff
18
+ * exec / null QA verdict) is never reported as ready.
19
+ *
20
+ * This module is the reusable engine — a future `sequant run --ready-gate`
21
+ * (out of scope for #683) can reuse `runReadyGate` directly. The command shell
22
+ * lives in `src/commands/ready.ts`.
23
+ */
24
+ import type { ExecutionConfig, PhaseResult, ProgressCallback } from "./types.js";
25
+ import type { QaVerdict } from "./run-log-schema.js";
26
+ import type { ReadyPolicy } from "../settings.js";
27
+ import type { IssueStatus } from "./state-schema.js";
28
+ import { type LoopProgressSnapshot } from "./qa-stagnation.js";
29
+ export type { ReadyPolicy } from "../settings.js";
30
+ /**
31
+ * Why the gate stopped. Drives `ready`, the persisted issue status, and the
32
+ * human-facing report headline.
33
+ */
34
+ export type ReadyTerminalReason =
35
+ /** `ac`: ACs objectively met (no AC_NOT_MET). Quality gaps reported, not fixed. */
36
+ "AC_MET"
37
+ /** Either policy: QA returned READY_FOR_MERGE. */
38
+ | "READY_FOR_MERGE"
39
+ /** Guard: hit the iteration cap before the threshold. Needs human. */
40
+ | "MAX_ITERATIONS"
41
+ /** Guard: token budget exhausted before the threshold. Needs human. */
42
+ | "TOKEN_BUDGET"
43
+ /** Guard: `/loop` produced no diff — can't make progress. Needs human. */
44
+ | "LOOP_NO_DIFF"
45
+ /** Guard: `/loop` phase itself failed. Needs human. */
46
+ | "LOOP_FAILED"
47
+ /** #534: zero-diff exec or null/unparseable QA verdict. Not ready. */
48
+ | "NO_IMPLEMENTATION";
49
+ /** A single gap surfaced by QA, classified for the report. */
50
+ export interface ReadyGapItem {
51
+ /** Gap description as surfaced by QA. */
52
+ description: string;
53
+ /**
54
+ * True when this finding overlaps one of the issue's Non-Goals. In `ac`
55
+ * mode these are explicitly report-only (never fed to the fix loop).
56
+ */
57
+ nonGoal: boolean;
58
+ }
59
+ /** Structured outcome of a ready-gate run. */
60
+ export interface ReadyResult {
61
+ issueNumber: number;
62
+ policy: ReadyPolicy;
63
+ /** True only when the gate certifies the work as merge-ready for a human. */
64
+ ready: boolean;
65
+ reason: ReadyTerminalReason;
66
+ /** Issue status to persist (`waiting_for_human_merge` iff ready). */
67
+ issueStatus: IssueStatus;
68
+ /** Number of QA passes executed. */
69
+ iterations: number;
70
+ /** Last parsed QA verdict (null if QA never produced one). */
71
+ finalVerdict: QaVerdict | null;
72
+ /** Gap descriptions the fix loop was asked to address across iterations. */
73
+ autoFixed: string[];
74
+ /** Gaps still present / accepted at exit (quality gaps, Non-Goal items). */
75
+ remaining: ReadyGapItem[];
76
+ /** Total tokens consumed across all phases (best-effort from token files). */
77
+ tokensUsed: number;
78
+ /** Human-readable markdown gap report (AC-4). */
79
+ report: string;
80
+ }
81
+ /**
82
+ * Thin phase-runner abstraction so the engine can be unit-tested without the
83
+ * full `executePhaseWithRetry` positional signature or a live agent driver.
84
+ */
85
+ export type ReadyPhaseRunner = (phase: "qa" | "loop", config: ExecutionConfig, worktreePath: string) => Promise<PhaseResult>;
86
+ export interface RunReadyGateOptions {
87
+ issueNumber: number;
88
+ worktreePath: string;
89
+ policy: ReadyPolicy;
90
+ /** Hard iteration cap on QA passes (AC-6). */
91
+ maxIterations: number;
92
+ /** Optional token budget; 0/undefined disables the token cap (AC-6). */
93
+ tokenBudget?: number;
94
+ /** Non-Goals parsed from the issue body, for report-only classification. */
95
+ nonGoals?: string[];
96
+ /** Per-phase timeout in seconds. */
97
+ phaseTimeout: number;
98
+ /** Whether MCP servers are enabled for phase execution. */
99
+ mcp: boolean;
100
+ verbose?: boolean;
101
+ /** Injectable phase runner — defaults to the real executePhaseWithRetry wrapper. */
102
+ runPhase: ReadyPhaseRunner;
103
+ /**
104
+ * #697: optional live-progress sink. The gate owns the qa→loop→qa loop, so it
105
+ * is the natural emit site (the `run` path emits from RunOrchestrator/batch-
106
+ * executor). Fires `start` before each phase and `complete`/`failed` after,
107
+ * carrying the 1-based QA-pass `iteration` so the renderer shows `loop N/M`.
108
+ * Optional — injected unit tests that omit it stay unaffected.
109
+ */
110
+ onProgress?: ProgressCallback;
111
+ /** Injectable token reader — defaults to reading `<worktree>/.sequant`. */
112
+ readTokensUsed?: (worktreePath: string) => number;
113
+ /** Injectable change detector — defaults to {@link hasExecChanges}. */
114
+ hasChangesFn?: (cwd: string) => boolean;
115
+ /** Injectable loop-progress snapshot — defaults to {@link snapshotLoopProgress}. */
116
+ snapshotFn?: (cwd: string) => LoopProgressSnapshot;
117
+ }
118
+ /**
119
+ * Pure exit predicate. Given a policy and a QA verdict, has the loop reached
120
+ * its stopping threshold?
121
+ *
122
+ * - `READY_FOR_MERGE` always stops (both policies).
123
+ * - `ac`: `AC_MET_BUT_NOT_A_PLUS` also stops — ACs are objectively met; the
124
+ * remaining gaps are quality-only and `ac` reports rather than fixes them.
125
+ * - `a-plus`: only `READY_FOR_MERGE` stops.
126
+ * - `AC_NOT_MET` / `NEEDS_VERIFICATION` never stop in either policy.
127
+ */
128
+ export declare function isAtThreshold(policy: ReadyPolicy, verdict: QaVerdict): boolean;
129
+ /**
130
+ * Does a gap description overlap a Non-Goal? Conservative token-overlap
131
+ * heuristic: ≥2 shared significant words marks the gap as Non-Goal-touching.
132
+ *
133
+ * @internal Exported for testing only.
134
+ */
135
+ export declare function gapTouchesNonGoals(gap: string, nonGoals: string[]): boolean;
136
+ /**
137
+ * Parse the issue body's Non-Goals section into a list of bullet items.
138
+ *
139
+ * Recognizes `## Non-goals`, `## Non-Goals`, `### Out of scope`, etc. Captures
140
+ * markdown bullet items until the next heading. Returns `[]` when no section is
141
+ * present.
142
+ *
143
+ * @internal Exported for testing only.
144
+ */
145
+ export declare function parseNonGoals(issueBody: string): string[];
146
+ /**
147
+ * Render the structured gap report (AC-4).
148
+ *
149
+ * @internal Exported for testing only.
150
+ */
151
+ export declare function formatReadyReport(result: ReadyResult): string;
152
+ /**
153
+ * Drive the policy-bounded `qa → loop → qa` ready gate.
154
+ */
155
+ export declare function runReadyGate(opts: RunReadyGateOptions): Promise<ReadyResult>;
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Ready gate engine (#683).
3
+ *
4
+ * Drives the `sequant ready <issue>` pipeline: a full-weight `qa → loop → qa`
5
+ * loop that reproduces the maintainer's manual fresh-session A+ pass
6
+ * deterministically, then STOPS at a human merge gate — it never merges.
7
+ *
8
+ * The loop's exit threshold is set by a **gate policy**:
9
+ *
10
+ * - `ac` (default): stop once no `AC_NOT_MET` verdict remains. Remaining
11
+ * quality/polish gaps are surfaced in the report but NOT auto-fixed. Findings
12
+ * that touch the issue's Non-Goals are report-only. Predictable, scope-
13
+ * respecting behavior for a team engineer with a fixed agenda.
14
+ * - `a-plus` (opt-in): loop toward `READY_FOR_MERGE`, auto-fixing quality gaps.
15
+ *
16
+ * Both policies are additionally bounded by `maxIterations`, an optional token
17
+ * budget, and the `LOOP_NO_DIFF` stagnation guard. The #534 class (zero-diff
18
+ * exec / null QA verdict) is never reported as ready.
19
+ *
20
+ * This module is the reusable engine — a future `sequant run --ready-gate`
21
+ * (out of scope for #683) can reuse `runReadyGate` directly. The command shell
22
+ * lives in `src/commands/ready.ts`.
23
+ */
24
+ import { snapshotLoopProgress, compareLoopProgress, } from "./qa-stagnation.js";
25
+ import { hasExecChanges } from "./phase-executor.js";
26
+ import { readTokenUsageFiles, aggregateTokenUsage, TOKEN_USAGE_DIR, } from "./token-utils.js";
27
+ import * as path from "path";
28
+ /**
29
+ * Pure exit predicate. Given a policy and a QA verdict, has the loop reached
30
+ * its stopping threshold?
31
+ *
32
+ * - `READY_FOR_MERGE` always stops (both policies).
33
+ * - `ac`: `AC_MET_BUT_NOT_A_PLUS` also stops — ACs are objectively met; the
34
+ * remaining gaps are quality-only and `ac` reports rather than fixes them.
35
+ * - `a-plus`: only `READY_FOR_MERGE` stops.
36
+ * - `AC_NOT_MET` / `NEEDS_VERIFICATION` never stop in either policy.
37
+ */
38
+ export function isAtThreshold(policy, verdict) {
39
+ if (verdict === "READY_FOR_MERGE")
40
+ return true;
41
+ if (policy === "ac")
42
+ return verdict === "AC_MET_BUT_NOT_A_PLUS";
43
+ return false;
44
+ }
45
+ const STOPWORDS = new Set([
46
+ "the",
47
+ "and",
48
+ "for",
49
+ "with",
50
+ "that",
51
+ "this",
52
+ "from",
53
+ "into",
54
+ "are",
55
+ "was",
56
+ "has",
57
+ "have",
58
+ "not",
59
+ "but",
60
+ "its",
61
+ "via",
62
+ "any",
63
+ "all",
64
+ "out",
65
+ "should",
66
+ "would",
67
+ "could",
68
+ "when",
69
+ "then",
70
+ "than",
71
+ "must",
72
+ "will",
73
+ ]);
74
+ function significantTokens(text) {
75
+ return new Set(text
76
+ .toLowerCase()
77
+ .replace(/[^a-z0-9\s-]/g, " ")
78
+ .split(/[\s-]+/)
79
+ .filter((w) => w.length > 3 && !STOPWORDS.has(w)));
80
+ }
81
+ /**
82
+ * Does a gap description overlap a Non-Goal? Conservative token-overlap
83
+ * heuristic: ≥2 shared significant words marks the gap as Non-Goal-touching.
84
+ *
85
+ * @internal Exported for testing only.
86
+ */
87
+ export function gapTouchesNonGoals(gap, nonGoals) {
88
+ if (nonGoals.length === 0)
89
+ return false;
90
+ const gapTokens = significantTokens(gap);
91
+ if (gapTokens.size === 0)
92
+ return false;
93
+ for (const ng of nonGoals) {
94
+ const ngTokens = significantTokens(ng);
95
+ let overlap = 0;
96
+ for (const t of ngTokens) {
97
+ if (gapTokens.has(t))
98
+ overlap++;
99
+ if (overlap >= 2)
100
+ return true;
101
+ }
102
+ }
103
+ return false;
104
+ }
105
+ /**
106
+ * Parse the issue body's Non-Goals section into a list of bullet items.
107
+ *
108
+ * Recognizes `## Non-goals`, `## Non-Goals`, `### Out of scope`, etc. Captures
109
+ * markdown bullet items until the next heading. Returns `[]` when no section is
110
+ * present.
111
+ *
112
+ * @internal Exported for testing only.
113
+ */
114
+ export function parseNonGoals(issueBody) {
115
+ if (!issueBody)
116
+ return [];
117
+ const lines = issueBody.split("\n");
118
+ const items = [];
119
+ let inSection = false;
120
+ const headingRe = /^#{1,6}\s+(.*)$/;
121
+ const nonGoalHeadingRe = /^(non-?goals?|out[ -]of[ -]scope)\b/i;
122
+ for (const line of lines) {
123
+ const heading = line.match(headingRe);
124
+ if (heading) {
125
+ inSection = nonGoalHeadingRe.test(heading[1].trim());
126
+ continue;
127
+ }
128
+ if (!inSection)
129
+ continue;
130
+ const bullet = line.match(/^\s*[-*]\s+(.+)$/);
131
+ if (bullet) {
132
+ // Strip surrounding markdown emphasis/backticks for cleaner matching.
133
+ const text = bullet[1].replace(/[`*]/g, "").trim();
134
+ if (text)
135
+ items.push(text);
136
+ }
137
+ }
138
+ return items;
139
+ }
140
+ function classifyGaps(gaps, nonGoals) {
141
+ return gaps.map((g) => ({
142
+ description: g,
143
+ nonGoal: gapTouchesNonGoals(g, nonGoals),
144
+ }));
145
+ }
146
+ function defaultReadTokensUsed(worktreePath) {
147
+ const dir = path.join(worktreePath, TOKEN_USAGE_DIR);
148
+ // Read without cleanup — the engine polls cumulatively across phases.
149
+ const files = readTokenUsageFiles(dir);
150
+ return aggregateTokenUsage(files).tokensUsed;
151
+ }
152
+ /**
153
+ * Build a minimal ExecutionConfig for a single ready-gate phase.
154
+ */
155
+ function buildPhaseConfig(opts, extra) {
156
+ return {
157
+ phases: [],
158
+ phaseTimeout: opts.phaseTimeout,
159
+ qualityLoop: false,
160
+ maxIterations: opts.maxIterations,
161
+ skipVerification: false,
162
+ sequential: true,
163
+ concurrency: 1,
164
+ parallel: false,
165
+ verbose: opts.verbose ?? false,
166
+ noSmartTests: false,
167
+ dryRun: false,
168
+ mcp: opts.mcp,
169
+ retry: true,
170
+ ...extra,
171
+ };
172
+ }
173
+ /**
174
+ * Render the structured gap report (AC-4).
175
+ *
176
+ * @internal Exported for testing only.
177
+ */
178
+ export function formatReadyReport(result) {
179
+ const headline = result.ready
180
+ ? "✅ READY — awaiting human merge decision"
181
+ : result.reason === "NO_IMPLEMENTATION"
182
+ ? "⛔ NOT READY — no implementation detected"
183
+ : "⚠️ NOT READY — needs human intervention";
184
+ const reasonText = {
185
+ AC_MET: "Acceptance Criteria are objectively met (no AC_NOT_MET). Remaining gaps are quality-only and reported below (policy `ac` does not auto-fix them).",
186
+ READY_FOR_MERGE: "QA returned READY_FOR_MERGE.",
187
+ MAX_ITERATIONS: "Hit the iteration cap before reaching the policy threshold. A human should review the remaining gaps.",
188
+ TOKEN_BUDGET: "Token budget exhausted before reaching the policy threshold. A human should review the remaining gaps.",
189
+ LOOP_NO_DIFF: "The fix loop made no diff (stagnation guard). Manual intervention required.",
190
+ LOOP_FAILED: "The fix loop phase failed. A human should investigate before merging.",
191
+ NO_IMPLEMENTATION: "Zero-diff worktree or null/unparseable QA verdict — there is nothing to certify (#534 guard).",
192
+ };
193
+ const lines = [];
194
+ lines.push(`## sequant ready — Issue #${result.issueNumber}`);
195
+ lines.push("");
196
+ lines.push(`**${headline}**`);
197
+ lines.push("");
198
+ lines.push(`- **Policy:** \`${result.policy}\``);
199
+ lines.push(`- **Final verdict:** ${result.finalVerdict ?? "(none)"}`);
200
+ lines.push(`- **Stop reason:** ${result.reason} — ${reasonText[result.reason]}`);
201
+ lines.push(`- **QA passes:** ${result.iterations}`);
202
+ lines.push(`- **Tokens used:** ${result.tokensUsed.toLocaleString()}`);
203
+ lines.push(`- **Final state:** \`${result.issueStatus}\` (never auto-merged)`);
204
+ lines.push("");
205
+ lines.push("### Auto-fixed");
206
+ if (result.autoFixed.length === 0) {
207
+ lines.push("- None");
208
+ }
209
+ else {
210
+ for (const item of result.autoFixed)
211
+ lines.push(`- ${item}`);
212
+ }
213
+ lines.push("");
214
+ lines.push("### Remaining / accepted gaps");
215
+ if (result.remaining.length === 0) {
216
+ lines.push("- None");
217
+ }
218
+ else {
219
+ for (const item of result.remaining) {
220
+ const tag = item.nonGoal ? " _(Non-Goal — report-only)_" : "";
221
+ lines.push(`- ${item.description}${tag}`);
222
+ }
223
+ }
224
+ lines.push("");
225
+ lines.push("> The human merge gate is intentional: `sequant ready` never merges. Review the gaps above, then merge manually when satisfied.");
226
+ return lines.join("\n");
227
+ }
228
+ /**
229
+ * Drive the policy-bounded `qa → loop → qa` ready gate.
230
+ */
231
+ export async function runReadyGate(opts) {
232
+ const { issueNumber, worktreePath, policy, maxIterations, tokenBudget, nonGoals = [], } = opts;
233
+ const readTokensUsed = opts.readTokensUsed ?? defaultReadTokensUsed;
234
+ const hasChangesFn = opts.hasChangesFn ?? hasExecChanges;
235
+ const snapshotFn = opts.snapshotFn ?? snapshotLoopProgress;
236
+ // #697: run a phase while emitting live-progress events around it. `iteration`
237
+ // is the 1-based QA-pass index so the renderer can render `loop N/M`. Emits
238
+ // `failed` (and re-throws) if the runner itself throws so the catch path in
239
+ // ready.ts disposes a renderer that already reflects the failed cell.
240
+ const runPhaseTracked = async (phase, config, iteration) => {
241
+ opts.onProgress?.(opts.issueNumber, phase, "start", { iteration });
242
+ let phaseResult;
243
+ try {
244
+ phaseResult = await opts.runPhase(phase, config, worktreePath);
245
+ }
246
+ catch (err) {
247
+ opts.onProgress?.(opts.issueNumber, phase, "failed", {
248
+ iteration,
249
+ error: err instanceof Error ? err.message : String(err),
250
+ });
251
+ throw err;
252
+ }
253
+ opts.onProgress?.(opts.issueNumber, phase, phaseResult.success === false ? "failed" : "complete", {
254
+ iteration,
255
+ durationSeconds: phaseResult.durationSeconds,
256
+ error: phaseResult.success === false ? phaseResult.error : undefined,
257
+ });
258
+ return phaseResult;
259
+ };
260
+ let iterations = 0;
261
+ let finalVerdict = null;
262
+ const autoFixed = [];
263
+ let remaining = [];
264
+ let tokensUsed = 0;
265
+ const finish = (reason) => {
266
+ const ready = reason === "AC_MET" || reason === "READY_FOR_MERGE";
267
+ const issueStatus = ready
268
+ ? "waiting_for_human_merge"
269
+ : "blocked";
270
+ const result = {
271
+ issueNumber,
272
+ policy,
273
+ ready,
274
+ reason,
275
+ issueStatus,
276
+ iterations,
277
+ finalVerdict,
278
+ autoFixed,
279
+ remaining,
280
+ tokensUsed,
281
+ report: "",
282
+ };
283
+ result.report = formatReadyReport(result);
284
+ return result;
285
+ };
286
+ const budgetExceeded = () => typeof tokenBudget === "number" &&
287
+ tokenBudget > 0 &&
288
+ tokensUsed >= tokenBudget;
289
+ // Loop is bounded by maxIterations QA passes. Each iteration: run QA, check
290
+ // the policy threshold + #534 guards, and (if not stopping) run one fix loop.
291
+ while (iterations < maxIterations) {
292
+ if (budgetExceeded()) {
293
+ return finish("TOKEN_BUDGET");
294
+ }
295
+ iterations++;
296
+ const qaResult = await runPhaseTracked("qa", buildPhaseConfig(opts, { fullQa: true }), iterations);
297
+ tokensUsed = readTokensUsed(worktreePath);
298
+ const verdict = qaResult.verdict ?? null;
299
+ // #534 guard: a null/unparseable verdict is never "ready".
300
+ if (!verdict) {
301
+ return finish("NO_IMPLEMENTATION");
302
+ }
303
+ // #534 guard: an empty worktree (no commits, no uncommitted work) is never
304
+ // "ready" — replays the #529/#570 empty-branch class.
305
+ if (!hasChangesFn(worktreePath)) {
306
+ return finish("NO_IMPLEMENTATION");
307
+ }
308
+ finalVerdict = verdict;
309
+ const gaps = qaResult.summary?.gaps ?? [];
310
+ remaining = classifyGaps(gaps, nonGoals);
311
+ // Policy threshold reached → stop at the human merge gate.
312
+ if (isAtThreshold(policy, verdict)) {
313
+ return finish(verdict === "READY_FOR_MERGE" ? "READY_FOR_MERGE" : "AC_MET");
314
+ }
315
+ // Not at threshold and out of iterations → clean halt, needs human.
316
+ if (iterations >= maxIterations) {
317
+ return finish("MAX_ITERATIONS");
318
+ }
319
+ if (budgetExceeded()) {
320
+ return finish("TOKEN_BUDGET");
321
+ }
322
+ // Run one fix loop. In `ac` mode we only reach here on AC_NOT_MET, so the
323
+ // gaps are AC gaps — feeding them via failedAcs keeps the loop scoped to
324
+ // the AC boundary (quality gaps are never fixed under `ac`). Non-Goal-
325
+ // touching findings are excluded from what we ask the loop to fix.
326
+ const fixableGaps = remaining
327
+ .filter((g) => !g.nonGoal)
328
+ .map((g) => g.description);
329
+ const before = snapshotFn(worktreePath);
330
+ const loopResult = await runPhaseTracked("loop", buildPhaseConfig(opts, {
331
+ lastVerdict: verdict,
332
+ failedAcs: fixableGaps.join("; ") || undefined,
333
+ promptContext: buildLoopContext(policy, verdict, fixableGaps),
334
+ }), iterations);
335
+ tokensUsed = readTokensUsed(worktreePath);
336
+ if (!loopResult.success) {
337
+ return finish("LOOP_FAILED");
338
+ }
339
+ const after = snapshotFn(worktreePath);
340
+ const progress = compareLoopProgress(before, after);
341
+ if (!progress.progressed) {
342
+ return finish("LOOP_NO_DIFF");
343
+ }
344
+ // The loop produced a diff — record what it was asked to fix and re-QA.
345
+ for (const g of fixableGaps) {
346
+ if (!autoFixed.includes(g))
347
+ autoFixed.push(g);
348
+ }
349
+ }
350
+ // Iteration cap reached without an explicit stop above (defensive).
351
+ return finish("MAX_ITERATIONS");
352
+ }
353
+ /**
354
+ * Build the prompt context handed to the `/loop` phase. Mirrors the
355
+ * batch-executor's `buildLoopContext` shape but scopes the instruction to the
356
+ * gate policy so `ac` runs do not chase quality-only gaps.
357
+ */
358
+ function buildLoopContext(policy, verdict, fixableGaps) {
359
+ const parts = [];
360
+ parts.push(`Ready gate (#683) — policy: ${policy}`);
361
+ parts.push(`QA Verdict: ${verdict}`);
362
+ if (policy === "ac") {
363
+ parts.push("Scope: fix ONLY the unmet Acceptance Criteria below. Do NOT address quality/polish gaps or anything touching the issue's Non-Goals — those are deliberately deferred under the `ac` policy.");
364
+ }
365
+ else {
366
+ parts.push("Scope: drive the work toward READY_FOR_MERGE by addressing the gaps below.");
367
+ }
368
+ if (fixableGaps.length > 0) {
369
+ parts.push("Gaps to address:");
370
+ for (const g of fixableGaps)
371
+ parts.push(`- ${g}`);
372
+ }
373
+ return parts.join("\n");
374
+ }
@@ -95,6 +95,12 @@ export function getNextActionHint(issue) {
95
95
  }
96
96
  case "waiting_for_qa_gate":
97
97
  return `sequant run ${issue.number} --phase qa`;
98
+ case "waiting_for_human_merge":
99
+ // `sequant ready` certified the work; a human reviews + merges manually.
100
+ if (issue.pr?.number) {
101
+ return `review gaps, then gh pr merge ${issue.pr.number}`;
102
+ }
103
+ return `review gaps, then merge manually`;
98
104
  case "ready_for_merge":
99
105
  if (issue.pr?.number) {
100
106
  return `gh pr merge ${issue.pr.number}`;
@@ -46,6 +46,7 @@ export declare const IssueStatusSchema: z.ZodEnum<{
46
46
  in_progress: "in_progress";
47
47
  not_started: "not_started";
48
48
  waiting_for_qa_gate: "waiting_for_qa_gate";
49
+ waiting_for_human_merge: "waiting_for_human_merge";
49
50
  ready_for_merge: "ready_for_merge";
50
51
  blocked: "blocked";
51
52
  abandoned: "abandoned";
@@ -222,6 +223,7 @@ export declare const IssueStateSchema: z.ZodObject<{
222
223
  in_progress: "in_progress";
223
224
  not_started: "not_started";
224
225
  waiting_for_qa_gate: "waiting_for_qa_gate";
226
+ waiting_for_human_merge: "waiting_for_human_merge";
225
227
  ready_for_merge: "ready_for_merge";
226
228
  blocked: "blocked";
227
229
  abandoned: "abandoned";
@@ -360,6 +362,7 @@ export declare const WorkflowStateSchema: z.ZodObject<{
360
362
  in_progress: "in_progress";
361
363
  not_started: "not_started";
362
364
  waiting_for_qa_gate: "waiting_for_qa_gate";
365
+ waiting_for_human_merge: "waiting_for_human_merge";
363
366
  ready_for_merge: "ready_for_merge";
364
367
  blocked: "blocked";
365
368
  abandoned: "abandoned";
@@ -46,6 +46,7 @@ export const IssueStatusSchema = z.enum([
46
46
  "not_started", // Issue tracked but no work begun
47
47
  "in_progress", // Actively being worked on
48
48
  "waiting_for_qa_gate", // QA completed, waiting for gate approval in chain mode
49
+ "waiting_for_human_merge", // `sequant ready` (#683) finished its A+ gate; awaiting human merge decision (never auto-merges)
49
50
  "ready_for_merge", // All phases passed, PR ready for review
50
51
  "merged", // PR merged, work complete
51
52
  "blocked", // Waiting on external input or dependency
@@ -136,6 +136,15 @@ export interface ExecutionConfig {
136
136
  * Default: false (opt-in for the initial rollout).
137
137
  */
138
138
  relayEnabled?: boolean;
139
+ /**
140
+ * Force full-weight (standalone) QA even under an orchestrator (#683).
141
+ * When true, the phase executor sets `SEQUANT_FULL_QA=1` in the agent
142
+ * environment for the `qa` phase. The QA skill honors this flag by running
143
+ * its standalone branch-freshness / process-state pre-flight checks even
144
+ * though `SEQUANT_ORCHESTRATOR` is also set. Used by `sequant ready` so its
145
+ * QA pass does NOT skip the checks that catch the #318/#529/#570 class.
146
+ */
147
+ fullQa?: boolean;
139
148
  }
140
149
  /**
141
150
  * Default execution configuration
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
- import { Box, useStdout } from "ink";
3
+ import { Box, Text, useStdout } from "ink";
4
4
  import { Header } from "./Header.js";
5
5
  import { IssueBox } from "./IssueBox.js";
6
+ import { selectVisibleIssues } from "./row-cap.js";
7
+ import { ROLLUP_COLOR } from "./theme.js";
6
8
  const POLL_MS = 100; // 10 Hz
7
9
  /**
8
10
  * Root TUI component.
@@ -37,5 +39,9 @@ export function App({ getSnapshot, onDone, }) {
37
39
  }, []);
38
40
  const columns = stdout?.columns ?? 80;
39
41
  const boxWidth = Math.min(columns - 2, 100);
40
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { snapshot: snapshot }), snapshot.issues.map((issue, i) => (_jsx(IssueBox, { state: issue, slot: i, width: boxWidth, now: now }, issue.number)))] }));
42
+ // #699 AC-4: clamp the number of boxes to the terminal height so a large
43
+ // batch on a short terminal can't overflow the frame (parity with the plain
44
+ // renderer's #624 row cap). Older completed issues collapse into `✔ N done`.
45
+ const { visible, rolledUpDoneCount } = selectVisibleIssues(snapshot.issues, stdout?.rows);
46
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { snapshot: snapshot }), visible.map((issue, i) => (_jsx(IssueBox, { state: issue, slot: i, width: boxWidth, now: now }, issue.number))), rolledUpDoneCount > 0 ? (_jsx(Text, { color: ROLLUP_COLOR, children: `✔ ${rolledUpDoneCount} done` })) : null] }));
41
47
  }
@@ -20,7 +20,7 @@ export function IssueBox({ state, slot, width, now, }) {
20
20
  const displayPhaseN = activePhaseIndex >= 0 ? activePhaseIndex + 1 : doneCount;
21
21
  const total = state.phases.length;
22
22
  const headerTitle = truncateToWidth(`#${state.number} ${state.title}`, Math.max(10, innerWidth - 20));
23
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: border, paddingX: 1, marginBottom: 1, width: width, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: border, children: headerTitle }), _jsxs(Text, { color: DIVIDER_COLOR, children: ["phase ", displayPhaseN, "/", total, " \u2022", " ", _jsx(ElapsedTimer, { startedAt: state.startedAt })] })] }), _jsx(Divider, { width: innerWidth, borderColor: border }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "branch " }), _jsx(Text, { children: truncateToWidth(state.branch, innerWidth - 8) })] }), _jsx(PhaseProgression, { phases: state.phases, borderColor: border }), state.currentPhase?.logPath ? (_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "log " }), _jsx(Text, { children: truncateToWidth(state.currentPhase.logPath, innerWidth - 8) })] })) : null] }), _jsx(Divider, { width: innerWidth, borderColor: border }), _jsx(Box, { flexDirection: "column", children: state.currentPhase ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "now " }), _jsx(Spinner, { color: border }), _jsxs(Text, { children: [" ", truncateToWidth(state.currentPhase.nowLine, innerWidth - 12)] })] }), _jsx(Box, { children: _jsxs(Text, { color: DIVIDER_COLOR, children: [" └ last activity ", formatSinceActivity(now, state.currentPhase.lastActivityAt)] }) })] })) : (_jsx(Text, { color: DIVIDER_COLOR, children: statusLine(state) })) })] }));
23
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: border, paddingX: 1, marginBottom: 1, width: width, children: [_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Text, { color: border, children: headerTitle }), _jsxs(Text, { color: DIVIDER_COLOR, children: ["phase ", displayPhaseN, "/", total, " \u2022", " ", _jsx(ElapsedTimer, { startedAt: state.startedAt })] })] }), _jsx(Divider, { width: innerWidth }), _jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "branch " }), _jsx(Text, { children: truncateToWidth(state.branch, innerWidth - 8) })] }), _jsx(PhaseProgression, { phases: state.phases, borderColor: border }), state.currentPhase?.logPath ? (_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "log " }), _jsx(Text, { children: truncateToWidth(state.currentPhase.logPath, innerWidth - 8) })] })) : null] }), _jsx(Divider, { width: innerWidth }), _jsx(Box, { flexDirection: "column", children: state.currentPhase ? (_jsxs(_Fragment, { children: [_jsxs(Box, { children: [_jsx(Text, { color: DIVIDER_COLOR, children: "now " }), _jsx(Spinner, { color: border }), _jsxs(Text, { children: [" ", truncateToWidth(state.currentPhase.nowLine, innerWidth - 12)] })] }), _jsx(Box, { children: _jsxs(Text, { color: DIVIDER_COLOR, children: [" └ last activity ", formatSinceActivity(now, state.currentPhase.lastActivityAt)] }) })] })) : (_jsx(Text, { color: DIVIDER_COLOR, children: statusLine(state) })) })] }));
24
24
  }
25
25
  function statusLine(state) {
26
26
  switch (state.status) {
@@ -34,9 +34,8 @@ function statusLine(state) {
34
34
  return "failed";
35
35
  }
36
36
  }
37
- function Divider({ width, borderColor, }) {
38
- const mid = "─".repeat(Math.max(0, width - 2));
39
- return (_jsxs(Text, { children: [_jsx(Text, { color: borderColor, children: "\u251C" }), _jsx(Text, { color: DIVIDER_COLOR, children: mid }), _jsx(Text, { color: borderColor, children: "\u2524" })] }));
37
+ function Divider({ width }) {
38
+ return _jsx(Text, { color: DIVIDER_COLOR, children: "─".repeat(Math.max(0, width)) });
40
39
  }
41
40
  function PhaseProgression({ phases, borderColor, }) {
42
41
  return (_jsxs(Box, { flexWrap: "wrap", children: [_jsx(Text, { color: DIVIDER_COLOR, children: "phases " }), phases.map((p, i) => {
@@ -1,15 +1,24 @@
1
1
  /**
2
2
  * Experimental multi-issue TUI entry point.
3
3
  *
4
- * Mounts an `ink` app that polls `RunOrchestrator.getSnapshot()` at 10 Hz.
5
- * Unmounts when the orchestrator reports `done` so the shell returns
4
+ * Mounts an `ink` app that polls a snapshot provider's `getSnapshot()` at
5
+ * 10 Hz. Unmounts when the snapshot reports `done` so the shell returns
6
6
  * cleanly. Only safe to call when `process.stdout.isTTY` is true.
7
+ *
8
+ * The provider is structural (`{ getSnapshot(): RunSnapshot }`) so any source
9
+ * of run state can drive the TUI — `RunOrchestrator` for `sequant run`, or the
10
+ * single-issue adapter `sequant ready` owns (#699). The TUI only ever reads
11
+ * `getSnapshot()`, never the orchestrator's batch lifecycle.
7
12
  */
8
- import type { RunOrchestrator } from "../../lib/workflow/run-orchestrator.js";
13
+ import type { RunSnapshot } from "../../lib/workflow/run-state.js";
14
+ /** Minimal structural contract the TUI needs from its state source. */
15
+ export interface SnapshotProvider {
16
+ getSnapshot(): RunSnapshot;
17
+ }
9
18
  export interface TuiHandle {
10
19
  /** Promise that resolves when the TUI unmounts. */
11
20
  done: Promise<void>;
12
21
  /** Force-unmount (e.g., on SIGINT fallback). */
13
22
  unmount: () => void;
14
23
  }
15
- export declare function renderTui(orchestrator: RunOrchestrator): TuiHandle;
24
+ export declare function renderTui(provider: SnapshotProvider): TuiHandle;