sequant 2.2.0 → 2.4.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 +81 -5
- package/dist/bin/cli.js +140 -13
- package/dist/src/commands/abort.d.ts +36 -0
- package/dist/src/commands/abort.js +138 -0
- 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 +46 -0
- package/dist/src/commands/prompt.js +273 -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 +42 -0
- package/dist/src/commands/run-progress.js +93 -0
- package/dist/src/commands/run.js +90 -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 +18 -0
- package/dist/src/commands/watch.js +211 -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 +220 -0
- package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
- package/dist/src/lib/cli-ui/run-renderer.d.ts +265 -0
- package/dist/src/lib/cli-ui/run-renderer.js +1390 -0
- package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
- package/dist/src/lib/cli-ui/scrollback-harness.js +294 -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/merge-check/types.js +1 -1
- 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 +112 -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 +70 -0
- package/dist/src/lib/relay/types.js +85 -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 +274 -185
- package/dist/src/lib/workflow/config-resolver.js +4 -0
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
- package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
- package/dist/src/lib/workflow/drivers/aider.js +9 -0
- package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
- package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
- package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
- package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
- package/dist/src/lib/workflow/event-emitter.js +102 -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/notice.d.ts +32 -0
- package/dist/src/lib/workflow/notice.js +38 -0
- package/dist/src/lib/workflow/phase-executor.d.ts +58 -16
- package/dist/src/lib/workflow/phase-executor.js +244 -130
- package/dist/src/lib/workflow/phase-mapper.d.ts +27 -13
- package/dist/src/lib/workflow/phase-mapper.js +70 -51
- package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
- package/dist/src/lib/workflow/phase-registry.js +233 -0
- 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-log-schema.d.ts +5 -55
- package/dist/src/lib/workflow/run-orchestrator.d.ts +70 -1
- package/dist/src/lib/workflow/run-orchestrator.js +464 -25
- 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 +31 -2
- package/dist/src/lib/workflow/state-manager.js +64 -1
- package/dist/src/lib/workflow/state-schema.d.ts +82 -35
- package/dist/src/lib/workflow/state-schema.js +63 -4
- package/dist/src/lib/workflow/types.d.ts +139 -16
- package/dist/src/lib/workflow/types.js +18 -13
- 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 +14 -6
- 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 +92 -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 +122 -68
- 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 +12 -6
- 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
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sequant watch <issue>` — tail the relay outbox for replies from a running
|
|
3
|
+
* headless session (#383). Uses `fs.watch()` when available, falls back to
|
|
4
|
+
* polling on platforms where `fs.watch` is unreliable (NFS, some WSL setups).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, statSync, createReadStream, watch } from "fs";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { outboxPathFor } from "../lib/relay/paths.js";
|
|
9
|
+
import { listArchives } from "../lib/relay/archive.js";
|
|
10
|
+
import { readPidFile } from "../lib/relay/pid.js";
|
|
11
|
+
import { StateManager } from "../lib/workflow/state-manager.js";
|
|
12
|
+
import { RelayResponseSchema } from "../lib/relay/types.js";
|
|
13
|
+
function formatTimestamp(iso) {
|
|
14
|
+
try {
|
|
15
|
+
const date = new Date(iso);
|
|
16
|
+
const hh = String(date.getHours()).padStart(2, "0");
|
|
17
|
+
const mm = String(date.getMinutes()).padStart(2, "0");
|
|
18
|
+
const ss = String(date.getSeconds()).padStart(2, "0");
|
|
19
|
+
return `${hh}:${mm}:${ss}`;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return iso;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function formatLine(reply, json) {
|
|
26
|
+
if (json)
|
|
27
|
+
return JSON.stringify(reply);
|
|
28
|
+
return chalk.gray(`[${formatTimestamp(reply.timestamp)}] `) + reply.message;
|
|
29
|
+
}
|
|
30
|
+
function readNewLines(path, state) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
if (!existsSync(path)) {
|
|
33
|
+
resolve([]);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const stat = statSync(path);
|
|
37
|
+
if (stat.size <= state.offset) {
|
|
38
|
+
// File was truncated/rotated — reset offset.
|
|
39
|
+
if (stat.size < state.offset)
|
|
40
|
+
state.offset = 0;
|
|
41
|
+
resolve([]);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const stream = createReadStream(path, {
|
|
45
|
+
start: state.offset,
|
|
46
|
+
end: stat.size - 1,
|
|
47
|
+
encoding: "utf-8",
|
|
48
|
+
});
|
|
49
|
+
let buf = state.partial;
|
|
50
|
+
stream.on("data", (chunk) => {
|
|
51
|
+
buf += chunk;
|
|
52
|
+
});
|
|
53
|
+
stream.on("error", (err) => reject(err));
|
|
54
|
+
stream.on("end", () => {
|
|
55
|
+
state.offset = stat.size;
|
|
56
|
+
const lines = buf.split("\n");
|
|
57
|
+
state.partial = lines.pop() ?? "";
|
|
58
|
+
const replies = [];
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
if (line.trim() === "")
|
|
61
|
+
continue;
|
|
62
|
+
try {
|
|
63
|
+
const parsed = RelayResponseSchema.safeParse(JSON.parse(line));
|
|
64
|
+
if (parsed.success)
|
|
65
|
+
replies.push(parsed.data);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* skip malformed */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
resolve(replies);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function watchCommand(argsAndOptions) {
|
|
76
|
+
const { args, options } = argsAndOptions;
|
|
77
|
+
const issueArg = args[0];
|
|
78
|
+
if (!issueArg) {
|
|
79
|
+
throw new Error("Usage: sequant watch <issue>");
|
|
80
|
+
}
|
|
81
|
+
const issueNumber = Number.parseInt(issueArg, 10);
|
|
82
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
83
|
+
throw new Error(`Invalid issue number: '${issueArg}'`);
|
|
84
|
+
}
|
|
85
|
+
const stateManager = new StateManager();
|
|
86
|
+
const issueState = await stateManager.getIssueState(issueNumber);
|
|
87
|
+
const cwd = options.cwd ?? process.cwd();
|
|
88
|
+
const outboxPath = outboxPathFor(issueNumber, {
|
|
89
|
+
worktreePath: issueState?.worktree,
|
|
90
|
+
cwd,
|
|
91
|
+
});
|
|
92
|
+
const pollIntervalMs = options.pollIntervalMs ?? 200;
|
|
93
|
+
const tail = { offset: 0, partial: "" };
|
|
94
|
+
// Seed tail offset at current EOF so we only show NEW replies.
|
|
95
|
+
if (existsSync(outboxPath)) {
|
|
96
|
+
tail.offset = statSync(outboxPath).size;
|
|
97
|
+
}
|
|
98
|
+
// Dead-relay detection (#645, Gap 3). The pidfile is written by activateRelay
|
|
99
|
+
// and removed by deactivateRelay. If it's absent and the outbox is absent at
|
|
100
|
+
// startup, there is nothing alive to watch — print a useful pointer and exit.
|
|
101
|
+
const initialPidPresent = readPidFile(issueNumber, cwd) !== null;
|
|
102
|
+
const initialOutboxPresent = existsSync(outboxPath);
|
|
103
|
+
if (!initialPidPresent && !initialOutboxPresent) {
|
|
104
|
+
const archives = listArchives(issueNumber, cwd);
|
|
105
|
+
const summary = `No active relay for #${issueNumber}.`;
|
|
106
|
+
const hint = archives[0]
|
|
107
|
+
? ` Most recent archive: ${archives[0]}`
|
|
108
|
+
: " (no archived runs found)";
|
|
109
|
+
if (options.json) {
|
|
110
|
+
console.log(JSON.stringify({
|
|
111
|
+
ok: false,
|
|
112
|
+
issue: issueNumber,
|
|
113
|
+
reason: "no-active-relay",
|
|
114
|
+
archive: archives[0] ?? null,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
console.log(chalk.yellow(summary + hint));
|
|
119
|
+
}
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!options.json) {
|
|
123
|
+
console.log(chalk.gray(`Watching #${issueNumber} outbox — Ctrl+C to stop`));
|
|
124
|
+
}
|
|
125
|
+
let stopped = false;
|
|
126
|
+
let endReason = null;
|
|
127
|
+
const stop = (reason = "signal") => {
|
|
128
|
+
stopped = true;
|
|
129
|
+
if (!endReason)
|
|
130
|
+
endReason = reason;
|
|
131
|
+
};
|
|
132
|
+
options.signal?.addEventListener("abort", () => stop("signal"));
|
|
133
|
+
process.on("SIGINT", () => {
|
|
134
|
+
stop("signal");
|
|
135
|
+
if (!options.json)
|
|
136
|
+
console.log(chalk.gray("\nStopped watching."));
|
|
137
|
+
process.exit(0);
|
|
138
|
+
});
|
|
139
|
+
let useWatcher = false;
|
|
140
|
+
let watcher = null;
|
|
141
|
+
try {
|
|
142
|
+
if (existsSync(outboxPath)) {
|
|
143
|
+
watcher = watch(outboxPath, { persistent: true }, () => {
|
|
144
|
+
// fs.watch fires on any modification; we still poll readNewLines.
|
|
145
|
+
});
|
|
146
|
+
useWatcher = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
useWatcher = false;
|
|
151
|
+
}
|
|
152
|
+
const emit = (replies) => {
|
|
153
|
+
for (const r of replies) {
|
|
154
|
+
console.log(formatLine(r, options.json === true));
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
// Polling loop — also used as a heartbeat when fs.watch is active so we
|
|
158
|
+
// don't miss events on filesystems where watch is unreliable.
|
|
159
|
+
let sawLivePid = initialPidPresent;
|
|
160
|
+
while (!stopped) {
|
|
161
|
+
try {
|
|
162
|
+
const replies = await readNewLines(outboxPath, tail);
|
|
163
|
+
emit(replies);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
/* transient — try again next tick */
|
|
167
|
+
}
|
|
168
|
+
// Dead-relay detection (#645, Gap 3). Once we've seen a live pidfile, its
|
|
169
|
+
// absence means the run has deactivated relay (archive complete). Drain
|
|
170
|
+
// one more poll for late writes, then exit cleanly.
|
|
171
|
+
const pidAlive = readPidFile(issueNumber, cwd) !== null;
|
|
172
|
+
if (sawLivePid && !pidAlive) {
|
|
173
|
+
try {
|
|
174
|
+
const finalReplies = await readNewLines(outboxPath, tail);
|
|
175
|
+
emit(finalReplies);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* swallow */
|
|
179
|
+
}
|
|
180
|
+
stop("relay-ended");
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
if (pidAlive)
|
|
184
|
+
sawLivePid = true;
|
|
185
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
186
|
+
}
|
|
187
|
+
if (watcher) {
|
|
188
|
+
try {
|
|
189
|
+
watcher.close();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* swallow */
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
void useWatcher; // currently unused beyond best-effort init
|
|
196
|
+
if (endReason === "relay-ended") {
|
|
197
|
+
const archives = listArchives(issueNumber, cwd);
|
|
198
|
+
if (options.json) {
|
|
199
|
+
console.log(JSON.stringify({
|
|
200
|
+
ok: true,
|
|
201
|
+
issue: issueNumber,
|
|
202
|
+
reason: "relay-ended",
|
|
203
|
+
archive: archives[0] ?? null,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
const hint = archives[0] ? ` Archive: ${archives[0]}` : "";
|
|
208
|
+
console.log(chalk.gray(`Run for #${issueNumber} ended.${hint}`));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -19,7 +19,7 @@ import type { AcceptanceCriterion } from "./workflow/state-schema.js";
|
|
|
19
19
|
/**
|
|
20
20
|
* Types of issues that can be flagged in AC
|
|
21
21
|
*/
|
|
22
|
-
export type ACLintIssueType = "vague" | "unmeasurable" | "incomplete" | "open_ended";
|
|
22
|
+
export type ACLintIssueType = "vague" | "unmeasurable" | "incomplete" | "open_ended" | "title-body-tension";
|
|
23
23
|
/**
|
|
24
24
|
* A lint issue found in an acceptance criterion
|
|
25
25
|
*/
|
|
@@ -194,6 +194,84 @@ const DEFAULT_LINT_PATTERNS = [
|
|
|
194
194
|
suggestion: "List all items explicitly or define boundaries",
|
|
195
195
|
},
|
|
196
196
|
];
|
|
197
|
+
/**
|
|
198
|
+
* Documentation-noun head words that suggest a doc-only verification bar
|
|
199
|
+
* when they appear in the AC title.
|
|
200
|
+
*/
|
|
201
|
+
const DOC_NOUNS = [
|
|
202
|
+
"note",
|
|
203
|
+
"comment",
|
|
204
|
+
"documentation",
|
|
205
|
+
"description",
|
|
206
|
+
"snippet",
|
|
207
|
+
"entry",
|
|
208
|
+
"mention",
|
|
209
|
+
];
|
|
210
|
+
/**
|
|
211
|
+
* Runtime-imperative phrases that suggest an execution/evidence bar
|
|
212
|
+
* when they appear in the AC body.
|
|
213
|
+
*/
|
|
214
|
+
const RUNTIME_IMPERATIVES = [
|
|
215
|
+
"execute",
|
|
216
|
+
"trigger",
|
|
217
|
+
"capture",
|
|
218
|
+
"verify by running",
|
|
219
|
+
"reproduce",
|
|
220
|
+
"confirm at runtime",
|
|
221
|
+
];
|
|
222
|
+
/**
|
|
223
|
+
* Slash-command runtime pattern: "run /<command>" anywhere in the body.
|
|
224
|
+
*/
|
|
225
|
+
const RUN_SLASH_RE = /\brun\s+\/[a-z][a-z0-9_-]*/i;
|
|
226
|
+
/**
|
|
227
|
+
* Detect title/body verification-method tension.
|
|
228
|
+
*
|
|
229
|
+
* Splits the AC description on the first separator (`.`, `\n`, `:`, or `—`).
|
|
230
|
+
* Treats the head as the "title" and the rest as the "body". Warns when the
|
|
231
|
+
* title contains a documentation-noun (suggesting a doc bar) AND the body
|
|
232
|
+
* contains a runtime-imperative (incl. inflections like `triggered`,
|
|
233
|
+
* `captured`) or `run /<command>` (suggesting a runtime bar).
|
|
234
|
+
*
|
|
235
|
+
* Warning-only — same convention as the regex-based DEFAULT_LINT_PATTERNS.
|
|
236
|
+
*
|
|
237
|
+
* @param ac - The acceptance criterion to check
|
|
238
|
+
* @returns A lint issue if tension is detected, otherwise null
|
|
239
|
+
*/
|
|
240
|
+
function detectTitleBodyTension(ac) {
|
|
241
|
+
const description = ac.description;
|
|
242
|
+
const splitIdx = description.search(/[.\n:—]/);
|
|
243
|
+
if (splitIdx <= 0)
|
|
244
|
+
return null;
|
|
245
|
+
const title = description.slice(0, splitIdx);
|
|
246
|
+
const body = description.slice(splitIdx + 1);
|
|
247
|
+
if (title.length === 0 || body.length === 0)
|
|
248
|
+
return null;
|
|
249
|
+
const titleLower = title.toLowerCase();
|
|
250
|
+
const bodyLower = body.toLowerCase();
|
|
251
|
+
const docNoun = DOC_NOUNS.find((noun) => new RegExp(`\\b${noun}\\b`, "i").test(titleLower));
|
|
252
|
+
if (!docNoun)
|
|
253
|
+
return null;
|
|
254
|
+
const runtimeImperative = RUNTIME_IMPERATIVES.find((imp) => {
|
|
255
|
+
if (imp.includes(" ")) {
|
|
256
|
+
return new RegExp(`\\b${imp}\\b`, "i").test(bodyLower);
|
|
257
|
+
}
|
|
258
|
+
if (imp.endsWith("e")) {
|
|
259
|
+
const stem = imp.slice(0, -1);
|
|
260
|
+
return new RegExp(`\\b${stem}(?:e|es|ed|ing)\\b`, "i").test(bodyLower);
|
|
261
|
+
}
|
|
262
|
+
return new RegExp(`\\b${imp}(?:s|ed|ing)?\\b`, "i").test(bodyLower);
|
|
263
|
+
});
|
|
264
|
+
const slashRun = RUN_SLASH_RE.test(body);
|
|
265
|
+
if (!runtimeImperative && !slashRun)
|
|
266
|
+
return null;
|
|
267
|
+
const matched = runtimeImperative ?? "run /<command>";
|
|
268
|
+
return {
|
|
269
|
+
type: "title-body-tension",
|
|
270
|
+
matchedPattern: `title="${docNoun}" + body="${matched}"`,
|
|
271
|
+
problem: "Title/body tension: title implies a documentation bar, body specifies a runtime/execution bar",
|
|
272
|
+
suggestion: "Two verification bars detected. Either (a) tighten the title to match the runtime body (e.g., 'Smoke test execution — capture evidence'), or (b) split the runtime requirement into a separate AC.",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
197
275
|
/**
|
|
198
276
|
* Lint a single acceptance criterion against all patterns
|
|
199
277
|
*
|
|
@@ -215,6 +293,9 @@ export function lintAcceptanceCriterion(ac, patterns = DEFAULT_LINT_PATTERNS) {
|
|
|
215
293
|
});
|
|
216
294
|
}
|
|
217
295
|
}
|
|
296
|
+
const tension = detectTitleBodyTension(ac);
|
|
297
|
+
if (tension)
|
|
298
|
+
issues.push(tension);
|
|
218
299
|
return {
|
|
219
300
|
ac,
|
|
220
301
|
issues,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicted file-collision detection between PROCEED issues.
|
|
3
|
+
*
|
|
4
|
+
* Step 5 of `/assess` already inspects active worktrees for in-flight overlap.
|
|
5
|
+
* This module adds a complementary heuristic: read the bodies of unstarted
|
|
6
|
+
* PROCEED issues and predict which pairs will modify the same file once
|
|
7
|
+
* they're both run in parallel worktrees.
|
|
8
|
+
*
|
|
9
|
+
* The detector scans markdown bodies for file-path mentions outside fenced
|
|
10
|
+
* code blocks and HTML comments, then computes pairwise intersections. A
|
|
11
|
+
* small exclusion list filters paths that nearly every PROCEED issue tends
|
|
12
|
+
* to touch (CHANGELOG.md, lockfiles).
|
|
13
|
+
*
|
|
14
|
+
* Tunables — including the exclusion list, path regex, and the
|
|
15
|
+
* slash-command-skill derivation rule — are documented in
|
|
16
|
+
* `references/predicted-collision-detection.md` so they can change without
|
|
17
|
+
* skill-prose edits.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Files that virtually every PROCEED issue mentions. Including them in
|
|
21
|
+
* pairwise intersection would flag every batch as colliding, training
|
|
22
|
+
* users to ignore the warning.
|
|
23
|
+
*/
|
|
24
|
+
export declare const EXCLUDED_PATHS: ReadonlySet<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Extract the set of file paths an issue body identifies as
|
|
27
|
+
* targets-of-modification.
|
|
28
|
+
*
|
|
29
|
+
* Strategy:
|
|
30
|
+
* 1. Strip fenced code blocks and HTML comments (AC-5 guard).
|
|
31
|
+
* 2. Pull every backtick-quoted path matching the source-tree regex,
|
|
32
|
+
* normalizing skill-mirror paths to their canonical bare form.
|
|
33
|
+
* 3. If the body mentions "3-dir sync", also pull bare
|
|
34
|
+
* `<name>/SKILL.md` references and `/<skill>` slash-command
|
|
35
|
+
* mentions (where `<skill>` is in KNOWN_SKILL_NAMES). Both already
|
|
36
|
+
* live in the canonical bare form.
|
|
37
|
+
* 4. Remove globally excluded paths.
|
|
38
|
+
*
|
|
39
|
+
* The canonical bare form (`qa/SKILL.md`, not `.claude/skills/qa/SKILL.md`)
|
|
40
|
+
* is what the dashboard's `Order:` annotations render, and it makes
|
|
41
|
+
* mirrored collisions deduplicate without a separate post-processing
|
|
42
|
+
* pass.
|
|
43
|
+
*/
|
|
44
|
+
export declare function extractPathsFromIssueBody(body: string): Set<string>;
|
|
45
|
+
/**
|
|
46
|
+
* A predicted file collision: 2+ issues whose bodies both name the same
|
|
47
|
+
* file as a target-of-modification.
|
|
48
|
+
*/
|
|
49
|
+
export interface CollisionResult {
|
|
50
|
+
/** Issue numbers involved, in ascending order. */
|
|
51
|
+
issues: number[];
|
|
52
|
+
/** Path of the shared file (POSIX-style). */
|
|
53
|
+
file: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Compute file-path overlaps across issue bodies.
|
|
57
|
+
*
|
|
58
|
+
* Each shared file emits one CollisionResult. When N issues all share a
|
|
59
|
+
* file, that's a single result with `issues.length === N` — the caller
|
|
60
|
+
* decides whether to chain-suggest based on count.
|
|
61
|
+
*
|
|
62
|
+
* Sort order: ascending file name, then ascending first-issue number.
|
|
63
|
+
*/
|
|
64
|
+
export declare function detectFileCollisions(issuePaths: Map<number, Set<string>>): CollisionResult[];
|
|
65
|
+
/**
|
|
66
|
+
* Rendered collision annotations ready for the dashboard output.
|
|
67
|
+
*
|
|
68
|
+
* - `orderLines` — one `Order:` line per pair (or per group).
|
|
69
|
+
* - `warnings` — `⚠ #N Modifies ... (overlaps #M); land sequentially`,
|
|
70
|
+
* one per affected issue per collision.
|
|
71
|
+
* - `chainSuggestion` — emitted only when ≥3 issues collide on the same
|
|
72
|
+
* file (AC-4); suggest-only, never auto-applied. Annotated with the
|
|
73
|
+
* historical chain-mode success rate at length≥3 (1/6 = 17%, per #604
|
|
74
|
+
* forensics) so users can weigh chain mode against the parallel default.
|
|
75
|
+
*/
|
|
76
|
+
export interface CollisionAnnotations {
|
|
77
|
+
orderLines: string[];
|
|
78
|
+
warnings: string[];
|
|
79
|
+
chainSuggestion?: string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Format collision results as dashboard annotations.
|
|
83
|
+
*
|
|
84
|
+
* - 2-issue collision: `Order: A → B (path)` plus a warning per issue.
|
|
85
|
+
* - 3+-issue collision on the same file: `Order: A → B → C (path)`,
|
|
86
|
+
* warnings per issue, plus a single `Chain:` suggestion.
|
|
87
|
+
*
|
|
88
|
+
* Multiple shared files between the same pair render as multiple
|
|
89
|
+
* Order: lines (one per file). Callers decide whether to truncate.
|
|
90
|
+
*/
|
|
91
|
+
export declare function formatCollisionAnnotations(results: CollisionResult[]): CollisionAnnotations;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicted file-collision detection between PROCEED issues.
|
|
3
|
+
*
|
|
4
|
+
* Step 5 of `/assess` already inspects active worktrees for in-flight overlap.
|
|
5
|
+
* This module adds a complementary heuristic: read the bodies of unstarted
|
|
6
|
+
* PROCEED issues and predict which pairs will modify the same file once
|
|
7
|
+
* they're both run in parallel worktrees.
|
|
8
|
+
*
|
|
9
|
+
* The detector scans markdown bodies for file-path mentions outside fenced
|
|
10
|
+
* code blocks and HTML comments, then computes pairwise intersections. A
|
|
11
|
+
* small exclusion list filters paths that nearly every PROCEED issue tends
|
|
12
|
+
* to touch (CHANGELOG.md, lockfiles).
|
|
13
|
+
*
|
|
14
|
+
* Tunables — including the exclusion list, path regex, and the
|
|
15
|
+
* slash-command-skill derivation rule — are documented in
|
|
16
|
+
* `references/predicted-collision-detection.md` so they can change without
|
|
17
|
+
* skill-prose edits.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Files that virtually every PROCEED issue mentions. Including them in
|
|
21
|
+
* pairwise intersection would flag every batch as colliding, training
|
|
22
|
+
* users to ignore the warning.
|
|
23
|
+
*/
|
|
24
|
+
export const EXCLUDED_PATHS = new Set([
|
|
25
|
+
"CHANGELOG.md",
|
|
26
|
+
"package-lock.json",
|
|
27
|
+
"yarn.lock",
|
|
28
|
+
"pnpm-lock.yaml",
|
|
29
|
+
]);
|
|
30
|
+
/**
|
|
31
|
+
* Slash-command names recognized as references to a skill's SKILL.md.
|
|
32
|
+
* Used by the slash-command-skill derivation rule when an issue body
|
|
33
|
+
* also signals 3-dir sync — the skill name maps deterministically to the
|
|
34
|
+
* three mirrored SKILL.md files.
|
|
35
|
+
*/
|
|
36
|
+
const KNOWN_SKILL_NAMES = [
|
|
37
|
+
"assess",
|
|
38
|
+
"spec",
|
|
39
|
+
"exec",
|
|
40
|
+
"qa",
|
|
41
|
+
"test",
|
|
42
|
+
"testgen",
|
|
43
|
+
"verify",
|
|
44
|
+
"loop",
|
|
45
|
+
"merger",
|
|
46
|
+
"security-review",
|
|
47
|
+
"fullsolve",
|
|
48
|
+
"docs",
|
|
49
|
+
"release",
|
|
50
|
+
"clean",
|
|
51
|
+
"improve",
|
|
52
|
+
"reflect",
|
|
53
|
+
"setup",
|
|
54
|
+
];
|
|
55
|
+
/**
|
|
56
|
+
* Regex matching backtick-quoted file paths under the project's tracked
|
|
57
|
+
* directories. The path component must start with a tracked directory
|
|
58
|
+
* root and end with a known source extension.
|
|
59
|
+
*/
|
|
60
|
+
const PATH_REGEX = /`((?:\.claude|templates|skills|src|bin|docs)\/[A-Za-z0-9_./@-]+\.(?:md|tsx?|json|sh))`/g;
|
|
61
|
+
/**
|
|
62
|
+
* Bare-filename match for skill files referenced alongside "3-dir sync"
|
|
63
|
+
* language. Captures e.g. `qa/SKILL.md` so we can expand it to the three
|
|
64
|
+
* skill roots.
|
|
65
|
+
*/
|
|
66
|
+
const SKILL_FILE_REGEX = /`((?:[a-z][a-z0-9_-]*\/)+SKILL\.md)`/g;
|
|
67
|
+
/**
|
|
68
|
+
* Slash-command mention regex (e.g. `/qa`, `/spec`). Captures the name
|
|
69
|
+
* for cross-reference against KNOWN_SKILL_NAMES. The non-word lookahead
|
|
70
|
+
* keeps `/qa-section` from matching as `/qa`.
|
|
71
|
+
*/
|
|
72
|
+
const SLASH_COMMAND_REGEX = /(?<![\w-])\/([a-z][a-z-]*)(?![\w-])/g;
|
|
73
|
+
/**
|
|
74
|
+
* Phrase that signals the cited skill file is mirrored to all three
|
|
75
|
+
* skill-root directories.
|
|
76
|
+
*/
|
|
77
|
+
const THREE_DIR_SYNC_PATTERN = /3[- ]dir(?:ectory)?\s+sync|across\s+all\s+three\s+skill\s+directories|across\s+(?:the\s+)?three\s+skill\s+directories/i;
|
|
78
|
+
/**
|
|
79
|
+
* Strip fenced code blocks and HTML comments from a markdown body so the
|
|
80
|
+
* path regex doesn't fire on quoted shell snippets or commented-out drafts.
|
|
81
|
+
*
|
|
82
|
+
* Inline backticks (single ` `) are preserved — the path regex requires a
|
|
83
|
+
* single-backtick wrapper, so this gives us the "paths quoted as code in
|
|
84
|
+
* prose count, paths inside a code block don't" behavior the AC-5 guard
|
|
85
|
+
* specifies.
|
|
86
|
+
*/
|
|
87
|
+
function stripCodeBlocksAndComments(body) {
|
|
88
|
+
return body.replace(/```[\s\S]*?```/g, "").replace(/<!--[\s\S]*?-->/g, "");
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Collapse a fully-qualified skill-mirror path to its canonical bare form.
|
|
92
|
+
*
|
|
93
|
+
* The repo maintains three byte-identical mirrors of every skill file
|
|
94
|
+
* under `.claude/skills/`, `templates/skills/`, and `skills/`. Treating
|
|
95
|
+
* those mirrors as separate paths in collision detection produces 3× the
|
|
96
|
+
* Order: lines and 6× the warnings for one logical conflict, since both
|
|
97
|
+
* sides of the 3-dir-sync expansion match each mirror.
|
|
98
|
+
*
|
|
99
|
+
* Normalizing to the bare subpath (e.g. `qa/SKILL.md`) makes mirrored
|
|
100
|
+
* collisions deduplicate naturally and matches the issue-body shorthand
|
|
101
|
+
* the dashboard's `Order:` annotation already uses.
|
|
102
|
+
*/
|
|
103
|
+
function normalizeSkillMirrorPath(path) {
|
|
104
|
+
const m = path.match(/^(?:\.claude\/skills\/|templates\/skills\/|skills\/)(.+)$/);
|
|
105
|
+
return m ? m[1] : path;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Extract the set of file paths an issue body identifies as
|
|
109
|
+
* targets-of-modification.
|
|
110
|
+
*
|
|
111
|
+
* Strategy:
|
|
112
|
+
* 1. Strip fenced code blocks and HTML comments (AC-5 guard).
|
|
113
|
+
* 2. Pull every backtick-quoted path matching the source-tree regex,
|
|
114
|
+
* normalizing skill-mirror paths to their canonical bare form.
|
|
115
|
+
* 3. If the body mentions "3-dir sync", also pull bare
|
|
116
|
+
* `<name>/SKILL.md` references and `/<skill>` slash-command
|
|
117
|
+
* mentions (where `<skill>` is in KNOWN_SKILL_NAMES). Both already
|
|
118
|
+
* live in the canonical bare form.
|
|
119
|
+
* 4. Remove globally excluded paths.
|
|
120
|
+
*
|
|
121
|
+
* The canonical bare form (`qa/SKILL.md`, not `.claude/skills/qa/SKILL.md`)
|
|
122
|
+
* is what the dashboard's `Order:` annotations render, and it makes
|
|
123
|
+
* mirrored collisions deduplicate without a separate post-processing
|
|
124
|
+
* pass.
|
|
125
|
+
*/
|
|
126
|
+
export function extractPathsFromIssueBody(body) {
|
|
127
|
+
const paths = new Set();
|
|
128
|
+
const cleaned = stripCodeBlocksAndComments(body);
|
|
129
|
+
for (const m of cleaned.matchAll(PATH_REGEX)) {
|
|
130
|
+
paths.add(normalizeSkillMirrorPath(m[1]));
|
|
131
|
+
}
|
|
132
|
+
const threeDir = THREE_DIR_SYNC_PATTERN.test(cleaned);
|
|
133
|
+
if (threeDir) {
|
|
134
|
+
for (const m of cleaned.matchAll(SKILL_FILE_REGEX)) {
|
|
135
|
+
paths.add(m[1]);
|
|
136
|
+
}
|
|
137
|
+
for (const m of cleaned.matchAll(SLASH_COMMAND_REGEX)) {
|
|
138
|
+
const name = m[1];
|
|
139
|
+
if (KNOWN_SKILL_NAMES.includes(name)) {
|
|
140
|
+
paths.add(`${name}/SKILL.md`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const excluded of EXCLUDED_PATHS) {
|
|
145
|
+
paths.delete(excluded);
|
|
146
|
+
}
|
|
147
|
+
return paths;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Compute file-path overlaps across issue bodies.
|
|
151
|
+
*
|
|
152
|
+
* Each shared file emits one CollisionResult. When N issues all share a
|
|
153
|
+
* file, that's a single result with `issues.length === N` — the caller
|
|
154
|
+
* decides whether to chain-suggest based on count.
|
|
155
|
+
*
|
|
156
|
+
* Sort order: ascending file name, then ascending first-issue number.
|
|
157
|
+
*/
|
|
158
|
+
export function detectFileCollisions(issuePaths) {
|
|
159
|
+
const fileToIssues = new Map();
|
|
160
|
+
for (const [issueNumber, paths] of issuePaths) {
|
|
161
|
+
for (const file of paths) {
|
|
162
|
+
let bucket = fileToIssues.get(file);
|
|
163
|
+
if (!bucket) {
|
|
164
|
+
bucket = new Set();
|
|
165
|
+
fileToIssues.set(file, bucket);
|
|
166
|
+
}
|
|
167
|
+
bucket.add(issueNumber);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const results = [];
|
|
171
|
+
for (const [file, issues] of fileToIssues) {
|
|
172
|
+
if (issues.size < 2)
|
|
173
|
+
continue;
|
|
174
|
+
results.push({
|
|
175
|
+
file,
|
|
176
|
+
issues: [...issues].sort((a, b) => a - b),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
results.sort((a, b) => {
|
|
180
|
+
if (a.file !== b.file)
|
|
181
|
+
return a.file < b.file ? -1 : 1;
|
|
182
|
+
return a.issues[0] - b.issues[0];
|
|
183
|
+
});
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Format collision results as dashboard annotations.
|
|
188
|
+
*
|
|
189
|
+
* - 2-issue collision: `Order: A → B (path)` plus a warning per issue.
|
|
190
|
+
* - 3+-issue collision on the same file: `Order: A → B → C (path)`,
|
|
191
|
+
* warnings per issue, plus a single `Chain:` suggestion.
|
|
192
|
+
*
|
|
193
|
+
* Multiple shared files between the same pair render as multiple
|
|
194
|
+
* Order: lines (one per file). Callers decide whether to truncate.
|
|
195
|
+
*/
|
|
196
|
+
export function formatCollisionAnnotations(results) {
|
|
197
|
+
const orderLines = [];
|
|
198
|
+
const warnings = [];
|
|
199
|
+
let chainSuggestion;
|
|
200
|
+
for (const r of results) {
|
|
201
|
+
const arrow = r.issues.join(" → ");
|
|
202
|
+
orderLines.push(`Order: ${arrow} (${r.file})`);
|
|
203
|
+
for (const n of r.issues) {
|
|
204
|
+
const others = r.issues.filter((m) => m !== n);
|
|
205
|
+
const overlapStr = others.map((m) => `#${m}`).join(", ");
|
|
206
|
+
warnings.push(`⚠ #${n} Modifies ${r.file} (overlaps ${overlapStr}); land sequentially`);
|
|
207
|
+
}
|
|
208
|
+
if (r.issues.length >= 3 && !chainSuggestion) {
|
|
209
|
+
const ids = r.issues.join(" ");
|
|
210
|
+
chainSuggestion =
|
|
211
|
+
`Chain: npx sequant run ${ids} --chain --qa-gate -Q ` +
|
|
212
|
+
`# alternative — ${r.issues.length} issues modify ${r.file} ` +
|
|
213
|
+
`(chain length≥3 historically 1/6 = 17%; see docs/reference/chain-mode-analysis-2026-05.md)`;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { orderLines, warnings, chainSuggestion };
|
|
217
|
+
}
|