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.
Files changed (156) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +81 -5
  4. package/dist/bin/cli.js +140 -13
  5. package/dist/src/commands/abort.d.ts +36 -0
  6. package/dist/src/commands/abort.js +138 -0
  7. package/dist/src/commands/doctor.d.ts +25 -0
  8. package/dist/src/commands/doctor.js +36 -1
  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 +46 -0
  13. package/dist/src/commands/prompt.js +273 -0
  14. package/dist/src/commands/run-display.d.ts +11 -2
  15. package/dist/src/commands/run-display.js +62 -28
  16. package/dist/src/commands/run-progress.d.ts +42 -0
  17. package/dist/src/commands/run-progress.js +93 -0
  18. package/dist/src/commands/run.js +90 -18
  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 +12 -0
  22. package/dist/src/commands/watch.d.ts +18 -0
  23. package/dist/src/commands/watch.js +211 -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 +220 -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 +265 -0
  35. package/dist/src/lib/cli-ui/run-renderer.js +1390 -0
  36. package/dist/src/lib/cli-ui/scrollback-harness.d.ts +112 -0
  37. package/dist/src/lib/cli-ui/scrollback-harness.js +294 -0
  38. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  39. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  40. package/dist/src/lib/locks/index.d.ts +7 -0
  41. package/dist/src/lib/locks/index.js +5 -0
  42. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  43. package/dist/src/lib/locks/lock-manager.js +433 -0
  44. package/dist/src/lib/locks/types.d.ts +59 -0
  45. package/dist/src/lib/locks/types.js +31 -0
  46. package/dist/src/lib/merge-check/types.js +1 -1
  47. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  48. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  49. package/dist/src/lib/relay/activation.d.ts +60 -0
  50. package/dist/src/lib/relay/activation.js +122 -0
  51. package/dist/src/lib/relay/archive.d.ts +34 -0
  52. package/dist/src/lib/relay/archive.js +112 -0
  53. package/dist/src/lib/relay/frame.d.ts +20 -0
  54. package/dist/src/lib/relay/frame.js +76 -0
  55. package/dist/src/lib/relay/index.d.ts +13 -0
  56. package/dist/src/lib/relay/index.js +13 -0
  57. package/dist/src/lib/relay/paths.d.ts +43 -0
  58. package/dist/src/lib/relay/paths.js +59 -0
  59. package/dist/src/lib/relay/pid.d.ts +34 -0
  60. package/dist/src/lib/relay/pid.js +72 -0
  61. package/dist/src/lib/relay/reader.d.ts +35 -0
  62. package/dist/src/lib/relay/reader.js +115 -0
  63. package/dist/src/lib/relay/types.d.ts +70 -0
  64. package/dist/src/lib/relay/types.js +85 -0
  65. package/dist/src/lib/relay/writer.d.ts +48 -0
  66. package/dist/src/lib/relay/writer.js +113 -0
  67. package/dist/src/lib/settings.d.ts +31 -1
  68. package/dist/src/lib/settings.js +18 -3
  69. package/dist/src/lib/version-check.d.ts +60 -5
  70. package/dist/src/lib/version-check.js +97 -9
  71. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  72. package/dist/src/lib/workflow/batch-executor.js +274 -185
  73. package/dist/src/lib/workflow/config-resolver.js +4 -0
  74. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +48 -1
  75. package/dist/src/lib/workflow/drivers/aider.d.ts +7 -1
  76. package/dist/src/lib/workflow/drivers/aider.js +9 -0
  77. package/dist/src/lib/workflow/drivers/claude-code.d.ts +17 -1
  78. package/dist/src/lib/workflow/drivers/claude-code.js +51 -2
  79. package/dist/src/lib/workflow/drivers/index.d.ts +1 -1
  80. package/dist/src/lib/workflow/event-emitter.d.ts +157 -0
  81. package/dist/src/lib/workflow/event-emitter.js +102 -0
  82. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  83. package/dist/src/lib/workflow/heartbeat.js +194 -0
  84. package/dist/src/lib/workflow/notice.d.ts +32 -0
  85. package/dist/src/lib/workflow/notice.js +38 -0
  86. package/dist/src/lib/workflow/phase-executor.d.ts +58 -16
  87. package/dist/src/lib/workflow/phase-executor.js +244 -130
  88. package/dist/src/lib/workflow/phase-mapper.d.ts +27 -13
  89. package/dist/src/lib/workflow/phase-mapper.js +70 -51
  90. package/dist/src/lib/workflow/phase-registry.d.ts +127 -0
  91. package/dist/src/lib/workflow/phase-registry.js +233 -0
  92. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  93. package/dist/src/lib/workflow/platforms/github.js +20 -3
  94. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  95. package/dist/src/lib/workflow/pr-status.js +41 -9
  96. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  97. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  98. package/dist/src/lib/workflow/run-log-schema.d.ts +5 -55
  99. package/dist/src/lib/workflow/run-orchestrator.d.ts +70 -1
  100. package/dist/src/lib/workflow/run-orchestrator.js +464 -25
  101. package/dist/src/lib/workflow/run-reflect.js +1 -1
  102. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  103. package/dist/src/lib/workflow/run-state.js +14 -0
  104. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  105. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  106. package/dist/src/lib/workflow/state-manager.d.ts +31 -2
  107. package/dist/src/lib/workflow/state-manager.js +64 -1
  108. package/dist/src/lib/workflow/state-schema.d.ts +82 -35
  109. package/dist/src/lib/workflow/state-schema.js +63 -4
  110. package/dist/src/lib/workflow/types.d.ts +139 -16
  111. package/dist/src/lib/workflow/types.js +18 -13
  112. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  113. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  114. package/dist/src/mcp/tools/run.d.ts +44 -0
  115. package/dist/src/mcp/tools/run.js +104 -13
  116. package/dist/src/ui/tui/App.d.ts +14 -0
  117. package/dist/src/ui/tui/App.js +41 -0
  118. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  119. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  120. package/dist/src/ui/tui/Header.d.ts +6 -0
  121. package/dist/src/ui/tui/Header.js +15 -0
  122. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  123. package/dist/src/ui/tui/IssueBox.js +68 -0
  124. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  125. package/dist/src/ui/tui/Spinner.js +18 -0
  126. package/dist/src/ui/tui/index.d.ts +15 -0
  127. package/dist/src/ui/tui/index.js +29 -0
  128. package/dist/src/ui/tui/theme.d.ts +29 -0
  129. package/dist/src/ui/tui/theme.js +52 -0
  130. package/dist/src/ui/tui/truncate.d.ts +11 -0
  131. package/dist/src/ui/tui/truncate.js +31 -0
  132. package/package.json +14 -6
  133. package/templates/agents/sequant-explorer.md +1 -0
  134. package/templates/agents/sequant-qa-checker.md +2 -1
  135. package/templates/agents/sequant-testgen.md +1 -0
  136. package/templates/hooks/post-tool.sh +92 -0
  137. package/templates/hooks/pre-tool.sh +18 -9
  138. package/templates/hooks/relay-check.sh +107 -0
  139. package/templates/relay/frame.txt +11 -0
  140. package/templates/scripts/cleanup-worktree.sh +25 -3
  141. package/templates/scripts/new-feature.sh +6 -0
  142. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  143. package/templates/skills/_shared/references/subagent-types.md +21 -8
  144. package/templates/skills/assess/SKILL.md +122 -68
  145. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  146. package/templates/skills/docs/SKILL.md +141 -22
  147. package/templates/skills/exec/SKILL.md +10 -8
  148. package/templates/skills/fullsolve/SKILL.md +79 -5
  149. package/templates/skills/loop/SKILL.md +28 -0
  150. package/templates/skills/merger/SKILL.md +621 -0
  151. package/templates/skills/qa/SKILL.md +727 -8
  152. package/templates/skills/setup/SKILL.md +12 -6
  153. package/templates/skills/spec/SKILL.md +52 -0
  154. package/templates/skills/spec/references/parallel-groups.md +7 -0
  155. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  156. package/templates/skills/testgen/SKILL.md +24 -17
