sequant 2.2.0 → 2.3.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 +73 -0
- package/dist/bin/cli.js +94 -9
- package/dist/src/commands/doctor.d.ts +25 -0
- package/dist/src/commands/doctor.js +36 -1
- package/dist/src/commands/locks.d.ts +67 -0
- package/dist/src/commands/locks.js +290 -0
- package/dist/src/commands/merge.js +11 -0
- package/dist/src/commands/prompt.d.ts +39 -0
- package/dist/src/commands/prompt.js +179 -0
- package/dist/src/commands/run-display.d.ts +11 -2
- package/dist/src/commands/run-display.js +62 -28
- package/dist/src/commands/run-progress.d.ts +32 -0
- package/dist/src/commands/run-progress.js +76 -0
- package/dist/src/commands/run.js +80 -18
- package/dist/src/commands/stats.d.ts +2 -0
- package/dist/src/commands/stats.js +94 -8
- package/dist/src/commands/status.js +12 -0
- package/dist/src/commands/watch.d.ts +16 -0
- package/dist/src/commands/watch.js +147 -0
- package/dist/src/lib/ac-linter.d.ts +1 -1
- package/dist/src/lib/ac-linter.js +81 -0
- package/dist/src/lib/assess-collision-detect.d.ts +91 -0
- package/dist/src/lib/assess-collision-detect.js +217 -0
- package/dist/src/lib/assess-comment-parser.d.ts +59 -1
- package/dist/src/lib/assess-comment-parser.js +124 -2
- package/dist/src/lib/cli-ui/format.d.ts +19 -0
- package/dist/src/lib/cli-ui/format.js +34 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
- package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
- package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
- package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
- package/dist/src/lib/locks/index.d.ts +7 -0
- package/dist/src/lib/locks/index.js +5 -0
- package/dist/src/lib/locks/lock-manager.d.ts +168 -0
- package/dist/src/lib/locks/lock-manager.js +433 -0
- package/dist/src/lib/locks/types.d.ts +59 -0
- package/dist/src/lib/locks/types.js +31 -0
- package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
- package/dist/src/lib/qa/markdown-only-ci.js +74 -0
- package/dist/src/lib/relay/activation.d.ts +60 -0
- package/dist/src/lib/relay/activation.js +122 -0
- package/dist/src/lib/relay/archive.d.ts +34 -0
- package/dist/src/lib/relay/archive.js +106 -0
- package/dist/src/lib/relay/frame.d.ts +20 -0
- package/dist/src/lib/relay/frame.js +76 -0
- package/dist/src/lib/relay/index.d.ts +13 -0
- package/dist/src/lib/relay/index.js +13 -0
- package/dist/src/lib/relay/paths.d.ts +43 -0
- package/dist/src/lib/relay/paths.js +59 -0
- package/dist/src/lib/relay/pid.d.ts +34 -0
- package/dist/src/lib/relay/pid.js +72 -0
- package/dist/src/lib/relay/reader.d.ts +35 -0
- package/dist/src/lib/relay/reader.js +115 -0
- package/dist/src/lib/relay/types.d.ts +68 -0
- package/dist/src/lib/relay/types.js +76 -0
- package/dist/src/lib/relay/writer.d.ts +48 -0
- package/dist/src/lib/relay/writer.js +113 -0
- package/dist/src/lib/settings.d.ts +31 -1
- package/dist/src/lib/settings.js +18 -3
- package/dist/src/lib/version-check.d.ts +60 -5
- package/dist/src/lib/version-check.js +97 -9
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +248 -175
- package/dist/src/lib/workflow/config-resolver.js +4 -0
- package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
- package/dist/src/lib/workflow/heartbeat.js +194 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +62 -8
- package/dist/src/lib/workflow/phase-executor.js +157 -16
- package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
- package/dist/src/lib/workflow/phase-mapper.js +17 -20
- package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
- package/dist/src/lib/workflow/platforms/github.js +20 -3
- package/dist/src/lib/workflow/pr-status.d.ts +18 -2
- package/dist/src/lib/workflow/pr-status.js +41 -9
- package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
- package/dist/src/lib/workflow/qa-stagnation.js +179 -0
- package/dist/src/lib/workflow/run-orchestrator.d.ts +39 -0
- package/dist/src/lib/workflow/run-orchestrator.js +340 -15
- package/dist/src/lib/workflow/run-reflect.js +1 -1
- package/dist/src/lib/workflow/run-state.d.ts +71 -0
- package/dist/src/lib/workflow/run-state.js +14 -0
- package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
- package/dist/src/lib/workflow/state-cleanup.js +17 -5
- package/dist/src/lib/workflow/state-manager.d.ts +12 -1
- package/dist/src/lib/workflow/state-manager.js +37 -0
- package/dist/src/lib/workflow/state-schema.d.ts +62 -0
- package/dist/src/lib/workflow/state-schema.js +35 -1
- package/dist/src/lib/workflow/types.d.ts +74 -1
- package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
- package/dist/src/lib/workflow/worktree-manager.js +15 -6
- package/dist/src/mcp/tools/run.d.ts +44 -0
- package/dist/src/mcp/tools/run.js +104 -13
- package/dist/src/ui/tui/App.d.ts +14 -0
- package/dist/src/ui/tui/App.js +41 -0
- package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
- package/dist/src/ui/tui/ElapsedTimer.js +31 -0
- package/dist/src/ui/tui/Header.d.ts +6 -0
- package/dist/src/ui/tui/Header.js +15 -0
- package/dist/src/ui/tui/IssueBox.d.ts +16 -0
- package/dist/src/ui/tui/IssueBox.js +68 -0
- package/dist/src/ui/tui/Spinner.d.ts +9 -0
- package/dist/src/ui/tui/Spinner.js +18 -0
- package/dist/src/ui/tui/index.d.ts +15 -0
- package/dist/src/ui/tui/index.js +29 -0
- package/dist/src/ui/tui/theme.d.ts +29 -0
- package/dist/src/ui/tui/theme.js +52 -0
- package/dist/src/ui/tui/truncate.d.ts +11 -0
- package/dist/src/ui/tui/truncate.js +31 -0
- package/package.json +10 -3
- package/templates/agents/sequant-explorer.md +1 -0
- package/templates/agents/sequant-qa-checker.md +2 -1
- package/templates/agents/sequant-testgen.md +1 -0
- package/templates/hooks/post-tool.sh +11 -0
- package/templates/hooks/pre-tool.sh +18 -9
- package/templates/hooks/relay-check.sh +107 -0
- package/templates/relay/frame.txt +11 -0
- package/templates/scripts/cleanup-worktree.sh +25 -3
- package/templates/scripts/new-feature.sh +6 -0
- package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
- package/templates/skills/_shared/references/subagent-types.md +21 -8
- package/templates/skills/assess/SKILL.md +103 -49
- package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
- package/templates/skills/docs/SKILL.md +141 -22
- package/templates/skills/exec/SKILL.md +10 -8
- package/templates/skills/fullsolve/SKILL.md +79 -5
- package/templates/skills/loop/SKILL.md +28 -0
- package/templates/skills/merger/SKILL.md +621 -0
- package/templates/skills/qa/SKILL.md +727 -8
- package/templates/skills/setup/SKILL.md +6 -0
- package/templates/skills/spec/SKILL.md +52 -0
- package/templates/skills/spec/references/parallel-groups.md +7 -0
- package/templates/skills/spec/references/recommended-workflow.md +4 -2
- package/templates/skills/testgen/SKILL.md +24 -17
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export const UI_LABELS = ["ui", "frontend", "admin", "web", "browser"];
|
|
14
14
|
/**
|
|
15
|
-
* Bug-related labels
|
|
15
|
+
* Bug-related labels (used by downstream metadata consumers)
|
|
16
16
|
*/
|
|
17
17
|
export const BUG_LABELS = ["bug", "fix", "hotfix", "patch"];
|
|
18
18
|
/**
|
|
19
|
-
* Documentation labels
|
|
19
|
+
* Documentation labels (used for issueType propagation and downstream metadata)
|
|
20
20
|
*/
|
|
21
21
|
export const DOCS_LABELS = ["docs", "documentation", "readme"];
|
|
22
22
|
/**
|
|
@@ -38,30 +38,19 @@ export const SECURITY_LABELS = [
|
|
|
38
38
|
*/
|
|
39
39
|
export function detectPhasesFromLabels(labels) {
|
|
40
40
|
const lowerLabels = labels.map((l) => l.toLowerCase());
|
|
41
|
-
// Check for bug/fix labels → exec → qa (skip spec)
|
|
42
|
-
const isBugFix = lowerLabels.some((label) => BUG_LABELS.some((bugLabel) => label === bugLabel));
|
|
43
|
-
// Check for docs labels → exec → qa (skip spec)
|
|
44
|
-
const isDocs = lowerLabels.some((label) => DOCS_LABELS.some((docsLabel) => label === docsLabel));
|
|
45
41
|
// Check for UI labels → add test phase
|
|
46
42
|
const isUI = lowerLabels.some((label) => UI_LABELS.some((uiLabel) => label === uiLabel));
|
|
47
43
|
// Check for complex labels → enable quality loop
|
|
48
44
|
const isComplex = lowerLabels.some((label) => COMPLEX_LABELS.some((complexLabel) => label === complexLabel));
|
|
49
45
|
// Check for security labels → add security-review phase
|
|
50
46
|
const isSecurity = lowerLabels.some((label) => SECURITY_LABELS.some((secLabel) => label === secLabel));
|
|
51
|
-
// Build phase list
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// UI workflow: spec → exec → test → qa
|
|
59
|
-
phases = ["spec", "exec", "test", "qa"];
|
|
60
|
-
}
|
|
61
|
-
else {
|
|
62
|
-
// Standard workflow: spec → exec → qa
|
|
63
|
-
phases = ["spec", "exec", "qa"];
|
|
64
|
-
}
|
|
47
|
+
// Build phase list — spec is always included by default (#533).
|
|
48
|
+
// Bug/docs labels no longer short-circuit spec; downstream consumers
|
|
49
|
+
// (e.g. `issueType: "docs"` propagation) still use DOCS_LABELS for
|
|
50
|
+
// metadata purposes, not for phase selection.
|
|
51
|
+
const phases = isUI
|
|
52
|
+
? ["spec", "exec", "test", "qa"]
|
|
53
|
+
: ["spec", "exec", "qa"];
|
|
65
54
|
// Add security-review phase after spec if security labels detected
|
|
66
55
|
if (isSecurity && phases.includes("spec")) {
|
|
67
56
|
const specIndex = phases.indexOf("spec");
|
|
@@ -132,6 +121,14 @@ export function determinePhasesForIssue(basePhases, labels, options) {
|
|
|
132
121
|
phases.splice(specIndex + 1, 0, "testgen");
|
|
133
122
|
}
|
|
134
123
|
}
|
|
124
|
+
// Add security-review phase after spec if requested.
|
|
125
|
+
// Idempotent vs label-based auto-detection in detectPhasesFromLabels.
|
|
126
|
+
if (options.securityReview && phases.includes("spec")) {
|
|
127
|
+
const specIndex = phases.indexOf("spec");
|
|
128
|
+
if (!phases.includes("security-review")) {
|
|
129
|
+
phases.splice(specIndex + 1, 0, "security-review");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
135
132
|
// Auto-detect UI issues and add test phase
|
|
136
133
|
if (hasUILabels(labels) && !phases.includes("test")) {
|
|
137
134
|
// Add test phase before qa if present, otherwise at the end
|
|
@@ -94,7 +94,7 @@ export declare class GitHubProvider implements PlatformProvider {
|
|
|
94
94
|
* Create a PR via `gh pr create` CLI, returning raw result.
|
|
95
95
|
* Used by worktree-manager.ts which needs access to stdout for URL extraction.
|
|
96
96
|
*/
|
|
97
|
-
createPRCliSync(title: string, body: string, head: string, cwd?: string): CreatePRCliResult;
|
|
97
|
+
createPRCliSync(title: string, body: string, head: string, cwd?: string, base?: string): CreatePRCliResult;
|
|
98
98
|
/**
|
|
99
99
|
* Batch fetch issue and PR status in a single GraphQL call.
|
|
100
100
|
* Returns a map keyed by issue/PR number.
|
|
@@ -137,8 +137,25 @@ export class GitHubProvider {
|
|
|
137
137
|
* Create a PR via `gh pr create` CLI, returning raw result.
|
|
138
138
|
* Used by worktree-manager.ts which needs access to stdout for URL extraction.
|
|
139
139
|
*/
|
|
140
|
-
createPRCliSync(title, body, head, cwd) {
|
|
141
|
-
const
|
|
140
|
+
createPRCliSync(title, body, head, cwd, base) {
|
|
141
|
+
const args = [
|
|
142
|
+
"pr",
|
|
143
|
+
"create",
|
|
144
|
+
"--title",
|
|
145
|
+
title,
|
|
146
|
+
"--body",
|
|
147
|
+
body,
|
|
148
|
+
"--head",
|
|
149
|
+
head,
|
|
150
|
+
];
|
|
151
|
+
if (base) {
|
|
152
|
+
args.push("--base", base);
|
|
153
|
+
}
|
|
154
|
+
const result = spawnSync("gh", args, {
|
|
155
|
+
stdio: "pipe",
|
|
156
|
+
cwd,
|
|
157
|
+
timeout: 30000,
|
|
158
|
+
});
|
|
142
159
|
return {
|
|
143
160
|
stdout: result.stdout?.toString() ?? "",
|
|
144
161
|
stderr: result.stderr?.toString() ?? "",
|
|
@@ -415,7 +432,7 @@ export class GitHubProvider {
|
|
|
415
432
|
});
|
|
416
433
|
}
|
|
417
434
|
async createPR(opts) {
|
|
418
|
-
const result = this.createPRCliSync(opts.title, opts.body, opts.head);
|
|
435
|
+
const result = this.createPRCliSync(opts.title, opts.body, opts.head, undefined, opts.base);
|
|
419
436
|
if (result.exitCode !== 0) {
|
|
420
437
|
const error = result.stderr.trim() || "Unknown error";
|
|
421
438
|
throw new Error(`gh pr create failed: ${error}`);
|
|
@@ -28,16 +28,32 @@ export declare function checkPRMergeStatus(prNumber: number): PRMergeStatus;
|
|
|
28
28
|
/**
|
|
29
29
|
* Check if a branch has been merged into a base branch using git
|
|
30
30
|
*
|
|
31
|
+
* "Merged" here means the branch was the source of an actual merge commit on
|
|
32
|
+
* the base branch — i.e., the branch tip appears as a non-first parent of some
|
|
33
|
+
* merge commit reachable from baseBranch. This deliberately excludes the case
|
|
34
|
+
* where the branch tip is just an ancestor of baseBranch with no commits ever
|
|
35
|
+
* added (e.g., a worktree branch created from main that was abandoned before
|
|
36
|
+
* any commits were made). Those branches are reachable from main but were
|
|
37
|
+
* never merged in any meaningful sense; the older `git branch --merged` check
|
|
38
|
+
* misclassified them as merged and caused subsequent runs to skip the still-
|
|
39
|
+
* open issue.
|
|
40
|
+
*
|
|
41
|
+
* Squash-merged branches do not satisfy this check (their tip is not on main
|
|
42
|
+
* after squash) — callers that need to detect squash merges should rely on
|
|
43
|
+
* commit-message detection (see {@link isIssueMergedIntoMain}'s `--grep` path)
|
|
44
|
+
* or a PR API check.
|
|
45
|
+
*
|
|
31
46
|
* @param branchName - The branch name to check (e.g., "feature/33-some-title")
|
|
32
47
|
* @param baseBranch - The base branch to check against (default: "main")
|
|
33
|
-
* @returns true if
|
|
48
|
+
* @returns true if a merge commit on baseBranch records branchName's tip as a
|
|
49
|
+
* non-first parent, false otherwise
|
|
34
50
|
*/
|
|
35
51
|
export declare function isBranchMergedIntoMain(branchName: string, baseBranch?: string): boolean;
|
|
36
52
|
/**
|
|
37
53
|
* Check if a feature branch for an issue is merged into a base branch
|
|
38
54
|
*
|
|
39
55
|
* Tries multiple detection methods:
|
|
40
|
-
* 1.
|
|
56
|
+
* 1. Find `feature/<N>-*` branches with `git branch -a` and check via {@link isBranchMergedIntoMain}
|
|
41
57
|
* 2. Check for merge commits mentioning the issue
|
|
42
58
|
*
|
|
43
59
|
* @param issueNumber - The issue number to check
|
|
@@ -31,22 +31,54 @@ export function checkPRMergeStatus(prNumber) {
|
|
|
31
31
|
/**
|
|
32
32
|
* Check if a branch has been merged into a base branch using git
|
|
33
33
|
*
|
|
34
|
+
* "Merged" here means the branch was the source of an actual merge commit on
|
|
35
|
+
* the base branch — i.e., the branch tip appears as a non-first parent of some
|
|
36
|
+
* merge commit reachable from baseBranch. This deliberately excludes the case
|
|
37
|
+
* where the branch tip is just an ancestor of baseBranch with no commits ever
|
|
38
|
+
* added (e.g., a worktree branch created from main that was abandoned before
|
|
39
|
+
* any commits were made). Those branches are reachable from main but were
|
|
40
|
+
* never merged in any meaningful sense; the older `git branch --merged` check
|
|
41
|
+
* misclassified them as merged and caused subsequent runs to skip the still-
|
|
42
|
+
* open issue.
|
|
43
|
+
*
|
|
44
|
+
* Squash-merged branches do not satisfy this check (their tip is not on main
|
|
45
|
+
* after squash) — callers that need to detect squash merges should rely on
|
|
46
|
+
* commit-message detection (see {@link isIssueMergedIntoMain}'s `--grep` path)
|
|
47
|
+
* or a PR API check.
|
|
48
|
+
*
|
|
34
49
|
* @param branchName - The branch name to check (e.g., "feature/33-some-title")
|
|
35
50
|
* @param baseBranch - The base branch to check against (default: "main")
|
|
36
|
-
* @returns true if
|
|
51
|
+
* @returns true if a merge commit on baseBranch records branchName's tip as a
|
|
52
|
+
* non-first parent, false otherwise
|
|
37
53
|
*/
|
|
38
54
|
export function isBranchMergedIntoMain(branchName, baseBranch = "main") {
|
|
39
55
|
try {
|
|
40
|
-
//
|
|
41
|
-
|
|
56
|
+
// Resolve the branch tip SHA. If the branch can't be resolved (deleted,
|
|
57
|
+
// typo'd, etc.), it can't be "merged" by any definition.
|
|
58
|
+
const tipResult = spawnSync("git", ["rev-parse", branchName], {
|
|
42
59
|
stdio: "pipe",
|
|
43
60
|
timeout: 10000,
|
|
44
61
|
});
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
62
|
+
if (tipResult.status !== 0)
|
|
63
|
+
return false;
|
|
64
|
+
const branchTip = tipResult.stdout.toString().trim();
|
|
65
|
+
if (!branchTip)
|
|
66
|
+
return false;
|
|
67
|
+
// Walk recent merge commits on baseBranch and check whether any records
|
|
68
|
+
// the branch tip as a non-first parent. The first parent of a merge
|
|
69
|
+
// commit is the prior tip of baseBranch; non-first parents are the
|
|
70
|
+
// sources being merged in.
|
|
71
|
+
const mergesResult = spawnSync("git", ["rev-list", "--merges", "--parents", "-200", baseBranch], { stdio: "pipe", timeout: 10000 });
|
|
72
|
+
if (mergesResult.status === 0 && mergesResult.stdout) {
|
|
73
|
+
const lines = mergesResult.stdout.toString().trim().split("\n");
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (!line)
|
|
76
|
+
continue;
|
|
77
|
+
const parts = line.split(" ");
|
|
78
|
+
if (parts.length > 2 && parts.slice(2).includes(branchTip)) {
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
50
82
|
}
|
|
51
83
|
}
|
|
52
84
|
catch {
|
|
@@ -58,7 +90,7 @@ export function isBranchMergedIntoMain(branchName, baseBranch = "main") {
|
|
|
58
90
|
* Check if a feature branch for an issue is merged into a base branch
|
|
59
91
|
*
|
|
60
92
|
* Tries multiple detection methods:
|
|
61
|
-
* 1.
|
|
93
|
+
* 1. Find `feature/<N>-*` branches with `git branch -a` and check via {@link isBranchMergedIntoMain}
|
|
62
94
|
* 2. Check for merge commits mentioning the issue
|
|
63
95
|
*
|
|
64
96
|
* @param issueNumber - The issue number to check
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA stagnation detection — early-exit guard for fullsolve's QA loop.
|
|
3
|
+
*
|
|
4
|
+
* Background (issue #581): when fullsolve's QA loop sees an `AC_NOT_MET`
|
|
5
|
+
* verdict it invokes `/loop` to apply fixes, then re-runs `/qa`. If `/loop`
|
|
6
|
+
* silently no-ops (no diff, no commit), the re-run produces the same verdict
|
|
7
|
+
* at the same SHA — wasting iterations without adding signal.
|
|
8
|
+
*
|
|
9
|
+
* This module exposes:
|
|
10
|
+
*
|
|
11
|
+
* - `detectStagnation()` — pure decision function. Given the latest qa marker,
|
|
12
|
+
* the current HEAD SHA, and whether the worktree is dirty, returns whether
|
|
13
|
+
* the next QA invocation would be wasted.
|
|
14
|
+
* - `recordStagnation()` — appends a stagnation entry to the per-issue
|
|
15
|
+
* record in `.sequant/state.json` so successive fullsolve runs can see
|
|
16
|
+
* the history.
|
|
17
|
+
*
|
|
18
|
+
* The fullsolve and loop SKILL.md files invoke a thin CLI shim that wraps
|
|
19
|
+
* these functions so the same code paths are exercised by tests and the
|
|
20
|
+
* orchestrated runtime.
|
|
21
|
+
*/
|
|
22
|
+
import { type StateManagerOptions } from "./state-manager.js";
|
|
23
|
+
import type { PhaseMarker } from "./state-schema.js";
|
|
24
|
+
/**
|
|
25
|
+
* Reason codes for halting the QA loop.
|
|
26
|
+
*/
|
|
27
|
+
export type StagnationReason = "SAME_SHA_NO_PROGRESS" | "LOOP_NO_DIFF";
|
|
28
|
+
/**
|
|
29
|
+
* Decision returned by `detectStagnation`.
|
|
30
|
+
*
|
|
31
|
+
* `stagnant === true` means the orchestrator should NOT re-invoke `/qa` at
|
|
32
|
+
* the current state — either escalate or hand off to `/loop` for one more
|
|
33
|
+
* fix attempt.
|
|
34
|
+
*/
|
|
35
|
+
export interface StagnationDecision {
|
|
36
|
+
stagnant: boolean;
|
|
37
|
+
reason?: StagnationReason;
|
|
38
|
+
/** Human-readable explanation suitable for logs / GitHub comments. */
|
|
39
|
+
message: string;
|
|
40
|
+
/** SHA the prior QA was recorded at, if any. */
|
|
41
|
+
priorSha?: string;
|
|
42
|
+
/** Verdict from the prior QA, if any. */
|
|
43
|
+
priorVerdict?: string;
|
|
44
|
+
}
|
|
45
|
+
export interface DetectStagnationInput {
|
|
46
|
+
/** Current `git rev-parse HEAD` for the worktree. */
|
|
47
|
+
currentSha: string;
|
|
48
|
+
/** Whether `git status --porcelain` is non-empty. */
|
|
49
|
+
isDirty: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Most recent qa phase marker (any status). Pass `null` when no marker has
|
|
52
|
+
* been recorded yet — that always means "fresh, run qa".
|
|
53
|
+
*/
|
|
54
|
+
lastMarker: PhaseMarker | null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Pure detection function: would invoking `/qa` again be a wasted cycle?
|
|
58
|
+
*
|
|
59
|
+
* The contract from issue #581 AC-1: if the prior qa marker is `failed`,
|
|
60
|
+
* its `commitSHA` matches HEAD, AND the worktree is clean, the next `/qa`
|
|
61
|
+
* call will produce the same verdict — so the orchestrator should escalate
|
|
62
|
+
* (or run `/loop` once) instead.
|
|
63
|
+
*/
|
|
64
|
+
export declare function detectStagnation(input: DetectStagnationInput): StagnationDecision;
|
|
65
|
+
/**
|
|
66
|
+
* Append a stagnation entry to the per-issue record. Schema-additive — older
|
|
67
|
+
* state files without the field will simply gain it on next write.
|
|
68
|
+
*/
|
|
69
|
+
export declare function recordStagnation(issueNumber: number, entry: {
|
|
70
|
+
sha: string;
|
|
71
|
+
verdict: string;
|
|
72
|
+
iteration: number;
|
|
73
|
+
reason: StagnationReason;
|
|
74
|
+
}, options?: StateManagerOptions): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Inputs required for the CLI shim. Loaded from git + GitHub at the call site.
|
|
77
|
+
*/
|
|
78
|
+
export interface CLIDetectInput {
|
|
79
|
+
currentSha: string;
|
|
80
|
+
isDirty: boolean;
|
|
81
|
+
lastMarker: PhaseMarker | null;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the worktree's HEAD SHA. Pure wrapper around `git rev-parse HEAD`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function readHeadSha(cwd?: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* Returns true when `git status --porcelain` reports any uncommitted change.
|
|
89
|
+
*/
|
|
90
|
+
export declare function readIsDirty(cwd?: string): boolean;
|
|
91
|
+
/**
|
|
92
|
+
* Snapshot a worktree's HEAD SHA and dirty bit. Used by `/loop` to detect
|
|
93
|
+
* whether a fix attempt actually produced a diff. We deliberately exclude
|
|
94
|
+
* `.sequant/state.json` writes from the dirty check — the helper itself
|
|
95
|
+
* writes there, and per issue #581 those writes do NOT count as progress.
|
|
96
|
+
*/
|
|
97
|
+
export interface LoopProgressSnapshot {
|
|
98
|
+
sha: string;
|
|
99
|
+
/** Path-relative dirty entries, excluding `.sequant/` state writes. */
|
|
100
|
+
dirty: string[];
|
|
101
|
+
}
|
|
102
|
+
export declare function snapshotLoopProgress(cwd?: string): LoopProgressSnapshot;
|
|
103
|
+
export interface LoopProgressDecision {
|
|
104
|
+
/** True when the snapshot pair shows a real diff (commit or working-tree change). */
|
|
105
|
+
progressed: boolean;
|
|
106
|
+
reason?: "LOOP_NO_DIFF";
|
|
107
|
+
message: string;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compare two `LoopProgressSnapshot`s. Returns `progressed: false` ONLY when
|
|
111
|
+
* both the SHA and the (state-excluded) dirty set are unchanged.
|
|
112
|
+
*
|
|
113
|
+
* State-file / settings writes are excluded from the dirty comparison per
|
|
114
|
+
* issue #581's open question — `/loop` may legitimately touch
|
|
115
|
+
* `.sequant/state.json` without that counting as a fix.
|
|
116
|
+
*/
|
|
117
|
+
export declare function compareLoopProgress(before: LoopProgressSnapshot, after: LoopProgressSnapshot): LoopProgressDecision;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA stagnation detection — early-exit guard for fullsolve's QA loop.
|
|
3
|
+
*
|
|
4
|
+
* Background (issue #581): when fullsolve's QA loop sees an `AC_NOT_MET`
|
|
5
|
+
* verdict it invokes `/loop` to apply fixes, then re-runs `/qa`. If `/loop`
|
|
6
|
+
* silently no-ops (no diff, no commit), the re-run produces the same verdict
|
|
7
|
+
* at the same SHA — wasting iterations without adding signal.
|
|
8
|
+
*
|
|
9
|
+
* This module exposes:
|
|
10
|
+
*
|
|
11
|
+
* - `detectStagnation()` — pure decision function. Given the latest qa marker,
|
|
12
|
+
* the current HEAD SHA, and whether the worktree is dirty, returns whether
|
|
13
|
+
* the next QA invocation would be wasted.
|
|
14
|
+
* - `recordStagnation()` — appends a stagnation entry to the per-issue
|
|
15
|
+
* record in `.sequant/state.json` so successive fullsolve runs can see
|
|
16
|
+
* the history.
|
|
17
|
+
*
|
|
18
|
+
* The fullsolve and loop SKILL.md files invoke a thin CLI shim that wraps
|
|
19
|
+
* these functions so the same code paths are exercised by tests and the
|
|
20
|
+
* orchestrated runtime.
|
|
21
|
+
*/
|
|
22
|
+
import { execSync } from "child_process";
|
|
23
|
+
import { StateManager, getStateManager, } from "./state-manager.js";
|
|
24
|
+
/**
|
|
25
|
+
* Pure detection function: would invoking `/qa` again be a wasted cycle?
|
|
26
|
+
*
|
|
27
|
+
* The contract from issue #581 AC-1: if the prior qa marker is `failed`,
|
|
28
|
+
* its `commitSHA` matches HEAD, AND the worktree is clean, the next `/qa`
|
|
29
|
+
* call will produce the same verdict — so the orchestrator should escalate
|
|
30
|
+
* (or run `/loop` once) instead.
|
|
31
|
+
*/
|
|
32
|
+
export function detectStagnation(input) {
|
|
33
|
+
const { currentSha, isDirty, lastMarker } = input;
|
|
34
|
+
if (!lastMarker) {
|
|
35
|
+
return { stagnant: false, message: "No prior qa marker — fresh run." };
|
|
36
|
+
}
|
|
37
|
+
if (lastMarker.phase !== "qa") {
|
|
38
|
+
return {
|
|
39
|
+
stagnant: false,
|
|
40
|
+
message: `Latest marker is for phase '${lastMarker.phase}', not qa.`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (lastMarker.status !== "failed") {
|
|
44
|
+
return {
|
|
45
|
+
stagnant: false,
|
|
46
|
+
message: `Prior qa marker status is '${lastMarker.status}', not 'failed'.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (!lastMarker.commitSHA) {
|
|
50
|
+
return {
|
|
51
|
+
stagnant: false,
|
|
52
|
+
message: "Prior qa marker has no commitSHA — cannot compare; fall through.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (lastMarker.commitSHA !== currentSha) {
|
|
56
|
+
return {
|
|
57
|
+
stagnant: false,
|
|
58
|
+
message: `Prior qa SHA ${lastMarker.commitSHA} ≠ HEAD ${currentSha}; new commits since last run.`,
|
|
59
|
+
priorSha: lastMarker.commitSHA,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (isDirty) {
|
|
63
|
+
return {
|
|
64
|
+
stagnant: false,
|
|
65
|
+
message: "Worktree dirty since last qa — uncommitted changes will produce different output.",
|
|
66
|
+
priorSha: lastMarker.commitSHA,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
stagnant: true,
|
|
71
|
+
reason: "SAME_SHA_NO_PROGRESS",
|
|
72
|
+
message: `Prior qa failed at HEAD ${currentSha} and worktree is clean — ` +
|
|
73
|
+
`re-running /qa would produce the same verdict.`,
|
|
74
|
+
priorSha: lastMarker.commitSHA,
|
|
75
|
+
priorVerdict: lastMarker.error,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Append a stagnation entry to the per-issue record. Schema-additive — older
|
|
80
|
+
* state files without the field will simply gain it on next write.
|
|
81
|
+
*/
|
|
82
|
+
export async function recordStagnation(issueNumber, entry, options = {}) {
|
|
83
|
+
const manager = options.statePath !== undefined
|
|
84
|
+
? new StateManager(options)
|
|
85
|
+
: getStateManager(options);
|
|
86
|
+
await manager.withLock(async () => {
|
|
87
|
+
const state = await manager.getState();
|
|
88
|
+
const issueState = state.issues[String(issueNumber)];
|
|
89
|
+
if (!issueState) {
|
|
90
|
+
throw new Error(`Cannot record stagnation: issue #${issueNumber} not found in state`);
|
|
91
|
+
}
|
|
92
|
+
const existing = issueState.qaStagnation ?? [];
|
|
93
|
+
existing.push({
|
|
94
|
+
sha: entry.sha,
|
|
95
|
+
verdict: entry.verdict,
|
|
96
|
+
iteration: entry.iteration,
|
|
97
|
+
reason: entry.reason,
|
|
98
|
+
detectedAt: new Date().toISOString(),
|
|
99
|
+
});
|
|
100
|
+
issueState.qaStagnation = existing;
|
|
101
|
+
issueState.lastActivity = new Date().toISOString();
|
|
102
|
+
await manager.saveState(state);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Read the worktree's HEAD SHA. Pure wrapper around `git rev-parse HEAD`.
|
|
107
|
+
*/
|
|
108
|
+
export function readHeadSha(cwd = process.cwd()) {
|
|
109
|
+
return execSync("git rev-parse HEAD", { cwd, encoding: "utf-8" }).trim();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Returns true when `git status --porcelain` reports any uncommitted change.
|
|
113
|
+
*/
|
|
114
|
+
export function readIsDirty(cwd = process.cwd()) {
|
|
115
|
+
return (execSync("git status --porcelain", { cwd, encoding: "utf-8" }).trim()
|
|
116
|
+
.length > 0);
|
|
117
|
+
}
|
|
118
|
+
const STATE_DIR_PREFIX = ".sequant/";
|
|
119
|
+
function readDirtyExcludingState(cwd = process.cwd()) {
|
|
120
|
+
// `git status --porcelain` (v1) always prefixes each line with exactly
|
|
121
|
+
// `XY ` (two status bytes + a space) before the path. We must slice on the
|
|
122
|
+
// RAW line — trimming first strips the leading space of unstaged-only
|
|
123
|
+
// entries (` M file.ts`) and a subsequent `.{1,3}` regex would chop real
|
|
124
|
+
// path bytes. See issue #581 QA review.
|
|
125
|
+
const out = execSync("git status --porcelain", {
|
|
126
|
+
cwd,
|
|
127
|
+
encoding: "utf-8",
|
|
128
|
+
});
|
|
129
|
+
return out
|
|
130
|
+
.split("\n")
|
|
131
|
+
.filter((line) => line.length > 0)
|
|
132
|
+
.map((line) => {
|
|
133
|
+
// Renamed entries use `R old -> new`; we only care about the new path.
|
|
134
|
+
const path = line.slice(3);
|
|
135
|
+
const arrow = path.indexOf(" -> ");
|
|
136
|
+
return arrow >= 0 ? path.slice(arrow + 4) : path;
|
|
137
|
+
})
|
|
138
|
+
.filter((path) => !path.startsWith(STATE_DIR_PREFIX));
|
|
139
|
+
}
|
|
140
|
+
export function snapshotLoopProgress(cwd = process.cwd()) {
|
|
141
|
+
return { sha: readHeadSha(cwd), dirty: readDirtyExcludingState(cwd) };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Compare two `LoopProgressSnapshot`s. Returns `progressed: false` ONLY when
|
|
145
|
+
* both the SHA and the (state-excluded) dirty set are unchanged.
|
|
146
|
+
*
|
|
147
|
+
* State-file / settings writes are excluded from the dirty comparison per
|
|
148
|
+
* issue #581's open question — `/loop` may legitimately touch
|
|
149
|
+
* `.sequant/state.json` without that counting as a fix.
|
|
150
|
+
*/
|
|
151
|
+
export function compareLoopProgress(before, after) {
|
|
152
|
+
if (before.sha !== after.sha) {
|
|
153
|
+
return {
|
|
154
|
+
progressed: true,
|
|
155
|
+
message: `HEAD advanced ${before.sha} → ${after.sha}.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const beforeSet = new Set(before.dirty);
|
|
159
|
+
const afterSet = new Set(after.dirty);
|
|
160
|
+
if (beforeSet.size !== afterSet.size) {
|
|
161
|
+
return {
|
|
162
|
+
progressed: true,
|
|
163
|
+
message: `Working-tree dirty count changed (${beforeSet.size} → ${afterSet.size}).`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
for (const path of afterSet) {
|
|
167
|
+
if (!beforeSet.has(path)) {
|
|
168
|
+
return {
|
|
169
|
+
progressed: true,
|
|
170
|
+
message: `New dirty path detected: ${path}.`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
progressed: false,
|
|
176
|
+
reason: "LOOP_NO_DIFF",
|
|
177
|
+
message: "/loop made no commit and no working-tree changes (excluding .sequant/ state writes) — manual intervention required.",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -7,11 +7,22 @@
|
|
|
7
7
|
* @module
|
|
8
8
|
*/
|
|
9
9
|
import type { ExecutionConfig, IssueResult, RunOptions, ProgressCallback } from "./types.js";
|
|
10
|
+
import type { RunSnapshot } from "./run-state.js";
|
|
10
11
|
import type { WorktreeInfo } from "./worktree-manager.js";
|
|
11
12
|
import { LogWriter } from "./log-writer.js";
|
|
12
13
|
import { StateManager } from "./state-manager.js";
|
|
13
14
|
import { ShutdownManager } from "../shutdown.js";
|
|
15
|
+
import type { LockFile } from "../locks/index.js";
|
|
14
16
|
import type { SequantSettings } from "../settings.js";
|
|
17
|
+
/**
|
|
18
|
+
* Build the stack-manifest line emitted into PR bodies under --stacked.
|
|
19
|
+
*
|
|
20
|
+
* Example for issues `[100, 101, 102]` at `currentIndex=1`:
|
|
21
|
+
* `Part of stack: #100 → #101 (this) → #102`
|
|
22
|
+
*
|
|
23
|
+
* @internal Exported for testing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildStackManifest(issueNumbers: number[], currentIndex: number): string;
|
|
15
26
|
/**
|
|
16
27
|
* Injectable services for RunOrchestrator.
|
|
17
28
|
* All optional — orchestrator degrades gracefully when services are absent.
|
|
@@ -64,6 +75,12 @@ export interface RunInit {
|
|
|
64
75
|
baseBranch?: string;
|
|
65
76
|
/** Per-phase progress callback */
|
|
66
77
|
onProgress?: ProgressCallback;
|
|
78
|
+
/**
|
|
79
|
+
* Invoked once the orchestrator is constructed but before execution begins.
|
|
80
|
+
* Used by the experimental TUI to attach a snapshot poller to the active
|
|
81
|
+
* orchestrator instance created inside `run()`.
|
|
82
|
+
*/
|
|
83
|
+
onOrchestratorReady?: (orchestrator: RunOrchestrator) => void;
|
|
67
84
|
}
|
|
68
85
|
/**
|
|
69
86
|
* Pure result of config resolution — no side effects, no services.
|
|
@@ -127,7 +144,24 @@ export interface RunResult {
|
|
|
127
144
|
*/
|
|
128
145
|
export declare class RunOrchestrator {
|
|
129
146
|
private readonly cfg;
|
|
147
|
+
private readonly issueStates;
|
|
148
|
+
private readonly phaseStartTimes;
|
|
149
|
+
private done;
|
|
130
150
|
constructor(config: OrchestratorConfig);
|
|
151
|
+
/**
|
|
152
|
+
* Point-in-time view of the entire run.
|
|
153
|
+
*
|
|
154
|
+
* Safe under concurrent reads: the returned object contains only freshly
|
|
155
|
+
* allocated arrays and plain records; no internal Map or mutable state
|
|
156
|
+
* reference is leaked. Callers may hold snapshots across awaits without
|
|
157
|
+
* observing torn writes.
|
|
158
|
+
*/
|
|
159
|
+
getSnapshot(): RunSnapshot;
|
|
160
|
+
/** Mark the run as completed so the dashboard can unmount. */
|
|
161
|
+
markDone(): void;
|
|
162
|
+
private initIssueStates;
|
|
163
|
+
private wrapProgress;
|
|
164
|
+
private applyProgressEvent;
|
|
131
165
|
/**
|
|
132
166
|
* Pure config resolution — no side effects.
|
|
133
167
|
*
|
|
@@ -157,5 +191,10 @@ export declare class RunOrchestrator {
|
|
|
157
191
|
private executeOneIssue;
|
|
158
192
|
private static recordMetrics;
|
|
159
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Build the synthetic `IssueResult` returned for an issue that was skipped
|
|
196
|
+
* because another sequant session holds its lock (#625).
|
|
197
|
+
*/
|
|
198
|
+
export declare function buildLockedResult(issueNumber: number, holder: LockFile): IssueResult;
|
|
160
199
|
/** Log a non-fatal warning: one-line summary always, detail in verbose. */
|
|
161
200
|
export declare function logNonFatalWarning(message: string, error: unknown, verbose: boolean): void;
|