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) {
@@ -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
+ }
@@ -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 and,
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 signalPhase = doneMatch?.[1] ?? skipMatch?.[1];
240
- if (!signalPhase)
241
- return null;
242
- if (signalPhase !== def.short && signalPhase !== def.key)
243
- 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;
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svharness",
3
- "version": "0.15.8",
3
+ "version": "0.15.10",
4
4
  "description": "CLI scaffolder for SDD-Driven Agent-Agnostic Coding Framework (harness)",
5
5
  "bin": {
6
6
  "svharness": "./bin/cli.js",