hamster-wheel-cli 0.1.0 → 0.2.0-beta.2
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/CHANGELOG.md +31 -1
- package/README.md +36 -0
- package/dist/cli.js +1917 -309
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +1917 -309
- package/dist/index.js.map +1 -1
- package/docs/ai-workflow.md +27 -36
- package/package.json +1 -1
- package/src/ai.ts +346 -1
- package/src/alias-viewer.ts +221 -0
- package/src/cli.ts +480 -29
- package/src/config.ts +6 -2
- package/src/gh.ts +20 -0
- package/src/git.ts +38 -3
- package/src/global-config.ts +149 -11
- package/src/log-tailer.ts +103 -0
- package/src/logs-viewer.ts +33 -11
- package/src/logs.ts +1 -0
- package/src/loop.ts +458 -120
- package/src/monitor.ts +240 -23
- package/src/multi-task.ts +117 -0
- package/src/plan.ts +61 -0
- package/src/quality.ts +48 -0
- package/src/runtime-tracker.ts +2 -1
- package/src/types.ts +23 -0
- package/tests/branch-name.test.ts +28 -0
- package/tests/e2e/cli.e2e.test.ts +41 -0
- package/tests/global-config.test.ts +52 -1
- package/tests/monitor.test.ts +17 -0
- package/tests/multi-task.test.ts +77 -0
package/src/monitor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { CurrentRegistryEntry, getLogsDir, readCurrentRegistry } from './logs';
|
|
3
|
+
import { CurrentRegistryEntry, getLogsDir, readCurrentRegistry, removeCurrentRegistry } from './logs';
|
|
4
4
|
|
|
5
5
|
interface TaskView {
|
|
6
6
|
readonly key: string;
|
|
@@ -9,16 +9,30 @@ interface TaskView {
|
|
|
9
9
|
readonly lines: string[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
interface ConfirmState {
|
|
13
|
+
readonly key: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TerminationResult {
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly isError: boolean;
|
|
19
|
+
readonly removed: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
interface MonitorState {
|
|
13
23
|
tasks: TaskView[];
|
|
14
24
|
selectedIndex: number;
|
|
15
25
|
selectedKey?: string;
|
|
16
|
-
|
|
26
|
+
lineOffsets: Map<string, number>;
|
|
17
27
|
stickToBottom: Map<string, boolean>;
|
|
18
28
|
lastError?: string;
|
|
29
|
+
confirm?: ConfirmState;
|
|
30
|
+
statusMessage?: string;
|
|
31
|
+
statusIsError?: boolean;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
const REFRESH_INTERVAL = 1000;
|
|
35
|
+
const TERMINATE_GRACE_MS = 800;
|
|
22
36
|
|
|
23
37
|
function getTerminalSize(): { rows: number; columns: number } {
|
|
24
38
|
const rows = process.stdout.rows ?? 24;
|
|
@@ -32,6 +46,81 @@ function truncateLine(line: string, width: number): string {
|
|
|
32
46
|
return line.slice(0, width);
|
|
33
47
|
}
|
|
34
48
|
|
|
49
|
+
function padLine(text: string, width: number): string {
|
|
50
|
+
if (width <= 0) return '';
|
|
51
|
+
const truncated = text.length > width ? text.slice(0, width) : text;
|
|
52
|
+
const padding = width - truncated.length;
|
|
53
|
+
const left = Math.floor(padding / 2);
|
|
54
|
+
const right = padding - left;
|
|
55
|
+
return `${' '.repeat(left)}${truncated}${' '.repeat(right)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildConfirmDialogLines(taskKey: string, columns: number): string[] {
|
|
59
|
+
if (columns <= 0) return [];
|
|
60
|
+
const message = `确认终止任务 ${taskKey}?`;
|
|
61
|
+
const hint = 'y 确认 / n 取消';
|
|
62
|
+
const minWidth = Math.max(message.length, hint.length, 4);
|
|
63
|
+
const innerWidth = Math.min(columns - 2, minWidth);
|
|
64
|
+
if (innerWidth <= 0) {
|
|
65
|
+
return [truncateLine(message, columns), truncateLine(hint, columns)];
|
|
66
|
+
}
|
|
67
|
+
const border = `+${'-'.repeat(innerWidth)}+`;
|
|
68
|
+
const lines = [
|
|
69
|
+
border,
|
|
70
|
+
`|${padLine(message, innerWidth)}|`,
|
|
71
|
+
`|${padLine(hint, innerWidth)}|`,
|
|
72
|
+
border
|
|
73
|
+
];
|
|
74
|
+
return lines.map(line => truncateLine(line, columns));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyDialogOverlay(lines: string[], dialogLines: string[]): void {
|
|
78
|
+
if (lines.length === 0 || dialogLines.length === 0) return;
|
|
79
|
+
const start = Math.max(0, Math.floor((lines.length - dialogLines.length) / 2));
|
|
80
|
+
for (let i = 0; i < dialogLines.length && start + i < lines.length; i += 1) {
|
|
81
|
+
lines[start + i] = dialogLines[i];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function resolveTerminationTarget(pid: number, platform: NodeJS.Platform = process.platform): number {
|
|
86
|
+
return platform === 'win32' ? pid : -pid;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isProcessAlive(pid: number): boolean {
|
|
90
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
91
|
+
try {
|
|
92
|
+
process.kill(pid, 0);
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
const err = error as NodeJS.ErrnoException;
|
|
96
|
+
if (err?.code === 'ESRCH') return false;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sleep(ms: number): Promise<void> {
|
|
102
|
+
return new Promise(resolve => {
|
|
103
|
+
setTimeout(resolve, ms);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setStatus(state: MonitorState, message?: string, isError = false): void {
|
|
108
|
+
state.statusMessage = message;
|
|
109
|
+
state.statusIsError = message ? isError : false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findTaskByKey(state: MonitorState, key: string): TaskView | undefined {
|
|
113
|
+
return state.tasks.find(task => task.key === key);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function safeRemoveRegistry(logFile: string): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
await removeCurrentRegistry(logFile);
|
|
119
|
+
} catch {
|
|
120
|
+
// 忽略清理失败,避免阻断 monitor 刷新
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
35
124
|
async function readLogLines(logFile: string): Promise<string[]> {
|
|
36
125
|
try {
|
|
37
126
|
const content = await fs.readFile(logFile, 'utf8');
|
|
@@ -47,7 +136,17 @@ async function readLogLines(logFile: string): Promise<string[]> {
|
|
|
47
136
|
async function loadTasks(logsDir: string): Promise<TaskView[]> {
|
|
48
137
|
const registry = await readCurrentRegistry();
|
|
49
138
|
const entries = Object.entries(registry).sort(([a], [b]) => a.localeCompare(b));
|
|
50
|
-
const
|
|
139
|
+
const aliveEntries: Array<[string, CurrentRegistryEntry]> = [];
|
|
140
|
+
for (const [key, meta] of entries) {
|
|
141
|
+
const pid = typeof meta.pid === 'number' ? meta.pid : undefined;
|
|
142
|
+
if (pid && !isProcessAlive(pid)) {
|
|
143
|
+
const logFile = meta.logFile ?? path.join(logsDir, key);
|
|
144
|
+
await safeRemoveRegistry(logFile);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
aliveEntries.push([key, meta]);
|
|
148
|
+
}
|
|
149
|
+
const tasks = await Promise.all(aliveEntries.map(async ([key, meta]) => {
|
|
51
150
|
const logFile = meta.logFile ?? path.join(logsDir, key);
|
|
52
151
|
const lines = await readLogLines(logFile);
|
|
53
152
|
return {
|
|
@@ -67,7 +166,7 @@ function buildHeader(state: MonitorState, columns: number): string {
|
|
|
67
166
|
const current = state.tasks[state.selectedIndex];
|
|
68
167
|
const total = state.tasks.length;
|
|
69
168
|
const index = state.selectedIndex + 1;
|
|
70
|
-
const title = `任务 ${index}/${total} | ${current.key} | ←/→ 切换任务 ↑/↓ 翻页 q 退出`;
|
|
169
|
+
const title = `任务 ${index}/${total} | ${current.key} | ←/→ 切换任务 ↑/↓ 上下 1 行 PageUp/PageDown 翻页 t 终止 q 退出`;
|
|
71
170
|
return truncateLine(title, columns);
|
|
72
171
|
}
|
|
73
172
|
|
|
@@ -75,11 +174,20 @@ function buildStatus(
|
|
|
75
174
|
task: TaskView,
|
|
76
175
|
page: { current: number; total: number },
|
|
77
176
|
columns: number,
|
|
78
|
-
errorMessage?: string
|
|
177
|
+
errorMessage?: string,
|
|
178
|
+
statusMessage?: string,
|
|
179
|
+
statusIsError?: boolean
|
|
79
180
|
): string {
|
|
80
181
|
const meta = task.meta;
|
|
81
182
|
const status = `轮次 ${meta.round} | Token ${meta.tokenUsed} | 页 ${page.current}/${page.total} | 项目 ${meta.path}`;
|
|
82
|
-
const
|
|
183
|
+
const extras: string[] = [];
|
|
184
|
+
if (errorMessage) {
|
|
185
|
+
extras.push(`刷新失败:${errorMessage}`);
|
|
186
|
+
}
|
|
187
|
+
if (statusMessage) {
|
|
188
|
+
extras.push(statusIsError ? `操作失败:${statusMessage}` : statusMessage);
|
|
189
|
+
}
|
|
190
|
+
const suffix = extras.length > 0 ? ` | ${extras.join(' | ')}` : '';
|
|
83
191
|
return truncateLine(`${status}${suffix}`, columns);
|
|
84
192
|
}
|
|
85
193
|
|
|
@@ -94,7 +202,11 @@ function render(state: MonitorState): void {
|
|
|
94
202
|
|
|
95
203
|
if (state.tasks.length === 0) {
|
|
96
204
|
const filler = Array.from({ length: pageSize }, () => '');
|
|
97
|
-
const statusText = state.lastError
|
|
205
|
+
const statusText = state.lastError
|
|
206
|
+
? `刷新失败:${state.lastError}`
|
|
207
|
+
: state.statusMessage
|
|
208
|
+
? (state.statusIsError ? `操作失败:${state.statusMessage}` : state.statusMessage)
|
|
209
|
+
: '等待后台任务启动…';
|
|
98
210
|
const status = truncateLine(statusText, columns);
|
|
99
211
|
const content = [header, ...filler, status].join('\n');
|
|
100
212
|
process.stdout.write(`\u001b[2J\u001b[H${content}`);
|
|
@@ -103,25 +215,33 @@ function render(state: MonitorState): void {
|
|
|
103
215
|
|
|
104
216
|
const current = state.tasks[state.selectedIndex];
|
|
105
217
|
const lines = current.lines;
|
|
106
|
-
const maxOffset = Math.max(0,
|
|
107
|
-
const offset = state.
|
|
218
|
+
const maxOffset = Math.max(0, lines.length - pageSize);
|
|
219
|
+
const offset = state.lineOffsets.get(current.key) ?? maxOffset;
|
|
108
220
|
const stick = state.stickToBottom.get(current.key) ?? true;
|
|
109
221
|
const nextOffset = Math.min(Math.max(stick ? maxOffset : offset, 0), maxOffset);
|
|
110
|
-
state.
|
|
222
|
+
state.lineOffsets.set(current.key, nextOffset);
|
|
111
223
|
state.stickToBottom.set(current.key, nextOffset === maxOffset);
|
|
112
224
|
|
|
113
|
-
const start = nextOffset
|
|
225
|
+
const start = nextOffset;
|
|
114
226
|
const pageLines = lines.slice(start, start + pageSize).map(line => truncateLine(line, columns));
|
|
115
227
|
while (pageLines.length < pageSize) {
|
|
116
228
|
pageLines.push('');
|
|
117
229
|
}
|
|
118
230
|
|
|
231
|
+
const totalPages = Math.max(1, Math.ceil(lines.length / pageSize));
|
|
232
|
+
const currentPage = Math.min(totalPages, Math.floor(nextOffset / pageSize) + 1);
|
|
119
233
|
const status = buildStatus(
|
|
120
234
|
current,
|
|
121
|
-
{ current:
|
|
235
|
+
{ current: currentPage, total: totalPages },
|
|
122
236
|
columns,
|
|
123
|
-
state.lastError
|
|
237
|
+
state.lastError,
|
|
238
|
+
state.statusMessage,
|
|
239
|
+
state.statusIsError
|
|
124
240
|
);
|
|
241
|
+
if (state.confirm) {
|
|
242
|
+
const dialogLines = buildConfirmDialogLines(state.confirm.key, columns);
|
|
243
|
+
applyDialogOverlay(pageLines, dialogLines);
|
|
244
|
+
}
|
|
125
245
|
const content = [header, ...pageLines, status].join('\n');
|
|
126
246
|
process.stdout.write(`\u001b[2J\u001b[H${content}`);
|
|
127
247
|
}
|
|
@@ -148,9 +268,9 @@ function updateSelection(state: MonitorState, tasks: TaskView[]): void {
|
|
|
148
268
|
state.selectedKey = tasks[state.selectedIndex]?.key;
|
|
149
269
|
|
|
150
270
|
const existing = new Set(tasks.map(task => task.key));
|
|
151
|
-
for (const key of state.
|
|
271
|
+
for (const key of state.lineOffsets.keys()) {
|
|
152
272
|
if (!existing.has(key)) {
|
|
153
|
-
state.
|
|
273
|
+
state.lineOffsets.delete(key);
|
|
154
274
|
}
|
|
155
275
|
}
|
|
156
276
|
for (const key of state.stickToBottom.keys()) {
|
|
@@ -158,6 +278,9 @@ function updateSelection(state: MonitorState, tasks: TaskView[]): void {
|
|
|
158
278
|
state.stickToBottom.delete(key);
|
|
159
279
|
}
|
|
160
280
|
}
|
|
281
|
+
if (state.confirm && !existing.has(state.confirm.key)) {
|
|
282
|
+
state.confirm = undefined;
|
|
283
|
+
}
|
|
161
284
|
}
|
|
162
285
|
|
|
163
286
|
function moveSelection(state: MonitorState, direction: -1 | 1): void {
|
|
@@ -167,26 +290,99 @@ function moveSelection(state: MonitorState, direction: -1 | 1): void {
|
|
|
167
290
|
state.selectedKey = state.tasks[state.selectedIndex]?.key;
|
|
168
291
|
}
|
|
169
292
|
|
|
293
|
+
function moveLine(state: MonitorState, direction: -1 | 1): void {
|
|
294
|
+
if (state.tasks.length === 0) return;
|
|
295
|
+
const current = state.tasks[state.selectedIndex];
|
|
296
|
+
const lines = current.lines;
|
|
297
|
+
const { rows } = getTerminalSize();
|
|
298
|
+
const pageSize = getPageSize(rows);
|
|
299
|
+
const maxOffset = Math.max(0, lines.length - pageSize);
|
|
300
|
+
const offset = state.lineOffsets.get(current.key) ?? maxOffset;
|
|
301
|
+
const nextOffset = Math.min(Math.max(offset + direction, 0), maxOffset);
|
|
302
|
+
state.lineOffsets.set(current.key, nextOffset);
|
|
303
|
+
state.stickToBottom.set(current.key, nextOffset === maxOffset);
|
|
304
|
+
}
|
|
305
|
+
|
|
170
306
|
function movePage(state: MonitorState, direction: -1 | 1): void {
|
|
171
307
|
if (state.tasks.length === 0) return;
|
|
172
308
|
const { rows } = getTerminalSize();
|
|
173
309
|
const pageSize = getPageSize(rows);
|
|
174
310
|
const current = state.tasks[state.selectedIndex];
|
|
175
311
|
const lines = current.lines;
|
|
176
|
-
const maxOffset = Math.max(0,
|
|
177
|
-
const offset = state.
|
|
178
|
-
const nextOffset = Math.min(Math.max(offset + direction, 0), maxOffset);
|
|
179
|
-
state.
|
|
312
|
+
const maxOffset = Math.max(0, lines.length - pageSize);
|
|
313
|
+
const offset = state.lineOffsets.get(current.key) ?? maxOffset;
|
|
314
|
+
const nextOffset = Math.min(Math.max(offset + direction * pageSize, 0), maxOffset);
|
|
315
|
+
state.lineOffsets.set(current.key, nextOffset);
|
|
180
316
|
state.stickToBottom.set(current.key, nextOffset === maxOffset);
|
|
181
317
|
}
|
|
182
318
|
|
|
319
|
+
async function terminateTask(task: TaskView): Promise<TerminationResult> {
|
|
320
|
+
const pid = typeof task.meta.pid === 'number' ? task.meta.pid : undefined;
|
|
321
|
+
if (!pid || pid <= 0) {
|
|
322
|
+
return { message: '任务未记录 PID,无法终止', isError: true, removed: false };
|
|
323
|
+
}
|
|
324
|
+
const target = resolveTerminationTarget(pid);
|
|
325
|
+
try {
|
|
326
|
+
process.kill(target, 'SIGTERM');
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const err = error as NodeJS.ErrnoException;
|
|
329
|
+
if (err?.code === 'ESRCH') {
|
|
330
|
+
await safeRemoveRegistry(task.logFile);
|
|
331
|
+
return { message: `任务 ${task.key} 已结束`, isError: false, removed: true };
|
|
332
|
+
}
|
|
333
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
334
|
+
return { message: `发送终止信号失败:${message}`, isError: true, removed: false };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await sleep(TERMINATE_GRACE_MS);
|
|
338
|
+
if (!isProcessAlive(pid)) {
|
|
339
|
+
await safeRemoveRegistry(task.logFile);
|
|
340
|
+
return { message: `任务 ${task.key} 已终止`, isError: false, removed: true };
|
|
341
|
+
}
|
|
342
|
+
return { message: `已发送终止信号,任务仍在运行`, isError: false, removed: false };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function terminateTaskByKey(state: MonitorState, key: string, refresh: () => Promise<void>): Promise<void> {
|
|
346
|
+
const task = findTaskByKey(state, key);
|
|
347
|
+
if (!task) {
|
|
348
|
+
setStatus(state, `任务 ${key} 已不存在`, true);
|
|
349
|
+
render(state);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const result = await terminateTask(task);
|
|
353
|
+
setStatus(state, result.message, result.isError);
|
|
354
|
+
if (result.removed) {
|
|
355
|
+
await refresh();
|
|
356
|
+
render(state);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
render(state);
|
|
360
|
+
}
|
|
361
|
+
|
|
183
362
|
function shouldExit(input: string): boolean {
|
|
184
363
|
if (input === '\u0003') return true;
|
|
185
364
|
if (input.toLowerCase() === 'q') return true;
|
|
186
365
|
return false;
|
|
187
366
|
}
|
|
188
367
|
|
|
189
|
-
function handleInput(state: MonitorState, input: string): void {
|
|
368
|
+
function handleInput(state: MonitorState, input: string, refresh: () => Promise<void>): void {
|
|
369
|
+
const lower = input.toLowerCase();
|
|
370
|
+
if (state.confirm) {
|
|
371
|
+
if (lower.includes('y')) {
|
|
372
|
+
const key = state.confirm.key;
|
|
373
|
+
state.confirm = undefined;
|
|
374
|
+
setStatus(state, `正在终止任务 ${key}...`);
|
|
375
|
+
void terminateTaskByKey(state, key, refresh);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (lower.includes('n') || input === '\u001b') {
|
|
379
|
+
state.confirm = undefined;
|
|
380
|
+
setStatus(state, '已取消终止');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
190
386
|
if (input.includes('\u001b[D')) {
|
|
191
387
|
moveSelection(state, -1);
|
|
192
388
|
return;
|
|
@@ -196,13 +392,34 @@ function handleInput(state: MonitorState, input: string): void {
|
|
|
196
392
|
return;
|
|
197
393
|
}
|
|
198
394
|
if (input.includes('\u001b[A')) {
|
|
199
|
-
|
|
395
|
+
moveLine(state, -1);
|
|
200
396
|
return;
|
|
201
397
|
}
|
|
202
398
|
if (input.includes('\u001b[B')) {
|
|
399
|
+
moveLine(state, 1);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (input.includes('\u001b[5~')) {
|
|
403
|
+
movePage(state, -1);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (input.includes('\u001b[6~')) {
|
|
203
407
|
movePage(state, 1);
|
|
204
408
|
return;
|
|
205
409
|
}
|
|
410
|
+
if (lower.includes('t')) {
|
|
411
|
+
if (state.tasks.length === 0) {
|
|
412
|
+
setStatus(state, '暂无可终止的任务', true);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const current = state.tasks[state.selectedIndex];
|
|
416
|
+
if (typeof current.meta.pid !== 'number' || current.meta.pid <= 0) {
|
|
417
|
+
setStatus(state, '任务未记录 PID,无法终止', true);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
state.confirm = { key: current.key };
|
|
421
|
+
setStatus(state, undefined);
|
|
422
|
+
}
|
|
206
423
|
}
|
|
207
424
|
|
|
208
425
|
function setupCleanup(cleanup: () => void): void {
|
|
@@ -231,7 +448,7 @@ export async function runMonitor(): Promise<void> {
|
|
|
231
448
|
const state: MonitorState = {
|
|
232
449
|
tasks: [],
|
|
233
450
|
selectedIndex: 0,
|
|
234
|
-
|
|
451
|
+
lineOffsets: new Map(),
|
|
235
452
|
stickToBottom: new Map()
|
|
236
453
|
};
|
|
237
454
|
|
|
@@ -281,7 +498,7 @@ export async function runMonitor(): Promise<void> {
|
|
|
281
498
|
cleanup();
|
|
282
499
|
process.exit(0);
|
|
283
500
|
}
|
|
284
|
-
handleInput(state, input);
|
|
501
|
+
handleInput(state, input, refresh);
|
|
285
502
|
render(state);
|
|
286
503
|
});
|
|
287
504
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export type MultiTaskMode = 'relay' | 'serial' | 'serial-continue' | 'parallel';
|
|
4
|
+
|
|
5
|
+
const MODE_ALIASES: Record<string, MultiTaskMode> = {
|
|
6
|
+
relay: 'relay',
|
|
7
|
+
serial: 'serial',
|
|
8
|
+
'serial-continue': 'serial-continue',
|
|
9
|
+
parallel: 'parallel',
|
|
10
|
+
接力模式: 'relay',
|
|
11
|
+
接力: 'relay',
|
|
12
|
+
串行执行: 'serial',
|
|
13
|
+
串行: 'serial',
|
|
14
|
+
串行执行但是失败也继续: 'serial-continue',
|
|
15
|
+
串行继续: 'serial-continue',
|
|
16
|
+
并行执行: 'parallel',
|
|
17
|
+
并行: 'parallel'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 解析多任务执行模式。
|
|
22
|
+
*/
|
|
23
|
+
export function parseMultiTaskMode(raw?: string): MultiTaskMode {
|
|
24
|
+
if (!raw) return 'relay';
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
if (!trimmed) return 'relay';
|
|
27
|
+
const resolved = MODE_ALIASES[trimmed];
|
|
28
|
+
if (!resolved) {
|
|
29
|
+
throw new Error(`未知 multi-task 模式: ${raw}`);
|
|
30
|
+
}
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 规范化任务列表(去空白、剔除空项)。
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeTaskList(input: string[] | string | undefined): string[] {
|
|
38
|
+
if (Array.isArray(input)) {
|
|
39
|
+
return input.map(task => task.trim()).filter(task => task.length > 0);
|
|
40
|
+
}
|
|
41
|
+
if (typeof input === 'string') {
|
|
42
|
+
const trimmed = input.trim();
|
|
43
|
+
return trimmed.length > 0 ? [trimmed] : [];
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TaskPlanInput {
|
|
49
|
+
readonly tasks: string[];
|
|
50
|
+
readonly mode: MultiTaskMode;
|
|
51
|
+
readonly useWorktree: boolean;
|
|
52
|
+
readonly baseBranch: string;
|
|
53
|
+
readonly branchInput?: string;
|
|
54
|
+
readonly worktreePath?: string;
|
|
55
|
+
readonly logFile?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TaskPlan {
|
|
59
|
+
readonly task: string;
|
|
60
|
+
readonly index: number;
|
|
61
|
+
readonly branchName?: string;
|
|
62
|
+
readonly baseBranch: string;
|
|
63
|
+
readonly worktreePath?: string;
|
|
64
|
+
readonly logFile?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildBranchNameSeries(branchInput: string | undefined, total: number): Array<string | undefined> {
|
|
68
|
+
if (total <= 0) return [];
|
|
69
|
+
if (!branchInput) {
|
|
70
|
+
return Array.from({ length: total }, () => undefined);
|
|
71
|
+
}
|
|
72
|
+
const baseName = branchInput;
|
|
73
|
+
const names: Array<string | undefined> = [baseName];
|
|
74
|
+
for (let i = 1; i < total; i += 1) {
|
|
75
|
+
names.push(`${baseName}-${i + 1}`);
|
|
76
|
+
}
|
|
77
|
+
return names;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function appendPathSuffix(filePath: string, suffix: string): string {
|
|
81
|
+
const parsed = path.parse(filePath);
|
|
82
|
+
const nextName = parsed.name ? `${parsed.name}-${suffix}` : suffix;
|
|
83
|
+
return path.join(parsed.dir, `${nextName}${parsed.ext}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function deriveIndexedPath(basePath: string | undefined, index: number, total: number, label: string): string | undefined {
|
|
87
|
+
if (!basePath) return undefined;
|
|
88
|
+
if (total <= 1 || index === 0) return basePath;
|
|
89
|
+
return appendPathSuffix(basePath, `${label}-${index + 1}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 构建多任务执行计划。
|
|
94
|
+
*/
|
|
95
|
+
export function buildTaskPlans(input: TaskPlanInput): TaskPlan[] {
|
|
96
|
+
const total = input.tasks.length;
|
|
97
|
+
if (total === 0) return [];
|
|
98
|
+
|
|
99
|
+
const branchNames: Array<string | undefined> = input.useWorktree
|
|
100
|
+
? buildBranchNameSeries(input.branchInput, total)
|
|
101
|
+
: input.tasks.map(() => input.branchInput);
|
|
102
|
+
|
|
103
|
+
return input.tasks.map((task, index) => {
|
|
104
|
+
const relayBaseBranch = input.useWorktree && input.mode === 'relay' && index > 0
|
|
105
|
+
? branchNames[index - 1] ?? input.baseBranch
|
|
106
|
+
: input.baseBranch;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
task,
|
|
110
|
+
index,
|
|
111
|
+
branchName: branchNames[index],
|
|
112
|
+
baseBranch: relayBaseBranch,
|
|
113
|
+
worktreePath: deriveIndexedPath(input.worktreePath, index, total, 'task'),
|
|
114
|
+
logFile: deriveIndexedPath(input.logFile, index, total, 'task')
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export interface PlanItem {
|
|
2
|
+
readonly index: number;
|
|
3
|
+
readonly raw: string;
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly completed: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const ITEM_PATTERN = /^(\s*)([-*+]|\d+\.)\s+(.*)$/;
|
|
9
|
+
|
|
10
|
+
function isCompleted(content: string): boolean {
|
|
11
|
+
if (content.includes('✅')) return true;
|
|
12
|
+
if (/\[[xX]\]/.test(content)) return true;
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeText(content: string): string {
|
|
17
|
+
return content
|
|
18
|
+
.replace(/\[[xX ]\]\s*/g, '')
|
|
19
|
+
.replace(/✅/g, '')
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 解析 plan.md 中的计划项。
|
|
25
|
+
*/
|
|
26
|
+
export function parsePlanItems(plan: string): PlanItem[] {
|
|
27
|
+
const lines = plan.split(/\r?\n/);
|
|
28
|
+
const items: PlanItem[] = [];
|
|
29
|
+
|
|
30
|
+
lines.forEach((line, index) => {
|
|
31
|
+
const match = line.match(ITEM_PATTERN);
|
|
32
|
+
if (!match) return;
|
|
33
|
+
const content = match[3] ?? '';
|
|
34
|
+
const text = normalizeText(content);
|
|
35
|
+
if (!text) return;
|
|
36
|
+
items.push({
|
|
37
|
+
index,
|
|
38
|
+
raw: line,
|
|
39
|
+
text,
|
|
40
|
+
completed: isCompleted(content)
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
return items;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 获取尚未完成的计划项。
|
|
49
|
+
*/
|
|
50
|
+
export function getPendingPlanItems(plan: string): PlanItem[] {
|
|
51
|
+
return parsePlanItems(plan).filter(item => !item.completed);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 获取最后一条未完成的计划项。
|
|
56
|
+
*/
|
|
57
|
+
export function getLastPendingPlanItem(plan: string): PlanItem | null {
|
|
58
|
+
const pending = getPendingPlanItems(plan);
|
|
59
|
+
if (pending.length === 0) return null;
|
|
60
|
+
return pending[pending.length - 1] ?? null;
|
|
61
|
+
}
|
package/src/quality.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface QualityCommand {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly command: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function hasScript(scripts: Record<string, string>, name: string): boolean {
|
|
10
|
+
return typeof scripts[name] === 'string' && scripts[name].trim().length > 0;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 读取 package.json,解析可用的代码质量检查命令。
|
|
15
|
+
*/
|
|
16
|
+
export async function detectQualityCommands(workDir: string): Promise<QualityCommand[]> {
|
|
17
|
+
const packagePath = path.join(workDir, 'package.json');
|
|
18
|
+
const exists = await fs.pathExists(packagePath);
|
|
19
|
+
if (!exists) return [];
|
|
20
|
+
|
|
21
|
+
const pkg = await fs.readJson(packagePath);
|
|
22
|
+
const scripts = typeof pkg === 'object' && pkg && typeof (pkg as { scripts?: unknown }).scripts === 'object'
|
|
23
|
+
? ((pkg as { scripts: Record<string, string> }).scripts ?? {})
|
|
24
|
+
: {};
|
|
25
|
+
|
|
26
|
+
const commands: QualityCommand[] = [];
|
|
27
|
+
const seen = new Set<string>();
|
|
28
|
+
|
|
29
|
+
const append = (name: string, command: string): void => {
|
|
30
|
+
if (seen.has(name)) return;
|
|
31
|
+
if (!hasScript(scripts, name)) return;
|
|
32
|
+
commands.push({ name, command });
|
|
33
|
+
seen.add(name);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
append('lint', 'yarn lint');
|
|
37
|
+
append('lint:ci', 'yarn lint:ci');
|
|
38
|
+
append('lint:check', 'yarn lint:check');
|
|
39
|
+
append('typecheck', 'yarn typecheck');
|
|
40
|
+
append('format:check', 'yarn format:check');
|
|
41
|
+
append('format:ci', 'yarn format:ci');
|
|
42
|
+
|
|
43
|
+
if (!hasScript(scripts, 'format:check') && !hasScript(scripts, 'format:ci')) {
|
|
44
|
+
append('format', 'yarn format');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return commands;
|
|
48
|
+
}
|
package/src/runtime-tracker.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -91,6 +91,7 @@ export interface PrConfig {
|
|
|
91
91
|
readonly bodyPath?: string;
|
|
92
92
|
readonly draft?: boolean;
|
|
93
93
|
readonly reviewers?: string[];
|
|
94
|
+
readonly autoMerge?: boolean;
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
/**
|
|
@@ -131,6 +132,7 @@ export interface LoopConfig {
|
|
|
131
132
|
readonly autoCommit: boolean;
|
|
132
133
|
readonly autoPush: boolean;
|
|
133
134
|
readonly skipInstall: boolean;
|
|
135
|
+
readonly skipQuality: boolean;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
/**
|
|
@@ -169,8 +171,29 @@ export interface StreamOptions {
|
|
|
169
171
|
*/
|
|
170
172
|
export interface IterationRecord {
|
|
171
173
|
readonly iteration: number;
|
|
174
|
+
readonly stage?: string;
|
|
172
175
|
readonly prompt: string;
|
|
173
176
|
readonly aiOutput: string;
|
|
174
177
|
readonly timestamp: string;
|
|
175
178
|
readonly testResults?: TestRunResult[];
|
|
179
|
+
readonly checkResults?: CheckRunResult[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 代码质量检查结果。
|
|
184
|
+
*/
|
|
185
|
+
export interface CheckRunResult {
|
|
186
|
+
readonly name: string;
|
|
187
|
+
readonly command: string;
|
|
188
|
+
readonly success: boolean;
|
|
189
|
+
readonly exitCode: number;
|
|
190
|
+
readonly stdout: string;
|
|
191
|
+
readonly stderr: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 主流程返回信息。
|
|
196
|
+
*/
|
|
197
|
+
export interface LoopResult {
|
|
198
|
+
readonly branchName?: string;
|
|
176
199
|
}
|