svharness 0.15.8 → 0.15.11

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.
@@ -3,8 +3,8 @@
3
3
  > **文档定位**:`svharness auto` 模式的完整设计书,面向本系统的开发者与维护者。
4
4
  > **上级文档**:`svharnessbuild/README.md`(CLI 总体规范)、`svharnessbuild/tmp/svharnessbuild-detail-design.md`(SDD)。
5
5
  > **参照工程**:[snarktank/ralph](https://github.com/snarktank/ralph)(外循环思路)。
6
- > **版本**:v2.5(2026-06-16,新增 Web 仪表盘、默认标志优化、优化容错)
7
- > **最后更新**:2026-06-16
6
+ > **版本**:v2.6(2026-06-26,任意错误不中断 + CostTracker 超限兜底优化)
7
+ > **最后更新**:2026-06-26
8
8
 
9
9
  ---
10
10
 
@@ -399,9 +399,10 @@ program
399
399
  .option('-y, --yes', '跳过所有交互确认(推荐无人值守)')
400
400
  .option('--verbose', '详细日志')
401
401
  // === 成本控制 ===
402
- .option('--max-total-calls <n>', '总 ACP 调用次数上限(含重试和优化),超出即冻结', '30')
402
+ .option('--max-total-calls <n>', '总 ACP 调用次数上限(含重试和优化),超出即冻结', '100')
403
403
  .option('--budget-usd <amount>', '费用预算上限(USD),超出暂停等待确认')
404
404
  .option('--cost-alert <amount>', '单次调用费用告警阈值(USD)', '5')
405
+ .option('--no-auto-repair', '禁用阶段失败自动修复(默认启用)')
405
406
  .action(runAuto);
406
407
  ```
407
408
 
@@ -440,20 +441,46 @@ class AutoOrchestrator {
440
441
  this.ensurePhaseRunnerReady();
441
442
 
442
443
  // === AGENT-2: 构建 ===
443
- await this.runHarnessBuild(); // 驱动 S10→S80 全流程
444
+ try {
445
+ await this.runHarnessBuild(); // 驱动 S10→S80 全流程
446
+ } catch (err) {
447
+ // v2.6: 构建阶段失败不裸崩溃,先 emit build_failed 事件再终止
448
+ dashboardEventBus.emitBuildFailed(undefined);
449
+ throw new Error(`构建阶段失败:${err.message};auto 构建终止,请在修复后重新执行`);
450
+ }
444
451
 
445
452
  // === AGENT-3 + AGENT-4: 审查 + 优化循环 ===
446
453
  let round = 0;
447
454
  while (round < this.ctx.maxOptimizeRounds) {
448
455
  round++;
449
- const score = await this.runHarnessReview(round); // 产出 第<n>评审报告.md
450
- if (score.composite_score >= this.ctx.targetScore && score.gate_pass) {
456
+ let review;
457
+ try {
458
+ review = await this.runHarnessReview(round); // 产出 第<n>评审报告.md
459
+ } catch (err) {
460
+ // v2.6: 资源限制(预算/调用次数超限)优雅终止,其他异常继续下一轮
461
+ if (err.message.includes('预算') || err.message.includes('调用次数') || err.message.includes('ACP 调用')) {
462
+ logger.error(`评审因资源限制终止:${err.message}`);
463
+ dashboardEventBus.emitBuildFailed(undefined);
464
+ throw err;
465
+ }
466
+ logger.warn(`评审异常:${err.message},继续下一轮`);
467
+ continue;
468
+ }
469
+ if (review.composite_score >= this.ctx.targetScore && review.gate_pass) {
451
470
  await this.runFinalize();
452
471
  break;
453
472
  }
454
473
  // AGENT-4 两步:先写 第<n>轮修改-plan.md,再实施
455
- await this.runOptimizePlan(round, score);
456
- await this.runOptimizeImplement(round, score);
474
+ try {
475
+ await this.runOptimizePlan(round, score);
476
+ await this.runOptimizeImplement(round, score);
477
+ } catch (err) {
478
+ // v2.6: 资源限制 break,其他异常继续
479
+ if (err.message.includes('预算') || err.message.includes('次数')) {
480
+ break;
481
+ }
482
+ logger.warn(`第 ${round} 轮优化失败:${err.message},继续下一轮`);
483
+ }
457
484
  }
458
485
  }
459
486
  }
@@ -620,13 +647,19 @@ class CostTracker {
620
647
  estimatedCostUsd: number;
621
648
  phaseCosts: Map<string, number>;
622
649
 
650
+ // v2.6: 80%/95% 预警告警标志(单次触发,不重复)
651
+ private budget80Warned = false;
652
+ private budget95Warned = false;
653
+ private calls80Warned = false;
654
+ private calls95Warned = false;
655
+
623
656
  constructor(
624
- private maxTotalCalls: number, // 默认 30
657
+ private maxTotalCalls: number, // 默认 100(v2.6 从 30 提升)
625
658
  private budgetUsd: number | null, // null = 不限制
626
659
  private alertThreshold: number, // 默认 5 USD
627
660
  ) {}
628
661
 
629
- async recordCall(phase: string, promptTokens: number, outputTokens: number): Promise<void> {
662
+ recordCall(phase: string, promptTokens = 0, outputTokens = 0): void {
630
663
  this.totalCalls++;
631
664
  const cost = estimateCost(promptTokens, outputTokens);
632
665
  this.estimatedCostUsd += cost;
@@ -635,14 +668,40 @@ class CostTracker {
635
668
  if (cost > this.alertThreshold) {
636
669
  logger.warn(`[成本] ${phase} 本次约 $${cost.toFixed(2)},累计 $${this.estimatedCostUsd.toFixed(2)}`);
637
670
  }
671
+
672
+ // v2.6: 预算 80%/95% 预警告警
673
+ if (this.budgetUsd !== null) {
674
+ const usagePct = (this.estimatedCostUsd / this.budgetUsd) * 100;
675
+ if (usagePct >= 80 && usagePct < 95 && !this.budget80Warned) {
676
+ this.budget80Warned = true;
677
+ logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),接近上限`);
678
+ }
679
+ if (usagePct >= 95 && !this.budget95Warned) {
680
+ this.budget95Warned = true;
681
+ logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),即将达到上限`);
682
+ }
683
+ }
684
+
685
+ // v2.6: 调用次数 80%/95% 预警告警
686
+ if (this.maxTotalCalls > 0) {
687
+ const callsPct = (this.totalCalls / this.maxTotalCalls) * 100;
688
+ if (callsPct >= 80 && callsPct < 95 && !this.calls80Warned) {
689
+ this.calls80Warned = true;
690
+ logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),接近上限`);
691
+ }
692
+ if (callsPct >= 95 && !this.calls95Warned) {
693
+ this.calls95Warned = true;
694
+ logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),即将达到上限`);
695
+ }
696
+ }
697
+
638
698
  if (this.totalCalls >= this.maxTotalCalls) {
639
- logger.warn(`[成本] 总调用次数 ${this.totalCalls} 已达上限 ${this.maxTotalCalls},暂停等待确认`);
640
- await enterCheckpoint('max-calls-exceeded');
699
+ throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls},累计费用 $${this.estimatedCostUsd.toFixed(2)}`);
641
700
  }
642
701
  if (this.budgetUsd !== null && this.estimatedCostUsd >= this.budgetUsd) {
643
- logger.warn(`[成本] 费用 $${this.estimatedCostUsd.toFixed(2)} 已达预算上限,暂停等待确认`);
644
- await enterCheckpoint('budget-exceeded');
702
+ throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}(${this.totalCalls} 次调用)`);
645
703
  }
704
+ dashboardEventBus.emitCostUpdated(this.estimatedCostUsd, this.totalCalls);
646
705
  }
647
706
 
648
707
  checkBudget(): 'ok' | 'alert' | 'exceeded';
@@ -650,10 +709,10 @@ class CostTracker {
650
709
  }
651
710
  ```
