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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +125 -163
- package/dist/bin/cli.js +13 -0
- package/dist/dashboard/server.js +1 -0
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +2 -2
- package/dist/marketplace/external_plugins/sequant/README.md +6 -3
- package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +92 -0
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +18 -9
- package/dist/marketplace/external_plugins/sequant/hooks/relay-check.sh +107 -0
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +21 -8
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +302 -86
- package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +109 -0
- package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +141 -22
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +83 -78
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +377 -137
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +28 -0
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +621 -0
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +741 -232
- package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +47 -1
- package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +12 -6
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +217 -964
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/parallel-groups.md +7 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/quality-checklist.md +75 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +4 -2
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +0 -27
- package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +24 -44
- package/dist/src/commands/ready-tui-adapter.d.ts +59 -0
- package/dist/src/commands/ready-tui-adapter.js +130 -0
- package/dist/src/commands/ready.d.ts +49 -0
- package/dist/src/commands/ready.js +243 -0
- package/dist/src/commands/status.js +4 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +7 -1
- package/dist/src/lib/cli-ui/run-renderer.js +28 -28
- package/dist/src/lib/settings.d.ts +34 -0
- package/dist/src/lib/settings.js +23 -1
- package/dist/src/lib/workflow/phase-executor.js +17 -2
- package/dist/src/lib/workflow/platforms/github.d.ts +6 -0
- package/dist/src/lib/workflow/platforms/github.js +17 -0
- package/dist/src/lib/workflow/ready-gate.d.ts +155 -0
- package/dist/src/lib/workflow/ready-gate.js +374 -0
- package/dist/src/lib/workflow/reconcile.js +6 -0
- package/dist/src/lib/workflow/state-schema.d.ts +3 -0
- package/dist/src/lib/workflow/state-schema.js +1 -0
- package/dist/src/lib/workflow/types.d.ts +9 -0
- package/dist/src/ui/tui/App.js +8 -2
- package/dist/src/ui/tui/IssueBox.js +3 -4
- package/dist/src/ui/tui/index.d.ts +13 -4
- package/dist/src/ui/tui/index.js +19 -5
- package/dist/src/ui/tui/row-cap.d.ts +51 -0
- package/dist/src/ui/tui/row-cap.js +76 -0
- package/dist/src/ui/tui/teardown.d.ts +20 -0
- package/dist/src/ui/tui/teardown.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +3 -0
- package/dist/src/ui/tui/theme.js +3 -0
- package/package.json +19 -8
- 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
|
package/dist/src/ui/tui/App.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
38
|
-
|
|
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 `
|
|
5
|
-
* Unmounts when the
|
|
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 {
|
|
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(
|
|
24
|
+
export declare function renderTui(provider: SnapshotProvider): TuiHandle;
|