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/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
- pageOffsets: Map<string, number>;
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 tasks = await Promise.all(entries.map(async ([key, meta]) => {
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 suffix = errorMessage ? ` | 刷新失败:${errorMessage}` : '';
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 ? `刷新失败:${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, Math.ceil(lines.length / pageSize) - 1);
107
- const offset = state.pageOffsets.get(current.key) ?? maxOffset;
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.pageOffsets.set(current.key, nextOffset);
222
+ state.lineOffsets.set(current.key, nextOffset);
111
223
  state.stickToBottom.set(current.key, nextOffset === maxOffset);
112
224
 
113
- const start = nextOffset * pageSize;
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: nextOffset + 1, total: Math.max(1, maxOffset + 1) },
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.pageOffsets.keys()) {
271
+ for (const key of state.lineOffsets.keys()) {
152
272
  if (!existing.has(key)) {
153
- state.pageOffsets.delete(key);
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, Math.ceil(lines.length / pageSize) - 1);
177
- const offset = state.pageOffsets.get(current.key) ?? maxOffset;
178
- const nextOffset = Math.min(Math.max(offset + direction, 0), maxOffset);
179
- state.pageOffsets.set(current.key, nextOffset);
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
- movePage(state, -1);
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
- pageOffsets: new Map(),
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
+ }
@@ -49,7 +49,8 @@ export async function createRunTracker(options: RunTrackerOptions): Promise<RunT
49
49
  command,
50
50
  round,
51
51
  tokenUsed,
52
- path
52
+ path,
53
+ pid: process.pid
53
54
  };
54
55
  await safeWrite(logFile, metadata, logger);
55
56
  };
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
  }