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,112 @@
1
+ /**
2
+ * Virtual-terminal harness for renderer regression tests (#647).
3
+ *
4
+ * The test stub embedded in TTYRenderer (see {@link
5
+ * ./run-renderer.ts#TTYTestStub}) mocks `log-update` itself — it cannot reveal
6
+ * whether the real `log-update` actually erases prior frames once the terminal
7
+ * scrolls. That gap is what allowed #624's fix to ship green while the
8
+ * underlying duplicate-header bug remained.
9
+ *
10
+ * This harness models a real terminal:
11
+ * - bounded visible viewport (rows × cols)
12
+ * - unbounded scrollback that captures every line that scrolls off the top
13
+ * - the ANSI escape vocabulary that `log-update@7` + `ansi-escapes`
14
+ * actually emit (cursor up/down/forward/back, eraseLine variants,
15
+ * SGR colour stripping, private mode set/reset, save/restore)
16
+ *
17
+ * With it, a test can wire the production renderer through a real
18
+ * `createLogUpdate` instance, replay an event sequence, and assert on
19
+ * `(visible + scrollback)` to catch any duplicate-header rendering — the
20
+ * exact regression #647 was opened for.
21
+ */
22
+ import { createLogUpdate } from "log-update";
23
+ export interface VirtualTerminalOptions {
24
+ rows: number;
25
+ cols: number;
26
+ /** Newline mode. POSIX shells default to ONLCR which translates `\n` to
27
+ * `\r\n`, so most apps see "move down + col 0". Default true. */
28
+ onlcr?: boolean;
29
+ }
30
+ /**
31
+ * Minimal vt100 model: visible grid + scrollback + cursor. Strips SGR colour
32
+ * codes (they're styling, not content) and ignores private-mode toggles
33
+ * (cursor hide/show). Implements the cursor and erase escapes that
34
+ * `log-update@7` actually emits.
35
+ */
36
+ export declare class VirtualTerminal {
37
+ readonly rows: number;
38
+ readonly cols: number;
39
+ private readonly onlcr;
40
+ /** visible[row][col] = char (always single-codepoint slot). */
41
+ visible: string[][];
42
+ /** Scrollback grows oldest-first as rows shift off the top. */
43
+ scrollback: string[];
44
+ cursorRow: number;
45
+ cursorCol: number;
46
+ constructor(opts: VirtualTerminalOptions);
47
+ write(text: string): void;
48
+ /** Visible viewport as a list of trimmed-right rows. */
49
+ getVisibleLines(): string[];
50
+ /** Single multi-line string of (scrollback + visible). */
51
+ getAllText(): string;
52
+ /** Match count of the regex against (scrollback + visible). */
53
+ countOccurrences(pattern: RegExp): number;
54
+ private putChar;
55
+ private linefeed;
56
+ /** Returns the index AFTER the consumed escape sequence. */
57
+ private handleEscape;
58
+ private handleCSI;
59
+ private executeCSI;
60
+ private eraseLine;
61
+ private eraseFromCursorToEndOfLine;
62
+ private eraseFromStartOfLineToCursor;
63
+ private eraseFromCursorToEndOfScreen;
64
+ private eraseFromStartOfScreenToCursor;
65
+ private eraseScreen;
66
+ }
67
+ /**
68
+ * Bundle a VirtualTerminal with a real `log-update` instance writing into it
69
+ * and a matching `stdoutWrite` for renderer event-line writes. Both paths hit
70
+ * the same VT, mirroring real-terminal interleaving.
71
+ *
72
+ * Production runs frequently hit a width/height mismatch between what
73
+ * `log-update` reads from `process.stdout` and what the real terminal actually
74
+ * uses (e.g. `process.stdout.columns` is undefined under `npx` so log-update
75
+ * falls back to 80 while the terminal is 200 cols). Those mismatches cause
76
+ * `previousLineCount` to under- or over-count the rows log-update actually
77
+ * wrote, breaking `eraseLines` and leaving stale rows in scrollback. The
78
+ * `streamColumns` / `streamRows` overrides let tests reproduce this without
79
+ * needing a real PTY.
80
+ */
81
+ export interface TerminalHarness {
82
+ vt: VirtualTerminal;
83
+ logUpdate: ReturnType<typeof createLogUpdate>;
84
+ stdoutWrite: (s: string) => void;
85
+ /**
86
+ * Out-of-band write that lands in the same VT as `logUpdate` and
87
+ * `stdoutWrite` — mirrors how a real pty merges stderr writes with stdout
88
+ * when both descriptors point at the same terminal. log-update has no
89
+ * knowledge of these writes, so they advance the cursor in ways
90
+ * `previousLineCount` cannot account for. Use this to reproduce the
91
+ * Mechanism #2-class bug (out-of-band writes break log-update's cursor
92
+ * model) that #647 AC-1 capture diagnosed.
93
+ */
94
+ stderrWrite: (s: string) => void;
95
+ }
96
+ export interface HarnessOptions extends VirtualTerminalOptions {
97
+ /**
98
+ * Width log-update is told about via `stream.columns`. Defaults to
99
+ * `opts.cols` (matched terminal). Override to simulate a mismatch where
100
+ * log-update wraps at one width but the real terminal wraps at another.
101
+ */
102
+ streamColumns?: number;
103
+ /**
104
+ * Height log-update is told about via `stream.rows`. Defaults to
105
+ * `opts.rows`. Override to simulate `process.stdout.rows = undefined`
106
+ * (the `npx` symptom): pass `undefined` explicitly via the harness's stream
107
+ * by setting this to a non-positive number — log-update then falls through
108
+ * to its internal `defaultHeight ?? 24`.
109
+ */
110
+ streamRows?: number;
111
+ }
112
+ export declare function createTerminalHarness(opts: HarnessOptions): TerminalHarness;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Virtual-terminal harness for renderer regression tests (#647).
3
+ *
4
+ * The test stub embedded in TTYRenderer (see {@link
5
+ * ./run-renderer.ts#TTYTestStub}) mocks `log-update` itself — it cannot reveal
6
+ * whether the real `log-update` actually erases prior frames once the terminal
7
+ * scrolls. That gap is what allowed #624's fix to ship green while the
8
+ * underlying duplicate-header bug remained.
9
+ *
10
+ * This harness models a real terminal:
11
+ * - bounded visible viewport (rows × cols)
12
+ * - unbounded scrollback that captures every line that scrolls off the top
13
+ * - the ANSI escape vocabulary that `log-update@7` + `ansi-escapes`
14
+ * actually emit (cursor up/down/forward/back, eraseLine variants,
15
+ * SGR colour stripping, private mode set/reset, save/restore)
16
+ *
17
+ * With it, a test can wire the production renderer through a real
18
+ * `createLogUpdate` instance, replay an event sequence, and assert on
19
+ * `(visible + scrollback)` to catch any duplicate-header rendering — the
20
+ * exact regression #647 was opened for.
21
+ */
22
+ import { createLogUpdate } from "log-update";
23
+ const ESC = "";
24
+ /**
25
+ * Minimal vt100 model: visible grid + scrollback + cursor. Strips SGR colour
26
+ * codes (they're styling, not content) and ignores private-mode toggles
27
+ * (cursor hide/show). Implements the cursor and erase escapes that
28
+ * `log-update@7` actually emits.
29
+ */
30
+ export class VirtualTerminal {
31
+ rows;
32
+ cols;
33
+ onlcr;
34
+ /** visible[row][col] = char (always single-codepoint slot). */
35
+ visible;
36
+ /** Scrollback grows oldest-first as rows shift off the top. */
37
+ scrollback = [];
38
+ cursorRow = 0;
39
+ cursorCol = 0;
40
+ constructor(opts) {
41
+ this.rows = opts.rows;
42
+ this.cols = opts.cols;
43
+ this.onlcr = opts.onlcr ?? true;
44
+ this.visible = Array.from({ length: this.rows }, () => Array(this.cols).fill(" "));
45
+ }
46
+ // ---------------------------------------------------------------- input
47
+ write(text) {
48
+ let i = 0;
49
+ while (i < text.length) {
50
+ const ch = text[i];
51
+ if (ch === ESC) {
52
+ i = this.handleEscape(text, i);
53
+ continue;
54
+ }
55
+ if (ch === "\n") {
56
+ this.linefeed();
57
+ i++;
58
+ continue;
59
+ }
60
+ if (ch === "\r") {
61
+ this.cursorCol = 0;
62
+ i++;
63
+ continue;
64
+ }
65
+ if (ch === "\b") {
66
+ if (this.cursorCol > 0)
67
+ this.cursorCol--;
68
+ i++;
69
+ continue;
70
+ }
71
+ this.putChar(ch);
72
+ i++;
73
+ }
74
+ }
75
+ // ---------------------------------------------------------------- output
76
+ /** Visible viewport as a list of trimmed-right rows. */
77
+ getVisibleLines() {
78
+ return this.visible.map((row) => row.join("").replace(/\s+$/, ""));
79
+ }
80
+ /** Single multi-line string of (scrollback + visible). */
81
+ getAllText() {
82
+ const visibleText = this.getVisibleLines().join("\n");
83
+ if (this.scrollback.length === 0)
84
+ return visibleText;
85
+ return this.scrollback.join("\n") + "\n" + visibleText;
86
+ }
87
+ /** Match count of the regex against (scrollback + visible). */
88
+ countOccurrences(pattern) {
89
+ const text = this.getAllText();
90
+ const flags = pattern.flags.includes("g")
91
+ ? pattern.flags
92
+ : pattern.flags + "g";
93
+ const globalPattern = new RegExp(pattern.source, flags);
94
+ const matches = text.match(globalPattern);
95
+ return matches?.length ?? 0;
96
+ }
97
+ // ------------------------------------------------------- internal: text
98
+ putChar(ch) {
99
+ if (this.cursorCol >= this.cols) {
100
+ // Auto-wrap into the next row. Most terminals do this; log-update wraps
101
+ // upstream so this rarely triggers in practice.
102
+ this.cursorCol = 0;
103
+ this.linefeed();
104
+ }
105
+ this.visible[this.cursorRow][this.cursorCol] = ch;
106
+ this.cursorCol++;
107
+ }
108
+ linefeed() {
109
+ if (this.cursorRow + 1 < this.rows) {
110
+ this.cursorRow++;
111
+ }
112
+ else {
113
+ // Bottom of viewport: scroll the top row into scrollback.
114
+ const top = this.visible.shift();
115
+ this.scrollback.push(top.join("").replace(/\s+$/, ""));
116
+ this.visible.push(Array(this.cols).fill(" "));
117
+ // Cursor stays clamped at last visible row.
118
+ }
119
+ if (this.onlcr)
120
+ this.cursorCol = 0;
121
+ }
122
+ // --------------------------------------------------- internal: escapes
123
+ /** Returns the index AFTER the consumed escape sequence. */
124
+ handleEscape(text, start) {
125
+ // Bare ESC at end → consume.
126
+ if (start + 1 >= text.length)
127
+ return text.length;
128
+ const next = text[start + 1];
129
+ // CSI: ESC [ ... <final>
130
+ if (next === "[") {
131
+ return this.handleCSI(text, start + 2);
132
+ }
133
+ // OSC: ESC ] ... BEL or ESC \
134
+ if (next === "]") {
135
+ let i = start + 2;
136
+ while (i < text.length) {
137
+ if (text[i] === "")
138
+ return i + 1;
139
+ if (text[i] === ESC && text[i + 1] === "\\")
140
+ return i + 2;
141
+ i++;
142
+ }
143
+ return text.length;
144
+ }
145
+ // 2-byte non-CSI escapes: ESC 7 / ESC 8 (save/restore — cursor only,
146
+ // safe to ignore for our uses).
147
+ return start + 2;
148
+ }
149
+ handleCSI(text, start) {
150
+ let i = start;
151
+ let isPrivate = false;
152
+ if (text[i] === "?" || text[i] === ">" || text[i] === "<") {
153
+ isPrivate = true;
154
+ i++;
155
+ }
156
+ let params = "";
157
+ while (i < text.length && /[0-9;]/.test(text[i])) {
158
+ params += text[i];
159
+ i++;
160
+ }
161
+ if (i >= text.length)
162
+ return text.length;
163
+ const final = text[i];
164
+ i++;
165
+ this.executeCSI(params, final, isPrivate);
166
+ return i;
167
+ }
168
+ executeCSI(params, final, isPrivate) {
169
+ const parts = params.length === 0 ? [] : params.split(";").map((p) => parseInt(p, 10));
170
+ const n = (idx, def) => {
171
+ const v = parts[idx];
172
+ return v === undefined || isNaN(v) ? def : v;
173
+ };
174
+ // Private modes (e.g. ?25l/?25h cursor hide/show) — ignore.
175
+ if (isPrivate)
176
+ return;
177
+ switch (final) {
178
+ case "A": // cursor up
179
+ this.cursorRow = Math.max(0, this.cursorRow - n(0, 1));
180
+ return;
181
+ case "B": // cursor down (no scroll)
182
+ this.cursorRow = Math.min(this.rows - 1, this.cursorRow + n(0, 1));
183
+ return;
184
+ case "C": // cursor forward
185
+ this.cursorCol = Math.min(this.cols - 1, this.cursorCol + n(0, 1));
186
+ return;
187
+ case "D": // cursor back
188
+ this.cursorCol = Math.max(0, this.cursorCol - n(0, 1));
189
+ return;
190
+ case "E": // cursor next line
191
+ this.cursorRow = Math.min(this.rows - 1, this.cursorRow + n(0, 1));
192
+ this.cursorCol = 0;
193
+ return;
194
+ case "F": // cursor prev line
195
+ this.cursorRow = Math.max(0, this.cursorRow - n(0, 1));
196
+ this.cursorCol = 0;
197
+ return;
198
+ case "G": // cursor absolute column (1-based)
199
+ this.cursorCol = Math.min(this.cols - 1, Math.max(0, n(0, 1) - 1));
200
+ return;
201
+ case "H": // cursor position (1-based row;col)
202
+ case "f":
203
+ this.cursorRow = Math.min(this.rows - 1, Math.max(0, n(0, 1) - 1));
204
+ this.cursorCol = Math.min(this.cols - 1, Math.max(0, n(1, 1) - 1));
205
+ return;
206
+ case "J": {
207
+ // erase in display
208
+ const mode = n(0, 0);
209
+ if (mode === 0)
210
+ this.eraseFromCursorToEndOfScreen();
211
+ else if (mode === 1)
212
+ this.eraseFromStartOfScreenToCursor();
213
+ else if (mode === 2 || mode === 3)
214
+ this.eraseScreen();
215
+ return;
216
+ }
217
+ case "K": {
218
+ // erase in line
219
+ const mode = n(0, 0);
220
+ if (mode === 0)
221
+ this.eraseFromCursorToEndOfLine();
222
+ else if (mode === 1)
223
+ this.eraseFromStartOfLineToCursor();
224
+ else if (mode === 2)
225
+ this.eraseLine();
226
+ return;
227
+ }
228
+ case "S": // scroll up
229
+ case "T": // scroll down
230
+ case "m": // SGR colour — ignore (we don't model styling)
231
+ case "s": // save cursor
232
+ case "u": // restore cursor
233
+ case "n": // device status report — ignore
234
+ case "h": // set mode — ignore
235
+ case "l": // reset mode — ignore
236
+ return;
237
+ }
238
+ }
239
+ eraseLine() {
240
+ for (let c = 0; c < this.cols; c++)
241
+ this.visible[this.cursorRow][c] = " ";
242
+ }
243
+ eraseFromCursorToEndOfLine() {
244
+ for (let c = this.cursorCol; c < this.cols; c++)
245
+ this.visible[this.cursorRow][c] = " ";
246
+ }
247
+ eraseFromStartOfLineToCursor() {
248
+ for (let c = 0; c <= this.cursorCol; c++)
249
+ this.visible[this.cursorRow][c] = " ";
250
+ }
251
+ eraseFromCursorToEndOfScreen() {
252
+ this.eraseFromCursorToEndOfLine();
253
+ for (let r = this.cursorRow + 1; r < this.rows; r++) {
254
+ for (let c = 0; c < this.cols; c++)
255
+ this.visible[r][c] = " ";
256
+ }
257
+ }
258
+ eraseFromStartOfScreenToCursor() {
259
+ for (let r = 0; r < this.cursorRow; r++) {
260
+ for (let c = 0; c < this.cols; c++)
261
+ this.visible[r][c] = " ";
262
+ }
263
+ this.eraseFromStartOfLineToCursor();
264
+ }
265
+ eraseScreen() {
266
+ for (let r = 0; r < this.rows; r++) {
267
+ for (let c = 0; c < this.cols; c++)
268
+ this.visible[r][c] = " ";
269
+ }
270
+ }
271
+ }
272
+ export function createTerminalHarness(opts) {
273
+ const vt = new VirtualTerminal(opts);
274
+ const stream = {
275
+ write: (chunk) => {
276
+ vt.write(chunk);
277
+ return true;
278
+ },
279
+ columns: opts.streamColumns ?? opts.cols,
280
+ rows: opts.streamRows ?? opts.rows,
281
+ isTTY: true,
282
+ };
283
+ // log-update reads `stream.columns` / `stream.rows` defensively; the cast is
284
+ // safe because we exercise only those fields plus `write`.
285
+ const lu = createLogUpdate(stream, {
286
+ showCursor: true,
287
+ });
288
+ return {
289
+ vt,
290
+ logUpdate: lu,
291
+ stdoutWrite: (s) => vt.write(s),
292
+ stderrWrite: (s) => vt.write(s),
293
+ };
294
+ }
@@ -0,0 +1,94 @@
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 type { AcceptanceCriterion } from "../workflow/state-schema.js";
35
+ /**
36
+ * Behavior keywords whose presence (≥2 distinct, OR matching the explicit
37
+ * pattern below) signals an AC describes a rule rather than a localized fix.
38
+ * Tunable here; cited from `references/behavior-rule-detection.md`.
39
+ */
40
+ export declare const BEHAVIOR_KEYWORDS: readonly ["default", "always", "never", "rule", "behavior", "skip"];
41
+ export type BehaviorKeyword = (typeof BEHAVIOR_KEYWORDS)[number];
42
+ /** A single touchpoint hit (file location matching a behavior-rule symbol). */
43
+ export interface TouchpointHit {
44
+ path: string;
45
+ line: number;
46
+ snippet: string;
47
+ }
48
+ export interface BehaviorRuleDetection {
49
+ triggered: boolean;
50
+ keywords: BehaviorKeyword[];
51
+ matchedPattern?: string;
52
+ }
53
+ /**
54
+ * Detect whether an AC describes a behavior rule.
55
+ *
56
+ * Trigger conditions:
57
+ * 1. ≥2 distinct {@link BEHAVIOR_KEYWORDS} present in the AC description
58
+ * (case-insensitive, word-boundary match), OR
59
+ * 2. Description matches one of the {@link EXPLICIT_PATTERNS}
60
+ * (e.g. "always X unless Y").
61
+ *
62
+ * Returns `triggered: false` for empty or undefined descriptions, single
63
+ * keyword matches without an explicit pattern, and file-specific ACs
64
+ * ("Update line 42 of foo.ts").
65
+ */
66
+ export declare function detectBehaviorRule(ac: AcceptanceCriterion): BehaviorRuleDetection;
67
+ /**
68
+ * Find touchpoints in the codebase that likely implement the behavior rule
69
+ * described by `ac`. Returns `[]` when {@link detectBehaviorRule} does not
70
+ * trigger (cheap short-circuit per the /spec performance budget).
71
+ *
72
+ * Heuristic:
73
+ * - Extract identifier-like symbols from the AC (backticked strings, file
74
+ * paths with extensions, ALL_CAPS / camelCase / kebab-case identifiers).
75
+ * - Walk {@link TOUCHPOINT_ROOTS}; for each line in matching files, mark a
76
+ * hit if the line contains any extracted symbol OR ≥2 distinct AC
77
+ * behavior keywords.
78
+ * - Hits are deduplicated by `path:line` and capped (per-file: 3, total: 200)
79
+ * to keep `/spec` output readable (callers can re-run with a tighter scope
80
+ * if needed).
81
+ */
82
+ export declare function findTouchpoints(ac: AcceptanceCriterion, repoRoot: string): TouchpointHit[];
83
+ /**
84
+ * Find OLD-rule survivors inside the diff blast radius. Used by `/qa` to flag
85
+ * an AC `NOT_MET` when the inverse of the asserted rule still has live code.
86
+ *
87
+ * Differs from {@link findTouchpoints}:
88
+ * - Scope is `diffPaths` (caller is responsible for pre-expanding to 1-hop
89
+ * importers when desired — this avoids embedding a TS-only importer scanner
90
+ * here and keeps the function language-agnostic).
91
+ * - Search terms are *inverse* keywords derived from the AC's keywords (and
92
+ * inverse English phrasing as a fallback when no symbols match).
93
+ */
94
+ export declare function findSurvivingInverseSymbols(ac: AcceptanceCriterion, repoRoot: string, diffPaths: string[]): TouchpointHit[];