sequant 2.1.2 → 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.
Files changed (146) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +73 -0
  4. package/dist/bin/cli.js +95 -9
  5. package/dist/src/commands/doctor.d.ts +25 -0
  6. package/dist/src/commands/doctor.js +36 -1
  7. package/dist/src/commands/init.d.ts +1 -0
  8. package/dist/src/commands/init.js +118 -0
  9. package/dist/src/commands/locks.d.ts +67 -0
  10. package/dist/src/commands/locks.js +290 -0
  11. package/dist/src/commands/merge.js +11 -0
  12. package/dist/src/commands/prompt.d.ts +39 -0
  13. package/dist/src/commands/prompt.js +179 -0
  14. package/dist/src/commands/run-display.d.ts +26 -0
  15. package/dist/src/commands/run-display.js +150 -0
  16. package/dist/src/commands/run-progress.d.ts +32 -0
  17. package/dist/src/commands/run-progress.js +76 -0
  18. package/dist/src/commands/run.js +83 -73
  19. package/dist/src/commands/stats.d.ts +2 -0
  20. package/dist/src/commands/stats.js +94 -8
  21. package/dist/src/commands/status.js +27 -1
  22. package/dist/src/commands/watch.d.ts +16 -0
  23. package/dist/src/commands/watch.js +147 -0
  24. package/dist/src/lib/ac-linter.d.ts +1 -1
  25. package/dist/src/lib/ac-linter.js +81 -0
  26. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  27. package/dist/src/lib/assess-collision-detect.js +217 -0
  28. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  29. package/dist/src/lib/assess-comment-parser.js +124 -2
  30. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  31. package/dist/src/lib/cli-ui/format.js +34 -0
  32. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
  33. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  34. package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
  35. package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
  36. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  37. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  38. package/dist/src/lib/locks/index.d.ts +7 -0
  39. package/dist/src/lib/locks/index.js +5 -0
  40. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  41. package/dist/src/lib/locks/lock-manager.js +433 -0
  42. package/dist/src/lib/locks/types.d.ts +59 -0
  43. package/dist/src/lib/locks/types.js +31 -0
  44. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  45. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  46. package/dist/src/lib/relay/activation.d.ts +60 -0
  47. package/dist/src/lib/relay/activation.js +122 -0
  48. package/dist/src/lib/relay/archive.d.ts +34 -0
  49. package/dist/src/lib/relay/archive.js +106 -0
  50. package/dist/src/lib/relay/frame.d.ts +20 -0
  51. package/dist/src/lib/relay/frame.js +76 -0
  52. package/dist/src/lib/relay/index.d.ts +13 -0
  53. package/dist/src/lib/relay/index.js +13 -0
  54. package/dist/src/lib/relay/paths.d.ts +43 -0
  55. package/dist/src/lib/relay/paths.js +59 -0
  56. package/dist/src/lib/relay/pid.d.ts +34 -0
  57. package/dist/src/lib/relay/pid.js +72 -0
  58. package/dist/src/lib/relay/reader.d.ts +35 -0
  59. package/dist/src/lib/relay/reader.js +115 -0
  60. package/dist/src/lib/relay/types.d.ts +68 -0
  61. package/dist/src/lib/relay/types.js +76 -0
  62. package/dist/src/lib/relay/writer.d.ts +48 -0
  63. package/dist/src/lib/relay/writer.js +113 -0
  64. package/dist/src/lib/settings.d.ts +31 -1
  65. package/dist/src/lib/settings.js +18 -3
  66. package/dist/src/lib/skill-version.d.ts +19 -0
  67. package/dist/src/lib/skill-version.js +68 -0
  68. package/dist/src/lib/templates.d.ts +1 -0
  69. package/dist/src/lib/templates.js +1 -1
  70. package/dist/src/lib/version-check.d.ts +60 -5
  71. package/dist/src/lib/version-check.js +97 -9
  72. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  73. package/dist/src/lib/workflow/batch-executor.js +249 -176
  74. package/dist/src/lib/workflow/config-resolver.js +4 -0
  75. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  76. package/dist/src/lib/workflow/heartbeat.js +194 -0
  77. package/dist/src/lib/workflow/phase-executor.d.ts +88 -3
  78. package/dist/src/lib/workflow/phase-executor.js +276 -52
  79. package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
  80. package/dist/src/lib/workflow/phase-mapper.js +17 -20
  81. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  82. package/dist/src/lib/workflow/platforms/github.js +20 -3
  83. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  84. package/dist/src/lib/workflow/pr-status.js +41 -9
  85. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  86. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  87. package/dist/src/lib/workflow/run-orchestrator.d.ts +76 -0
  88. package/dist/src/lib/workflow/run-orchestrator.js +382 -29
  89. package/dist/src/lib/workflow/run-reflect.js +1 -1
  90. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  91. package/dist/src/lib/workflow/run-state.js +14 -0
  92. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  93. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  94. package/dist/src/lib/workflow/state-manager.d.ts +12 -1
  95. package/dist/src/lib/workflow/state-manager.js +37 -0
  96. package/dist/src/lib/workflow/state-schema.d.ts +62 -0
  97. package/dist/src/lib/workflow/state-schema.js +35 -1
  98. package/dist/src/lib/workflow/types.d.ts +74 -1
  99. package/dist/src/lib/workflow/worktree-manager.d.ts +12 -4
  100. package/dist/src/lib/workflow/worktree-manager.js +76 -17
  101. package/dist/src/mcp/tools/run.d.ts +44 -0
  102. package/dist/src/mcp/tools/run.js +104 -13
  103. package/dist/src/ui/tui/App.d.ts +14 -0
  104. package/dist/src/ui/tui/App.js +41 -0
  105. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  106. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  107. package/dist/src/ui/tui/Header.d.ts +6 -0
  108. package/dist/src/ui/tui/Header.js +15 -0
  109. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  110. package/dist/src/ui/tui/IssueBox.js +68 -0
  111. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  112. package/dist/src/ui/tui/Spinner.js +18 -0
  113. package/dist/src/ui/tui/index.d.ts +15 -0
  114. package/dist/src/ui/tui/index.js +29 -0
  115. package/dist/src/ui/tui/theme.d.ts +29 -0
  116. package/dist/src/ui/tui/theme.js +52 -0
  117. package/dist/src/ui/tui/truncate.d.ts +11 -0
  118. package/dist/src/ui/tui/truncate.js +31 -0
  119. package/package.json +10 -3
  120. package/templates/agents/sequant-explorer.md +1 -0
  121. package/templates/agents/sequant-qa-checker.md +2 -1
  122. package/templates/agents/sequant-testgen.md +1 -0
  123. package/templates/hooks/post-tool.sh +11 -0
  124. package/templates/hooks/pre-tool.sh +18 -9
  125. package/templates/hooks/relay-check.sh +107 -0
  126. package/templates/relay/frame.txt +11 -0
  127. package/templates/scripts/cleanup-worktree.sh +25 -3
  128. package/templates/scripts/new-feature.sh +6 -0
  129. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  130. package/templates/skills/_shared/references/subagent-types.md +21 -8
  131. package/templates/skills/assess/SKILL.md +261 -94
  132. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  133. package/templates/skills/docs/SKILL.md +141 -22
  134. package/templates/skills/exec/SKILL.md +10 -49
  135. package/templates/skills/fullsolve/SKILL.md +80 -32
  136. package/templates/skills/loop/SKILL.md +28 -0
  137. package/templates/skills/merger/SKILL.md +621 -0
  138. package/templates/skills/qa/SKILL.md +746 -8
  139. package/templates/skills/qa/scripts/quality-checks.sh +47 -1
  140. package/templates/skills/setup/SKILL.md +6 -0
  141. package/templates/skills/spec/SKILL.md +217 -964
  142. package/templates/skills/spec/references/parallel-groups.md +7 -0
  143. package/templates/skills/spec/references/quality-checklist.md +75 -0
  144. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  145. package/templates/skills/test/SKILL.md +0 -27
  146. package/templates/skills/testgen/SKILL.md +24 -44