652
711
 
653
- **默认建议**(首次运行保守):
712
+ **默认建议**(v2.6 提升):
654
713
 
655
- - `--max-total-calls 30`(约 $10–$30 API 成本)
656
- - `--max-optimize-rounds 2`(大多数场景 2 轮内要么通过要么卡住)
714
+ - `--max-total-calls 100`(约 $30–$100 API 成本,覆盖完整构建+多轮优化)
715
+ - `--max-optimize-rounds 5`(默认值不变)
657
716
 
658
717
  ### 4.5 完成标记协议
659
718
 
@@ -1265,25 +1324,68 @@ async function rerunOutdatedPhases(
1265
1324
  }
1266
1325
  ```
1267
1326
 
1268
- ### 6.4 失败重试策略
1327
+ ### 6.4 失败重试策略(v2.6 重写)
1328
+
1329
+ 整个重试循环 + auto-repair + SKIPPED 跳过链,全部在 `PhaseRunner.runPhase()` 内实现。
1330
+
1331
+ **流程图**:
1332
+
1333
+ ```
1334
+ for attempt = 0..maxRetriesPerPhase:
1335
+ cost.recordCall()
1336
+ ┌─ try-catch 捕获 CostTracker 异常 ──────────────┐
1337
+ │ acpOk=false → tryResolveFromArtifacts(免费) │
1338
+ │ ├─ 命中 → DONE (「资源受限产物兜底」) ✅ │
1339
+ │ └─ 未命中 → break 退出循环 │
1340
+ └─────────────────────────────────────────────────┘
1341
+ ┌─ acpOk=true → ACP 调用 → 状态检查 ─────────────┐
1342
+ │ ├─ 状态 DONE/SKIPPED → return ✅ │
1343
+ │ ├─ 信号兜底 <PHASE_DONE/SKIP> → return ✅ │
1344
+ │ └─ 产物兜底(检查磁盘产物)→ return ✅ │
1345
+ └─────────────────────────────────────────────────┘
1346
+
1347
+ 重试循环结束后(尝试全部耗尽):
1348
+ 1. emitPhaseFailed
1349
+ 2. if (autoRepair !== false):
1350
+ tryAutoRepair() — 再次调 ACP 修复数据
1351
+ ├─ ACP 修复成功 → DONE ✅
1352
+ └─ 修复失败 → 日志警告
1353
+ 3. 标记 SKIPPED + writeBuildState → 继续下一阶段 ▶️
1354
+ (不再 throw Error,构建不会中断)
1355
+ ```
1356
+
1357
+ **关键差异**(对比 v2.5):
1358
+
1359
+ | 方面 | v2.5 | v2.6 |
1360
+ |------|------|------|
1361
+ | retry 耗尽后 | `throw new Error()` | `tryAutoRepair()` + 标记 `SKIPPED` |
1362
+ | CostTracker 超限 | 异常穿透 → 进程终止 | try-catch 捕获 → 免费产物兜底 + `break` |
1363
+ | 仪表盘事件 | 无 `build_failed` | 新增全链路 `build_failed` 事件 |
1364
+ | YAML 写入 | 直接 `writeFile` | `.tmp` + `rename` 原子写入 |
1365
+ | 默认调用次数 | 30 | 100 |
1366
+
1367
+ **`tryAutoRepair()` 实现**:
1269
1368
 
1270
1369
  ```typescript
