svharness 0.15.10 → 0.15.13

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.
Files changed (49) hide show
  1. package/README.md +106 -2
  2. package/auto/auto-harness-detail-design.md +342 -36
  3. package/auto/skills/svharness/SKILL.md +44 -1
  4. package/auto/templates/auto-pack-req.config.example.yaml +29 -0
  5. package/auto/templates/auto.config.example.yaml +2 -2
  6. package/auto/templates/phase-prompts/S40-adversarial-review.md +31 -0
  7. package/auto/templates/phase-prompts/S40-review.md +22 -0
  8. package/auto/templates/phase-prompts/S40-sample-trace.md +26 -0
  9. package/auto/templates/requirements-review-rubric.yaml +79 -0
  10. package/dist/commands/auto-pack-req.js +70 -0
  11. package/dist/commands/auto.js +18 -2
  12. package/dist/commands/convert.js +2 -0
  13. package/dist/commands/shell-integration.js +29 -56
  14. package/dist/config/merge-options.js +2 -0
  15. package/dist/config/normalize.js +7 -0
  16. package/dist/constants/convert.js +5 -0
  17. package/dist/core/markitdown-client.js +7 -2
  18. package/dist/dashboard/event-bus.js +3 -0
  19. package/dist/dashboard/index.js +1 -1
  20. package/dist/dashboard/sse-handler.js +3 -0
  21. package/dist/dashboard/static/js/app.js +5 -0
  22. package/dist/index.js +79 -6
  23. package/dist/lib/acp-agent-cli-resolver.js +193 -0
  24. package/dist/lib/acp-client.js +4 -2
  25. package/dist/lib/agent-launcher.js +9 -4
  26. package/dist/lib/auto-assets.js +1 -0
  27. package/dist/lib/auto-optimize.js +23 -3
  28. package/dist/lib/auto-orchestrator.js +79 -20
  29. package/dist/lib/auto-pack-req-context.js +88 -0
  30. package/dist/lib/auto-pack-req-finalize.js +28 -0
  31. package/dist/lib/auto-req-pack-review.js +161 -0
  32. package/dist/lib/auto-sample-trace.js +144 -0
  33. package/dist/lib/auto-state.js +130 -14
  34. package/dist/lib/cost-tracker.js +30 -2
  35. package/dist/lib/gate-checker.js +20 -10
  36. package/dist/lib/phase-prompt-loader.js +4 -0
  37. package/dist/lib/phase-runner.js +234 -71
  38. package/dist/lib/ps-codechat-alias.js +29 -16
  39. package/dist/lib/review-parser.js +14 -0
  40. package/dist/lib/score-aggregator.js +21 -0
  41. package/dist/lib/win-registry.js +1 -1
  42. package/dist/utils/validate-args.js +2 -1
  43. package/package.json +3 -2
  44. package/templates/codechat/Start-CodeChat.ps1 +176 -135
  45. package/templates/codechat/bash.exe.stackdump +29 -0
  46. package/templates/codechat/docs/project-structure.md +59 -0
  47. package/templates/svharness.config.example.yaml +2 -0
  48. package/dist/lib/launch-script-path.js +0 -24
  49. package/templates/codechat/.claude/.env +0 -59
@@ -13,10 +13,21 @@ const ESSENTIAL_FILES = [
13
13
  'AGENTS_BUILD.md',
14
14
  '.harness-build-state.yaml',
15
15
  ];