@@ -10,6 +10,8 @@ import { StateManager } from "../lib/workflow/state-manager.js";
10
10
  import { rebuildStateFromLogs, cleanupStaleEntries, } from "../lib/workflow/state-utils.js";
11
11
  import { reconcileState, getNextActionHint, formatRelativeTime, } from "../lib/workflow/reconcile.js";
12
12
  import { getSettingsWithWarnings } from "../lib/settings.js";
13
+ import { getSkillVersions } from "../lib/skill-version.js";
14
+ import { LockManager, formatLockedMessage } from "../lib/locks/index.js";
13
15
  /**
14
16
  * Run reconciliation and display warnings.
15
17
  * Returns the reconcile result for use in display.
@@ -181,11 +183,16 @@ function displayIssueSummary(issues) {
181
183
  const hintDisplay = hint
182
184
  ? chalk.gray(`→ ${hint.length > 30 ? hint.substring(0, 27) + "..." : hint}`)
183
185
  : "";
186
+ // Relay column (#383). Shows ✓ when active, "-" otherwise.
187
+ const relayDisplay = issue.relay?.enabled
188
+ ? chalk.green("on")
189
+ : chalk.gray("-");
184
190
  rows.push([
185
191
  `#${issue.number}`,
186
192
  title,
187
193
  colorStatus(issue.status, issue.resolvedAt),
188
194
  issue.currentPhase || "-",
195
+ relayDisplay,
189
196
  hintDisplay,
190
197
  ]);
191
198
  }
@@ -198,6 +205,7 @@ function displayIssueSummary(issues) {
198
205
  { header: "Title", width: 32 },
199
206
  { header: "Status", width: 20 },
200
207
  { header: "Phase", width: 10 },
208
+ { header: "Relay", width: 5 },
201
209
  { header: "Next", width: 34 },
202
210
  ],
203
211
  }));
@@ -263,13 +271,26 @@ export async function statusCommand(options = {}) {
263
271
  console.log(chalk.yellow(` ${w.message}`));
264
272
  }
265
273
  }
266
- // Count skills
274
+ // Count skills and check versions
267
275
  const skillsDir = ".claude/skills";
268
276
  if (await fileExists(skillsDir)) {
269
277
  try {
270
278
  const skills = await readdir(skillsDir);
271
279
  const skillCount = skills.filter((s) => !s.startsWith(".")).length;
272
280
  console.log(chalk.gray(`Skills: ${skillCount}`));
281
+ // Show skill version info
282
+ const { getTemplatesDir } = await import("../lib/templates.js");
283
+ const { join } = await import("path");
284
+ const templateSkillsDir = join(getTemplatesDir(), "skills");
285
+ const skillVersions = await getSkillVersions(templateSkillsDir);
286
+ const outdated = skillVersions.filter((s) => s.updateAvailable);
287
+ if (outdated.length > 0) {
288
+ console.log(chalk.yellow(`\n Skill updates available (${outdated.length}):`));
289
+ for (const s of outdated) {
290
+ console.log(chalk.yellow(` ${s.name}: v${s.installedVersion} → v${s.templateVersion}`));
291
+ }
292
+ console.log(chalk.gray(" Run `sequant init --upgrade-skills` to update."));
293
+ }
273
294
  }
274
295
  catch {
275
296
  // Ignore errors
@@ -342,6 +363,11 @@ async function displayIssueState(options) {
342
363
  else if (issueState) {
343
364
  console.log(chalk.bold(`\nIssue #${options.issue} State\n`));
344
365
  console.log(formatIssueState(issueState));
366
+ const lockManager = new LockManager();
367
+ const holder = lockManager.check(options.issue);
368
+ if (holder) {
369
+ console.log(chalk.yellow(` ! ${formatLockedMessage(options.issue, holder)}`));
370
+ }
345
371
  const hint = getNextActionHint(issueState);
346
372
  if (hint) {
347
373
  console.log(chalk.cyan(`\n Next: ${hint}`));
@@ -0,0 +1,16 @@
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
+ export interface WatchCommandOptions {
7
+ json?: boolean;
8
+ /** Poll interval (ms); used when fs.watch is unavailable. Default: 200. */
9
+ pollIntervalMs?: number;
10
+ /** Abort signal for clean shutdown (tests). */
11
+ signal?: AbortSignal;
12
+ }
13
+ export declare function watchCommand(argsAndOptions: {
14
+ args: string[];
15
+ options: WatchCommandOptions;
16
+ }): Promise<void>;
@@ -0,0 +1,147 @@
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 { StateManager } from "../lib/workflow/state-manager.js";
10
+ import { RelayResponseSchema } from "../lib/relay/types.js";
11
+ function formatTimestamp(iso) {
12
+ try {
13
+ const date = new Date(iso);
14
+ const hh = String(date.getHours()).padStart(2, "0");
15
+ const mm = String(date.getMinutes()).padStart(2, "0");
16
+ const ss = String(date.getSeconds()).padStart(2, "0");
17
+ return `${hh}:${mm}:${ss}`;
18
+ }
19
+ catch {
20
+ return iso;
21
+ }
22
+ }
23
+ function formatLine(reply, json) {
24
+ if (json)
25
+ return JSON.stringify(reply);
26
+ return chalk.gray(`[${formatTimestamp(reply.timestamp)}] `) + reply.message;
27
+ }
28
+ function readNewLines(path, state) {
29
+ return new Promise((resolve, reject) => {
30
+ if (!existsSync(path)) {
31
+ resolve([]);
32
+ return;
33
+ }
34
+ const stat = statSync(path);
35
+ if (stat.size <= state.offset) {
36
+ // File was truncated/rotated — reset offset.
37
+ if (stat.size < state.offset)
38
+ state.offset = 0;
39
+ resolve([]);
40
+ return;
41
+ }
42
+ const stream = createReadStream(path, {
43
+ start: state.offset,
44
+ end: stat.size - 1,
45
+ encoding: "utf-8",
46
+ });
47
+ let buf = state.partial;
48
+ stream.on("data", (chunk) => {
49
+ buf += chunk;
50
+ });
51
+ stream.on("error", (err) => reject(err));
52
+ stream.on("end", () => {
53
+ state.offset = stat.size;
54
+ const lines = buf.split("\n");
55
+ state.partial = lines.pop() ?? "";
56
+ const replies = [];
57
+ for (const line of lines) {
58
+ if (line.trim() === "")
59
+ continue;
60
+ try {
61
+ const parsed = RelayResponseSchema.safeParse(JSON.parse(line));
62
+ if (parsed.success)
63
+ replies.push(parsed.data);
64
+ }
65
+ catch {
66
+ /* skip malformed */
67
+ }
68
+ }
69
+ resolve(replies);
70
+ });
71
+ });
72
+ }
73
+ export async function watchCommand(argsAndOptions) {
74
+ const { args, options } = argsAndOptions;
75
+ const issueArg = args[0];
76
+ if (!issueArg) {
77
+ throw new Error("Usage: sequant watch <issue>");
78
+ }
79
+ const issueNumber = Number.parseInt(issueArg, 10);
80
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
81
+ throw new Error(`Invalid issue number: '${issueArg}'`);
82
+ }
83
+ const stateManager = new StateManager();
84
+ const issueState = await stateManager.getIssueState(issueNumber);
85
+ const outboxPath = outboxPathFor(issueNumber, {
86
+ worktreePath: issueState?.worktree,
87
+ });
88
+ const pollIntervalMs = options.pollIntervalMs ?? 200;
89
+ const tail = { offset: 0, partial: "" };
90
+ // Seed tail offset at current EOF so we only show NEW replies.
91
+ if (existsSync(outboxPath)) {
92
+ tail.offset = statSync(outboxPath).size;
93
+ }
94
+ if (!options.json) {
95
+ console.log(chalk.gray(`Watching #${issueNumber} outbox — Ctrl+C to stop`));
96
+ }
97
+ let stopped = false;
98
+ const stop = () => {
99
+ stopped = true;
100
+ };
101
+ options.signal?.addEventListener("abort", stop);
102
+ process.on("SIGINT", () => {
103
+ stop();
104
+ if (!options.json)
105
+ console.log(chalk.gray("\nStopped watching."));
106
+ process.exit(0);
107
+ });
108
+ let useWatcher = false;
109
+ let watcher = null;
110
+ try {
111
+ if (existsSync(outboxPath)) {
112
+ watcher = watch(outboxPath, { persistent: true }, () => {
113
+ // fs.watch fires on any modification; we still poll readNewLines.
114
+ });
115
+ useWatcher = true;
116
+ }
117
+ }
118
+ catch {
119
+ useWatcher = false;
120
+ }
121
+ const emit = (replies) => {
122
+ for (const r of replies) {
123
+ console.log(formatLine(r, options.json === true));
124
+ }
125
+ };
126
+ // Polling loop — also used as a heartbeat when fs.watch is active so we
127
+ // don't miss events on filesystems where watch is unreliable.
128
+ while (!stopped) {
129
+ try {
130
+ const replies = await readNewLines(outboxPath, tail);
131
+ emit(replies);
132
+ }
133
+ catch {
134
+ /* transient — try again next tick */
135
+ }
136
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
137
+ }
138
+ if (watcher) {
139
+ try {
140
+ watcher.close();
141
+ }
142
+ catch {
143
+ /* swallow */
144
+ }
145
+ }
146
+ void useWatcher; // currently unused beyond best-effort init
147
+ }
@@ -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
+ }