1271
- async retryPhase(phase: string, lastResult: AcpResult) {
1272
- // 分析失败原因
1273
- const doctorOutput = await runDoctor(this.harnessRoot, phase);
1274
- const fixPrompt = buildFixPrompt(phase, lastResult.output, doctorOutput);
1275
-
1276
- for (let attempt = 1; attempt <= MAX_RETRIES_PER_PHASE; attempt++) {
1277
- logger.warn(`${phase} 门禁未通过,第 ${attempt} 次修复尝试...`);
1278
- const retryResult = await this.acp.call({
1279
- workDir: this.harnessRoot,
1280
- prompt: fixPrompt
1370
+ private async tryAutoRepair(def, harnessRoot, phaseStartTime): Promise<boolean> {
1371
+ try {
1372
+ this.cost.recordCall(def.key + '_repair');
1373
+ const result = await this.acp.call({
1374
+ workDir: harnessRoot,
1375
+ phase: def.key + '_repair',
1376
+ prompt: wrapAcpPrompt(repairPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`),
1281
1377
  });
1282
- const state = await readBuildState(this.harnessRoot);
1283
- if (state.phases[phase]?.status === 'DONE') return;
1378
+ // 三级兜底:状态检查 信号兜底 → 产物兜底
1379
+ const state = await readBuildState(harnessRoot);
1380
+ if (phaseStatus(state, def.key) === 'DONE') return true;
1381
+ const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
1382
+ if (resolved) return true;
1383
+ const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
1384
+ if (artifactResolved) return true;
1385
+ return false;
1386
+ } catch (err) {
1387
+ return false; // 任何异常不穿透,调用方标记 SKIPPED
1284
1388
  }
1285
-
1286
- throw new PhaseFailedError(phase, `超过最大重试次数 ${MAX_RETRIES_PER_PHASE}`);
1287
1389
  }
1288
1390
  ```
1289
1391
 
@@ -2171,8 +2273,11 @@ auto:
2171
2273
  dashboard: true # v2.5 新增:启动本地 Web 仪表盘(端口 7879)
2172
2274
  dashboardPort: 7879 # 仪表盘 HTTP 端口
2173
2275
 
2276
+ # v2.6 新增:阶段失败自动修复(默认启用)
2277
+ autoRepair: true # false 则禁用 ACP 自动修复,阶段失败后直接 SKIPPED
2278
+
2174
2279
  # 成本控制
2175
- maxTotalCalls: 30
2280
+ maxTotalCalls: 100 # v2.6 从 30 提升(对应约 $30-$100 API 成本)
2176
2281
  budgetUsd: null # null = 不限制
2177
2282
  costAlertThreshold: 5 # USD,单次调用告警阈值
2178
2283
 
@@ -2180,6 +2285,7 @@ auto:
2180
2285
  acpMode: auto # auto | sdk | cli-print
2181
2286
  acpTimeoutMs: 1800000 # 30 分钟(v2.4 从 10 分钟提升)
2182
2287
  maxRetriesPerPhase: 2 # 每阶段最大重试次数
2288
+ autoRepair: true # v2.6
2183
2289
 
2184
2290
  # 构建参数(同 svharness.config.yaml build 节)
2185
2291
  generateWiki: false # build 时是否直接生成完整 wiki(false = 由 auto 驱动 ACP 生成)
@@ -2627,3 +2733,71 @@ PhaseRunner/AutoOrchestrator/CostTracker
2627
2733
  → broadcast SSE event → 浏览器三面板自动更新
2628
2734
  ```
2629
2735
 
2736
+ ---
2737
+
2738
+ ## 附录:v2.6 实现修订记录(2026-06-26)
2739
+
2740
+ 根据审查与验证反馈,新增任意错误不中断机制与 CostTracker 超限兜底优化:
2741
+
2742
+ | # | 文件 | 修订内容 | 类型 | 对应 § |
2743
+ |---|------|---------|------|--------|
2744
+ | 1 | `src/lib/auto-orchestrator.ts` | **runBuildPhases() try-catch**:初始构建失败时 `logger.error` + `emitBuildFailed` + 带上下文重新抛出 | Bug 修复 | §3.3 |
2745
+ | 2 | `src/lib/auto-orchestrator.ts` | **评审阶段 try-catch**:`runHarnessReview()` 单独 try-catch,资源限制优雅终止,其他异常继续下一轮 | 增强 | §3.3 |
2746
+ | 3 | `src/lib/auto-orchestrator.ts` | **优化循环 catch break**:CostTracker 超限时 `break` 而非 `continue`,避免下一轮再次触发崩溃 | Bug 修复 | §3.3 |
2747
+ | 4 | `src/lib/phase-runner.ts` | **重试耗尽 + auto-repair + SKIPPED**:ACP 重试(默认 3 次)全部耗尽后,先调 `tryAutoRepair()` 尝试 ACP 自动修复数据,修复失败则标记 SKIPPED 并继续下一阶段(不再 `throw Error`) | 新增 | §6.4 |
2748
+ | 5 | `src/lib/phase-runner.ts` | **CostTracker 超限兜底**:`cost.recordCall()` 包 try-catch,捕获后走 `else` 分支免费产物兜底 + `break` 退出重试循环;同时修复了 `if(acpOk)` 缺少闭合 brace 导致的代码结构 Bug | Bug 修复 | §6.4 |
2749
+ | 6 | `src/lib/phase-runner.ts` | **tryAutoRepair() 方法**:新增 `tryAutoRepair()` 私有方法,调用 ACP agent 诊断修复数据问题,叠加三级 fallback(状态/信号/产物) | 新增 | §6.4 |
2750
+ | 7 | `src/lib/auto-state.ts` | **YAML 原子写入**:`writeBuildState()` 改为先写 `.tmp` 再 `fs.rename` 原子替换;`readBuildState()` dedupe 修复路径也统一使用 `.tmp` + `rename` | 增强 | §6.5 |
2751
+ | 8 | `src/lib/cost-tracker.ts` | **80%/95% 预警告警**:新增 4 个一次性预警标志,预算/调用次数达到 80% 和 95% 时各触发一次 `logger.warn`;超限 `throw` 附带完整累计费用和调用次数信息 | 增强 | §4.4 |
2752
+ | 9 | `src/commands/auto.ts` | **默认值提升**:`maxTotalCalls` 默认 30→100;`autoRepair` 默认 `true` 传入 `AutoContext` | 配置 | §12.1 |
2753
+ | 10 | `src/index.ts` | **CLI 选项更新**:`--max-total-calls` 帮助文本更新为默认 100;新增 `--no-auto-repair` 选项 | 配置 | §3.1 |
2754
+ | 11 | `src/dashboard/event-bus.ts` | **build_failed 事件**:新增 `'build_failed'` 事件类型和 `emitBuildFailed()` 方法 | 新增 | §15.1 |
2755
+ | 12 | `src/dashboard/sse-handler.ts` | **build_failed 状态**:`build_failed` 事件将 `BuildSnapshot.status` 置为 `'failed'` | 增强 | §15.2 |
2756
+ | 13 | `src/dashboard/index.ts` | **事件路由**:`build_failed` 注册到事件订阅数组 | 增强 | §15.3 |
2757
+ | 14 | `src/dashboard/static/js/app.js` | **前端失败提示**:`build_failed` 处理器在日志面板显示 `❌ 构建失败` | 增强 | §15.5 |
2758
+ | 15 | `auto/templates/auto.config.example.yaml` | **示例值更新**:`maxTotalCalls: 100` | 配置 | §12.1 |
2759
+
2760
+ ### 架构变更
2761
+
2762
+ ```
2763
+ v2.5 → v2.6 关键变更:
2764
+
2765
+ PhaseRunner.runPhase() 重试循环
2766
+ ├── 原:cost.recordCall() 裸调用 → 异常穿透 → 进程终止
2767
+ ├── 新:try-catch 捕获 → acpOk=false → else 分支免费产物兜底 → break
2768
+
2769
+ ├── 原:重试耗尽 → throw Error → 构建崩溃
2770
+ └── 新:重试耗尽 → tryAutoRepair() → 修复成功 DONE / 修复失败 SKIPPED ▶️
2771
+
2772
+ CostTracker.recordCall()
2773
+ ├── 原:超限直接 throw
2774
+ └── 新:80% → warn | 95% → warn | 100% → throw(含完整累计信息)
2775
+
2776
+ auto-state.ts writeBuildState()
2777
+ ├── 原:直接 writeFile
2778
+ └── 新:writeFile(.tmp) → rename(.yaml) 原子写入
2779
+
2780
+ 仪表盘事件
2781
+ └── 新增 build_failed(event-bus → sse-handler → app.js 全链路)
2782
+ ```
2783
+
2784
+ ### 容器对比示意
2785
+
2786
+ ```
2787
+ v2.6 PhaseRunner 重试循环容器边界:
2788
+
2789
+ for (attempt = 0; attempt <= maxRetries; attempt++) { ← 容器:重试循环
2790
+ try { cost.recordCall(); } catch { acpOk=false; }
2791
+ if (acpOk) {
2792
+ // ACP 调用 + 状态检查 + 信号兜底 + 产物兜底
2793
+ } else {
2794
+ // 资源耗尽:免费产物兜底 + break
2795
+ }
2796
+ if (attempt < maxRetries) { ... retry warning ... }
2797
+ } ← 容器闭合
2798
+
2799
+ emitPhaseFailed ← 在循环外
2800
+ if (autoRepair) { tryAutoRepair(); } ← 在循环外
2801
+ mark SKIPPED + writeBuildState + continue ← 在循环外
2802
+ ```
2803
+
@@ -33,7 +33,7 @@ auto:
33
33
  acpMode: auto
34
34
  acpTimeoutMs: 600000
35
35
  maxRetriesPerPhase: 2
36
- maxTotalCalls: 30
36
+ maxTotalCalls: 100
37
37
  budgetUsd: null
38
38
  costAlertThreshold: 5
39
39
 
@@ -1,5 +1,13 @@
1
1
  你是 harness 构建 Agent。当前任务:完成 {{HARNESS_ROOT}} 的 S40_extract_requirements 阶段。
2
2
 
3
+ ## 硬约束(必须遵守,违反则任务失败)
4
+ 你输出内容的**最后一行**必须精确匹配以下字符串(不含引号、不含多余空格、不含换行):
5
+ ```
6
+ <PHASE_DONE>S40</PHASE_DONE>
7
+ ```
8
+ 此信号是自动化流程检测阶段完成的唯一可靠方式。无论前面的执行结果如何,最后一行必须输出此信号。
9
+ 你可以在信号之后不再输出任何内容。
10
+
3
11
  ## 项目上下文
4
12
  - 构建目的:{{AUTO_GOAL}}
5
13
  - 工作根目录:{{HARNESS_ROOT}}
@@ -11,5 +19,4 @@
11
19
  4. 每条需求必须包含 `unit_id`、`source_doc`、`source_anchor`、`source_excerpt`、`acceptance`。
12
20
  5. 运行 `svharness requirements verify --harness {{HARNESS_ROOT}} --strict`,失败则修复后重试。
13
21
  6. 通过后最小补丁更新 `.harness-build-state.yaml`:`S40_extract_requirements.status = DONE`。
14
- 7. 输出末尾:`<PHASE_DONE>S40</PHASE_DONE>`。
15
22
 
@@ -73,7 +73,7 @@ async function resolveAutoContext(positionalWorkdir, opts) {
73
73
  autoApprove: !!opts.autoApprove,
74
74
  yes: !!opts.yes,
75
75
  verbose: !!opts.verbose,
76
- maxTotalCalls: normalizeInteger(opts.maxTotalCalls, 30, '--max-total-calls'),
76
+ maxTotalCalls: normalizeInteger(opts.maxTotalCalls, 100, '--max-total-calls'),
77
77
  budgetUsd: opts.budgetUsd ?? null,
78
78
  costAlertThreshold: normalizeNumber(opts.costAlertThreshold, 5, '--cost-alert'),
79
79
  acpMode: opts.acpMode === 'sdk' || opts.acpMode === 'cli-print' ? opts.acpMode : 'auto',
@@ -85,6 +85,7 @@ async function resolveAutoContext(positionalWorkdir, opts) {
85
85
  maxPhase: opts.maxPhase,
86
86
  goal: opts.goal,
87
87
  buildOverrides: opts,
88
+ autoRepair: opts.autoRepair ?? true,
88
89
  };
89
90
  }
90
91
  function normalizeNumber(value, fallback, name) {
@@ -9,6 +9,9 @@ class DashboardEventBus extends node_events_1.EventEmitter {
9
9
  emitBuildCompleted(finalScore) {
10
10
  this.emitEvent('build_completed', { finalScore });
11
11
  }
12
+ emitBuildFailed(finalScore) {
13
+ this.emitEvent('build_failed', { finalScore });
14
+ }
12
15
  emitPhaseStarted(key, short, description, attempt) {
13
16
  this.emitEvent('phase_started', { key, short, description, attempt });
14
17
  }
@@ -24,7 +24,7 @@ function startDashboard(port = 7879) {
24
24
  server = (0, server_1.createServer)(event_bus_1.dashboardEventBus, sseManager);
25
25
  // Subscribe to all events from the bus and forward to SSE clients
26
26
  const eventTypes = [
27
- 'build_started', 'build_completed',
27
+ 'build_started', 'build_completed', 'build_failed',
28
28
  'phase_started', 'phase_completed', 'phase_failed',
29
29
  'acp_output',
30
30
  'cost_updated', 'score_updated',
@@ -101,6 +101,9 @@ class SseManager {
101
101
  this.buildSnapshot.score = event.data.finalScore;
102
102
  }
103
103
  break;
104
+ case 'build_failed':
105
+ this.buildSnapshot.status = 'failed';
106
+ break;
104
107
  }
105
108
  }
106
109
  getSnapshot() {
@@ -323,6 +323,11 @@ function connectSSE() {
323
323
  addLog({ type: 'success', text: `✅ 构建完成!${data.finalScore != null ? `最终评分: ${data.finalScore}` : ''}`, timestamp: Date.now() });
324
324
  updateStatus();
325
325
  },
326
+ build_failed() {
327
+ state.status = 'failed';
328
+ addLog({ type: 'error', text: '❌ 构建失败!请检查终端日志', timestamp: Date.now() });
329
+ updateStatus();
330
+ },
326
331
  phase_started(data) {
327
332
  state.currentPhase = data.key;
328
333
  state.phaseStatuses[data.key] = 'IN_PROGRESS';
package/dist/index.js CHANGED
@@ -255,7 +255,7 @@ function main() {
255
255
  .option('--auto-approve', '自动通过无人值守下的表单门禁')
256
256
  .option('-y, --yes', '跳过交互确认')
257
257
  .option('--verbose', '显示详细日志')
258
- .option('--max-total-calls <n>', 'ACP 调用次数上限(默认 30)', (v) => Number(v))
258
+ .option('--max-total-calls <n>', 'ACP 调用次数上限(默认 100)', (v) => Number(v))
259
259
  .option('--budget-usd <amount>', '费用预算上限(USD)', (v) => Number(v))
260
260
  .option('--cost-alert <amount>', '单次调用费用告警阈值(USD)', (v) => Number(v))
261
261
  .option('--acp-mode <mode>', 'ACP 模式:auto | cli-print | sdk')
@@ -264,6 +264,7 @@ function main() {
264
264
  .option('--prompt-dir <path>', '覆盖 auto phase prompt 模板目录')
265
265
  .option('--baseline-auto-extract <mode>', 'explicit | conservative | disabled')
266
266
  .option('--convert-endpoint <url>', 'markitdown_serve 基址')
267
+ .option('--no-auto-repair', '禁用阶段失败自动修复(默认启用)')
267
268
  .action(async (workdir, opts, cmd) => {
268
269
  try {
269
270
  await (0, auto_1.runAuto)(workdir, {
@@ -27,7 +27,7 @@ async function runOptimizePlan(ctx, acp, cost, round, review) {
27
27
  const result = await acp.call({
28
28
  workDir: ctx.harnessRoot,
29
29
  phase: 'optimize-plan',
30
- prompt,
30
+ prompt: (0, phase_prompt_loader_1.wrapAcpPrompt)(prompt, '<AUTO_SIGNAL>PLAN_DONE</AUTO_SIGNAL>'),
31
31
  timeoutMs: ctx.acpTimeoutMs,
32
32
  });
33
33
  if (!result.success)
@@ -55,7 +55,7 @@ async function runOptimizeImplement(ctx, acp, cost, round, review) {
55
55
  const result = await acp.call({
56
56
  workDir: ctx.harnessRoot,
57
57
  phase: 'optimize-implement',
58
- prompt,
58
+ prompt: (0, phase_prompt_loader_1.wrapAcpPrompt)(prompt, '<AUTO_SIGNAL>OPTIMIZE_DONE</AUTO_SIGNAL>'),
59
59
  timeoutMs: ctx.acpTimeoutMs,
60
60
  });
61
61
  if (!result.success) {
@@ -67,7 +67,15 @@ class AutoOrchestrator {
67
67
  this.phaseRunner = new phase_runner_1.PhaseRunner(this.ctx, this.acp, this.cost);
68
68
  event_bus_1.dashboardEventBus.emitBuildStarted(phase_runner_1.PHASE_DEFS.length);
69
69
  await this.ensureReviewRubric();
70
- await this.phaseRunner.runBuildPhases();
70
+ try {
71
+ await this.phaseRunner.runBuildPhases();
72
+ }
73
+ catch (err) {
74
+ const msg = err.message;
75
+ logger_1.logger.error(`[auto] 构建阶段执行失败:${msg}`);
76
+ event_bus_1.dashboardEventBus.emitBuildFailed(undefined);
77
+ throw new Error(`构建阶段失败:${msg};auto 构建终止,请在修复后重新执行`);
78
+ }
71
79
  if (this.ctx.maxPhase && phaseNumber(this.ctx.maxPhase) < 85) {
72
80
  logger_1.logger.info(`已到达 --max-phase ${this.ctx.maxPhase},跳过评审优化`);
73
81
  event_bus_1.dashboardEventBus.emitBuildCompleted(undefined);
@@ -77,7 +85,20 @@ class AutoOrchestrator {
77
85
  let finalReview;
78
86
  for (let round = 1; round <= this.ctx.maxOptimizeRounds; round++) {
79
87
  event_bus_1.dashboardEventBus.emitReviewStarted(round);
80
- const review = await this.runHarnessReview(round);
88
+ let review;
89
+ try {
90
+ review = await this.runHarnessReview(round);
91
+ }
92
+ catch (err) {
93
+ const msg = err.message;
94
+ if (msg.includes('预算') || msg.includes('调用次数') || msg.includes('ACP 调用')) {
95
+ logger_1.logger.error(`[auto] 第 ${round} 轮评审因资源限制终止:${msg}`);
96
+ event_bus_1.dashboardEventBus.emitBuildFailed(undefined);
97
+ throw err; // 资源限制是整个流程的硬终止
98
+ }
99
+ logger_1.logger.warn(`[auto] 第 ${round} 轮评审异常:${msg},继续下一轮`);
100
+ continue;
101
+ }
81
102
  event_bus_1.dashboardEventBus.emitReviewCompleted(round, review.composite_score, review.critical_count);
82
103
  event_bus_1.dashboardEventBus.emitScoreUpdated(review.composite_score, round);
83
104
  if (review.composite_score >= this.ctx.targetScore &&
@@ -103,7 +124,12 @@ class AutoOrchestrator {
103
124
  event_bus_1.dashboardEventBus.emitOptimizeCompleted(round);
104
125
  }
105
126
  catch (err) {
106
- logger_1.logger.warn(`第 ${round} 轮优化执行失败:${err.message},状态重置已完成,下一轮可继续`);
127
+ const msg = err.message;
128
+ if (msg.includes('预算') || msg.includes('次数') || msg.includes('ACP 调用')) {
129
+ logger_1.logger.warn(`[auto] 第 ${round} 轮优化因资源限制终止:${msg}`);
130
+ break; // 资源限制触发时终止优化循环,保留当前评分
131
+ }
132
+ logger_1.logger.warn(`第 ${round} 轮优化执行失败:${msg},状态重置已完成,下一轮可继续`);
107
133
  }
108
134
  }
109
135
  event_bus_1.dashboardEventBus.emitCostUpdated(this.cost.report().estimatedCostUsd, this.cost.report().totalCalls);
@@ -32,7 +32,7 @@ async function runHarnessReview(ctx, acp, cost, round) {
32
32
  const result = await acp.call({
33
33
  workDir: ctx.harnessRoot,
34
34
  phase: 'S85_pre_seal_validation',
35
- prompt,
35
+ prompt: (0, phase_prompt_loader_1.wrapAcpPrompt)(prompt, '<AUTO_SIGNAL>REVIEW_DONE</AUTO_SIGNAL>'),
36
36
  timeoutMs: ctx.acpTimeoutMs,
37
37
  });
38
38
  await fs_extra_1.default.writeFile(node_path_1.default.join(ctx.harnessRoot, 'auto-review', `review-round-${round}.log`), [result.output, result.error ? `\n[stderr]\n${result.error}` : ''].join(''), 'utf8');
@@ -74,7 +74,9 @@ async function readBuildState(harnessRoot) {
74
74
  if (err instanceof js_yaml_1.default.YAMLException && err.message.includes('duplicated mapping key')) {
75
75
  const fixed = dedupePhases(raw);
76
76
  // Persist the fixed content so subsequent reads also succeed
77
- await fs_extra_1.default.writeFile(statePath, fixed, 'utf8');
77
+ const tmpPath = statePath + '.tmp';
78
+ await fs_extra_1.default.writeFile(tmpPath, fixed, 'utf8');
79
+ await fs_extra_1.default.rename(tmpPath, statePath);
78
80
  const fixedBody = fixed.replace(/^#.*\n/gm, '');
79
81
  const state = (js_yaml_1.default.load(fixedBody) ?? {});
80
82
  state.phases = state.phases ?? {};
@@ -85,7 +87,12 @@ async function readBuildState(harnessRoot) {
85
87
  }
86
88
  async function writeBuildState(harnessRoot, state) {
87
89
  const statePath = node_path_1.default.join(harnessRoot, '.harness-build-state.yaml');
88
- await fs_extra_1.default.writeFile(statePath, STATE_HEADER + js_yaml_1.default.dump(state, { lineWidth: 100, noRefs: true, sortKeys: false }), 'utf8');
90
+ const tmpPath = statePath + '.tmp';
91
+ const content = STATE_HEADER + js_yaml_1.default.dump(state, { lineWidth: 100, noRefs: true, sortKeys: false });
92
+ // Write to temp file first, then atomic rename to prevent partial writes
93
+ // on process kill. On the same filesystem, rename is atomic.
94
+ await fs_extra_1.default.writeFile(tmpPath, content, 'utf8');
95
+ await fs_extra_1.default.rename(tmpPath, statePath);
89
96
  }
90
97
  function phaseStatus(state, phase) {
91
98
  return state.phases?.[phase]?.status;
@@ -11,6 +11,10 @@ class CostTracker {
11
11
  totalCalls = 0;
12
12
  estimatedCostUsd = 0;
13
13
  phaseCosts = new Map();
14
+ budget80Warned = false;
15
+ budget95Warned = false;
16
+ calls80Warned = false;
17
+ calls95Warned = false;
14
18
  constructor(maxTotalCalls, budgetUsd, alertThreshold) {
15
19
  this.maxTotalCalls = maxTotalCalls;
16
20
  this.budgetUsd = budgetUsd;
@@ -24,11 +28,35 @@ class CostTracker {
24
28
  if (cost > this.alertThreshold) {
25
29
  logger_1.logger.warn(`[成本] ${phase} 本次约 $${cost.toFixed(2)},累计 $${this.estimatedCostUsd.toFixed(2)}`);
26
30
  }
31
+ // Budget pre-warnings (80% → 95% → hard limit)
32
+ if (this.budgetUsd !== null) {
33
+ const usagePct = (this.estimatedCostUsd / this.budgetUsd) * 100;
34
+ if (usagePct >= 80 && usagePct < 95 && !this.budget80Warned) {
35
+ this.budget80Warned = true;
36
+ logger_1.logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),接近上限`);
37
+ }
38
+ if (usagePct >= 95 && !this.budget95Warned) {
39
+ this.budget95Warned = true;
40
+ logger_1.logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),即将达到上限`);
41
+ }
42
+ }
43
+ // Call count pre-warnings (80% → 95% → hard limit)
44
+ if (this.maxTotalCalls > 0) {
45
+ const callsPct = (this.totalCalls / this.maxTotalCalls) * 100;
46
+ if (callsPct >= 80 && callsPct < 95 && !this.calls80Warned) {
47
+ this.calls80Warned = true;
48
+ logger_1.logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),接近上限`);
49
+ }
50
+ if (callsPct >= 95 && !this.calls95Warned) {
51
+ this.calls95Warned = true;
52
+ logger_1.logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),即将达到上限`);
53
+ }
54
+ }
27
55
  if (this.totalCalls > this.maxTotalCalls) {
28
- throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls}`);
56
+ throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls},累计费用 $${this.estimatedCostUsd.toFixed(2)}`);
29
57
  }
30
58
  if (this.budgetUsd !== null && this.estimatedCostUsd > this.budgetUsd) {
31
- throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}`);
59
+ throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}(${this.totalCalls} 次调用)`);
32
60
  }
33
61
  event_bus_1.dashboardEventBus.emitCostUpdated(this.estimatedCostUsd, this.totalCalls);
34
62
  }
@@ -5,8 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.loadPhasePrompt = loadPhasePrompt;
7
7
  exports.renderPrompt = renderPrompt;
8
+ exports.wrapAcpPrompt = wrapAcpPrompt;
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
10
  const node_path_1 = __importDefault(require("node:path"));
11
+ const logger_1 = require("../utils/logger");
10
12
  const auto_assets_1 = require("./auto-assets");
11
13
  async function loadPhasePrompt(templateName, vars, promptDirOverride) {
12
14
  const assets = (0, auto_assets_1.resolveAutoAssetPaths)(promptDirOverride);
@@ -20,6 +22,37 @@ async function loadPhasePrompt(templateName, vars, promptDirOverride) {
20
22
  function renderPrompt(template, vars) {
21
23
  return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
22
24
  const value = vars[key];
25
+ if (value === undefined || value === null) {
26
+ logger_1.logger.warn(`prompt 模板中占位符 {{${key}}} 未提供值,保留原样`);
27
+ }
23
28
  return value === undefined || value === null ? match : String(value);
24
29
  });
25
30
  }
31
+ /**
32
+ * Wrap an auto-mode ACP prompt with a non-interactive preamble.
33
+ *
34
+ * All auto phase prompts run via `claude --print` (no stdin backchannel).
35
+ * This preamble forces Claude to:
36
+ * - Never ask questions or wait for confirmation
37
+ * - Always emit a completion signal as the very last line of output
38
+ * - Output nothing after the signal
39
+ *
40
+ * Call this for EVERY acp.call() invocation, including phases that bypass
41
+ * PhaseRunner (S85 review, optimize-plan, optimize-implement).
42
+ */
43
+ function wrapAcpPrompt(rawPrompt, completionSignal) {
44
+ return [
45
+ '## 非交互模式硬约束(必须遵守,违反则任务失败)',
46
+ '你处于自动化流水线的非交互模式。禁止向用户提问、征求确认或等待人工决策。',
47
+ '直接执行所有操作,不要停下来询问。',
48
+ `你输出内容的最后一行必须精确匹配以下字符串(不含引号、不含多余空格、不含换行,之后不要再输出任何内容):`,
49
+ '```',
50
+ completionSignal,
51
+ '```',
52
+ '此信号是自动化流程识别阶段完成的唯一方式。',
53
+ '',
54
+ '---',
55
+ '',
56
+ rawPrompt,
57
+ ].join('\n');
58
+ }
@@ -108,43 +108,107 @@ class PhaseRunner {
108
108
  return;
109
109
  }
