svharness 0.15.8 → 0.15.10
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.
|
@@ -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
|
|
|
@@ -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) {
|
package/dist/lib/auto-review.js
CHANGED
|
@@ -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');
|
|
@@ -5,6 +5,7 @@ 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"));
|
|
10
11
|
const auto_assets_1 = require("./auto-assets");
|
|
@@ -23,3 +24,31 @@ function renderPrompt(template, vars) {
|
|
|
23
24
|
return value === undefined || value === null ? match : String(value);
|
|
24
25
|
});
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Wrap an auto-mode ACP prompt with a non-interactive preamble.
|
|
29
|
+
*
|
|
30
|
+
* All auto phase prompts run via `claude --print` (no stdin backchannel).
|
|
31
|
+
* This preamble forces Claude to:
|
|
32
|
+
* - Never ask questions or wait for confirmation
|
|
33
|
+
* - Always emit a completion signal as the very last line of output
|
|
34
|
+
* - Output nothing after the signal
|
|
35
|
+
*
|
|
36
|
+
* Call this for EVERY acp.call() invocation, including phases that bypass
|
|
37
|
+
* PhaseRunner (S85 review, optimize-plan, optimize-implement).
|
|
38
|
+
*/
|
|
39
|
+
function wrapAcpPrompt(rawPrompt, completionSignal) {
|
|
40
|
+
return [
|
|
41
|
+
'## 非交互模式硬约束(必须遵守,违反则任务失败)',
|
|
42
|
+
'你处于自动化流水线的非交互模式。禁止向用户提问、征求确认或等待人工决策。',
|
|
43
|
+
'直接执行所有操作,不要停下来询问。',
|
|
44
|
+
`你输出内容的最后一行必须精确匹配以下字符串(不含引号、不含多余空格、不含换行,之后不要再输出任何内容):`,
|
|
45
|
+
'```',
|
|
46
|
+
completionSignal,
|
|
47
|
+
'```',
|
|
48
|
+
'此信号是自动化流程识别阶段完成的唯一方式。',
|
|
49
|
+
'',
|
|
50
|
+
'---',
|
|
51
|
+
'',
|
|
52
|
+
rawPrompt,
|
|
53
|
+
].join('\n');
|
|
54
|
+
}
|
package/dist/lib/phase-runner.js
CHANGED
|
@@ -107,12 +107,18 @@ class PhaseRunner {
|
|
|
107
107
|
logger_1.logger.plain(`\n--- ${def.key} prompt ---\n${prompt}\n`);
|
|
108
108
|
return;
|
|
109
109
|
}
|
|
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>`);
|
|
110
116
|
for (let attempt = 0; attempt <= this.ctx.maxRetriesPerPhase; attempt++) {
|
|
111
117
|
this.cost.recordCall(def.key);
|
|
112
118
|
const result = await this.acp.call({
|
|
113
119
|
workDir: harnessRoot,
|
|
114
120
|
phase: def.key,
|
|
115
|
-
prompt,
|
|
121
|
+
prompt: boundedPrompt,
|
|
116
122
|
timeoutMs: this.ctx.acpTimeoutMs,
|
|
117
123
|
onData: (text) => event_bus_1.dashboardEventBus.emitAcpOutput(def.key, text),
|
|
118
124
|
});
|
|
@@ -139,6 +145,17 @@ class PhaseRunner {
|
|
|
139
145
|
logger_1.logger.success(`${def.key} ${label}`);
|
|
140
146
|
return;
|
|
141
147
|
}
|
|
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;
|
|
158
|
+
}
|
|
142
159
|
if (attempt < this.ctx.maxRetriesPerPhase) {
|
|
143
160
|
logger_1.logger.warn(`${def.key} 未更新为 DONE/SKIPPED,准备重试第 ${attempt + 1} 次`);
|
|
144
161
|
}
|
|
@@ -227,25 +244,146 @@ class PhaseRunner {
|
|
|
227
244
|
logger_1.logger.success('S30_convert_docs ✅ 完成(机械检查)');
|
|
228
245
|
}
|
|
229
246
|
/**
|
|
230
|
-
* Parse Claude's output for <PHASE_DONE> / <PHASE_SKIP> signals
|
|
231
|
-
* if the phase matches, programmatically update the state file.
|
|
247
|
+
* Parse Claude's output for <PHASE_DONE> / <PHASE_SKIP> / <AUTO_SIGNAL> signals
|
|
248
|
+
* and, if the phase matches, programmatically update the state file.
|
|
232
249
|
* This is a fallback for when Claude cannot edit the YAML itself.
|
|
250
|
+
*
|
|
251
|
+
* <PHASE_DONE>S40</PHASE_DONE> — exact phase match required (phase-runner phases)
|
|
252
|
+
* <AUTO_SIGNAL>REVIEW_DONE</AUTO_SIGNAL> — any AUTO_SIGNAL triggers DONE (review/optimize phases)
|
|
233
253
|
*/
|
|
234
254
|
async tryResolveFromSignal(def, harnessRoot, result) {
|
|
235
255
|
if (!result.completionSignal)
|
|
236
256
|
return null;
|
|
237
257
|
const doneMatch = result.completionSignal.match(/^<PHASE_DONE>(\w+)<\/PHASE_DONE>$/);
|
|
238
258
|
const skipMatch = result.completionSignal.match(/^<PHASE_SKIP>(\w+)<\/PHASE_SKIP>$/);
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
259
|
+
const autoSignal = result.completionSignal.match(/^<AUTO_SIGNAL>(\w+)<\/AUTO_SIGNAL>$/);
|
|
260
|
+
// <PHASE_DONE>/<PHASE_SKIP>: verify phase matches
|
|
261
|
+
if (doneMatch || skipMatch) {
|
|
262
|
+
const signalPhase = doneMatch?.[1] ?? skipMatch?.[1];
|
|
263
|
+
if (!signalPhase)
|
|
264
|
+
return null;
|
|
265
|
+
if (signalPhase !== def.short && signalPhase !== def.key)
|
|
266
|
+
return null;
|
|
267
|
+
const targetStatus = doneMatch ? 'DONE' : 'SKIPPED';
|
|
268
|
+
const state = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
269
|
+
(0, auto_state_1.setPhaseStatus)(state, def.key, targetStatus);
|
|
270
|
+
await (0, auto_state_1.writeBuildState)(harnessRoot, state);
|
|
271
|
+
return targetStatus;
|
|
272
|
+
}
|
|
273
|
+
// <AUTO_SIGNAL>: any signal value resolves as DONE (used by S85/optimize phases)
|
|
274
|
+
if (autoSignal) {
|
|
275
|
+
const state = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
276
|
+
(0, auto_state_1.setPhaseStatus)(state, def.key, 'DONE');
|
|
277
|
+
await (0, auto_state_1.writeBuildState)(harnessRoot, state);
|
|
278
|
+
return 'DONE';
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* 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.
|
|
286
|
+
*
|
|
287
|
+
* Each phase defines its own detection criteria.
|
|
288
|
+
*/
|
|
289
|
+
async tryResolveFromArtifacts(def, harnessRoot) {
|
|
290
|
+
try {
|
|
291
|
+
switch (def.key) {
|
|
292
|
+
case 'S40_extract_requirements': {
|
|
293
|
+
const yamlDir = node_path_1.default.join(harnessRoot, 'requirements', 'yaml');
|
|
294
|
+
if (await fs_extra_1.default.pathExists(yamlDir)) {
|
|
295
|
+
const entries = await fs_extra_1.default.readdir(yamlDir);
|
|
296
|
+
const yamlFiles = entries.filter((e) => e.endsWith('.yaml'));
|
|
297
|
+
if (yamlFiles.length > 0) {
|
|
298
|
+
break; // qualified → mark DONE below
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
case 'S50_generate_specs': {
|
|
304
|
+
// specs/ must have at least one non-empty subdirectory
|
|
305
|
+
const specsDir = node_path_1.default.join(harnessRoot, 'specs');
|
|
306
|
+
let found = false;
|
|
307
|
+
if (await fs_extra_1.default.pathExists(specsDir)) {
|
|
308
|
+
const subs = (await fs_extra_1.default.readdir(specsDir))
|
|
309
|
+
.filter((e) => e !== '_work');
|
|
310
|
+
for (const sub of subs) {
|
|
311
|
+
const subDir = node_path_1.default.join(specsDir, sub);
|
|
312
|
+
const stat = await fs_extra_1.default.stat(subDir);
|
|
313
|
+
if (stat.isDirectory()) {
|
|
314
|
+
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
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (found)
|
|
323
|
+
break; // break switch → mark DONE
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
case 'S60_process_references': {
|
|
327
|
+
// references/yaml/index.yaml is the primary output artifact
|
|
328
|
+
const indexYaml = node_path_1.default.join(harnessRoot, 'references', 'yaml', 'index.yaml');
|
|
329
|
+
if (await fs_extra_1.default.pathExists(indexYaml)) {
|
|
330
|
+
break; // qualified
|
|
331
|
+
}
|
|
332
|
+
// Also accept SKIPPED state if references/md/ is empty (no references to process)
|
|
333
|
+
const refMdDir = node_path_1.default.join(harnessRoot, 'references', 'md');
|
|
334
|
+
if (await fs_extra_1.default.pathExists(refMdDir)) {
|
|
335
|
+
const files = await fs_extra_1.default.readdir(refMdDir);
|
|
336
|
+
if (files.filter((f) => f !== '.gitkeep').length === 0) {
|
|
337
|
+
// No reference documents — SKIPPED is the correct outcome
|
|
338
|
+
logger_1.logger.info(`${def.key} 产物兜底:无参考资料,自动标记 SKIPPED`);
|
|
339
|
+
const state = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
340
|
+
(0, auto_state_1.setPhaseStatus)(state, def.key, 'SKIPPED');
|
|
341
|
+
await (0, auto_state_1.writeBuildState)(harnessRoot, state);
|
|
342
|
+
return 'SKIPPED';
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
case 'S65_customize_agent_env': {
|
|
348
|
+
// agent-env/rules/ or agent-env/skills/ has content
|
|
349
|
+
const rulesDir = node_path_1.default.join(harnessRoot, 'agent-env', 'rules');
|
|
350
|
+
const skillsDir = node_path_1.default.join(harnessRoot, 'agent-env', 'skills');
|
|
351
|
+
if (await hasNonEmptyFiles(rulesDir) || await hasAnySubdirWithFiles(skillsDir)) {
|
|
352
|
+
break; // qualified
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
case 'S70_runtime_assets': {
|
|
357
|
+
// Check tasks/ or agent-env/skills/ or agent-env/rules/ for content
|
|
358
|
+
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)) {
|
|
362
|
+
break; // qualified
|
|
363
|
+
}
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
case 'S80_seed_memory': {
|
|
367
|
+
const memoryInbox = node_path_1.default.join(harnessRoot, 'agent-env', 'memory', 'inbox');
|
|
368
|
+
if (await hasNonEmptyFiles(memoryInbox)) {
|
|
369
|
+
break; // qualified
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
return null; // no artifact-based detection for this phase
|
|
375
|
+
}
|
|
376
|
+
// If we reach here, the phase's artifacts were detected as present,
|
|
377
|
+
// so auto-resolve the phase to DONE.
|
|
378
|
+
logger_1.logger.info(`${def.key} 产物兜底:检测到输出产物,自动标记 DONE`);
|
|
379
|
+
const state = await (0, auto_state_1.readBuildState)(harnessRoot);
|
|
380
|
+
(0, auto_state_1.setPhaseStatus)(state, def.key, 'DONE');
|
|
381
|
+
await (0, auto_state_1.writeBuildState)(harnessRoot, state);
|
|
382
|
+
return 'DONE';
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return null; // silently fall through to normal retry on any filesystem error
|
|
386
|
+
}
|
|
249
387
|
}
|
|
250
388
|
async writeAcpLog(phase, output, error) {
|
|
251
389
|
if (!this.ctx.harnessRoot)
|
|
@@ -261,3 +399,26 @@ function phaseNumber(phase) {
|
|
|
261
399
|
const match = phase.match(/^S(\d+)/i);
|
|
262
400
|
return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
|
|
263
401
|
}
|
|
402
|
+
/** Check if a directory exists and contains at least one file (excluding .gitkeep). */
|
|
403
|
+
async function hasNonEmptyFiles(dir) {
|
|
404
|
+
if (!(await fs_extra_1.default.pathExists(dir)))
|
|
405
|
+
return false;
|
|
406
|
+
const entries = await fs_extra_1.default.readdir(dir);
|
|
407
|
+
return entries.some((e) => e !== '.gitkeep');
|
|
408
|
+
}
|
|
409
|
+
/** Check if a directory exists and has at least one subdirectory with files. */
|
|
410
|
+
async function hasAnySubdirWithFiles(dir) {
|
|
411
|
+
if (!(await fs_extra_1.default.pathExists(dir)))
|
|
412
|
+
return false;
|
|
413
|
+
const entries = await fs_extra_1.default.readdir(dir);
|
|
414
|
+
for (const entry of entries) {
|
|
415
|
+
const subPath = node_path_1.default.join(dir, entry);
|
|
416
|
+
const stat = await fs_extra_1.default.stat(subPath);
|
|
417
|
+
if (stat.isDirectory()) {
|
|
418
|
+
const files = await fs_extra_1.default.readdir(subPath);
|
|
419
|
+
if (files.some((f) => f !== '.gitkeep'))
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return false;
|
|
424
|
+
}
|