hamster-wheel-cli 0.1.0 → 0.2.0-beta.1

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/loop.ts CHANGED
@@ -1,14 +1,28 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'node:path';
3
- import { buildPrompt, formatIterationRecord, mergeTokenUsage, runAi } from './ai';
3
+ import {
4
+ buildBranchNamePrompt,
5
+ buildDocsPrompt,
6
+ buildFixPrompt,
7
+ buildPlanItemPrompt,
8
+ buildPlanningPrompt,
9
+ buildQualityPrompt,
10
+ buildTestPrompt,
11
+ formatIterationRecord,
12
+ mergeTokenUsage,
13
+ parseBranchName,
14
+ runAi
15
+ } from './ai';
4
16
  import { ensureDependencies } from './deps';
5
- import { GhPrInfo, createPr, listFailedRuns, viewPr } from './gh';
17
+ import { GhPrInfo, createPr, enableAutoMerge, listFailedRuns, viewPr } from './gh';
6
18
  import { Logger } from './logger';
7
- import { commitAll, ensureWorktree, getCurrentBranch, getRepoRoot, isBranchPushed, isWorktreeClean, pushBranch, removeWorktree } from './git';
19
+ import { commitAll, ensureWorktree, generateBranchNameFromTask, getCurrentBranch, getRepoRoot, isBranchPushed, isWorktreeClean, pushBranch, removeWorktree } from './git';
8
20
  import { formatCommandLine } from './logs';
21
+ import { getPendingPlanItems } from './plan';
22
+ import { detectQualityCommands } from './quality';
9
23
  import { createRunTracker } from './runtime-tracker';
10
24
  import { buildFallbackSummary, buildSummaryPrompt, ensurePrBodySections, parseDeliverySummary } from './summary';
11
- import { CommitMessage, DeliverySummary, LoopConfig, TestRunResult, TokenUsage, WorkflowFiles, WorktreeResult } from './types';
25
+ import { CheckRunResult, CommitMessage, DeliverySummary, LoopConfig, LoopResult, TestRunResult, TokenUsage, WorkflowFiles, WorktreeResult } from './types';
12
26
  import { appendSection, ensureFile, isoNow, readFileSafe, runCommand } from './utils';
13
27
  import { buildWebhookPayload, sendWebhookNotifications } from './webhook';
14
28
 
@@ -18,14 +32,20 @@ async function ensureWorkflowFiles(workflowFiles: WorkflowFiles): Promise<void>
18
32
  await ensureFile(workflowFiles.notesFile, '# 持久化记忆\n');
19
33
  }
20
34
 
21
- const MAX_TEST_LOG_LENGTH = 4000;
35
+ const MAX_LOG_LENGTH = 4000;
22
36
 