110
110
  for (let attempt = 0; attempt <= this.ctx.maxRetriesPerPhase; attempt++) {
111
- this.cost.recordCall(def.key);
112
- const result = await this.acp.call({
113
- workDir: harnessRoot,
114
- phase: def.key,
115
- prompt,
116
- timeoutMs: this.ctx.acpTimeoutMs,
117
- onData: (text) => event_bus_1.dashboardEventBus.emitAcpOutput(def.key, text),
118
- });
119
- await this.writeAcpLog(def.short, result.output, result.error);
120
- if (!result.success) {
121
- logger_1.logger.warn(`${def.key} ACP 调用失败:${result.error ?? 'unknown error'}`);
111
+ // Recalculate boundedPrompt inside the retry loop so each attempt gets:
112
+ // 1) A fresh wrap with the attempt number in context, and
113
+ // 2) Awareness of any partial artifacts left by previous attempts.
114
+ // Without this, every retry receives the same prompt and Claude would
115
+ // start from scratch rather than incrementally fixing.
116
+ const attemptPrompt = attempt > 0
117
+ ? `[重试第 ${attempt} 次] 这是第 ${attempt} 次尝试。如果上一次尝试已经产生了部分文件,请检查并修复它们,不要从头开始。\n${prompt}`
118
+ : prompt;
119
+ const boundedPrompt = (0, phase_prompt_loader_1.wrapAcpPrompt)(attemptPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`);
120
+ // Try ACP call; on CostTracker budget/call-limit error, fall back to
121
+ // free recovery checks (signal + artifact detection) without crashing.
122
+ let acpOk = true;
123
+ try {
124
+ this.cost.recordCall(def.key);
122
125
  }
123
- const after = await (0, auto_state_1.readBuildState)(harnessRoot);
124
- const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
125
- if (afterStatus === 'DONE' || afterStatus === 'SKIPPED') {
126
- const elapsed = Date.now() - phaseStartTime;
127
- event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, afterStatus, elapsed);
128
- const label = afterStatus === 'DONE' ? '✅ 完成' : '⏭️ 跳过';
129
- logger_1.logger.success(`${def.key} ${label}`);
130
- return;
126
+ catch (err) {
127
+ acpOk = false;
128
+ logger_1.logger.warn(`${def.key} 资源限制:${err.message},跳过 ACP 调用并尝试产物兜底`);
131
129
  }
132
- // Fallback: Claude may have emitted a completion signal in its text output
133
- // but failed to edit the YAML state file. Rely on the signal instead.
134
- const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
135
- if (resolved) {
136
- const elapsed = Date.now() - phaseStartTime;
137
- event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, resolved, elapsed);
138
- const label = resolved === 'DONE' ? '✅ 完成(信号兜底)' : '⏭️ 跳过(信号兜底)';
139
- logger_1.logger.success(`${def.key} ${label}`);
140
- return;
130
+ if (acpOk) {
131
+ const result = await this.acp.call({
132
+ workDir: harnessRoot,
133
+ phase: def.key,
134
+ prompt: boundedPrompt,
135
+ timeoutMs: this.ctx.acpTimeoutMs,
136
+ onData: (text) => event_bus_1.dashboardEventBus.emitAcpOutput(def.key, text),
137
+ });
138
+ await this.writeAcpLog(def.short, result.output, result.error);
139
+ if (!result.success) {
140
+ logger_1.logger.warn(`${def.key} ACP 调用失败:${result.error ?? 'unknown error'}`);
141
+ }
142
+ const after = await (0, auto_state_1.readBuildState)(harnessRoot);
143
+ const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
144
+ if (afterStatus === 'DONE' || afterStatus === 'SKIPPED') {
145
+ const elapsed = Date.now() - phaseStartTime;
146
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, afterStatus, elapsed);
147
+ const label = afterStatus === 'DONE' ? '✅ 完成' : '⏭️ 跳过';
148
+ logger_1.logger.success(`${def.key} ${label}`);
149
+ return;
150
+ }
151
+ // Fallback: Claude may have emitted a completion signal in its text output
152
+ // but failed to edit the YAML state file. Rely on the signal instead.
153
+ const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
154
+ if (resolved) {
155
+ const elapsed = Date.now() - phaseStartTime;
156
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, resolved, elapsed);
157
+ const label = resolved === 'DONE' ? '✅ 完成(信号兜底)' : '⏭️ 跳过(信号兜底)';
158
+ logger_1.logger.success(`${def.key} ${label}`);
159
+ return;
160
+ }
161
+ // Fallback: detect phase completion by checking for expected output artifacts.
162
+ // Some phases (e.g. S40_extract_requirements) may have produced all required
163
+ // artifacts even though Claude failed to emit the completion signal or update the
164
+ // YAML state file. Detect and resolve programmatically to avoid false retries.
165
+ const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
166
+ if (artifactResolved) {
167
+ const elapsed = Date.now() - phaseStartTime;
168
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
169
+ logger_1.logger.success(`${def.key} ✅ 完成(产物兜底检测)`);
170
+ return;
171
+ }
172
+ }
173
+ else {
174
+ // Budget/call-limit exhausted: skip ACP but try free artifact fallback.
175
+ // The previous ACP attempt (which was already paid for) may have left valid
176
+ // artifacts on disk even though the state file wasn't updated to DONE.
177
+ logger_1.logger.warn(`${def.key} 资源已耗尽,尝试产物兜底恢复...`);
178
+ const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, { success: true }, phaseStartTime);
179
+ if (artifactResolved) {
180
+ const elapsed = Date.now() - phaseStartTime;
181
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
182
+ logger_1.logger.success(`${def.key} ✅ 完成(资源受限产物兜底)`);
183
+ return;
184
+ }
185
+ // Break out of retry loop — no more ACP calls are possible.
186
+ break;
141
187
  }
142
188
  if (attempt < this.ctx.maxRetriesPerPhase) {
143
189
  logger_1.logger.warn(`${def.key} 未更新为 DONE/SKIPPED,准备重试第 ${attempt + 1} 次`);
144
190
  }
145
191
  }
146
192
  event_bus_1.dashboardEventBus.emitPhaseFailed(def.key, 'state 未更新为 DONE/SKIPPED', this.ctx.maxRetriesPerPhase);
147
- throw new Error(`${def.key} 未完成:state 未更新为 DONE/SKIPPED`);
193
+ // All retries exhausted try ACP auto-repair before giving up
194
+ if (this.ctx.autoRepair !== false) {
195
+ logger_1.logger.warn(`${def.key} 重试耗尽,启动 ACP 自动修复...`);
196
+ const repaired = await this.tryAutoRepair(def, harnessRoot, phaseStartTime);
197
+ if (repaired) {
198
+ const elapsed = Date.now() - phaseStartTime;
199
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, 'DONE', elapsed);
200
+ logger_1.logger.success(`${def.key} ✅ 完成(ACP 自动修复)`);
201
+ return;
202
+ }
203
+ logger_1.logger.warn(`${def.key} ACP 自动修复未生效`);
204
+ }
205
+ // Don't throw — mark as SKIPPED so the build continues to subsequent phases
206
+ logger_1.logger.warn(`${def.key} 已耗尽所有重试次数,标记 SKIPPED 后继续执行后续阶段`);
207
+ const skipState = await (0, auto_state_1.readBuildState)(harnessRoot);
208
+ (0, auto_state_1.setPhaseStatus)(skipState, def.key, 'SKIPPED');
209
+ skipState.phases[def.key].note = '重试耗尽且自动修复未生效,已自动跳过';
210
+ await (0, auto_state_1.writeBuildState)(harnessRoot, skipState);
211
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, 'SKIPPED', Date.now() - phaseStartTime);
148
212
  }
149
213
  async runMechanicalPre(def) {
150
214
  if (!this.ctx.harnessRoot || this.ctx.dryRun)
@@ -227,25 +291,235 @@ class PhaseRunner {
227
291
  logger_1.logger.success('S30_convert_docs ✅ 完成(机械检查)');
228
292
  }
229
293
  /**
230
- * Parse Claude's output for <PHASE_DONE> / <PHASE_SKIP> signals and,
231
- * if the phase matches, programmatically update the state file.
294
+ * Parse Claude's output for <PHASE_DONE> / <PHASE_SKIP> / <AUTO_SIGNAL> signals
295
+ * and, if the phase matches, programmatically update the state file.
232
296
  * This is a fallback for when Claude cannot edit the YAML itself.
297
+ *
298
+ * <PHASE_DONE>S40</PHASE_DONE> — exact phase match required (phase-runner phases)
299
+ * <AUTO_SIGNAL>REVIEW_DONE</AUTO_SIGNAL> — any AUTO_SIGNAL triggers DONE (review/optimize phases)
233
300
  */
234
301
  async tryResolveFromSignal(def, harnessRoot, result) {
235
302
  if (!result.completionSignal)
236
303
  return null;
237
304
  const doneMatch = result.completionSignal.match(/^<PHASE_DONE>(\w+)<\/PHASE_DONE>$/);
238
305
  const skipMatch = result.completionSignal.match(/^<PHASE_SKIP>(\w+)<\/PHASE_SKIP>$/);
239
- const signalPhase = doneMatch?.[1] ?? skipMatch?.[1];
240
- if (!signalPhase)
241
- return null;
242
- if (signalPhase !== def.short && signalPhase !== def.key)
306
+ const autoSignal = result.completionSignal.match(/^<AUTO_SIGNAL>(\w+)<\/AUTO_SIGNAL>$/);
307
+ // <PHASE_DONE>/<PHASE_SKIP>: verify phase matches
308
+ if (doneMatch || skipMatch) {
309
+ const signalPhase = doneMatch?.[1] ?? skipMatch?.[1];
310
+ if (!signalPhase)
311
+ return null;
312
+ if (signalPhase !== def.short && signalPhase !== def.key)
313
+ return null;
314
+ const targetStatus = doneMatch ? 'DONE' : 'SKIPPED';
315
+ const state = await (0, auto_state_1.readBuildState)(harnessRoot);
316
+ (0, auto_state_1.setPhaseStatus)(state, def.key, targetStatus);
317
+ await (0, auto_state_1.writeBuildState)(harnessRoot, state);
318
+ return targetStatus;
319
+ }
320
+ // <AUTO_SIGNAL>: resolves as DONE for any signal value.
321
+ // Note: this branch is only reachable for phase-runner phases (S10-S80),
322
+ // which all use <PHASE_DONE> signals via wrapAcpPrompt, not <AUTO_SIGNAL>.
323
+ // The <AUTO_SIGNAL> protocol is used by auto-optimize.ts and auto-review.ts
324
+ // which bypass PhaseRunner entirely. If this fires unexpectedly, the log
325
+ // below provides an audit trail.
326
+ if (autoSignal) {
327
+ logger_1.logger.warn(`${def.key} 收到意外 AUTO_SIGNAL「${autoSignal[1]}」(phase-runner 阶段应使用 PHASE_DONE)、自动标记 DONE`);
328
+ const state = await (0, auto_state_1.readBuildState)(harnessRoot);
329
+ (0, auto_state_1.setPhaseStatus)(state, def.key, 'DONE');
330
+ await (0, auto_state_1.writeBuildState)(harnessRoot, state);
331
+ return 'DONE';
332
+ }
333
+ return null;
334
+ }
335
+ /**
336
+ * Detect phase completion by checking for expected output artifacts on disk.
337
+ * Used when ACP succeeded but failed to emit a completion signal or update
338
+ * the YAML state file.
339
+ *
340
+ * Only runs when result.success === true — ACP failure artifacts are too
341
+ * unreliable to use as a completion signal.
342
+ */
343
+ async tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime) {
344
+ // Only use artifact fallback when ACP actually succeeded.
345
+ // Failed ACP calls may leave partial/incomplete artifacts that would
346
+ // cause false-positive phase resolution, bypassing retry logic.
347
+ if (!result.success)
243
348
  return null;
244
- const targetStatus = doneMatch ? 'DONE' : 'SKIPPED';
245
- const state = await (0, auto_state_1.readBuildState)(harnessRoot);
246
- (0, auto_state_1.setPhaseStatus)(state, def.key, targetStatus);
247
- await (0, auto_state_1.writeBuildState)(harnessRoot, state);
248
- return targetStatus;
349
+ try {
350
+ switch (def.key) {
351
+ case 'S40_extract_requirements': {
352
+ const yamlDir = node_path_1.default.join(harnessRoot, 'requirements', 'yaml');
353
+ if (await fs_extra_1.default.pathExists(yamlDir)) {
354
+ const entries = await fs_extra_1.default.readdir(yamlDir);
355
+ const yamlFiles = entries
356
+ .filter((e) => e.endsWith('.yaml'));
357
+ if (yamlFiles.length > 0) {
358
+ // Verify at minimum that the yaml file has substantive content
359
+ // created during or after this phase started.
360
+ const validYamlFiles = (await Promise.all(yamlFiles.map(async (f) => {
361
+ const fp = node_path_1.default.join(yamlDir, f);
362
+ try {
363
+ const stat = await fs_extra_1.default.stat(fp);
364
+ return stat.size > 50 && stat.mtimeMs >= phaseStartTime - 5000;
365
+ }
366
+ catch {
367
+ return false;
368
+ }
369
+ }))).filter(Boolean);
370
+ if (validYamlFiles.length > 0)
371
+ break; // qualified
372
+ }
373
+ }
374
+ return null;
375
+ }
376
+ case 'S50_generate_specs': {
377
+ // specs/ must have at least one non-empty subdirectory
378
+ // with a non-trivial .md or .yaml file (>100 bytes) created during this phase.
379
+ const specsDir = node_path_1.default.join(harnessRoot, 'specs');
380
+ let found = false;
381
+ if (await fs_extra_1.default.pathExists(specsDir)) {
382
+ const subs = (await fs_extra_1.default.readdir(specsDir))
383
+ .filter((e) => e !== '_work');
384
+ for (const sub of subs) {
385
+ const subDir = node_path_1.default.join(specsDir, sub);
386
+ let stat;
387
+ try {
388
+ stat = await fs_extra_1.default.stat(subDir);
389
+ }
390
+ catch {
391
+ continue;
392
+ }
393
+ if (stat.isDirectory()) {
394
+ const files = await fs_extra_1.default.readdir(subDir);
395
+ for (const f of files) {
396
+ if (!f.endsWith('.md') && !f.endsWith('.yaml'))
397
+ continue;
398
+ const fp = node_path_1.default.join(subDir, f);
399
+ try {
400
+ const fstat = await fs_extra_1.default.stat(fp);
401
+ if (fstat.size > 100 && fstat.mtimeMs >= phaseStartTime - 5000) {
402
+ found = true;
403
+ break;
404
+ }
405
+ }
406
+ catch {
407
+ continue;
408
+ }
409
+ }
410
+ if (found)
411
+ break;
412
+ }
413
+ }
414
+ }
415
+ if (found)
416
+ break;
417
+ return null;
418
+ }
419
+ case 'S60_process_references': {
420
+ // references/yaml/index.yaml is the primary output artifact
421
+ const indexYaml = node_path_1.default.join(harnessRoot, 'references', 'yaml', 'index.yaml');
422
+ if (await fs_extra_1.default.pathExists(indexYaml) && await isRecentFile(indexYaml, phaseStartTime)) {
423
+ break; // qualified
424
+ }
425
+ // Also accept SKIPPED state if references/md/ is empty (no references to process)
426
+ const refMdDir = node_path_1.default.join(harnessRoot, 'references', 'md');
427
+ if (await fs_extra_1.default.pathExists(refMdDir)) {
428
+ const files = await fs_extra_1.default.readdir(refMdDir);
429
+ if (files.filter((f) => f !== '.gitkeep').length === 0) {
430
+ // No reference documents — SKIPPED is the correct outcome
431
+ logger_1.logger.info(`${def.key} 产物兜底:无参考资料,自动标记 SKIPPED`);
432
+ const state = await (0, auto_state_1.readBuildState)(harnessRoot);
433
+ (0, auto_state_1.setPhaseStatus)(state, def.key, 'SKIPPED');
434
+ await (0, auto_state_1.writeBuildState)(harnessRoot, state);
435
+ return 'SKIPPED';
436
+ }
437
+ }
438
+ return null;
439
+ }
440
+ case 'S65_customize_agent_env': {
441
+ // agent-env/rules/ or agent-env/skills/ has content from this phase
442
+ const rulesDir = node_path_1.default.join(harnessRoot, 'agent-env', 'rules');
443
+ const skillsDir = node_path_1.default.join(harnessRoot, 'agent-env', 'skills');
444
+ if (await hasNonGitkeepEntries(rulesDir) || await hasAnySubdirWithFiles(skillsDir)) {
445
+ break; // qualified
446
+ }
447
+ return null;
448
+ }
449
+ case 'S70_runtime_assets': {
450
+ // Check tasks/ for content; avoid skills/ overlap with S65 detection.
451
+ const s70TasksDir = node_path_1.default.join(harnessRoot, 'tasks');
452
+ if (await hasNonGitkeepEntries(s70TasksDir)) {
453
+ break; // qualified
454
+ }
455
+ return null;
456
+ }
457
+ case 'S80_seed_memory': {
458
+ const memoryInbox = node_path_1.default.join(harnessRoot, 'agent-env', 'memory', 'inbox');
459
+ if (await hasNonGitkeepEntries(memoryInbox)) {
460
+ break; // qualified
461
+ }
462
+ return null;
463
+ }
464
+ default:
465
+ return null; // no artifact-based detection for this phase
466
+ }
467
+ // If we reach here, the phase's artifacts were detected as present,
468
+ // so auto-resolve the phase to DONE.
469
+ logger_1.logger.info(`${def.key} 产物兜底:检测到输出产物,自动标记 DONE`);
470
+ const state = await (0, auto_state_1.readBuildState)(harnessRoot);
471
+ (0, auto_state_1.setPhaseStatus)(state, def.key, 'DONE');
472
+ await (0, auto_state_1.writeBuildState)(harnessRoot, state);
473
+ return 'DONE';
474
+ }
475
+ catch (err) {
476
+ // Log IO errors rather than swallowing them silently.
477
+ // Previously this was a bare catch {} which masked real filesystem
478
+ // issues (permissions, disk full, race conditions).
479
+ logger_1.logger.warn(`${def.key} 产物兜底检测异常:${err instanceof Error ? err.message : String(err)}`);
480
+ return null; // fall through to normal retry
481
+ }
482
+ }
483
+ /**
484
+ * Try to auto-repair a failed phase by calling ACP with a diagnosis prompt.
485
+ * The ACP agent inspects the harness, fixes data issues, and updates the state.
486
+ */
487
+ async tryAutoRepair(def, harnessRoot, phaseStartTime) {
488
+ try {
489
+ const desc = PHASE_DESC[def.key] ?? def.key;
490
+ const repairPrompt = `[ACP AUTO-REPAIR] 阶段 ${def.key}(${desc})执行失败。\n\n` +
491
+ `请检查 harness 目录 ${harnessRoot},识别并修复该阶段应产生的产物问题,\n` +
492
+ `然后更新 .harness-build-state.yaml 中 ${def.key} 的状态为 DONE。\n\n` +
493
+ `注意:\n` +
494
+ `1. 只关注 ${def.key} 阶段的问题,不要修改其他阶段的产物或状态\n` +
495
+ `2. 修复完成后务必在输出末尾输出 <PHASE_DONE>${def.short}</PHASE_DONE>\n`;
496
+ this.cost.recordCall(def.key + '_repair');
497
+ const result = await this.acp.call({
498
+ workDir: harnessRoot,
499
+ phase: def.key + '_repair',
500
+ prompt: (0, phase_prompt_loader_1.wrapAcpPrompt)(repairPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`),
501
+ timeoutMs: this.ctx.acpTimeoutMs,
502
+ });
503
+ await this.writeAcpLog(def.short + '-repair', result.output, result.error);
504
+ // Check state
505
+ const after = await (0, auto_state_1.readBuildState)(harnessRoot);
506
+ const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
507
+ if (afterStatus === 'DONE' || afterStatus === 'SKIPPED')
508
+ return true;
509
+ // Check signal fallback
510
+ const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
511
+ if (resolved)
512
+ return true;
513
+ // Check artifact fallback
514
+ const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
515
+ if (artifactResolved)
516
+ return true;
517
+ return false;
518
+ }
519
+ catch (err) {
520
+ logger_1.logger.warn(`${def.key} 自动修复异常:${err instanceof Error ? err.message : String(err)}`);
521
+ return false;
522
+ }
249
523
  }
