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.
- package/README.md +106 -2
- package/auto/auto-harness-detail-design.md +342 -36
- package/auto/skills/svharness/SKILL.md +44 -1
- package/auto/templates/auto-pack-req.config.example.yaml +29 -0
- package/auto/templates/auto.config.example.yaml +2 -2
- package/auto/templates/phase-prompts/S40-adversarial-review.md +31 -0
- package/auto/templates/phase-prompts/S40-review.md +22 -0
- package/auto/templates/phase-prompts/S40-sample-trace.md +26 -0
- package/auto/templates/requirements-review-rubric.yaml +79 -0
- package/dist/commands/auto-pack-req.js +70 -0
- package/dist/commands/auto.js +18 -2
- package/dist/commands/convert.js +2 -0
- package/dist/commands/shell-integration.js +29 -56
- package/dist/config/merge-options.js +2 -0
- package/dist/config/normalize.js +7 -0
- package/dist/constants/convert.js +5 -0
- package/dist/core/markitdown-client.js +7 -2
- package/dist/dashboard/event-bus.js +3 -0
- package/dist/dashboard/index.js +1 -1
- package/dist/dashboard/sse-handler.js +3 -0
- package/dist/dashboard/static/js/app.js +5 -0
- package/dist/index.js +79 -6
- package/dist/lib/acp-agent-cli-resolver.js +193 -0
- package/dist/lib/acp-client.js +4 -2
- package/dist/lib/agent-launcher.js +9 -4
- package/dist/lib/auto-assets.js +1 -0
- package/dist/lib/auto-optimize.js +23 -3
- package/dist/lib/auto-orchestrator.js +79 -20
- package/dist/lib/auto-pack-req-context.js +88 -0
- package/dist/lib/auto-pack-req-finalize.js +28 -0
- package/dist/lib/auto-req-pack-review.js +161 -0
- package/dist/lib/auto-sample-trace.js +144 -0
- package/dist/lib/auto-state.js +130 -14
- package/dist/lib/cost-tracker.js +30 -2
- package/dist/lib/gate-checker.js +20 -10
- package/dist/lib/phase-prompt-loader.js +4 -0
- package/dist/lib/phase-runner.js +234 -71
- package/dist/lib/ps-codechat-alias.js +29 -16
- package/dist/lib/review-parser.js +14 -0
- package/dist/lib/score-aggregator.js +21 -0
- package/dist/lib/win-registry.js +1 -1
- package/dist/utils/validate-args.js +2 -1
- package/package.json +3 -2
- package/templates/codechat/Start-CodeChat.ps1 +176 -135
- package/templates/codechat/bash.exe.stackdump +29 -0
- package/templates/codechat/docs/project-structure.md +59 -0
- package/templates/svharness.config.example.yaml +2 -0
- package/dist/lib/launch-script-path.js +0 -24
- package/templates/codechat/.claude/.env +0 -59
package/dist/lib/gate-checker.js
CHANGED
|
@@ -13,10 +13,21 @@ const ESSENTIAL_FILES = [
|
|
|
13
13
|
'AGENTS_BUILD.md',
|
|
14
14
|
'.harness-build-state.yaml',
|
|
15
15
|
];
|
|
16
|
-
|
|
17
|
-
|
|
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 =
|
|
53
|
-
|
|
54
|
-
|
|
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
|
}
|
package/dist/lib/phase-runner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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>:
|
|
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
|
|
285
|
-
*
|
|
348
|
+
* Used when ACP succeeded but failed to emit a completion signal or update
|
|
349
|
+
* the YAML state file.
|
|
286
350
|
*
|
|
287
|
-
*
|
|
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
|
|
366
|
+
const yamlFiles = entries
|
|
367
|
+
.filter((e) => e.endsWith('.yaml'));
|
|
297
368
|
if (yamlFiles.length > 0) {
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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;
|
|
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
|
|
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/
|
|
461
|
+
// Check tasks/ for content; avoid skills/ overlap with S65 detection.
|
|
358
462
|
const s70TasksDir = node_path_1.default.join(harnessRoot, 'tasks');
|
|
359
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
403
|
-
async function
|
|
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
|
-
|
|
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(
|
|
61
|
-
const escapedPath =
|
|
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
|
-
&
|
|
85
|
+
& '${escapedPath}' -Action Start -WorkDir "$Path" @ExtraArgs
|
|
73
86
|
}
|
|
74
87
|
# <<< codechat <<<
|
|
75
88
|
|
|
76
89
|
`;
|
|
77
90
|
}
|
|
78
|
-
function profileHasSvharnessAlias(content,
|
|
91
|
+
function profileHasSvharnessAlias(content, ps1Path) {
|
|
79
92
|
if (!START_MARKER.test(content)) {
|
|
80
93
|
return false;
|
|
81
94
|
}
|
|
82
|
-
const needle = normalizePathForCompare(
|
|
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
|
|
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,
|
|
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(
|
|
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 → ${
|
|
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
|
|
160
|
+
let ps1Path = '';
|
|
148
161
|
try {
|
|
149
|
-
|
|
162
|
+
ps1Path = resolveStartCodeChatPs1Path();
|
|
150
163
|
}
|
|
151
164
|
catch {
|
|
152
|
-
|
|
165
|
+
ps1Path = '(未找到)';
|
|
153
166
|
}
|
|
154
167
|
let installed = false;
|
|
155
|
-
if (fs_extra_1.default.existsSync(profilePath) &&
|
|
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,
|
|
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) {
|