16
- async function runMechanicalGates(harnessRoot, verbose = false) {
17
- const doctor_exit = await runDoctorGate(harnessRoot);
16
+ const REQ_PACK_ESSENTIAL = [
17
+ 'harness.yaml',
18
+ '.harness-build-state.yaml',
19
+ 'requirements/raw',
20
+ 'requirements/md',
21
+ 'requirements/yaml',
22
+ 'requirements/_work',
23
+ ];
24
+ async function runMechanicalGates(harnessRoot, opts = {}) {
25
+ const normalized = typeof opts === 'boolean' ? { verbose: opts } : opts;
26
+ const scope = normalized.scope ?? 'full';
27
+ const verbose = normalized.verbose ?? false;
28
+ const doctor_exit = await runDoctorGate(harnessRoot, scope);
18
29
  const verify_exit = await runVerifyGate(harnessRoot, verbose);
19
- const file_completeness_pct = await computeFileCompleteness(harnessRoot);
30
+ const file_completeness_pct = await computeFileCompleteness(harnessRoot, scope);
20
31
  return {
21
32
  doctor_exit,
22
33
  verify_exit,
@@ -24,11 +35,11 @@ async function runMechanicalGates(harnessRoot, verbose = false) {
24
35
  doctor_passed: doctor_exit === 0,
25
36
  };
26
37
  }
27
- async function runDoctorGate(harnessRoot) {
38
+ async function runDoctorGate(harnessRoot, scope) {
28
39
  try {
29
40
  const report = await (0, index_1.runDoctorChecks)({
30
41
  harnessRoot,
31
- mode: 'pre-seal',
42
+ mode: scope === 'requirements' ? 'requirements' : 'pre-seal',
32
43
  strict: true,
33
44
  });
34
45
  return report.passed ? 0 : 1;
@@ -48,11 +59,10 @@ async function runVerifyGate(harnessRoot, verbose) {
48
59
  return 1;
49
60
  }
50
61
  }
51
- async function computeFileCompleteness(harnessRoot) {
52
- const checks = [...ESSENTIAL_FILES];
53
- for (const dir of ['requirements', 'references', 'specs', 'agent-env', 'tasks']) {
54
- checks.push(dir);
55
- }
62
+ async function computeFileCompleteness(harnessRoot, scope) {
63
+ const checks = scope === 'requirements'
64
+ ? [...REQ_PACK_ESSENTIAL]
65
+ : [...ESSENTIAL_FILES, 'requirements', 'references', 'specs', 'agent-env', 'tasks'];
56
66
  let present = 0;
57
67
  for (const rel of checks) {
58
68
  if (await fs_extra_1.default.pathExists(node_path_1.default.join(harnessRoot, rel)))
@@ -8,6 +8,7 @@ exports.renderPrompt = renderPrompt;
8
8
  exports.wrapAcpPrompt = wrapAcpPrompt;
9
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
+ const logger_1 = require("../utils/logger");
11
12
  const auto_assets_1 = require("./auto-assets");
12
13
  async function loadPhasePrompt(templateName, vars, promptDirOverride) {
13
14
  const assets = (0, auto_assets_1.resolveAutoAssetPaths)(promptDirOverride);
@@ -21,6 +22,9 @@ async function loadPhasePrompt(templateName, vars, promptDirOverride) {
21
22
  function renderPrompt(template, vars) {
22
23
  return template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
23
24
  const value = vars[key];
25
+ if (value === undefined || value === null) {
26
+ logger_1.logger.warn(`prompt 模板中占位符 {{${key}}} 未提供值,保留原样`);
27
+ }
24
28
  return value === undefined || value === null ? match : String(value);
25
29
  });
26
30
  }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.PhaseRunner = exports.PHASE_DEFS = void 0;
6
+ exports.PhaseRunner = exports.SCOPE_PHASES = exports.PHASE_DEFS = void 0;
7
7
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const logger_1 = require("../utils/logger");
@@ -28,6 +28,10 @@ exports.PHASE_DEFS = [
28
28
  { key: 'S70_runtime_assets', short: 'S70', mode: 'acp', promptTemplate: 'S70-runtime-assets.md' },
29
29
  { key: 'S80_seed_memory', short: 'S80', mode: 'acp', promptTemplate: 'S80-seed-memory.md' },
30
30
  ];
31
+ exports.SCOPE_PHASES = {
32
+ full: null,
33
+ requirements: ['S30_convert_docs', 'S40_extract_requirements'],
34
+ };
31
35
  /** 阶段描述映射,供阶段执行时显示给用户了解当前进展。 */
32
36
  const PHASE_DESC = {
33
37
  S10_wiki: '生成 baseline wiki 正文',
@@ -62,7 +66,14 @@ class PhaseRunner {
62
66
  async getBuildPhases() {
63
67
  const harnessRoot = this.ctx.harnessRoot;
64
68
  const state = await (0, auto_state_1.readBuildState)(harnessRoot);
65
- return exports.PHASE_DEFS.filter((def) => def.key in state.phases);
69
+ const allowed = exports.SCOPE_PHASES[this.ctx.scope];
70
+ return exports.PHASE_DEFS.filter((def) => {
71
+ if (!(def.key in state.phases))
72
+ return false;
73
+ if (allowed && !allowed.includes(def.key))
74
+ return false;
75
+ return true;
76
+ });
66
77
  }
67
78
  shouldStopAtMaxPhase(def) {
68
79
  if (!this.ctx.maxPhase)
@@ -107,61 +118,108 @@ class PhaseRunner {
107
118
  logger_1.logger.plain(`\n--- ${def.key} prompt ---\n${prompt}\n`);
108
119
  return;
109
120
  }
110
- // Prepend a non-interactive preamble to every ACP prompt. This prevents
111
- // Claude from asking questions or waiting for confirmation (it has no
112
- // stdin backchannel in --print mode) and forces a completion signal as
113
- // the very last line of output so the harness can detect phase completion
114
- // reliably.
115
- const boundedPrompt = (0, phase_prompt_loader_1.wrapAcpPrompt)(prompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`);
116
121
  for (let attempt = 0; attempt <= this.ctx.maxRetriesPerPhase; attempt++) {
117
- this.cost.recordCall(def.key);
118
- const result = await this.acp.call({
119
- workDir: harnessRoot,
120
- phase: def.key,
121
- prompt: boundedPrompt,
122
- timeoutMs: this.ctx.acpTimeoutMs,
123
- onData: (text) => event_bus_1.dashboardEventBus.emitAcpOutput(def.key, text),
124
- });
125
- await this.writeAcpLog(def.short, result.output, result.error);
126
- if (!result.success) {
127
- logger_1.logger.warn(`${def.key} ACP 调用失败:${result.error ?? 'unknown error'}`);
122
+ // Recalculate boundedPrompt inside the retry loop so each attempt gets:
123
+ // 1) A fresh wrap with the attempt number in context, and
124
+ // 2) Awareness of any partial artifacts left by previous attempts.
125
+ // Without this, every retry receives the same prompt and Claude would
126
+ // start from scratch rather than incrementally fixing.
127
+ const attemptPrompt = attempt > 0
128
+ ? `[重试第 ${attempt} 次] 这是第 ${attempt} 次尝试。如果上一次尝试已经产生了部分文件,请检查并修复它们,不要从头开始。\n${prompt}`
129
+ : prompt;
130
+ const boundedPrompt = (0, phase_prompt_loader_1.wrapAcpPrompt)(attemptPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`);
131
+ // Try ACP call; on CostTracker budget/call-limit error, fall back to
132
+ // free recovery checks (signal + artifact detection) without crashing.
133
+ let acpOk = true;
134
+ try {
135
+ this.cost.recordCall(def.key);
128
136
  }
129
- const after = await (0, auto_state_1.readBuildState)(harnessRoot);
130
- const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
131
- if (afterStatus === 'DONE' || afterStatus === 'SKIPPED') {
132
- const elapsed = Date.now() - phaseStartTime;
133
- event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, afterStatus, elapsed);
134
- const label = afterStatus === 'DONE' ? '✅ 完成' : '⏭️ 跳过';
135
- logger_1.logger.success(`${def.key} ${label}`);
136
- return;
137
+ catch (err) {
138
+ acpOk = false;
139
+ logger_1.logger.warn(`${def.key} 资源限制:${err.message},跳过 ACP 调用并尝试产物兜底`);
137
140
  }
138
- // Fallback: Claude may have emitted a completion signal in its text output
139
- // but failed to edit the YAML state file. Rely on the signal instead.
140
- const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
141
- if (resolved) {
142
- const elapsed = Date.now() - phaseStartTime;
143
- event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, resolved, elapsed);
144
- const label = resolved === 'DONE' ? '✅ 完成(信号兜底)' : '⏭️ 跳过(信号兜底)';
145
- logger_1.logger.success(`${def.key} ${label}`);
146
- return;
141
+ if (acpOk) {
142
+ const result = await this.acp.call({
143
+ workDir: harnessRoot,
144
+ phase: def.key,
145
+ prompt: boundedPrompt,
146
+ timeoutMs: this.ctx.acpTimeoutMs,
147
+ onData: (text) => event_bus_1.dashboardEventBus.emitAcpOutput(def.key, text),
148
+ });
149
+ await this.writeAcpLog(def.short, result.output, result.error);
150
+ if (!result.success) {
151
+ logger_1.logger.warn(`${def.key} ACP 调用失败:${result.error ?? 'unknown error'}`);
152
+ }
153
+ const after = await (0, auto_state_1.readBuildState)(harnessRoot);
154
+ const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
155
+ if (afterStatus === 'DONE' || afterStatus === 'SKIPPED') {
156
+ const elapsed = Date.now() - phaseStartTime;
157
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, afterStatus, elapsed);
158
+ const label = afterStatus === 'DONE' ? '✅ 完成' : '⏭️ 跳过';
159
+ logger_1.logger.success(`${def.key} ${label}`);
160
+ return;
161
+ }
162
+ // Fallback: Claude may have emitted a completion signal in its text output
163
+ // but failed to edit the YAML state file. Rely on the signal instead.
164
+ const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
165
+ if (resolved) {
166
+ const elapsed = Date.now() - phaseStartTime;
167
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, resolved, elapsed);
168
+ const label = resolved === 'DONE' ? '✅ 完成(信号兜底)' : '⏭️ 跳过(信号兜底)';
169
+ logger_1.logger.success(`${def.key} ${label}`);
170
+ return;
171
+ }
172
+ // Fallback: detect phase completion by checking for expected output artifacts.
173
+ // Some phases (e.g. S40_extract_requirements) may have produced all required
174
+ // artifacts even though Claude failed to emit the completion signal or update the
175
+ // YAML state file. Detect and resolve programmatically to avoid false retries.
176
+ const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
177
+ if (artifactResolved) {
178
+ const elapsed = Date.now() - phaseStartTime;
179
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
180
+ logger_1.logger.success(`${def.key} ✅ 完成(产物兜底检测)`);
181
+ return;
182
+ }
147
183
  }
148
- // Fallback: detect phase completion by checking for expected output artifacts.
149
- // Some phases (e.g. S40_extract_requirements) may have produced all required
150
- // artifacts even though Claude failed to emit the completion signal or update the
151
- // YAML state file. Detect and resolve programmatically to avoid false retries.
152
- const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot);
153
- if (artifactResolved) {
154
- const elapsed = Date.now() - phaseStartTime;
155
- event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
156
- logger_1.logger.success(`${def.key} 完成(产物兜底检测)`);
157
- return;
184
+ else {
185
+ // Budget/call-limit exhausted: skip ACP but try free artifact fallback.
186
+ // The previous ACP attempt (which was already paid for) may have left valid
187
+ // artifacts on disk even though the state file wasn't updated to DONE.
188
+ logger_1.logger.warn(`${def.key} 资源已耗尽,尝试产物兜底恢复...`);
189
+ const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, { success: true }, phaseStartTime);
190
+ if (artifactResolved) {
191
+ const elapsed = Date.now() - phaseStartTime;
192
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, artifactResolved, elapsed);
193
+ logger_1.logger.success(`${def.key} ✅ 完成(资源受限产物兜底)`);
194
+ return;
195
+ }
196
+ // Break out of retry loop — no more ACP calls are possible.
197
+ break;
158
198
  }
159
199
  if (attempt < this.ctx.maxRetriesPerPhase) {
160
200
  logger_1.logger.warn(`${def.key} 未更新为 DONE/SKIPPED,准备重试第 ${attempt + 1} 次`);
161
201
  }
162
202
  }
163
203
  event_bus_1.dashboardEventBus.emitPhaseFailed(def.key, 'state 未更新为 DONE/SKIPPED', this.ctx.maxRetriesPerPhase);
164
- throw new Error(`${def.key} 未完成:state 未更新为 DONE/SKIPPED`);
204
+ // All retries exhausted try ACP auto-repair before giving up
205
+ if (this.ctx.autoRepair !== false) {
206
+ logger_1.logger.warn(`${def.key} 重试耗尽,启动 ACP 自动修复...`);
207
+ const repaired = await this.tryAutoRepair(def, harnessRoot, phaseStartTime);
208
+ if (repaired) {
209
+ const elapsed = Date.now() - phaseStartTime;
210
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, 'DONE', elapsed);
211
+ logger_1.logger.success(`${def.key} ✅ 完成(ACP 自动修复)`);
212
+ return;
213
+ }
214
+ logger_1.logger.warn(`${def.key} ACP 自动修复未生效`);
215
+ }
216
+ // Don't throw — mark as SKIPPED so the build continues to subsequent phases
217
+ logger_1.logger.warn(`${def.key} 已耗尽所有重试次数,标记 SKIPPED 后继续执行后续阶段`);
218
+ const skipState = await (0, auto_state_1.readBuildState)(harnessRoot);
219
+ (0, auto_state_1.setPhaseStatus)(skipState, def.key, 'SKIPPED');
220
+ skipState.phases[def.key].note = '重试耗尽且自动修复未生效,已自动跳过';
221
+ await (0, auto_state_1.writeBuildState)(harnessRoot, skipState);
222
+ event_bus_1.dashboardEventBus.emitPhaseCompleted(def.key, 'SKIPPED', Date.now() - phaseStartTime);
165
223
  }
166
224
  async runMechanicalPre(def) {
167
225
  if (!this.ctx.harnessRoot || this.ctx.dryRun)
@@ -270,8 +328,14 @@ class PhaseRunner {
270
328
  await (0, auto_state_1.writeBuildState)(harnessRoot, state);
271
329
  return targetStatus;
272
330
  }
273
- // <AUTO_SIGNAL>: any signal value resolves as DONE (used by S85/optimize phases)
331
+ // <AUTO_SIGNAL>: resolves as DONE for any signal value.
332
+ // Note: this branch is only reachable for phase-runner phases (S10-S80),
333
+ // which all use <PHASE_DONE> signals via wrapAcpPrompt, not <AUTO_SIGNAL>.
334
+ // The <AUTO_SIGNAL> protocol is used by auto-optimize.ts and auto-review.ts
335
+ // which bypass PhaseRunner entirely. If this fires unexpectedly, the log
336
+ // below provides an audit trail.
274
337
  if (autoSignal) {
338
+ logger_1.logger.warn(`${def.key} 收到意外 AUTO_SIGNAL「${autoSignal[1]}」(phase-runner 阶段应使用 PHASE_DONE)、自动标记 DONE`);
275
339
  const state = await (0, auto_state_1.readBuildState)(harnessRoot);
276
340
  (0, auto_state_1.setPhaseStatus)(state, def.key, 'DONE');
277
341
  await (0, auto_state_1.writeBuildState)(harnessRoot, state);
@@ -281,27 +345,48 @@ class PhaseRunner {
281
345
  }
282
346
  /**
283
347
  * Detect phase completion by checking for expected output artifacts on disk.
284
- * Used as a last-resort fallback when Claude completed the work but failed to
285
- * emit <PHASE_DONE> or update the YAML state file.
348
+ * Used when ACP succeeded but failed to emit a completion signal or update
349
+ * the YAML state file.
286
350
  *
287
- * Each phase defines its own detection criteria.
351
+ * Only runs when result.success === true — ACP failure artifacts are too
352
+ * unreliable to use as a completion signal.
288
353
  */
289
- async tryResolveFromArtifacts(def, harnessRoot) {
354
+ async tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime) {
355
+ // Only use artifact fallback when ACP actually succeeded.
356
+ // Failed ACP calls may leave partial/incomplete artifacts that would
357
+ // cause false-positive phase resolution, bypassing retry logic.
358
+ if (!result.success)
359
+ return null;
290
360
  try {
291
361
  switch (def.key) {
292
362
  case 'S40_extract_requirements': {
293
363
  const yamlDir = node_path_1.default.join(harnessRoot, 'requirements', 'yaml');
294
364
  if (await fs_extra_1.default.pathExists(yamlDir)) {
295
365
  const entries = await fs_extra_1.default.readdir(yamlDir);
296
- const yamlFiles = entries.filter((e) => e.endsWith('.yaml'));
366
+ const yamlFiles = entries
367
+ .filter((e) => e.endsWith('.yaml'));
297
368
  if (yamlFiles.length > 0) {
298
- break; // qualified mark DONE below
369
+ // Verify at minimum that the yaml file has substantive content
370
+ // created during or after this phase started.
371
+ const validYamlFiles = (await Promise.all(yamlFiles.map(async (f) => {
372
+ const fp = node_path_1.default.join(yamlDir, f);
373
+ try {
374
+ const stat = await fs_extra_1.default.stat(fp);
375
+ return stat.size > 50 && stat.mtimeMs >= phaseStartTime - 5000;
376
+ }
377
+ catch {
378
+ return false;
379
+ }
380
+ }))).filter(Boolean);
381
+ if (validYamlFiles.length > 0)
382
+ break; // qualified
299
383
  }
300
384
  }
301
385
  return null;
302
386
  }
303
387
  case 'S50_generate_specs': {
304
388
  // specs/ must have at least one non-empty subdirectory
389
+ // with a non-trivial .md or .yaml file (>100 bytes) created during this phase.
305
390
  const specsDir = node_path_1.default.join(harnessRoot, 'specs');
306
391
  let found = false;
307
392
  if (await fs_extra_1.default.pathExists(specsDir)) {
@@ -309,24 +394,43 @@ class PhaseRunner {
309
394
  .filter((e) => e !== '_work');
310
395
  for (const sub of subs) {
311
396
  const subDir = node_path_1.default.join(specsDir, sub);
312
- const stat = await fs_extra_1.default.stat(subDir);
397
+ let stat;
398
+ try {
399
+ stat = await fs_extra_1.default.stat(subDir);
400
+ }
401
+ catch {
402
+ continue;
403
+ }
313
404
  if (stat.isDirectory()) {
314
405
  const files = await fs_extra_1.default.readdir(subDir);
315
- if (files.some((f) => f.endsWith('.md') || f.endsWith('.yaml'))) {
316
- found = true;
317
- break; // break for loop only; 'found' carries the result
406
+ for (const f of files) {
407
+ if (!f.endsWith('.md') && !f.endsWith('.yaml'))
408
+ continue;
409
+ const fp = node_path_1.default.join(subDir, f);
410
+ try {
411
+ const fstat = await fs_extra_1.default.stat(fp);
412
+ if (fstat.size > 100 && fstat.mtimeMs >= phaseStartTime - 5000) {
413
+ found = true;
414
+ break;
415
+ }
416
+ }
417
+ catch {
418
+ continue;
419
+ }
318
420
  }
421
+ if (found)
422
+ break;
319
423
  }
320
424
  }
321
425
  }
322
426
  if (found)
323
- break; // break switch → mark DONE
427
+ break;
324
428
  return null;
325
429
  }
326
430
  case 'S60_process_references': {
327
431
  // references/yaml/index.yaml is the primary output artifact
328
432
  const indexYaml = node_path_1.default.join(harnessRoot, 'references', 'yaml', 'index.yaml');
329
- if (await fs_extra_1.default.pathExists(indexYaml)) {
433
+ if (await fs_extra_1.default.pathExists(indexYaml) && await isRecentFile(indexYaml, phaseStartTime)) {
330
434
  break; // qualified
331
435
  }
332
436
  // Also accept SKIPPED state if references/md/ is empty (no references to process)
@@ -345,27 +449,25 @@ class PhaseRunner {
345
449
  return null;
346
450
  }
347
451
  case 'S65_customize_agent_env': {
348
- // agent-env/rules/ or agent-env/skills/ has content
452
+ // agent-env/rules/ or agent-env/skills/ has content from this phase
349
453
  const rulesDir = node_path_1.default.join(harnessRoot, 'agent-env', 'rules');
350
454
  const skillsDir = node_path_1.default.join(harnessRoot, 'agent-env', 'skills');
351
- if (await hasNonEmptyFiles(rulesDir) || await hasAnySubdirWithFiles(skillsDir)) {
455
+ if (await hasNonGitkeepEntries(rulesDir) || await hasAnySubdirWithFiles(skillsDir)) {
352
456
  break; // qualified
353
457
  }
354
458
  return null;
355
459
  }
356
460
  case 'S70_runtime_assets': {
357
- // Check tasks/ or agent-env/skills/ or agent-env/rules/ for content
461
+ // Check tasks/ for content; avoid skills/ overlap with S65 detection.
358
462
  const s70TasksDir = node_path_1.default.join(harnessRoot, 'tasks');
359
- const s70SkillsDir = node_path_1.default.join(harnessRoot, 'agent-env', 'skills');
360
- if (await hasNonEmptyFiles(s70TasksDir) ||
361
- await hasAnySubdirWithFiles(s70SkillsDir)) {
463
+ if (await hasNonGitkeepEntries(s70TasksDir)) {
362
464
  break; // qualified
363
465
  }
364
466
  return null;
365
467
  }
366
468
  case 'S80_seed_memory': {
367
469
  const memoryInbox = node_path_1.default.join(harnessRoot, 'agent-env', 'memory', 'inbox');
368
- if (await hasNonEmptyFiles(memoryInbox)) {
470
+ if (await hasNonGitkeepEntries(memoryInbox)) {
369
471
  break; // qualified
370
472
  }
371
473
  return null;
@@ -381,8 +483,53 @@ class PhaseRunner {
381
483
  await (0, auto_state_1.writeBuildState)(harnessRoot, state);
382
484
  return 'DONE';
383
485
  }
384
- catch {
385
- return null; // silently fall through to normal retry on any filesystem error
486
+ catch (err) {
487
+ // Log IO errors rather than swallowing them silently.
488
+ // Previously this was a bare catch {} which masked real filesystem
489
+ // issues (permissions, disk full, race conditions).
490
+ logger_1.logger.warn(`${def.key} 产物兜底检测异常:${err instanceof Error ? err.message : String(err)}`);
491
+ return null; // fall through to normal retry
492
+ }
493
+ }
494
+ /**
495
+ * Try to auto-repair a failed phase by calling ACP with a diagnosis prompt.
496
+ * The ACP agent inspects the harness, fixes data issues, and updates the state.
497
+ */
498
+ async tryAutoRepair(def, harnessRoot, phaseStartTime) {
499
+ try {
500
+ const desc = PHASE_DESC[def.key] ?? def.key;
501
+ const repairPrompt = `[ACP AUTO-REPAIR] 阶段 ${def.key}(${desc})执行失败。\n\n` +
502
+ `请检查 harness 目录 ${harnessRoot},识别并修复该阶段应产生的产物问题,\n` +
503
+ `然后更新 .harness-build-state.yaml 中 ${def.key} 的状态为 DONE。\n\n` +
504
+ `注意:\n` +
505
+ `1. 只关注 ${def.key} 阶段的问题,不要修改其他阶段的产物或状态\n` +
506
+ `2. 修复完成后务必在输出末尾输出 <PHASE_DONE>${def.short}</PHASE_DONE>\n`;
507
+ this.cost.recordCall(def.key + '_repair');
508
+ const result = await this.acp.call({
509
+ workDir: harnessRoot,
510
+ phase: def.key + '_repair',
511
+ prompt: (0, phase_prompt_loader_1.wrapAcpPrompt)(repairPrompt, `<PHASE_DONE>${def.short}</PHASE_DONE>`),
512
+ timeoutMs: this.ctx.acpTimeoutMs,
513
+ });
514
+ await this.writeAcpLog(def.short + '-repair', result.output, result.error);
515
+ // Check state
516
+ const after = await (0, auto_state_1.readBuildState)(harnessRoot);
517
+ const afterStatus = (0, auto_state_1.phaseStatus)(after, def.key);
518
+ if (afterStatus === 'DONE' || afterStatus === 'SKIPPED')
519
+ return true;
520
+ // Check signal fallback
521
+ const resolved = await this.tryResolveFromSignal(def, harnessRoot, result);
522
+ if (resolved)
523
+ return true;
524
+ // Check artifact fallback
525
+ const artifactResolved = await this.tryResolveFromArtifacts(def, harnessRoot, result, phaseStartTime);
526
+ if (artifactResolved)
527
+ return true;
528
+ return false;
529
+ }
530
+ catch (err) {
531
+ logger_1.logger.warn(`${def.key} 自动修复异常:${err instanceof Error ? err.message : String(err)}`);
532
+ return false;
386
533
  }
387
534
  }
388
535
  async writeAcpLog(phase, output, error) {
@@ -399,8 +546,8 @@ function phaseNumber(phase) {
399
546
  const match = phase.match(/^S(\d+)/i);
400
547
  return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
401
548
  }
402
- /** Check if a directory exists and contains at least one file (excluding .gitkeep). */
403
- async function hasNonEmptyFiles(dir) {
549
+ /** Check if a directory exists and contains at least one entry (excluding .gitkeep). */
550
+ async function hasNonGitkeepEntries(dir) {
404
551
  if (!(await fs_extra_1.default.pathExists(dir)))
405
552
  return false;
406
553
  const entries = await fs_extra_1.default.readdir(dir);
@@ -422,3 +569,19 @@ async function hasAnySubdirWithFiles(dir) {
422
569
  }
423
570
  return false;
424
571
  }
572
+ /**
573
+ * Check if a file was last modified at or after a given timestamp.
574
+ * Used to distinguish artifacts produced by the current phase attempt
575
+ * from stale files left by a previous build, preventing false-positive
576
+ * artifact detection.
577
+ */
578
+ async function isRecentFile(filePath, sinceMs) {
579
+ try {
580
+ const stat = await fs_extra_1.default.stat(filePath);
581
+ // Allow 5-second tolerance for filesystem timestamp granularity
582
+ return stat.mtimeMs >= sinceMs - 5000;
583
+ }
584
+ catch {
585
+ return false;
586
+ }
587
+ }
@@ -13,7 +13,20 @@ const fs_extra_1 = __importDefault(require("fs-extra"));
13
13
  const path_1 = __importDefault(require("path"));
14
14
  const child_process_1 = require("child_process");
15
15
  const logger_1 = require("../utils/logger");
16
- const launch_script_path_1 = require("./launch-script-path");
16
+ /**
17
+ * Resolve absolute path to templates/codechat/Start-CodeChat.ps1 in the installed package.
18
+ */
19
+ function resolveStartCodeChatPs1Path() {
20
+ const fromDist = path_1.default.resolve(__dirname, '..', '..', 'templates', 'codechat', 'Start-CodeChat.ps1');
21
+ if (fs_extra_1.default.existsSync(fromDist)) {
22
+ return fromDist;
23
+ }
24
+ const fromCwd = path_1.default.resolve(__dirname, '..', '..', '..', 'templates', 'codechat', 'Start-CodeChat.ps1');
25
+ if (fs_extra_1.default.existsSync(fromCwd)) {
26
+ return fromCwd;
27
+ }
28
+ throw new Error(`未找到 Start-CodeChat.ps1(预期:${fromDist})`);
29
+ }
17
30
  const START_MARKER = /^#\s*>>>\s*codechat\s*>>>/;
18
31
  const END_MARKER = /^#\s*<<<\s*codechat\s*<<</;
19
32
  function assertWin32() {
@@ -57,8 +70,8 @@ function stripCodechatBlock(content) {
57
70
  const joined = clean.join('\n').replace(/\n+$/, '');
58
71
  return { clean: joined ? `${joined}\n` : '', hadBlock };
59
72
  }
60
- function buildAliasSnippet(launchScriptPath) {
61
- const escapedPath = launchScriptPath.replace(/'/g, "''");
73
+ function buildAliasSnippet(ps1Path) {
74
+ const escapedPath = ps1Path.replace(/'/g, "''");
62
75
  return `
63
76
 
64
77
  # >>> codechat >>>
@@ -69,17 +82,17 @@ function codechat {
69
82
  [string[]]$ExtraArgs
70
83
  )
71
84
  if (-not $Path) { $Path = (Get-Location).Path }
72
- & node '${escapedPath}' "$Path" @ExtraArgs
85
+ & '${escapedPath}' -Action Start -WorkDir "$Path" @ExtraArgs
73
86
  }
74
87
  # <<< codechat <<<
75
88
 
76
89
  `;
77
90
  }
78
- function profileHasSvharnessAlias(content, launchScriptPath) {
91
+ function profileHasSvharnessAlias(content, ps1Path) {
79
92
  if (!START_MARKER.test(content)) {
80
93
  return false;
81
94
  }
82
- const needle = normalizePathForCompare(launchScriptPath);
95
+ const needle = normalizePathForCompare(ps1Path);
83
96
  return normalizePathForCompare(content).includes(needle);
84
97
  }
85
98
  /**
@@ -89,11 +102,11 @@ function profileHasSvharnessAlias(content, launchScriptPath) {
89
102
  async function installCodechatAlias(opts = {}) {
90
103
  assertWin32();
91
104
  const profilePath = getPowerShellProfilePath();
92
- const launchScriptPath = (0, launch_script_path_1.resolveLaunchScriptPath)();
105
+ const ps1Path = resolveStartCodeChatPs1Path();
93
106
  const existing = (await fs_extra_1.default.pathExists(profilePath))
94
107
  ? await fs_extra_1.default.readFile(profilePath, 'utf8')
95
108
  : '';
96
- if (profileHasSvharnessAlias(existing, launchScriptPath)) {
109
+ if (profileHasSvharnessAlias(existing, ps1Path)) {
97
110
  if (!opts.silent) {
98
111
  logger_1.logger.info(`PowerShell 别名 codechat 已存在:${profilePath}`);
99
112
  }
@@ -104,11 +117,11 @@ async function installCodechatAlias(opts = {}) {
104
117
  await fs_extra_1.default.writeFile(profilePath, '', 'utf8');
105
118
  }
106
119
  const { clean, hadBlock } = stripCodechatBlock(existing);
107
- const snippet = buildAliasSnippet(launchScriptPath);
120
+ const snippet = buildAliasSnippet(ps1Path);
108
121
  await fs_extra_1.default.writeFile(profilePath, `${clean}${snippet}`, 'utf8');
109
122
  if (!opts.silent) {
110
123
  if (hadBlock) {
111
- logger_1.logger.success(`已更新 PowerShell 别名 codechat → ${launchScriptPath}`);
124
+ logger_1.logger.success(`已更新 PowerShell 别名 codechat → ${ps1Path}`);
112
125
  }
113
126
  else {
114
127
  logger_1.logger.success(`已安装 PowerShell 别名 codechat → ${profilePath}`);
@@ -144,24 +157,24 @@ async function uninstallCodechatAlias(opts = {}) {
144
157
  function getCodechatAliasStatus() {
145
158
  assertWin32();
146
159
  const profilePath = getPowerShellProfilePath();
147
- let launchScriptPath = '';
160
+ let ps1Path = '';
148
161
  try {
149
- launchScriptPath = (0, launch_script_path_1.resolveLaunchScriptPath)();
162
+ ps1Path = resolveStartCodeChatPs1Path();
150
163
  }
151
164
  catch {
152
- launchScriptPath = '(未找到)';
165
+ ps1Path = '(未找到)';
153
166
  }
154
167
  let installed = false;
155
- if (fs_extra_1.default.existsSync(profilePath) && launchScriptPath !== '(未找到)') {
168
+ if (fs_extra_1.default.existsSync(profilePath) && ps1Path !== '(未找到)') {
156
169
  try {
157
170
  const content = fs_extra_1.default.readFileSync(profilePath, 'utf8');
158
- installed = profileHasSvharnessAlias(content, launchScriptPath);
171
+ installed = profileHasSvharnessAlias(content, ps1Path);
159
172
  }
160
173
  catch {
161
174
  installed = false;
162
175
  }
163
176
  }
164
- return { profilePath, installed, launchScriptPath };
177
+ return { profilePath, installed, launchScriptPath: ps1Path };
165
178
  }
166
179
  function printCodechatAliasStatus() {
167
180
  const status = getCodechatAliasStatus();
@@ -3,10 +3,21 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.parseReportFrontmatter = parseReportFrontmatter;
6
7
  exports.parseReviewReport = parseReviewReport;
8
+ exports.parseGapsFromMarkdown = parseGapsFromMarkdown;
7
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
10
  const js_yaml_1 = __importDefault(require("js-yaml"));
9
11
  const score_aggregator_1 = require("./score-aggregator");
12
+ async function parseReportFrontmatter(reportPath) {
13
+ if (!(await fs_extra_1.default.pathExists(reportPath)))
14
+ return null;
15
+ const content = await fs_extra_1.default.readFile(reportPath, 'utf8');
16
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
17
+ if (!match)
18
+ return null;
19
+ return js_yaml_1.default.load(match[1]);
20
+ }
10
21
  async function parseReviewReport(reportPath) {
11
22
  if (!(await fs_extra_1.default.pathExists(reportPath)))
12
23
  return null;
@@ -51,6 +62,9 @@ function asRecord(value) {
51
62
  return typeof value === 'object' && value !== null ? value : {};
52
63
  }
53
64
  function parseGaps(content) {
65
+ return parseGapsFromMarkdown(content);
66
+ }
67
+ function parseGapsFromMarkdown(content) {
54
68
  const gaps = [];
55
69
  const lines = content.split(/\r?\n/);
56
70
  for (const line of lines) {