250
524
  async writeAcpLog(phase, output, error) {
251
525
  if (!this.ctx.harnessRoot)
@@ -261,3 +535,42 @@ function phaseNumber(phase) {
261
535
  const match = phase.match(/^S(\d+)/i);
262
536
  return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
263
537
  }
538
+ /** Check if a directory exists and contains at least one entry (excluding .gitkeep). */
539
+ async function hasNonGitkeepEntries(dir) {
540
+ if (!(await fs_extra_1.default.pathExists(dir)))
541
+ return false;
542
+ const entries = await fs_extra_1.default.readdir(dir);
543
+ return entries.some((e) => e !== '.gitkeep');
544
+ }
545
+ /** Check if a directory exists and has at least one subdirectory with files. */
546
+ async function hasAnySubdirWithFiles(dir) {
547
+ if (!(await fs_extra_1.default.pathExists(dir)))
548
+ return false;
549
+ const entries = await fs_extra_1.default.readdir(dir);
550
+ for (const entry of entries) {
551
+ const subPath = node_path_1.default.join(dir, entry);
552
+ const stat = await fs_extra_1.default.stat(subPath);
553
+ if (stat.isDirectory()) {
554
+ const files = await fs_extra_1.default.readdir(subPath);
555
+ if (files.some((f) => f !== '.gitkeep'))
556
+ return true;
557
+ }
558
+ }
559
+ return false;
560
+ }
561
+ /**
562
+ * Check if a file was last modified at or after a given timestamp.
563
+ * Used to distinguish artifacts produced by the current phase attempt
564
+ * from stale files left by a previous build, preventing false-positive
565
+ * artifact detection.
566
+ */
567
+ async function isRecentFile(filePath, sinceMs) {
568
+ try {
569
+ const stat = await fs_extra_1.default.stat(filePath);
570
+ // Allow 5-second tolerance for filesystem timestamp granularity
571
+ return stat.mtimeMs >= sinceMs - 5000;
572
+ }
573
+ catch {
574
+ return false;
575
+ }
576
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svharness",
3
- "version": "0.15.8",
3
+ "version": "0.15.11",
4
4
  "description": "CLI scaffolder for SDD-Driven Agent-Agnostic Coding Framework (harness)",
5
5
  "bin": {
6
6
  "svharness": "./bin/cli.js",