23
- function trimOutput(output: string, limit = MAX_TEST_LOG_LENGTH): string {
37
+ function trimOutput(output: string, limit = MAX_LOG_LENGTH): string {
24
38
  if (!output) return '';
25
39
  if (output.length <= limit) return output;
26
40
  return `${output.slice(0, limit)}\n……(输出已截断,原始长度 ${output.length} 字符)`;
27
41
  }
28
42
 
43
+ function truncateText(text: string, limit = 24): string {
44
+ const trimmed = text.trim();
45
+ if (trimmed.length <= limit) return trimmed;
46
+ return `${trimmed.slice(0, limit)}...`;
47
+ }
48
+
29
49
  async function safeCommandOutput(command: string, args: string[], cwd: string, logger: Logger, label: string, verboseCommand: string): Promise<string> {
30
50
  const result = await runCommand(command, args, {
31
51
  cwd,
@@ -66,6 +86,77 @@ async function runSingleTest(kind: 'unit' | 'e2e', command: string, cwd: string,
66
86
  };
67
87
  }
68
88
 
89
+ async function runQualityChecks(commands: { name: string; command: string }[], cwd: string, logger: Logger): Promise<CheckRunResult[]> {
90
+ const results: CheckRunResult[] = [];
91
+ for (const item of commands) {
92
+ logger.info(`执行质量检查: ${item.command}`);
93
+ const result = await runCommand('bash', ['-lc', item.command], {
94
+ cwd,
95
+ logger,
96
+ verboseLabel: 'shell',
97
+ verboseCommand: `bash -lc "${item.command}"`
98
+ });
99
+ results.push({
100
+ name: item.name,
101
+ command: item.command,
102
+ success: result.exitCode === 0,
103
+ exitCode: result.exitCode,
104
+ stdout: trimOutput(result.stdout.trim()),
105
+ stderr: trimOutput(result.stderr.trim())
106
+ });
107
+ }
108
+ return results;
109
+ }
110
+
111
+ function buildCheckResultSummary(results: CheckRunResult[]): string {
112
+ if (results.length === 0) return '(未执行质量检查)';
113
+ return results
114
+ .map(result => {
115
+ const status = result.success ? '通过' : `失败(退出码 ${result.exitCode})`;
116
+ const output = result.success ? '' : `\n${result.stderr || result.stdout || '(无输出)'}`;
117
+ return `- ${result.name}: ${status}|命令: ${result.command}${output}`;
118
+ })
119
+ .join('\n');
120
+ }
121
+
122
+ function buildFailedCheckSummary(results: CheckRunResult[]): string {
123
+ return buildCheckResultSummary(results.filter(result => !result.success));
124
+ }
125
+
126
+ function buildTestResultSummary(results: TestRunResult[]): string {
127
+ if (results.length === 0) return '(未执行测试)';
128
+ return results
129
+ .map(result => {
130
+ const label = result.kind === 'unit' ? '单元测试' : 'e2e 测试';
131
+ const status = result.success ? '通过' : `失败(退出码 ${result.exitCode})`;
132
+ const output = result.success ? '' : `\n${result.stderr || result.stdout || '(无输出)'}`;
133
+ return `- ${label}: ${status}|命令: ${result.command}${output}`;
134
+ })
135
+ .join('\n');
136
+ }
137
+
138
+ function buildFailedTestSummary(results: TestRunResult[]): string {
139
+ return buildTestResultSummary(results.filter(result => !result.success));
140
+ }
141
+
142
+ function formatSystemRecord(stage: string, detail: string, timestamp: string): string {
143
+ return [
144
+ `### 记录 | ${timestamp} | ${stage}`,
145
+ '',
146
+ detail,
147
+ ''
148
+ ].join('\n');
149
+ }
150
+
151
+ function shouldSkipQuality(content: string, cliSkip: boolean): boolean {
152
+ if (cliSkip) return true;
153
+ const normalized = content.replace(/\s+/g, '');
154
+ if (!normalized) return false;
155
+ return normalized.includes('不要检查代码质量')
156
+ || normalized.includes('不检查代码质量')
157
+ || normalized.includes('跳过代码质量');
158
+ }
159
+
69
160
  async function runTests(config: LoopConfig, workDir: string, logger: Logger): Promise<TestRunResult[]> {
70
161
  const results: TestRunResult[] = [];
71
162
 
@@ -82,6 +173,23 @@ async function runTests(config: LoopConfig, workDir: string, logger: Logger): Pr
82
173
  return results;
83
174
  }
84
175
 
176
+ async function runTestsSafely(config: LoopConfig, workDir: string, logger: Logger): Promise<TestRunResult[]> {
177
+ try {
178
+ return await runTests(config, workDir, logger);
179
+ } catch (error) {
180
+ const errorMessage = String(error);
181
+ logger.warn(`测试执行异常: ${errorMessage}`);
182
+ return [{
183
+ kind: 'unit',
184
+ command: config.tests.unitCommand ?? '未知测试命令',
185
+ success: false,
186
+ exitCode: -1,
187
+ stdout: '',
188
+ stderr: trimOutput(errorMessage)
189
+ }];
190
+ }
191
+ }
192
+
85
193
  function reRootPath(filePath: string, repoRoot: string, workDir: string): string {
86
194
  const relative = path.relative(repoRoot, filePath);
87
195
  if (relative.startsWith('..')) return filePath;
@@ -161,29 +269,28 @@ async function cleanupWorktreeIfSafe(context: WorktreeCleanupContext): Promise<v
161
269
  /**
162
270
  * 执行主迭代循环。
163
271
  */
164
- export async function runLoop(config: LoopConfig): Promise<void> {
272
+ export async function runLoop(config: LoopConfig): Promise<LoopResult> {
165
273
  const logger = new Logger({ verbose: config.verbose, logFile: config.logFile });
166
274
  const repoRoot = await getRepoRoot(config.cwd, logger);
167
275
  logger.debug(`仓库根目录: ${repoRoot}`);
168
276
 
169
- const worktreeResult: WorktreeResult = config.git.useWorktree
170
- ? await ensureWorktree(config.git, repoRoot, logger)
171
- : { path: repoRoot, created: false };
172
- const workDir = worktreeResult.path;
173
- const worktreeCreated = worktreeResult.created;
174
- logger.debug(`工作目录: ${workDir}`);
277
+ let branchName = config.git.branchName;
278
+ let workDir = repoRoot;
279
+ let worktreeCreated = false;
175
280
 
176
281
  const commandLine = formatCommandLine(process.argv);
177
- const runTracker = await createRunTracker({
178
- logFile: config.logFile,
179
- command: commandLine,
180
- path: workDir,
181
- logger
182
- });
282
+ let runTracker: Awaited<ReturnType<typeof createRunTracker>> | null = null;
183
283
 
184
- let branchName = config.git.branchName;
284
+ let accumulatedUsage: TokenUsage | null = null;
285
+ let lastTestResults: TestRunResult[] | null = null;
286
+ let lastAiOutput = '';
185
287
  let lastRound = 0;
186
288
  let runError: string | null = null;
289
+ let prInfo: GhPrInfo | null = null;
290
+ let prFailed = false;
291
+ let sessionIndex = 0;
292
+
293
+ const preWorktreeRecords: string[] = [];
187
294
 
188
295
  const notifyWebhook = async (event: 'task_start' | 'iteration_start' | 'task_end', iteration: number, stage: string): Promise<void> => {
189
296
  const payload = buildWebhookPayload({
@@ -197,16 +304,54 @@ export async function runLoop(config: LoopConfig): Promise<void> {
197
304
  };
198
305
 
199
306
  try {
200
- if (!branchName) {
201
- try {
202
- branchName = await getCurrentBranch(workDir, logger);
203
- } catch (error) {
204
- const message = error instanceof Error ? error.message : String(error);
205
- logger.warn(`读取分支名失败,webhook 中将缺失分支信息:${message}`);
307
+ await notifyWebhook('task_start', 0, '任务开始');
308
+
309
+ if (config.git.useWorktree && !branchName) {
310
+ const branchPrompt = buildBranchNamePrompt({ task: config.task });
311
+ await notifyWebhook('iteration_start', sessionIndex + 1, '分支名生成');
312
+ logger.info('分支名生成提示构建完成,调用 AI CLI...');
313
+ const aiResult = await runAi(branchPrompt, config.ai, logger, repoRoot);
314
+ accumulatedUsage = mergeTokenUsage(accumulatedUsage, aiResult.usage);
315
+ lastAiOutput = aiResult.output;
316
+ sessionIndex += 1;
317
+ lastRound = sessionIndex;
318
+
319
+ const record = formatIterationRecord({
320
+ iteration: sessionIndex,
321
+ stage: '分支名生成',
322
+ prompt: branchPrompt,
323
+ aiOutput: aiResult.output,
324
+ timestamp: isoNow()
325
+ });
326
+ preWorktreeRecords.push(record);
327
+
328
+ const parsed = parseBranchName(aiResult.output);
329
+ if (parsed) {
330
+ branchName = parsed;
331
+ logger.info(`AI 生成分支名:${branchName}`);
332
+ } else {
333
+ branchName = generateBranchNameFromTask(config.task);
334
+ logger.warn(`未解析到 AI 分支名,使用兜底分支:${branchName}`);
206
335
  }
207
336
  }
208
337
 
209
- await notifyWebhook('task_start', 0, '任务开始');
338
+ const worktreeResult: WorktreeResult = config.git.useWorktree
339
+ ? await ensureWorktree({ ...config.git, branchName }, repoRoot, logger)
340
+ : { path: repoRoot, created: false };
341
+ workDir = worktreeResult.path;
342
+ worktreeCreated = worktreeResult.created;
343
+ logger.debug(`工作目录: ${workDir}`);
344
+
345
+ runTracker = await createRunTracker({
346
+ logFile: config.logFile,
347
+ command: commandLine,
348
+ path: workDir,
349
+ logger
350
+ });
351
+
352
+ if (runTracker && sessionIndex > 0) {
353
+ await runTracker.update(sessionIndex, accumulatedUsage?.totalTokens ?? 0);
354
+ }
210
355
 
211
356
  if (config.skipInstall) {
212
357
  logger.info('已跳过依赖检查');
@@ -217,94 +362,231 @@ export async function runLoop(config: LoopConfig): Promise<void> {
217
362
  const workflowFiles = reRootWorkflowFiles(config.workflowFiles, repoRoot, workDir);
218
363
  await ensureWorkflowFiles(workflowFiles);
219
364
 
365
+ if (preWorktreeRecords.length > 0) {
366
+ for (const record of preWorktreeRecords) {
367
+ await appendSection(workflowFiles.notesFile, record);
368
+ }
369
+ logger.success(`已写入分支名生成记录至 ${workflowFiles.notesFile}`);
370
+ }
371
+
220
372
  const planContent = await readFileSafe(workflowFiles.planFile);
221
373
  if (planContent.trim().length === 0) {
222
374
  logger.warn('plan 文件为空,建议 AI 首轮生成计划');
223
375
  }
224
376
 
377
+ if (!branchName) {
378
+ try {
379
+ branchName = await getCurrentBranch(workDir, logger);
380
+ } catch (error) {
381
+ const message = error instanceof Error ? error.message : String(error);
382
+ logger.warn(`读取分支名失败,webhook 中将缺失分支信息:${message}`);
383
+ }
384
+ }
385
+
225
386
  const aiConfig = config.ai;
387
+ const loadContext = async (): Promise<{ workflowGuide: string; plan: string; notes: string }> => ({
388
+ workflowGuide: await readFileSafe(workflowFiles.workflowDoc),
389
+ plan: await readFileSafe(workflowFiles.planFile),
390
+ notes: await readFileSafe(workflowFiles.notesFile)
391
+ });
392
+
393
+ const runAiSession = async (
394
+ stage: string,
395
+ prompt: string,
396
+ extras?: { testResults?: TestRunResult[]; checkResults?: CheckRunResult[]; cwd?: string }
397
+ ): Promise<void> => {
398
+ sessionIndex += 1;
399
+ await notifyWebhook('iteration_start', sessionIndex, stage);
400
+ logger.info(`${stage} 提示构建完成,调用 AI CLI...`);
226
401
 
227
- let accumulatedUsage: TokenUsage | null = null;
228
- let lastTestResults: TestRunResult[] | null = null;
229
- let lastAiOutput = '';
230
- let prInfo: GhPrInfo | null = null;
231
- let prFailed = false;
402
+ const aiResult = await runAi(prompt, aiConfig, logger, extras?.cwd ?? workDir);
403
+ accumulatedUsage = mergeTokenUsage(accumulatedUsage, aiResult.usage);
404
+ lastAiOutput = aiResult.output;
232
405
 
233
- for (let i = 1; i <= config.iterations; i += 1) {
234
- await notifyWebhook('iteration_start', i, `开始第 ${i} 轮迭代`);
406
+ const record = formatIterationRecord({
407
+ iteration: sessionIndex,
408
+ stage,
409
+ prompt,
410
+ aiOutput: aiResult.output,
411
+ timestamp: isoNow(),
412
+ testResults: extras?.testResults,
413
+ checkResults: extras?.checkResults
414
+ });
415
+
416
+ await appendSection(workflowFiles.notesFile, record);
417
+ logger.success(`已将${stage}输出写入 ${workflowFiles.notesFile}`);
235
418
 
236
- const workflowGuide = await readFileSafe(workflowFiles.workflowDoc);
237
- const plan = await readFileSafe(workflowFiles.planFile);
238
- const notes = await readFileSafe(workflowFiles.notesFile);
239
- logger.debug(`加载提示上下文,长度:workflow=${workflowGuide.length}, plan=${plan.length}, notes=${notes.length}`);
419
+ lastRound = sessionIndex;
420
+ await runTracker?.update(sessionIndex, accumulatedUsage?.totalTokens ?? 0);
421
+ };
240
422
 
241
- const prompt = buildPrompt({
423
+ {
424
+ const { workflowGuide, plan, notes } = await loadContext();
425
+ const planningPrompt = buildPlanningPrompt({
242
426
  task: config.task,
243
427
  workflowGuide,
244
428
  plan,
245
429
  notes,
246
- iteration: i
430
+ branchName
247
431
  });
248
- logger.debug(`第 ${i} 轮提示长度: ${prompt.length}`);
432
+ await runAiSession('计划生成', planningPrompt);
433
+ }
249
434
 
250
- logger.info(`第 ${i} 轮提示构建完成,调用 AI CLI...`);
251
- const aiResult = await runAi(prompt, aiConfig, logger, workDir);
252
- accumulatedUsage = mergeTokenUsage(accumulatedUsage, aiResult.usage);
253
- lastAiOutput = aiResult.output;
435
+ let refreshedPlan = await readFileSafe(workflowFiles.planFile);
436
+ if (/(测试|test|e2e|单测)/i.test(refreshedPlan)) {
437
+ logger.warn('检测到 plan 中可能包含测试相关事项,建议保留开发内容并移除测试项。');
438
+ }
254
439
 
255
- const hitStop = aiResult.output.includes(config.stopSignal);
256
- let testResults: TestRunResult[] = [];
257
- const shouldRunTests = config.runTests || config.runE2e;
258
- if (shouldRunTests) {
259
- try {
260
- testResults = await runTests(config, workDir, logger);
261
- } catch (error) {
262
- const errorMessage = String(error);
263
- logger.warn(`测试执行异常: ${errorMessage}`);
264
- testResults = [{
265
- kind: 'unit',
266
- command: config.tests.unitCommand ?? '未知测试命令',
267
- success: false,
268
- exitCode: -1,
269
- stdout: '',
270
- stderr: trimOutput(errorMessage)
271
- }];
272
- }
273
- }
440
+ let pendingItems = getPendingPlanItems(refreshedPlan);
441
+ if (pendingItems.length === 0) {
442
+ logger.info('计划暂无待执行项,跳过计划执行循环');
443
+ const record = formatSystemRecord('计划执行', '未发现待执行计划项,已跳过执行循环。', isoNow());
444
+ await appendSection(workflowFiles.notesFile, record);
445
+ }
274
446
 
275
- const record = formatIterationRecord({
276
- iteration: i,
277
- prompt,
278
- aiOutput: aiResult.output,
279
- timestamp: isoNow(),
280
- testResults
447
+ let planRounds = 0;
448
+ while (pendingItems.length > 0) {
449
+ if (planRounds >= config.iterations) {
450
+ throw new Error('计划执行达到最大迭代次数,仍有未完成项');
451
+ }
452
+ const lastItem = pendingItems[pendingItems.length - 1];
453
+ const { workflowGuide, plan, notes } = await loadContext();
454
+ const itemPrompt = buildPlanItemPrompt({
455
+ task: config.task,
456
+ workflowGuide,
457
+ plan,
458
+ notes,
459
+ item: lastItem.text
281
460
  });
461
+ await runAiSession(`执行计划项:${truncateText(lastItem.text)}`, itemPrompt);
462
+ planRounds += 1;
463
+ refreshedPlan = await readFileSafe(workflowFiles.planFile);
464
+ pendingItems = getPendingPlanItems(refreshedPlan);
465
+ }
282
466
 
467
+ const agentsContent = await readFileSafe(path.join(workDir, 'AGENTS.md'));
468
+ const skipQuality = shouldSkipQuality(agentsContent, config.skipQuality);
469
+ if (skipQuality) {
470
+ const record = formatSystemRecord('代码质量检查', '已按配置/AGENTS.md 跳过代码质量检查。', isoNow());
283
471
  await appendSection(workflowFiles.notesFile, record);
284
- logger.success(`已将第 ${i} 轮输出写入 ${workflowFiles.notesFile}`);
472
+ logger.info('已跳过代码质量检查');
473
+ } else {
474
+ const qualityCommands = await detectQualityCommands(workDir);
475
+ if (qualityCommands.length === 0) {
476
+ const record = formatSystemRecord('代码质量检查', '未检测到可执行的质量检查命令,已跳过。', isoNow());
477
+ await appendSection(workflowFiles.notesFile, record);
478
+ logger.info('未检测到质量检查命令,跳过该阶段');
479
+ } else {
480
+ let qualityResults = await runQualityChecks(qualityCommands, workDir, logger);
481
+ const { workflowGuide, plan, notes } = await loadContext();
482
+ const qualityPrompt = buildQualityPrompt({
483
+ task: config.task,
484
+ workflowGuide,
485
+ plan,
486
+ notes,
487
+ commands: qualityCommands.map(item => item.command),
488
+ results: buildCheckResultSummary(qualityResults)
489
+ });
490
+ await runAiSession('代码质量检查', qualityPrompt, { checkResults: qualityResults });
491
+
492
+ let hasQualityFailure = qualityResults.some(result => !result.success);
493
+ let fixRounds = 0;
494
+ while (hasQualityFailure) {
495
+ if (fixRounds >= config.iterations) {
496
+ throw new Error('代码质量修复达到最大轮次,仍未通过');
497
+ }
498
+ const latest = await loadContext();
499
+ const fixPrompt = buildFixPrompt({
500
+ task: config.task,
501
+ workflowGuide: latest.workflowGuide,
502
+ plan: latest.plan,
503
+ notes: latest.notes,
504
+ stage: '代码质量',
505
+ errors: buildFailedCheckSummary(qualityResults)
506
+ });
507
+ await runAiSession('代码质量修复', fixPrompt);
508
+ fixRounds += 1;
509
+ qualityResults = await runQualityChecks(qualityCommands, workDir, logger);
510
+ hasQualityFailure = qualityResults.some(result => !result.success);
285
511
 
286
- lastTestResults = testResults;
287
- lastRound = i;
288
- await runTracker?.update(i, accumulatedUsage?.totalTokens ?? 0);
512
+ const recheckRecord = formatSystemRecord('代码质量复核', buildCheckResultSummary(qualityResults), isoNow());
513
+ await appendSection(workflowFiles.notesFile, recheckRecord);
514
+ }
515
+ }
516
+ }
289
517
 
290
- const hasTestFailure = testResults.some(result => !result.success);
518
+ if (config.runTests || config.runE2e) {
519
+ let testResults = await runTestsSafely(config, workDir, logger);
291
520
 
292
- if (hitStop && !hasTestFailure) {
293
- logger.info(`检测到停止标记 ${config.stopSignal},提前结束循环`);
294
- break;
521
+ lastTestResults = testResults;
522
+ const testCommands: string[] = [];
523
+ if (config.runTests && config.tests.unitCommand) {
524
+ testCommands.push(config.tests.unitCommand);
525
+ }
526
+ if (config.runE2e && config.tests.e2eCommand) {
527
+ testCommands.push(config.tests.e2eCommand);
295
528
  }
296
- if (hitStop && hasTestFailure) {
297
- logger.info(`检测到停止标记 ${config.stopSignal},但测试失败,继续进入下一轮修复`);
529
+
530
+ const { workflowGuide, plan, notes } = await loadContext();
531
+ const testPrompt = buildTestPrompt({
532
+ task: config.task,
533
+ workflowGuide,
534
+ plan,
535
+ notes,
536
+ commands: testCommands,
537
+ results: buildTestResultSummary(testResults)
538
+ });
539
+ await runAiSession('测试执行', testPrompt, { testResults });
540
+
541
+ let hasTestFailure = testResults.some(result => !result.success);
542
+ let fixRounds = 0;
543
+ while (hasTestFailure) {
544
+ if (fixRounds >= config.iterations) {
545
+ throw new Error('测试修复达到最大轮次,仍未通过');
546
+ }
547
+ const latest = await loadContext();
548
+ const fixPrompt = buildFixPrompt({
549
+ task: config.task,
550
+ workflowGuide: latest.workflowGuide,
551
+ plan: latest.plan,
552
+ notes: latest.notes,
553
+ stage: '测试',
554
+ errors: buildFailedTestSummary(testResults)
555
+ });
556
+ await runAiSession('测试修复', fixPrompt, { testResults });
557
+ fixRounds += 1;
558
+
559
+ testResults = await runTestsSafely(config, workDir, logger);
560
+ lastTestResults = testResults;
561
+ hasTestFailure = testResults.some(result => !result.success);
562
+
563
+ const recheckRecord = formatSystemRecord('测试复核', buildTestResultSummary(testResults), isoNow());
564
+ await appendSection(workflowFiles.notesFile, recheckRecord);
298
565
  }
566
+ } else {
567
+ const record = formatSystemRecord('测试执行', '未开启单元测试或 e2e 测试,已跳过。', isoNow());
568
+ await appendSection(workflowFiles.notesFile, record);
569
+ logger.info('未开启测试阶段');
299
570
  }
300
571
 
301
- const lastTestFailed = lastTestResults?.some(result => !result.success) ?? false;
572
+ {
573
+ const { workflowGuide, plan, notes } = await loadContext();
574
+ const docsPrompt = buildDocsPrompt({
575
+ task: config.task,
576
+ workflowGuide,
577
+ plan,
578
+ notes
579
+ });
580
+ await runAiSession('文档更新', docsPrompt);
581
+ }
302
582
 
583
+ const lastTestFailed = lastTestResults?.some(result => !result.success) ?? false;
303
584
  if (lastTestFailed) {
304
585
  logger.warn('存在未通过的测试,已跳过自动提交/推送/PR');
305
586
  }
306
587
 
307
588
  let deliverySummary: DeliverySummary | null = null;
589
+ const deliveryNotes: string[] = [];
308
590
  const shouldPrepareDelivery = !lastTestFailed && (config.autoCommit || config.pr.enable);
309
591
  if (shouldPrepareDelivery) {
310
592
  const [gitStatus, diffStat] = await Promise.all([
@@ -334,57 +616,112 @@ export async function runLoop(config: LoopConfig): Promise<void> {
334
616
  if (!deliverySummary) {
335
617
  deliverySummary = buildFallbackSummary({ task: config.task, testResults: lastTestResults });
336
618
  }
619
+ if (deliverySummary) {
620
+ deliveryNotes.push(`交付摘要:提交 ${deliverySummary.commitTitle}|PR ${deliverySummary.prTitle}`);
621
+ }
337
622
  }
338
623
  await runTracker?.update(lastRound, accumulatedUsage?.totalTokens ?? 0);
339
624
 
340
- if (config.autoCommit && !lastTestFailed) {
341
- const summary = deliverySummary ?? buildFallbackSummary({ task: config.task, testResults: lastTestResults });
342
- const commitMessage: CommitMessage = {
343
- title: summary.commitTitle,
344
- body: summary.commitBody
345
- };
346
- await commitAll(commitMessage, workDir, logger).catch(error => {
347
- logger.warn(String(error));
348
- });
625
+ if (config.autoCommit) {
626
+ if (lastTestFailed) {
627
+ deliveryNotes.push('自动提交:已跳过(测试未通过)');
628
+ } else {
629
+ const summary = deliverySummary ?? buildFallbackSummary({ task: config.task, testResults: lastTestResults });
630
+ const commitMessage: CommitMessage = {
631
+ title: summary.commitTitle,
632
+ body: summary.commitBody
633
+ };
634
+ try {
635
+ const committed = await commitAll(commitMessage, workDir, logger);
636
+ deliveryNotes.push(committed ? `自动提交:已提交(${commitMessage.title})` : '自动提交:未生成提交(可能无变更或提交失败)');
637
+ } catch (error) {
638
+ deliveryNotes.push(`自动提交:失败(${String(error)})`);
639
+ }
640
+ }
641
+ } else {
642
+ deliveryNotes.push('自动提交:未开启');
349
643
  }
350
644
 
351
- if (config.autoPush && branchName && !lastTestFailed) {
352
- await pushBranch(branchName, workDir, logger).catch(error => {
353
- logger.warn(String(error));
354
- });
645
+ if (config.autoPush) {
646
+ if (lastTestFailed) {
647
+ deliveryNotes.push('自动推送:已跳过(测试未通过)');
648
+ } else if (!branchName) {
649
+ deliveryNotes.push('自动推送:已跳过(缺少分支名)');
650
+ } else {
651
+ try {
652
+ await pushBranch(branchName, workDir, logger);
653
+ deliveryNotes.push(`自动推送:已推送(${branchName})`);
654
+ } catch (error) {
655
+ deliveryNotes.push(`自动推送:失败(${String(error)})`);
656
+ }
657
+ }
658
+ } else {
659
+ deliveryNotes.push('自动推送:未开启');
355
660
  }
356
661
 
357
- if (config.pr.enable && branchName && !lastTestFailed) {
358
- logger.info('开始创建 PR...');
359
- const summary = deliverySummary ?? buildFallbackSummary({ task: config.task, testResults: lastTestResults });
360
- const prTitleCandidate = config.pr.title?.trim() || summary.prTitle;
361
- const prBodyContent = ensurePrBodySections(summary.prBody, {
362
- commitTitle: summary.commitTitle,
363
- commitBody: summary.commitBody,
364
- testResults: lastTestResults
365
- });
366
- const bodyFile = config.pr.bodyPath ?? buildBodyFile(workDir);
367
- await writePrBody(bodyFile, prBodyContent, Boolean(config.pr.bodyPath));
368
-
369
- const createdPr = await createPr(branchName, { ...config.pr, title: prTitleCandidate, bodyPath: bodyFile }, workDir, logger);
370
- prInfo = createdPr;
371
- if (createdPr) {
372
- logger.success(`PR 已创建: ${createdPr.url}`);
373
- const failedRuns = await listFailedRuns(branchName, workDir, logger);
374
- if (failedRuns.length > 0) {
375
- failedRuns.forEach(run => {
376
- logger.warn(`Actions 失败: ${run.name} (${run.status}/${run.conclusion ?? 'unknown'}) ${run.url}`);
377
- });
378
- }
662
+ if (config.pr.enable) {
663
+ if (lastTestFailed) {
664
+ deliveryNotes.push('PR 创建:已跳过(测试未通过)');
665
+ } else if (!branchName) {
666
+ deliveryNotes.push('PR 创建:已跳过(缺少分支名)');
379
667
  } else {
380
- prFailed = true;
381
- logger.error('PR 创建失败,详见上方 gh 输出');
668
+ logger.info('开始创建 PR...');
669
+ const summary = deliverySummary ?? buildFallbackSummary({ task: config.task, testResults: lastTestResults });
670
+ const prTitleCandidate = config.pr.title?.trim() || summary.prTitle;
671
+ const prBodyContent = ensurePrBodySections(summary.prBody, {
672
+ commitTitle: summary.commitTitle,
673
+ commitBody: summary.commitBody,
674
+ testResults: lastTestResults
675
+ });
676
+ const bodyFile = config.pr.bodyPath ?? buildBodyFile(workDir);
677
+ await writePrBody(bodyFile, prBodyContent, Boolean(config.pr.bodyPath));
678
+
679
+ const createdPr = await createPr(branchName, { ...config.pr, title: prTitleCandidate, bodyPath: bodyFile }, workDir, logger);
680
+ prInfo = createdPr;
681
+ if (createdPr) {
682
+ logger.success(`PR 已创建: ${createdPr.url}`);
683
+ deliveryNotes.push(`PR 创建:已完成(${createdPr.url})`);
684
+ if (config.pr.autoMerge) {
685
+ const target = createdPr.number > 0 ? createdPr.number : createdPr.url;
686
+ const merged = await enableAutoMerge(target, workDir, logger);
687
+ if (merged) {
688
+ deliveryNotes.push('PR 自动合并:已启用');
689
+ } else {
690
+ deliveryNotes.push('PR 自动合并:启用失败');
691
+ prFailed = true;
692
+ }
693
+ } else {
694
+ deliveryNotes.push('PR 自动合并:未开启');
695
+ }
696
+ const failedRuns = await listFailedRuns(branchName, workDir, logger);
697
+ if (failedRuns.length > 0) {
698
+ failedRuns.forEach(run => {
699
+ logger.warn(`Actions 失败: ${run.name} (${run.status}/${run.conclusion ?? 'unknown'}) ${run.url}`);
700
+ });
701
+ }
702
+ } else {
703
+ prFailed = true;
704
+ deliveryNotes.push('PR 创建:失败(详见 gh 输出)');
705
+ logger.error('PR 创建失败,详见上方 gh 输出');
706
+ }
382
707
  }
383
- } else if (branchName && !config.pr.enable) {
708
+ } else if (branchName) {
384
709
  logger.info('未开启 PR 创建(--pr 未传),尝试查看已有 PR');
385
710
  const existingPr = await viewPr(branchName, workDir, logger);
386
711
  prInfo = existingPr;
387
- if (existingPr) logger.info(`已有 PR: ${existingPr.url}`);
712
+ if (existingPr) {
713
+ logger.info(`已有 PR: ${existingPr.url}`);
714
+ deliveryNotes.push(`PR 创建:未开启(已存在 PR:${existingPr.url})`);
715
+ } else {
716
+ deliveryNotes.push('PR 创建:未开启(未检测到已有 PR)');
717
+ }
718
+ } else {
719
+ deliveryNotes.push('PR 创建:未开启(缺少分支名)');
720
+ }
721
+
722
+ if (deliveryNotes.length > 0) {
723
+ const record = formatSystemRecord('提交与PR', deliveryNotes.join('\n'), isoNow());
724
+ await appendSection(workflowFiles.notesFile, record);
388
725
  }
389
726
 
390
727
  if (accumulatedUsage) {
@@ -411,6 +748,7 @@ export async function runLoop(config: LoopConfig): Promise<void> {
411
748
  }
412
749
 
413
750
  logger.success(`wheel-ai 迭代流程结束|Token 总计 ${accumulatedUsage?.totalTokens ?? '未知'}`);
751
+ return { branchName };
414
752
  } catch (error) {
415
753
  runError = error instanceof Error ? error.message : String(error);
416
754
  throw error;