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,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
|
+
}
|