speci 0.1.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/README.md +523 -0
- package/bin/speci.ts +228 -0
- package/lib/commands/init.ts +299 -0
- package/lib/commands/monitor.ts +579 -0
- package/lib/commands/plan.ts +112 -0
- package/lib/commands/refactor.ts +157 -0
- package/lib/commands/run.ts +531 -0
- package/lib/commands/status.ts +209 -0
- package/lib/commands/task.ts +133 -0
- package/lib/config.ts +644 -0
- package/lib/copilot.ts +229 -0
- package/lib/errors.ts +166 -0
- package/lib/state.ts +148 -0
- package/lib/ui/banner.ts +109 -0
- package/lib/ui/box.ts +161 -0
- package/lib/ui/colors.ts +110 -0
- package/lib/ui/glyphs.ts +91 -0
- package/lib/ui/palette.ts +118 -0
- package/lib/ui/terminal.ts +118 -0
- package/lib/utils/atomic-write.ts +147 -0
- package/lib/utils/gate.ts +197 -0
- package/lib/utils/i18n.ts +92 -0
- package/lib/utils/lock.ts +189 -0
- package/lib/utils/logger.ts +143 -0
- package/lib/utils/preflight.ts +236 -0
- package/lib/utils/process.ts +127 -0
- package/lib/utils/signals.ts +145 -0
- package/lib/utils/suggest.ts +71 -0
- package/package.json +38 -0
- package/templates/agents/speci-fix.md +107 -0
- package/templates/agents/speci-impl.md +152 -0
- package/templates/agents/speci-plan.md +771 -0
- package/templates/agents/speci-refactor.md +652 -0
- package/templates/agents/speci-review.md +169 -0
- package/templates/agents/speci-task.md +369 -0
- package/templates/agents/speci-tidy.md +84 -0
- package/templates/agents/subagents/final_reviewer.prompt.md +143 -0
- package/templates/agents/subagents/mvt_generator.prompt.md +171 -0
- package/templates/agents/subagents/plan_codebase_context.prompt.md +29 -0
- package/templates/agents/subagents/plan_initial_planner.prompt.md +31 -0
- package/templates/agents/subagents/plan_refine_architecture.prompt.md +21 -0
- package/templates/agents/subagents/plan_refine_dataflow.prompt.md +23 -0
- package/templates/agents/subagents/plan_refine_edgecases.prompt.md +23 -0
- package/templates/agents/subagents/plan_refine_errors.prompt.md +22 -0
- package/templates/agents/subagents/plan_refine_final.prompt.md +25 -0
- package/templates/agents/subagents/plan_refine_integration.prompt.md +22 -0
- package/templates/agents/subagents/plan_refine_performance.prompt.md +23 -0
- package/templates/agents/subagents/plan_refine_requirements.prompt.md +16 -0
- package/templates/agents/subagents/plan_refine_security.prompt.md +22 -0
- package/templates/agents/subagents/plan_refine_testing.prompt.md +21 -0
- package/templates/agents/subagents/plan_requirements_deep_dive.prompt.md +30 -0
- package/templates/agents/subagents/progress_generator.prompt.md +178 -0
- package/templates/agents/subagents/refactor_analyze_crosscutting.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_duplication.prompt.md +65 -0
- package/templates/agents/subagents/refactor_analyze_errors.prompt.md +65 -0
- package/templates/agents/subagents/refactor_analyze_functions.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_naming.prompt.md +65 -0
- package/templates/agents/subagents/refactor_analyze_performance.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_state.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_structure.prompt.md +64 -0
- package/templates/agents/subagents/refactor_analyze_testing.prompt.md +66 -0
- package/templates/agents/subagents/refactor_analyze_types.prompt.md +66 -0
- package/templates/agents/subagents/refactor_review_completeness.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_final.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_risks.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_roadmap.prompt.md +63 -0
- package/templates/agents/subagents/refactor_review_technical.prompt.md +63 -0
- package/templates/agents/subagents/task_generator.prompt.md +145 -0
- package/templates/agents/subagents/task_reviewer.prompt.md +85 -0
- package/templates/speci.config.json +36 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monitor Command (TUI) Implementation
|
|
3
|
+
*
|
|
4
|
+
* Provides a real-time Terminal User Interface for viewing Speci log output.
|
|
5
|
+
* Runs in alternate screen buffer with keyboard navigation and auto-scrolling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createReadStream, statSync, existsSync } from 'node:fs';
|
|
9
|
+
import { basename } from 'node:path';
|
|
10
|
+
import { loadConfig } from '../config.js';
|
|
11
|
+
import { getLockInfo } from '../utils/lock.js';
|
|
12
|
+
import { colorize, visibleLength } from '../ui/colors.js';
|
|
13
|
+
import { terminalState } from '../ui/terminal.js';
|
|
14
|
+
import {
|
|
15
|
+
installSignalHandlers,
|
|
16
|
+
registerCleanup,
|
|
17
|
+
removeSignalHandlers,
|
|
18
|
+
} from '../utils/signals.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Monitor command options
|
|
22
|
+
*/
|
|
23
|
+
export interface MonitorOptions {
|
|
24
|
+
logFile?: string;
|
|
25
|
+
pollInterval?: number;
|
|
26
|
+
maxLines?: number;
|
|
27
|
+
verbose?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* MonitorUI class - handles TUI rendering and interaction
|
|
32
|
+
*/
|
|
33
|
+
class MonitorUI {
|
|
34
|
+
private lines: string[] = [];
|
|
35
|
+
private scrollOffset = 0;
|
|
36
|
+
private autoScroll = true;
|
|
37
|
+
private lastSize = 0;
|
|
38
|
+
private lastMtime = 0;
|
|
39
|
+
private logPath: string;
|
|
40
|
+
private maxLines: number;
|
|
41
|
+
private pollInterval: number;
|
|
42
|
+
private running = false;
|
|
43
|
+
private pollTimer: NodeJS.Timeout | null = null;
|
|
44
|
+
private partialLine = '';
|
|
45
|
+
private verbose: boolean;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
logPath: string,
|
|
49
|
+
maxLines: number,
|
|
50
|
+
pollInterval: number,
|
|
51
|
+
verbose: boolean
|
|
52
|
+
) {
|
|
53
|
+
this.logPath = logPath;
|
|
54
|
+
this.maxLines = maxLines;
|
|
55
|
+
this.pollInterval = pollInterval;
|
|
56
|
+
this.verbose = verbose;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Start the monitor UI
|
|
61
|
+
*/
|
|
62
|
+
async start(): Promise<void> {
|
|
63
|
+
this.running = true;
|
|
64
|
+
|
|
65
|
+
// Enter TUI mode
|
|
66
|
+
this.enterTUIMode();
|
|
67
|
+
|
|
68
|
+
// Setup keyboard handler
|
|
69
|
+
this.setupKeyboardHandler();
|
|
70
|
+
|
|
71
|
+
// Initial file load
|
|
72
|
+
await this.loadInitialContent();
|
|
73
|
+
|
|
74
|
+
// Start polling loop
|
|
75
|
+
this.startPolling();
|
|
76
|
+
|
|
77
|
+
// Initial render
|
|
78
|
+
this.render();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Enter TUI mode - alternate screen, hidden cursor, raw input
|
|
83
|
+
*/
|
|
84
|
+
private enterTUIMode(): void {
|
|
85
|
+
// Enter alternate screen buffer
|
|
86
|
+
terminalState.enterAltScreen();
|
|
87
|
+
// Hide cursor
|
|
88
|
+
terminalState.hideCursor();
|
|
89
|
+
// Disable line wrap
|
|
90
|
+
terminalState.disableLineWrap();
|
|
91
|
+
// Enable raw mode
|
|
92
|
+
if (process.stdin.setRawMode) {
|
|
93
|
+
process.stdin.setRawMode(true);
|
|
94
|
+
}
|
|
95
|
+
process.stdin.resume();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Exit TUI mode - restore terminal state
|
|
100
|
+
*/
|
|
101
|
+
private exitTUIMode(): void {
|
|
102
|
+
// Terminal state restoration is handled by signal cleanup
|
|
103
|
+
// Just restore the captured state
|
|
104
|
+
terminalState.restore();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Setup keyboard event handler
|
|
109
|
+
*/
|
|
110
|
+
private setupKeyboardHandler(): void {
|
|
111
|
+
process.stdin.on('data', (data) => {
|
|
112
|
+
const key = data.toString();
|
|
113
|
+
|
|
114
|
+
switch (key) {
|
|
115
|
+
case 'q':
|
|
116
|
+
case '\x03': // Ctrl+C
|
|
117
|
+
this.exit();
|
|
118
|
+
break;
|
|
119
|
+
case 'k':
|
|
120
|
+
case '\x1b[A': // Up arrow
|
|
121
|
+
this.scrollUp(1);
|
|
122
|
+
break;
|
|
123
|
+
case 'j':
|
|
124
|
+
case '\x1b[B': // Down arrow
|
|
125
|
+
this.scrollDown(1);
|
|
126
|
+
break;
|
|
127
|
+
case 'u':
|
|
128
|
+
case '\x1b[5~': // Page Up
|
|
129
|
+
this.scrollUp(this.getPageSize());
|
|
130
|
+
break;
|
|
131
|
+
case 'd':
|
|
132
|
+
case '\x1b[6~': // Page Down
|
|
133
|
+
this.scrollDown(this.getPageSize());
|
|
134
|
+
break;
|
|
135
|
+
case 'g':
|
|
136
|
+
this.scrollToTop();
|
|
137
|
+
break;
|
|
138
|
+
case 'G':
|
|
139
|
+
this.scrollToBottom();
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Load initial log file content
|
|
147
|
+
*/
|
|
148
|
+
private async loadInitialContent(): Promise<void> {
|
|
149
|
+
if (!existsSync(this.logPath)) {
|
|
150
|
+
this.lines.push('Waiting for log file...');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const stats = statSync(this.logPath);
|
|
156
|
+
this.lastSize = stats.size;
|
|
157
|
+
this.lastMtime = stats.mtimeMs;
|
|
158
|
+
|
|
159
|
+
// Read entire file initially
|
|
160
|
+
const content = await this.readFile(0, stats.size);
|
|
161
|
+
const lines = content.split('\n');
|
|
162
|
+
|
|
163
|
+
// Add all complete lines
|
|
164
|
+
for (const line of lines) {
|
|
165
|
+
if (line.length > 0) {
|
|
166
|
+
this.lines.push(line);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Trim to max lines
|
|
171
|
+
this.trimToMaxLines();
|
|
172
|
+
|
|
173
|
+
// Start at bottom
|
|
174
|
+
this.scrollToBottom();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
this.lines.push(`Error loading log file: ${(err as Error).message}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Read file content from start to end position
|
|
182
|
+
*/
|
|
183
|
+
private async readFile(start: number, end: number): Promise<string> {
|
|
184
|
+
return new Promise((resolve, reject) => {
|
|
185
|
+
const stream = createReadStream(this.logPath, {
|
|
186
|
+
start,
|
|
187
|
+
end: end - 1,
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
let buffer = '';
|
|
192
|
+
stream.on('data', (chunk) => {
|
|
193
|
+
buffer += chunk;
|
|
194
|
+
});
|
|
195
|
+
stream.on('end', () => resolve(buffer));
|
|
196
|
+
stream.on('error', reject);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Start polling for file changes
|
|
202
|
+
*/
|
|
203
|
+
private startPolling(): void {
|
|
204
|
+
this.pollTimer = setInterval(async () => {
|
|
205
|
+
if (!this.running) return;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const stats = statSync(this.logPath);
|
|
209
|
+
const currentSize = stats.size;
|
|
210
|
+
const currentMtime = stats.mtimeMs;
|
|
211
|
+
|
|
212
|
+
if (currentSize !== this.lastSize || currentMtime !== this.lastMtime) {
|
|
213
|
+
await this.readNewContent(currentSize);
|
|
214
|
+
this.lastSize = currentSize;
|
|
215
|
+
this.lastMtime = currentMtime;
|
|
216
|
+
this.render();
|
|
217
|
+
}
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// File might not exist yet or be temporarily unavailable
|
|
220
|
+
if (this.verbose) {
|
|
221
|
+
this.logDebug(`Poll error: ${(err as Error).message}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}, this.pollInterval);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Read new content from log file
|
|
229
|
+
*/
|
|
230
|
+
private async readNewContent(currentSize: number): Promise<void> {
|
|
231
|
+
if (currentSize < this.lastSize) {
|
|
232
|
+
// File was truncated/rotated - read from beginning
|
|
233
|
+
this.lines = [];
|
|
234
|
+
this.lastSize = 0;
|
|
235
|
+
this.partialLine = '';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (currentSize === this.lastSize) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
// Read new bytes
|
|
244
|
+
const newContent = await this.readFile(this.lastSize, currentSize);
|
|
245
|
+
|
|
246
|
+
// Prepend any partial line from last read
|
|
247
|
+
const fullContent = this.partialLine + newContent;
|
|
248
|
+
|
|
249
|
+
// Split into lines
|
|
250
|
+
const lines = fullContent.split('\n');
|
|
251
|
+
|
|
252
|
+
// Handle last line (might be partial)
|
|
253
|
+
if (!fullContent.endsWith('\n') && lines.length > 0) {
|
|
254
|
+
// Last line is partial, keep it for next read
|
|
255
|
+
this.partialLine = lines.pop() || '';
|
|
256
|
+
} else {
|
|
257
|
+
this.partialLine = '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Add complete lines to buffer
|
|
261
|
+
for (const line of lines) {
|
|
262
|
+
if (line.length > 0) {
|
|
263
|
+
this.lines.push(line);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Trim to max lines
|
|
268
|
+
this.trimToMaxLines();
|
|
269
|
+
|
|
270
|
+
// Auto-scroll if at bottom
|
|
271
|
+
if (this.autoScroll) {
|
|
272
|
+
this.scrollToBottom();
|
|
273
|
+
}
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (this.verbose) {
|
|
276
|
+
this.logDebug(`Read error: ${(err as Error).message}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Trim line buffer to max lines
|
|
283
|
+
*/
|
|
284
|
+
private trimToMaxLines(): void {
|
|
285
|
+
while (this.lines.length > this.maxLines) {
|
|
286
|
+
this.lines.shift();
|
|
287
|
+
// Adjust scroll offset
|
|
288
|
+
if (this.scrollOffset > 0) {
|
|
289
|
+
this.scrollOffset--;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Get page size (terminal height - status bar)
|
|
296
|
+
*/
|
|
297
|
+
private getPageSize(): number {
|
|
298
|
+
return Math.max(1, (process.stdout.rows || 24) - 2);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Scroll up by N lines
|
|
303
|
+
*/
|
|
304
|
+
private scrollUp(count: number): void {
|
|
305
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - count);
|
|
306
|
+
this.autoScroll = false;
|
|
307
|
+
this.render();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Scroll down by N lines
|
|
312
|
+
*/
|
|
313
|
+
private scrollDown(count: number): void {
|
|
314
|
+
const maxOffset = Math.max(0, this.lines.length - this.getPageSize());
|
|
315
|
+
this.scrollOffset = Math.min(maxOffset, this.scrollOffset + count);
|
|
316
|
+
|
|
317
|
+
// Re-enable auto-scroll if at bottom
|
|
318
|
+
if (this.scrollOffset >= maxOffset) {
|
|
319
|
+
this.autoScroll = true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
this.render();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Scroll to top
|
|
327
|
+
*/
|
|
328
|
+
private scrollToTop(): void {
|
|
329
|
+
this.scrollOffset = 0;
|
|
330
|
+
this.autoScroll = false;
|
|
331
|
+
this.render();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Scroll to bottom
|
|
336
|
+
*/
|
|
337
|
+
private scrollToBottom(): void {
|
|
338
|
+
const maxOffset = Math.max(0, this.lines.length - this.getPageSize());
|
|
339
|
+
this.scrollOffset = maxOffset;
|
|
340
|
+
this.autoScroll = true;
|
|
341
|
+
this.render();
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Render the TUI
|
|
346
|
+
*/
|
|
347
|
+
private render(): void {
|
|
348
|
+
const rows = process.stdout.rows || 24;
|
|
349
|
+
const columns = process.stdout.columns || 80;
|
|
350
|
+
const contentRows = rows - 2; // Reserve 2 lines for status bar
|
|
351
|
+
|
|
352
|
+
// Clear screen and move cursor home
|
|
353
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
354
|
+
|
|
355
|
+
// Render visible lines
|
|
356
|
+
const startLine = this.scrollOffset;
|
|
357
|
+
const endLine = Math.min(startLine + contentRows, this.lines.length);
|
|
358
|
+
|
|
359
|
+
for (let i = startLine; i < endLine; i++) {
|
|
360
|
+
const line = this.lines[i];
|
|
361
|
+
// Truncate to terminal width
|
|
362
|
+
const truncated = this.truncateToWidth(line, columns);
|
|
363
|
+
process.stdout.write(truncated + '\n');
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Pad remaining lines
|
|
367
|
+
for (let i = endLine - startLine; i < contentRows; i++) {
|
|
368
|
+
process.stdout.write('\n');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Render status bar
|
|
372
|
+
this.renderStatusBar(rows, columns);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Truncate line to visible width (respecting ANSI codes)
|
|
377
|
+
*/
|
|
378
|
+
private truncateToWidth(line: string, width: number): string {
|
|
379
|
+
if (visibleLength(line) <= width) {
|
|
380
|
+
return line;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Need to truncate while preserving ANSI codes
|
|
384
|
+
let result = '';
|
|
385
|
+
let visibleCount = 0;
|
|
386
|
+
let inEscape = false;
|
|
387
|
+
let escapeBuffer = '';
|
|
388
|
+
|
|
389
|
+
for (let i = 0; i < line.length; i++) {
|
|
390
|
+
const char = line[i];
|
|
391
|
+
|
|
392
|
+
if (char === '\x1b') {
|
|
393
|
+
inEscape = true;
|
|
394
|
+
escapeBuffer = char;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (inEscape) {
|
|
399
|
+
escapeBuffer += char;
|
|
400
|
+
if (char === 'm') {
|
|
401
|
+
// End of escape sequence
|
|
402
|
+
result += escapeBuffer;
|
|
403
|
+
inEscape = false;
|
|
404
|
+
escapeBuffer = '';
|
|
405
|
+
}
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Regular character
|
|
410
|
+
if (visibleCount < width) {
|
|
411
|
+
result += char;
|
|
412
|
+
visibleCount++;
|
|
413
|
+
} else {
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return result;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Render status bar at bottom
|
|
423
|
+
*/
|
|
424
|
+
private async renderStatusBar(rows: number, columns: number): Promise<void> {
|
|
425
|
+
try {
|
|
426
|
+
const config = await loadConfig();
|
|
427
|
+
const lockInfo = await getLockInfo(config);
|
|
428
|
+
|
|
429
|
+
const fileName = basename(this.logPath);
|
|
430
|
+
const updateTime = new Date().toLocaleTimeString();
|
|
431
|
+
const status = lockInfo.locked
|
|
432
|
+
? `Running (${lockInfo.elapsed || '00:00:00'})`
|
|
433
|
+
: 'Stopped';
|
|
434
|
+
const scrollPos = `${this.scrollOffset + 1}/${this.lines.length}`;
|
|
435
|
+
|
|
436
|
+
// Build status line parts
|
|
437
|
+
const leftPart = ` ${fileName} `;
|
|
438
|
+
const middlePart = ` ${status} `;
|
|
439
|
+
const rightPart = ` ${scrollPos} | ${updateTime} `;
|
|
440
|
+
|
|
441
|
+
// Position cursor at bottom (last row)
|
|
442
|
+
process.stdout.write(`\x1b[${rows};1H`);
|
|
443
|
+
|
|
444
|
+
// Build and render status line
|
|
445
|
+
const statusLine = this.buildStatusLine(
|
|
446
|
+
leftPart,
|
|
447
|
+
middlePart,
|
|
448
|
+
rightPart,
|
|
449
|
+
columns
|
|
450
|
+
);
|
|
451
|
+
process.stdout.write(colorize(statusLine, 'white'));
|
|
452
|
+
|
|
453
|
+
// Second status line (help text)
|
|
454
|
+
const helpText = ' q:quit ↑↓/jk:scroll g/G:top/bottom u/d:page ';
|
|
455
|
+
process.stdout.write(`\x1b[${rows - 1};1H`);
|
|
456
|
+
const paddedHelp = helpText.padEnd(columns, ' ');
|
|
457
|
+
process.stdout.write(colorize(paddedHelp, 'sky200'));
|
|
458
|
+
} catch {
|
|
459
|
+
// Fallback if config/lock fails
|
|
460
|
+
const helpText = ' q:quit ↑↓/jk:scroll g/G:top/bottom u/d:page ';
|
|
461
|
+
process.stdout.write(`\x1b[${rows};1H`);
|
|
462
|
+
const paddedHelp = helpText.padEnd(columns, ' ');
|
|
463
|
+
process.stdout.write(colorize(paddedHelp, 'white'));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Build status line with proper spacing
|
|
469
|
+
*/
|
|
470
|
+
private buildStatusLine(
|
|
471
|
+
left: string,
|
|
472
|
+
middle: string,
|
|
473
|
+
right: string,
|
|
474
|
+
width: number
|
|
475
|
+
): string {
|
|
476
|
+
const leftLen = left.length;
|
|
477
|
+
const middleLen = middle.length;
|
|
478
|
+
const rightLen = right.length;
|
|
479
|
+
|
|
480
|
+
// Calculate spacing
|
|
481
|
+
const totalContent = leftLen + middleLen + rightLen;
|
|
482
|
+
const totalPadding = width - totalContent;
|
|
483
|
+
|
|
484
|
+
if (totalPadding < 0) {
|
|
485
|
+
// Not enough space, truncate
|
|
486
|
+
return (left + middle + right).substring(0, width);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Distribute padding
|
|
490
|
+
const leftPad = Math.floor(totalPadding / 2);
|
|
491
|
+
const rightPad = totalPadding - leftPad;
|
|
492
|
+
|
|
493
|
+
return left + ' '.repeat(leftPad) + middle + ' '.repeat(rightPad) + right;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Log debug message (when verbose)
|
|
498
|
+
*/
|
|
499
|
+
private logDebug(message: string): void {
|
|
500
|
+
if (this.verbose) {
|
|
501
|
+
this.lines.push(`[DEBUG] ${message}`);
|
|
502
|
+
this.trimToMaxLines();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Exit the monitor
|
|
508
|
+
*/
|
|
509
|
+
private exit(): void {
|
|
510
|
+
this.running = false;
|
|
511
|
+
|
|
512
|
+
// Stop polling
|
|
513
|
+
if (this.pollTimer) {
|
|
514
|
+
clearInterval(this.pollTimer);
|
|
515
|
+
this.pollTimer = null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Exit TUI mode
|
|
519
|
+
this.exitTUIMode();
|
|
520
|
+
|
|
521
|
+
// Exit process
|
|
522
|
+
process.exit(0);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Monitor command handler
|
|
528
|
+
*
|
|
529
|
+
* @param options - Command options
|
|
530
|
+
*/
|
|
531
|
+
export default async function monitor(
|
|
532
|
+
options: MonitorOptions = {}
|
|
533
|
+
): Promise<void> {
|
|
534
|
+
try {
|
|
535
|
+
const config = await loadConfig();
|
|
536
|
+
|
|
537
|
+
// Determine log file path
|
|
538
|
+
const logPath = options.logFile || config.paths.logs;
|
|
539
|
+
|
|
540
|
+
// Validate log file exists
|
|
541
|
+
if (!existsSync(logPath)) {
|
|
542
|
+
console.error(colorize(`✗ Log file not found: ${logPath}`, 'error'));
|
|
543
|
+
console.error(' Start a speci run first with: speci run');
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Install signal handlers
|
|
548
|
+
installSignalHandlers();
|
|
549
|
+
|
|
550
|
+
// Capture and register terminal restore
|
|
551
|
+
const savedState = terminalState.capture();
|
|
552
|
+
registerCleanup(() => {
|
|
553
|
+
terminalState.restore(savedState);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Create and start monitor UI
|
|
557
|
+
const ui = new MonitorUI(
|
|
558
|
+
logPath,
|
|
559
|
+
options.maxLines ?? 10000,
|
|
560
|
+
options.pollInterval ?? 500,
|
|
561
|
+
options.verbose ?? false
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
await ui.start();
|
|
565
|
+
|
|
566
|
+
// Keep process alive
|
|
567
|
+
await new Promise(() => {
|
|
568
|
+
/* never resolves */
|
|
569
|
+
});
|
|
570
|
+
} catch (err) {
|
|
571
|
+
console.error(
|
|
572
|
+
colorize(`✗ Monitor error: ${(err as Error).message}`, 'error')
|
|
573
|
+
);
|
|
574
|
+
process.exit(1);
|
|
575
|
+
} finally {
|
|
576
|
+
// Cleanup handlers on exit
|
|
577
|
+
removeSignalHandlers();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Command Module
|
|
3
|
+
*
|
|
4
|
+
* Invokes GitHub Copilot CLI in interactive mode for plan generation.
|
|
5
|
+
* Users can have an interactive conversation with Copilot to develop
|
|
6
|
+
* implementation plans for features, projects, or refactoring efforts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync } from 'node:fs';
|
|
10
|
+
import { loadConfig, resolveAgentPath } from '../config.js';
|
|
11
|
+
import { buildCopilotArgs, spawnCopilot } from '../copilot.js';
|
|
12
|
+
import { preflight } from '../utils/preflight.js';
|
|
13
|
+
import { renderBanner } from '../ui/banner.js';
|
|
14
|
+
import { log } from '../utils/logger.js';
|
|
15
|
+
import { drawBox } from '../ui/box.js';
|
|
16
|
+
import { colorize } from '../ui/colors.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Options for the plan command
|
|
20
|
+
*/
|
|
21
|
+
export interface PlanOptions {
|
|
22
|
+
/** Output file path for plan */
|
|
23
|
+
output?: string;
|
|
24
|
+
/** Custom agent path override */
|
|
25
|
+
agent?: string;
|
|
26
|
+
/** Show detailed output */
|
|
27
|
+
verbose?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Display information box about command invocation
|
|
32
|
+
* @param agentPath - Path to agent being used
|
|
33
|
+
* @param outputPath - Output path or 'stdout'
|
|
34
|
+
*/
|
|
35
|
+
function displayCommandInfo(agentPath: string, outputPath: string): void {
|
|
36
|
+
const content = [
|
|
37
|
+
colorize('Agent:', 'sky400') + ` ${agentPath}`,
|
|
38
|
+
colorize('Mode:', 'sky400') + ' Interactive',
|
|
39
|
+
colorize('Output:', 'sky400') + ` ${outputPath}`,
|
|
40
|
+
];
|
|
41
|
+
console.log(
|
|
42
|
+
drawBox(content, { title: 'Plan Generation', borderColor: 'sky500' })
|
|
43
|
+
);
|
|
44
|
+
console.log();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Plan command handler
|
|
49
|
+
* Initializes interactive Copilot session for plan generation
|
|
50
|
+
* @param options - Command options
|
|
51
|
+
*/
|
|
52
|
+
export async function plan(options: PlanOptions = {}): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
// Display banner
|
|
55
|
+
renderBanner();
|
|
56
|
+
console.log();
|
|
57
|
+
|
|
58
|
+
// Load configuration
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
|
|
61
|
+
// Run preflight checks
|
|
62
|
+
await preflight(config, {
|
|
63
|
+
requireCopilot: true,
|
|
64
|
+
requireConfig: true,
|
|
65
|
+
requireProgress: false,
|
|
66
|
+
requireGit: false,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Resolve agent path (override or config)
|
|
70
|
+
const agentPath = options.agent
|
|
71
|
+
? options.agent
|
|
72
|
+
: resolveAgentPath(config, 'plan');
|
|
73
|
+
|
|
74
|
+
// Validate agent file exists
|
|
75
|
+
if (!existsSync(agentPath)) {
|
|
76
|
+
log.error(`Agent file not found: ${agentPath}`);
|
|
77
|
+
log.info('Check config.agents.plan or provide --agent flag');
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Display command info
|
|
82
|
+
displayCommandInfo(agentPath, options.output || 'stdout');
|
|
83
|
+
|
|
84
|
+
// Build Copilot args
|
|
85
|
+
const args = buildCopilotArgs(config, {
|
|
86
|
+
interactive: true,
|
|
87
|
+
agent: agentPath,
|
|
88
|
+
allowAll: config.copilot.permissions === 'allow-all',
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Add output flag if specified
|
|
92
|
+
if (options.output) {
|
|
93
|
+
args.push('--output', options.output);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Spawn Copilot with stdio:inherit
|
|
97
|
+
log.debug(`Spawning: copilot ${args.join(' ')}`);
|
|
98
|
+
const exitCode = await spawnCopilot(args, { inherit: true });
|
|
99
|
+
|
|
100
|
+
// Exit with Copilot's exit code
|
|
101
|
+
process.exit(exitCode);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error instanceof Error) {
|
|
104
|
+
log.error(`Plan command failed: ${error.message}`);
|
|
105
|
+
} else {
|
|
106
|
+
log.error(`Plan command failed: ${String(error)}`);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default plan;
|