@@ -0,0 +1,467 @@
1
+ /**
2
+ * Behavior-Rule Detector (issue #552)
3
+ *
4
+ * Shared heuristic for `/spec` (proactive) and `/qa` (reactive) phases that
5
+ * detects when an AC describes a *behavior rule* (e.g. "default becomes X",
6
+ * "always include Y", "never skip Z") and, when triggered, surfaces all
7
+ * touchpoints in the codebase that likely implement the rule.
8
+ *
9
+ * Behavior rules are routinely duplicated across a skill prompt
10
+ * (LLM-interpreted) AND the runtime TypeScript that backs it. Without this
11
+ * detector, edits land at one site and the other goes stale — see issue #533
12
+ * (motivating miss; documented in `references/behavior-rule-detection.md`).
13
+ *
14
+ * Three exported functions:
15
+ * - `detectBehaviorRule` — cheap keyword check; gates the more expensive greps
16
+ * - `findTouchpoints` — used by `/spec` to enumerate likely implementations
17
+ * - `findSurvivingInverseSymbols` — used by `/qa` to flag OLD-rule survivors
18
+ * inside the diff blast radius
19
+ *
20
+ * The keyword set is the source of truth in this file (per the /spec Open
21
+ * Question on keyword location). The reference doc cites it.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * import { detectBehaviorRule, findTouchpoints } from "./behavior-rule-detector.ts";
26
+ *
27
+ * const detection = detectBehaviorRule(ac);
28
+ * if (detection.triggered) {
29
+ * const hits = findTouchpoints(ac, process.cwd());
30
+ * for (const h of hits) console.log(`${h.path}:${h.line} ${h.snippet}`);
31
+ * }
32
+ * ```
33
+ */
34
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
35
+ import { join, relative } from "node:path";
36
+ /**
37
+ * Behavior keywords whose presence (≥2 distinct, OR matching the explicit
38
+ * pattern below) signals an AC describes a rule rather than a localized fix.
39
+ * Tunable here; cited from `references/behavior-rule-detection.md`.
40
+ */
41
+ export const BEHAVIOR_KEYWORDS = [
42
+ "default",
43
+ "always",
44
+ "never",
45
+ "rule",
46
+ "behavior",
47
+ "skip",
48
+ ];
49
+ /**
50
+ * Per-stem inflection regex map. Mirrors the stem-aware pattern landed by
51
+ * #597 in `src/lib/ac-linter.ts`: build `\b<stem>(<suffixes>)?\b` so the
52
+ * keyword check matches common inflections (`defaults`, `skipping`,
53
+ * `behaviors`) without over-matching identifier-shaped tokens. Word
54
+ * boundaries are preserved so e.g. `defaultValue` does NOT match.
55
+ *
56
+ * - `default` → matches `default`/`defaults`/`defaulted`/`defaulting`
57
+ * - `always` / `never` — adverbs, no inflections
58
+ * - `rule` — e-ending: `rule`/`rules`/`ruled`/`ruling`
59
+ * - `behavior` — `behavior`/`behaviors`/`behavioral`/`behaviorally`
60
+ * - `skip` — double-`p` per English: `skip`/`skips`/`skipped`/`skipping`
61
+ *
62
+ * Source-of-truth lives in this map so the test suite can read it (rather
63
+ * than each call site rebuilding the regex inline).
64
+ */
65
+ const KEYWORD_REGEXES = {
66
+ default: /\bdefault(?:s|ed|ing)?\b/i,
67
+ always: /\balways\b/i,
68
+ never: /\bnever\b/i,
69
+ rule: /\brul(?:e|es|ed|ing)\b/i,
70
+ behavior: /\bbehavior(?:s|al|ally)?\b/i,
71
+ skip: /\bskip(?:s|ped|ping)?\b/i,
72
+ };
73
+ /**
74
+ * Explicit behavior-rule patterns that trigger detection even with a single
75
+ * keyword (false-positive guard exception). Two families:
76
+ *
77
+ * 1. Mid-sentence rule constructs:
78
+ * - "always X unless Y", "never X unless Y", "default X when Y"
79
+ * 2. Imperative AC openers — when an AC description begins with a capitalized
80
+ * `Always` / `Never` / `Default` followed by a verb, it's almost certainly
81
+ * a behavior rule even with a single keyword. Covers the AC-5 literals
82
+ * "Always include Y" and "Never skip Z" which #552's prior threshold missed.
83
+ * Case-sensitive on purpose: matches the imperative-rule register, not
84
+ * "the system always defaults to..." prose mid-paragraph.
85
+ */
86
+ const EXPLICIT_PATTERNS = [
87
+ /\balways\b[^.]*?\bunless\b/i,
88
+ /\bnever\b[^.]*?\bunless\b/i,
89
+ /\bdefault\b[^.]*?\bwhen\b/i,
90
+ /^\s*Always\s+\w+/,
91
+ /^\s*Never\s+\w+/,
92
+ /^\s*Default\s+\w+/,
93
+ ];
94
+ /**
95
+ * Inverse-keyword map — used by `findSurvivingInverseSymbols` to derive search
96
+ * terms for OLD-rule survivors inside the diff blast radius. Asymmetric on
97
+ * purpose: e.g. an AC asserting the NEW rule "always include spec" should
98
+ * search for "skip" / "exclude" / "bypass" survivors, not "always" itself.
99
+ *
100
+ * High-noise common English words (`include`, `run`, `auto`, `old`,
101
+ * `previous`) were pruned from this map after QA's self-dogfood pass on
102
+ * #552 returned 50 survivors entirely from definitional documentation —
103
+ * those words appear in any English prose. {@link deriveInverseTerms} also
104
+ * filters terms in {@link COMMON_WORDS} as defense-in-depth for future
105
+ * tuners.
106
+ */
107
+ const INVERSE_KEYWORDS = {
108
+ default: ["skip", "exclude", "bypass", "override"],
109
+ always: ["skip", "never", "exclude", "conditional", "shortcut"],
110
+ never: ["always", "default", "exclude"],
111
+ rule: ["exception", "override", "shortcut", "bypass"],
112
+ behavior: ["legacy", "deprecated"],
113
+ skip: ["always", "default", "exclude"],
114
+ };
115
+ /**
116
+ * Roots scanned by `findTouchpoints`. Order matters when `TOTAL_CAP` is hit:
117
+ * earlier roots are always represented in the results. `bin/` and
118
+ * `src/commands/` are scanned because CLI option registration (Commander.js
119
+ * `.option()` chains in `bin/cli.ts`, `RunOptions` interface in
120
+ * `src/commands/run.ts`) is a recurring rule-drift site — see the "CLI wiring
121
+ * gap" pitfall called out in this project's CLAUDE.md memory. `templates/skills/`
122
+ * and `skills/` are intentionally omitted — they mirror `.claude/skills/` 1:1
123
+ * and including them would triple-count every hit.
124
+ */
125
+ const TOUCHPOINT_ROOTS = ["src/lib", "src/commands", "bin", ".claude/skills"];
126
+ const TOUCHPOINT_EXTENSIONS = [".md", ".ts", ".tsx"];
127
+ /**
128
+ * Total survivor cap on `findSurvivingInverseSymbols` results. Lower than
129
+ * `findTouchpoints`'s total cap (200) because survivors are surfaced inside
130
+ * the QA verdict and a long list drowns out the rule-relevant hits — a
131
+ * tighter cap forces operators to triage the highest-signal blast-radius
132
+ * lines first.
133
+ */
134
+ const SURVIVOR_TOTAL_CAP = 50;
135
+ /** Skip these directories when walking — they explode the corpus without value. */
136
+ const WALK_SKIP_DIRS = new Set([
137
+ "node_modules",
138
+ ".git",
139
+ "dist",
140
+ "build",
141
+ ".next",
142
+ "coverage",
143
+ "__tests__",
144
+ "__snapshots__",
145
+ ]);
146
+ /**
147
+ * Detect whether an AC describes a behavior rule.
148
+ *
149
+ * Trigger conditions:
150
+ * 1. ≥2 distinct {@link BEHAVIOR_KEYWORDS} present in the AC description
151
+ * (case-insensitive, word-boundary match), OR
152
+ * 2. Description matches one of the {@link EXPLICIT_PATTERNS}
153
+ * (e.g. "always X unless Y").
154
+ *
155
+ * Returns `triggered: false` for empty or undefined descriptions, single
156
+ * keyword matches without an explicit pattern, and file-specific ACs
157
+ * ("Update line 42 of foo.ts").
158
+ */
159
+ export function detectBehaviorRule(ac) {
160
+ const description = ac?.description ?? "";
161
+ if (!description) {
162
+ return { triggered: false, keywords: [] };
163
+ }
164
+ const matched = new Set();
165
+ for (const kw of BEHAVIOR_KEYWORDS) {
166
+ if (KEYWORD_REGEXES[kw].test(description))
167
+ matched.add(kw);
168
+ }
169
+ // Explicit pattern overrides the ≥2-keyword threshold (e.g. "always X
170
+ // unless Y" only contains one keyword but is unambiguously a behavior rule).
171
+ for (const re of EXPLICIT_PATTERNS) {
172
+ if (re.test(description)) {
173
+ return {
174
+ triggered: true,
175
+ keywords: [...matched],
176
+ matchedPattern: re.source,
177
+ };
178
+ }
179
+ }
180
+ return {
181
+ triggered: matched.size >= 2,
182
+ keywords: [...matched],
183
+ };
184
+ }
185
+ /**
186
+ * Find touchpoints in the codebase that likely implement the behavior rule
187
+ * described by `ac`. Returns `[]` when {@link detectBehaviorRule} does not
188
+ * trigger (cheap short-circuit per the /spec performance budget).
189
+ *
190
+ * Heuristic:
191
+ * - Extract identifier-like symbols from the AC (backticked strings, file
192
+ * paths with extensions, ALL_CAPS / camelCase / kebab-case identifiers).
193
+ * - Walk {@link TOUCHPOINT_ROOTS}; for each line in matching files, mark a
194
+ * hit if the line contains any extracted symbol OR ≥2 distinct AC
195
+ * behavior keywords.
196
+ * - Hits are deduplicated by `path:line` and capped (per-file: 3, total: 200)
197
+ * to keep `/spec` output readable (callers can re-run with a tighter scope
198
+ * if needed).
199
+ */
200
+ export function findTouchpoints(ac, repoRoot) {
201
+ const detection = detectBehaviorRule(ac);
202
+ if (!detection.triggered)
203
+ return [];
204
+ if (!repoRoot || !existsSync(repoRoot))
205
+ return [];
206
+ const symbols = extractSymbols(ac.description);
207
+ const keywords = detection.keywords;
208
+ const hits = [];
209
+ const seen = new Set();
210
+ const perFileCount = new Map();
211
+ // Caps tuned to keep /spec output readable while ensuring breadth of
212
+ // coverage — a per-file ceiling prevents one chatty file from drowning out
213
+ // the rest of the corpus, and a total ceiling caps the worst case.
214
+ const PER_FILE_CAP = 3;
215
+ const TOTAL_CAP = 200;
216
+ for (const root of TOUCHPOINT_ROOTS) {
217
+ const fullRoot = join(repoRoot, root);
218
+ if (!existsSync(fullRoot))
219
+ continue;
220
+ for (const file of walkFiles(fullRoot)) {
221
+ if (!TOUCHPOINT_EXTENSIONS.some((ext) => file.endsWith(ext)))
222
+ continue;
223
+ // Test files implement *checks* of behavior rules, not the rules
224
+ // themselves. Excluding them keeps `findTouchpoints` focused on the
225
+ // implementation sites a /spec planner needs to enumerate.
226
+ if (/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(file))
227
+ continue;
228
+ const content = safeReadFile(file);
229
+ if (!content)
230
+ continue;
231
+ const relPath = relative(repoRoot, file);
232
+ const lines = content.split("\n");
233
+ for (let i = 0; i < lines.length; i++) {
234
+ const line = lines[i];
235
+ if (!matchesBehaviorSite(line, symbols, keywords))
236
+ continue;
237
+ const key = `${relPath}:${i + 1}`;
238
+ if (seen.has(key))
239
+ continue;
240
+ const count = perFileCount.get(relPath) ?? 0;
241
+ if (count >= PER_FILE_CAP)
242
+ break;
243
+ seen.add(key);
244
+ perFileCount.set(relPath, count + 1);
245
+ hits.push({
246
+ path: relPath,
247
+ line: i + 1,
248
+ snippet: line.trim().slice(0, 200),
249
+ });
250
+ if (hits.length >= TOTAL_CAP)
251
+ return hits;
252
+ }
253
+ }
254
+ }
255
+ return hits;
256
+ }
257
+ /**
258
+ * Find OLD-rule survivors inside the diff blast radius. Used by `/qa` to flag
259
+ * an AC `NOT_MET` when the inverse of the asserted rule still has live code.
260
+ *
261
+ * Differs from {@link findTouchpoints}:
262
+ * - Scope is `diffPaths` (caller is responsible for pre-expanding to 1-hop
263
+ * importers when desired — this avoids embedding a TS-only importer scanner
264
+ * here and keeps the function language-agnostic).
265
+ * - Search terms are *inverse* keywords derived from the AC's keywords (and
266
+ * inverse English phrasing as a fallback when no symbols match).
267
+ */
268
+ export function findSurvivingInverseSymbols(ac, repoRoot, diffPaths) {
269
+ const detection = detectBehaviorRule(ac);
270
+ if (!detection.triggered)
271
+ return [];
272
+ if (!repoRoot || !existsSync(repoRoot))
273
+ return [];
274
+ if (!Array.isArray(diffPaths) || diffPaths.length === 0)
275
+ return [];
276
+ const inverseTerms = deriveInverseTerms(detection.keywords);
277
+ if (inverseTerms.length === 0)
278
+ return [];
279
+ const hits = [];
280
+ const seen = new Set();
281
+ for (const relPath of diffPaths) {
282
+ if (!relPath)
283
+ continue;
284
+ const fullPath = join(repoRoot, relPath);
285
+ if (!existsSync(fullPath))
286
+ continue;
287
+ let stat;
288
+ try {
289
+ stat = statSync(fullPath);
290
+ }
291
+ catch {
292
+ continue;
293
+ }
294
+ if (!stat.isFile())
295
+ continue;
296
+ if (!TOUCHPOINT_EXTENSIONS.some((ext) => fullPath.endsWith(ext)))
297
+ continue;
298
+ const content = safeReadFile(fullPath);
299
+ if (!content)
300
+ continue;
301
+ const lines = content.split("\n");
302
+ for (let i = 0; i < lines.length; i++) {
303
+ const line = lines[i];
304
+ if (!matchesAnyTerm(line, inverseTerms))
305
+ continue;
306
+ const key = `${relPath}:${i + 1}`;
307
+ if (seen.has(key))
308
+ continue;
309
+ seen.add(key);
310
+ hits.push({
311
+ path: relPath,
312
+ line: i + 1,
313
+ snippet: line.trim().slice(0, 200),
314
+ });
315
+ if (hits.length >= SURVIVOR_TOTAL_CAP)
316
+ return hits;
317
+ }
318
+ }
319
+ return hits;
320
+ }
321
+ // ---------- helpers ----------
322
+ const SYMBOL_REGEXES = [
323
+ /`([^`\n]+)`/g, // backtick-quoted: `spec`, `BUG_LABELS`
324
+ /"([^"\n]{3,})"/g, // double-quoted phrase
325
+ /\*\*([^*\n]+)\*\*/g, // bold: **always X unless Y**
326
+ /\b([A-Z][A-Z0-9_]{2,})\b/g, // SCREAMING_SNAKE: BUG_LABELS, DOCS_LABELS
327
+ /\b([a-z][A-Za-z0-9]+(?:[A-Z][A-Za-z0-9]+){1,})\b/g, // camelCase
328
+ /([a-zA-Z][\w-]*\.(?:ts|tsx|js|jsx|md|json))/g, // file paths
329
+ /([a-z][\w-]+\/[\w./-]+)/g, // directory paths: src/lib/...
330
+ ];
331
+ function extractSymbols(text) {
332
+ if (!text)
333
+ return [];
334
+ const out = new Set();
335
+ for (const re of SYMBOL_REGEXES) {
336
+ const r = new RegExp(re.source, re.flags);
337
+ let m;
338
+ while ((m = r.exec(text)) !== null) {
339
+ const sym = (m[1] || "").trim();
340
+ // Reject common English words and very short tokens.
341
+ if (sym.length < 3)
342
+ continue;
343
+ if (BEHAVIOR_KEYWORDS.includes(sym.toLowerCase()))
344
+ continue;
345
+ if (COMMON_WORDS.has(sym.toLowerCase()))
346
+ continue;
347
+ out.add(sym);
348
+ }
349
+ }
350
+ return [...out];
351
+ }
352
+ const COMMON_WORDS = new Set([
353
+ "the",
354
+ "and",
355
+ "for",
356
+ "with",
357
+ "from",
358
+ "this",
359
+ "that",
360
+ "into",
361
+ "when",
362
+ "then",
363
+ "than",
364
+ "given",
365
+ "should",
366
+ "becomes",
367
+ "include",
368
+ "exists",
369
+ "true",
370
+ "false",
371
+ "not",
372
+ "yes",
373
+ "no",
374
+ "are",
375
+ "was",
376
+ "but",
377
+ "out",
378
+ "all",
379
+ "any",
380
+ "use",
381
+ "via",
382
+ "etc",
383
+ "issue",
384
+ "code",
385
+ "line",
386
+ "ok",
387
+ ]);
388
+ function matchesBehaviorSite(line, symbols, keywords) {
389
+ if (!line.trim())
390
+ return false;
391
+ for (const sym of symbols) {
392
+ if (line.includes(sym))
393
+ return true;
394
+ }
395
+ let count = 0;
396
+ for (const kw of keywords) {
397
+ if (KEYWORD_REGEXES[kw].test(line)) {
398
+ count++;
399
+ if (count >= 2)
400
+ return true;
401
+ }
402
+ }
403
+ return false;
404
+ }
405
+ function matchesAnyTerm(line, terms) {
406
+ if (!line.trim())
407
+ return false;
408
+ for (const t of terms) {
409
+ if (!t)
410
+ continue;
411
+ if (new RegExp(`\\b${escapeRegex(t)}\\b`, "i").test(line))
412
+ return true;
413
+ }
414
+ return false;
415
+ }
416
+ function escapeRegex(s) {
417
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
418
+ }
419
+ function deriveInverseTerms(keywords) {
420
+ const out = new Set();
421
+ for (const k of keywords) {
422
+ for (const inv of INVERSE_KEYWORDS[k] ?? []) {
423
+ // Defense-in-depth: drop any inverse term that's a common English
424
+ // word, even if a future tuner adds it to INVERSE_KEYWORDS. Common
425
+ // words produce false-positive survivors on every prose line.
426
+ if (COMMON_WORDS.has(inv.toLowerCase()))
427
+ continue;
428
+ out.add(inv);
429
+ }
430
+ }
431
+ return [...out];
432
+ }
433
+ function safeReadFile(path) {
434
+ try {
435
+ return readFileSync(path, "utf8");
436
+ }
437
+ catch {
438
+ return null;
439
+ }
440
+ }
441
+ function* walkFiles(dir) {
442
+ let entries;
443
+ try {
444
+ entries = readdirSync(dir);
445
+ }
446
+ catch {
447
+ return;
448
+ }
449
+ for (const entry of entries) {
450
+ if (WALK_SKIP_DIRS.has(entry))
451
+ continue;
452
+ const full = join(dir, entry);
453
+ let stat;
454
+ try {
455
+ stat = statSync(full);
456
+ }
457
+ catch {
458
+ continue;
459
+ }
460
+ if (stat.isDirectory()) {
461
+ yield* walkFiles(full);
462
+ }
463
+ else if (stat.isFile()) {
464
+ yield full;
465
+ }
466
+ }
467
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Public surface for the issue-level concurrency lock (#625).
3
+ */
4
+ export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, } from "./lock-manager.js";
5
+ export type { LockManagerOptions } from "./lock-manager.js";
6
+ export { DEFAULT_LOCKS_DIR, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
7
+ export type { AcquireResult, LockFile, LockListing, SignalOtherResult, SignalReason, } from "./types.js";
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Public surface for the issue-level concurrency lock (#625).
3
+ */
4
+ export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, } from "./lock-manager.js";
5
+ export { DEFAULT_LOCKS_DIR, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
@@ -0,0 +1,168 @@
1
+ /**
2
+ * LockManager — per-issue filesystem lock to prevent concurrent sequant
3
+ * sessions from targeting the same issue (#625).
4
+ *
5
+ * Each lock is a single file at `<locksDir>/<issue>.lock`, claimed via
6
+ * `open(O_CREAT|O_EXCL)`. A separate file (rather than a field inside
7
+ * `state.json`) keeps acquisition atomic — no read-modify-write race.
8
+ *
9
+ * Stale detection (in order):
10
+ * 1. `hostname === os.hostname()`: check `process.kill(pid, 0)`.
11
+ * Not alive → cleared.
12
+ * 2. Cross-host: PID check is meaningless. Use age only.
13
+ * 3. Age fallback (any host): `startedAt > staleAgeMs ago` → cleared.
14
+ *
15
+ * MCP / orchestrator mode: when `SEQUANT_ORCHESTRATOR` is set, every public
16
+ * method is a no-op (no fs touches, no warnings). Mirrors the
17
+ * `OrchestratorRenderer` pattern at `src/lib/cli-ui/run-renderer.ts:244`.
18
+ */
19
+ import { type AcquireResult, type LockFile, type LockListing, type SignalOtherResult } from "./types.js";
20
+ export interface LockManagerOptions {
21
+ /** Directory holding `<issue>.lock` files (default: `.sequant/locks`). */
22
+ locksDir?: string;
23
+ /**
24
+ * Age cutoff (ms) before a cross-host lock is considered stale by time.
25
+ * Default 2h. Does NOT apply to skill-shell locks — see `skillLockTtlMs`.
26
+ */
27
+ staleAgeMs?: number;
28
+ /**
29
+ * Age cutoff (ms) for skill-shell locks (`skipPidCheck: true`). Default 6h.
30
+ * Longer than `staleAgeMs` because skill shells can't refresh PID liveness;
31
+ * the lock has to bridge long /fullsolve runs with multi-iteration QA loops.
32
+ */
33
+ skillLockTtlMs?: number;
34
+ /** Override for orchestrator detection (test seam). */
35
+ orchestratorMode?: boolean;
36
+ /** Override for `os.hostname()` (test seam). */
37
+ hostname?: string;
38
+ /** Override for current process PID (test seam). */
39
+ pid?: number;
40
+ /** Predicate: is PID alive on this host? (test seam) */
41
+ isPidAlive?: (pid: number) => boolean;
42
+ /** Clock (ms since epoch). Test seam. */
43
+ now?: () => number;
44
+ }
45
+ /** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
46
+ export declare function isOrchestratorMode(): boolean;
47
+ /** Resolve the locks directory honoring `SEQUANT_LOCKS_DIR` for test isolation. */
48
+ export declare function resolveLocksDir(explicit?: string): string;
49
+ /**
50
+ * Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
51
+ * skill-shell lock TTL. Returns `null` when unset or unparseable so the
52
+ * caller can fall back to the constructor option / default.
53
+ */
54
+ export declare function resolveSkillLockTtlMs(): number | null;
55
+ /** Default same-host PID check. `process.kill(pid, 0)` throws if not alive. */
56
+ export declare function defaultIsPidAlive(pid: number): boolean;
57
+ /** Build the canonical "issue is in use" error message (AC: error format). */
58
+ export declare function formatLockedMessage(issue: number, holder: LockFile): string;
59
+ /**
60
+ * Decide whether a lock should be treated as stale.
61
+ * Pure function: no I/O. Returns `null` if the lock is fresh.
62
+ */
63
+ export declare function classifyStaleness(args: {
64
+ holder: LockFile;
65
+ myHostname: string;
66
+ now: number;
67
+ staleAgeMs: number;
68
+ /** TTL for skill-shell (skipPidCheck) locks; falls back to staleAgeMs. */
69
+ skillLockTtlMs?: number;
70
+ isPidAlive: (pid: number) => boolean;
71
+ }): "pid-dead" | "age-exceeded" | null;
72
+ export declare class LockManager {
73
+ private readonly locksDir;
74
+ private readonly staleAgeMs;
75
+ private readonly skillLockTtlMs;
76
+ private readonly orchestratorMode;
77
+ private readonly hostname;
78
+ private readonly pid;
79
+ private readonly isPidAlive;
80
+ private readonly now;
81
+ /** Issues this instance has claimed and not yet released. */
82
+ private readonly held;
83
+ constructor(options?: LockManagerOptions);
84
+ /** True if all operations are no-ops (orchestrator/MCP mode). */
85
+ get isNoop(): boolean;
86
+ /** Absolute path to the locks directory. */
87
+ getLocksDir(): string;
88
+ /** Path to the lock file for a given issue. */
89
+ lockPathFor(issue: number): string;
90
+ /**
91
+ * Try to acquire the lock for `issue`. Returns a discriminated union.
92
+ *
93
+ * Behavior:
94
+ * - Same-host stale (PID dead): silently cleared, then acquired.
95
+ * - Cross-host within age window: blocked.
96
+ * - Cross-host beyond `staleAgeMs`: silently cleared, then acquired.
97
+ * - Orchestrator mode: returns `{ acquired: true, lockPath: '' }` no-op.
98
+ *
99
+ * `options.skipPidCheck` marks the lock so future stale checks skip the
100
+ * same-host PID probe and fall back to age-only — used for skill shells
101
+ * whose Node PID dies between acquire and release.
102
+ */
103
+ acquire(issue: number, command: string, options?: {
104
+ skipPidCheck?: boolean;
105
+ }): AcquireResult;
106
+ /**
107
+ * Take over the lock unconditionally (writes a new lock). Used by --force.
108
+ * Does NOT signal the prior PID — caller invokes `signal()` separately
109
+ * to opt in to that behavior (AC: --force does NOT signal).
110
+ */
111
+ forceAcquire(issue: number, command: string, options?: {
112
+ skipPidCheck?: boolean;
113
+ }): {
114
+ lockPath: string;
115
+ previous: LockFile | null;
116
+ };
117
+ /**
118
+ * SIGTERM the prior PID iff it is alive on this host. The `reason` discriminator
119
+ * lets callers produce accurate log lines for each refusal branch (#637).
120
+ * No-op in orchestrator mode or for cross-host holders.
121
+ */
122
+ signalOther(holder: LockFile, signal?: NodeJS.Signals): SignalOtherResult;
123
+ /**
124
+ * Release the lock for `issue` if this process is its holder.
125
+ * Safe to call repeatedly; safe to call when no lock exists.
126
+ */
127
+ release(issue: number): void;
128
+ /**
129
+ * Release a lock claimed by a previous, now-dead, short-lived process on
130
+ * the same host — the skill-shell pattern (`skipPidCheck: true`). Used by
131
+ * `sequant locks release` to let skills hand back ownership. Returns
132
+ * `true` when a lock was removed.
133
+ */
134
+ releaseExternal(issue: number): boolean;
135
+ /** Release every lock this instance holds. */
136
+ releaseAll(): void;
137
+ /**
138
+ * Read the holder for `issue` without acquiring. Returns null when missing
139
+ * or unparseable. Used by read-only commands (`status`, `merge`, `assess`).
140
+ */
141
+ check(issue: number): LockFile | null;
142
+ /** List every active lock with computed staleness metadata. */
143
+ list(): LockListing[];
144
+ /**
145
+ * Manually clear a lock. Used by `sequant locks clear`. Returns true if a
146
+ * lock was removed. With `safetyCheck` (default), refuses to clear a
147
+ * fresh same-host lock whose PID is alive — the caller should use
148
+ * `--force` semantics for that.
149
+ */
150
+ clearLock(issue: number, options?: {
151
+ safetyCheck?: boolean;
152
+ }): {
153
+ cleared: boolean;
154
+ reason: string;
155
+ };
156
+ private ensureLocksDir;
157
+ /**
158
+ * Write a new lock atomically using `O_CREAT | O_EXCL`. Races safely:
159
+ * if another process wins, returns `{ acquired: false }` with the winner.
160
+ */
161
+ private writeAtomic;
162
+ private readLockSafe;
163
+ private unlinkSafe;
164
+ /** True iff the lock file at `path` is missing (test helper). */
165
+ static missing(path: string): boolean;
166
+ /** Stat helper for tests — returns mtime or null. */
167
+ static mtime(path: string): Date | null;
168
+ }