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
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.looksLikeMaterialsPackRoot = looksLikeMaterialsPackRoot;
7
+ exports.looksLikeRequirementsFolder = looksLikeRequirementsFolder;
8
+ exports.resolveAutoPackReqWorkContext = resolveAutoPackReqWorkContext;
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const fs_extra_1 = __importDefault(require("fs-extra"));
11
+ const harness_resolver_1 = require("./harness-resolver");
12
+ const DOC_FILE_RE = /\.(md|docx|doc|pdf|xlsx|xls|pptx|ppt|txt|csv)$/i;
13
+ const MATERIALS_PACK_SUBDIR_RE = /^(requirements?|baseline|references?|extra[-_]?skills?|需求|接口|协议|skill)/i;
14
+ /** 当前目录是否像资料包根(含 requirements/、baseline/ 等子目录) */
15
+ async function looksLikeMaterialsPackRoot(dir) {
16
+ if (!(await fs_extra_1.default.pathExists(dir)))
17
+ return false;
18
+ const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
19
+ for (const entry of entries) {
20
+ if (!entry.isDirectory())
21
+ continue;
22
+ if (MATERIALS_PACK_SUBDIR_RE.test(entry.name))
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+ /** 当前目录是否像 requirements 资料夹(含文档文件,且不像资料包根) */
28
+ async function looksLikeRequirementsFolder(dir) {
29
+ if (!(await fs_extra_1.default.pathExists(dir)))
30
+ return false;
31
+ if (await (0, harness_resolver_1.isHarnessRoot)(dir))
32
+ return false;
33
+ if (await looksLikeMaterialsPackRoot(dir))
34
+ return false;
35
+ const entries = await fs_extra_1.default.readdir(dir, { withFileTypes: true });
36
+ const topFiles = entries.filter((e) => e.isFile());
37
+ if (topFiles.some((f) => DOC_FILE_RE.test(f.name)))
38
+ return true;
39
+ for (const entry of entries) {
40
+ if (!entry.isDirectory() || entry.name.startsWith('.'))
41
+ continue;
42
+ const sub = node_path_1.default.join(dir, entry.name);
43
+ const subEntries = await fs_extra_1.default.readdir(sub, { withFileTypes: true });
44
+ if (subEntries.some((f) => f.isFile() && DOC_FILE_RE.test(f.name)))
45
+ return true;
46
+ }
47
+ return false;
48
+ }
49
+ /**
50
+ * 解析 auto_pack_req 工作上下文。
51
+ * 无显式 workdir 时:若当前目录本身是 requirements 资料夹,则以其父目录为 workDir 并固定 requirements 输入。
52
+ */
53
+ async function resolveAutoPackReqWorkContext(positionalWorkdir, configWorkDir, requirementsOverride) {
54
+ const explicit = positionalWorkdir ?? configWorkDir;
55
+ if (explicit) {
56
+ const workDir = node_path_1.default.resolve(explicit);
57
+ const harnessRoot = await (0, harness_resolver_1.resolveHarnessRoot)(workDir);
58
+ return {
59
+ workDir,
60
+ harnessRoot,
61
+ requirementsPath: requirementsOverride
62
+ ? node_path_1.default.isAbsolute(requirementsOverride)
63
+ ? requirementsOverride
64
+ : node_path_1.default.resolve(workDir, requirementsOverride)
65
+ : undefined,
66
+ mode: harnessRoot ? 'harness-rerun' : 'materials-pack',
67
+ };
68
+ }
69
+ const cwd = process.cwd();
70
+ const harnessFromCwd = await (0, harness_resolver_1.resolveHarnessRoot)(cwd);
71
+ if (harnessFromCwd) {
72
+ return { workDir: cwd, harnessRoot: harnessFromCwd, mode: 'harness-rerun' };
73
+ }
74
+ if (await looksLikeMaterialsPackRoot(cwd)) {
75
+ return { workDir: cwd, harnessRoot: null, mode: 'materials-pack' };
76
+ }
77
+ if (await looksLikeRequirementsFolder(cwd)) {
78
+ const parent = node_path_1.default.dirname(cwd);
79
+ const harnessFromParent = await (0, harness_resolver_1.resolveHarnessRoot)(parent);
80
+ return {
81
+ workDir: parent,
82
+ harnessRoot: harnessFromParent,
83
+ requirementsPath: node_path_1.default.resolve(cwd),
84
+ mode: harnessFromParent ? 'harness-rerun' : 'requirements-cwd',
85
+ };
86
+ }
87
+ return { workDir: cwd, harnessRoot: null, mode: 'materials-pack' };
88
+ }
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.finalizeReqPack = finalizeReqPack;
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const logger_1 = require("../utils/logger");
10
+ async function finalizeReqPack(ctx, review) {
11
+ if (!ctx.harnessRoot)
12
+ throw new Error('缺少 harnessRoot,无法完成需求包流程');
13
+ const outPath = node_path_1.default.join(ctx.harnessRoot, 'auto-review', 'pack-req-complete.json');
14
+ await fs_extra_1.default.ensureDir(node_path_1.default.dirname(outPath));
15
+ await fs_extra_1.default.writeFile(outPath, JSON.stringify({
16
+ completed_at: new Date().toISOString(),
17
+ scope: 'requirements',
18
+ composite_score: review.composite_score,
19
+ target_score: ctx.targetScore,
20
+ gate_pass: review.gate_pass,
21
+ critical_count: review.critical_count,
22
+ mechanical_score: review.mechanical_score,
23
+ adversarial_score: review.review_score,
24
+ report_path: node_path_1.default.relative(ctx.harnessRoot, review.report_path).replace(/\\/g, '/'),
25
+ note: 'auto_pack_req 需求条目化闭环完成(未执行 S90 封存)',
26
+ }, null, 2), 'utf8');
27
+ logger_1.logger.success(`auto_pack_req 完成:综合分 ${review.composite_score.toFixed(1)} > ${ctx.targetScore},产物见 auto-review/`);
28
+ }
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.runReqPackReview = runReqPackReview;
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const logger_1 = require("../utils/logger");
10
+ const phase_prompt_loader_1 = require("./phase-prompt-loader");
11
+ const gate_checker_1 = require("./gate-checker");
12
+ const score_aggregator_1 = require("./score-aggregator");
13
+ const auto_sample_trace_1 = require("./auto-sample-trace");
14
+ const review_parser_1 = require("./review-parser");
15
+ async function runReqPackReview(ctx, acp, cost, round) {
16
+ if (!ctx.harnessRoot)
17
+ throw new Error('缺少 harnessRoot,无法评审');
18
+ await fs_extra_1.default.ensureDir(node_path_1.default.join(ctx.harnessRoot, 'auto-review'));
19
+ const gates = await (0, gate_checker_1.runMechanicalGates)(ctx.harnessRoot, {
20
+ scope: 'requirements',
21
+ verbose: ctx.verbose,
22
+ });
23
+ await (0, auto_sample_trace_1.writeSampleFragmentsFile)(ctx.harnessRoot, ctx.sampleCount);
24
+ const selfReviewPath = node_path_1.default.join(ctx.harnessRoot, 'auto-review', `第${round}轮需求自评报告.md`);
25
+ const adversarialPath = node_path_1.default.join(ctx.harnessRoot, 'auto-review', `第${round}轮对抗评审.md`);
26
+ const samplePath = node_path_1.default.join(ctx.harnessRoot, 'auto-review', `第${round}轮抽样验证报告.md`);
27
+ if (ctx.dryRun) {
28
+ for (const template of ['S40-review.md', 'S40-adversarial-review.md', 'S40-sample-trace.md']) {
29
+ const prompt = await (0, phase_prompt_loader_1.loadPhasePrompt)(template, buildPromptVars(ctx, round, gates), ctx.promptDir);
30
+ logger_1.logger.plain(`\n--- ${template} ---\n${prompt}\n`);
31
+ }
32
+ return dryRunReqPackReview(round, gates);
33
+ }
34
+ await runAcpReviewPhase(ctx, acp, cost, round, 'S40-review.md', 'req_self_review', '<AUTO_SIGNAL>REQ_REVIEW_DONE</AUTO_SIGNAL>');
35
+ await runAcpReviewPhase(ctx, acp, cost, round, 'S40-adversarial-review.md', 'req_adversarial_review', '<AUTO_SIGNAL>ADV_REVIEW_DONE</AUTO_SIGNAL>');
36
+ await runAcpReviewPhase(ctx, acp, cost, round, 'S40-sample-trace.md', 'req_sample_trace', '<AUTO_SIGNAL>SAMPLE_TRACE_DONE</AUTO_SIGNAL>');
37
+ const selfRaw = await (0, review_parser_1.parseReportFrontmatter)(selfReviewPath);
38
+ const advRaw = await (0, review_parser_1.parseReportFrontmatter)(adversarialPath);
39
+ const sampleRaw = await (0, review_parser_1.parseReportFrontmatter)(samplePath);
40
+ if (!advRaw || !sampleRaw) {
41
+ throw new Error(`第 ${round} 轮需求评审报告缺失或 frontmatter 格式错误`);
42
+ }
43
+ const adversarial = parseAdversarialScore(advRaw);
44
+ const sampleTrace = parseSampleTraceScore(sampleRaw);
45
+ const selfContent = await fs_extra_1.default.readFile(selfReviewPath, 'utf8').catch(() => '');
46
+ const gaps = (0, review_parser_1.parseGapsFromMarkdown)(selfContent);
47
+ const criticalCount = countCriticalIssues(adversarial, gaps);
48
+ const scores = (0, score_aggregator_1.computeReqPackScore)(gates, adversarial.average, sampleTrace.average, criticalCount);
49
+ const review = {
50
+ round,
51
+ report_path: adversarialPath,
52
+ dimension_scores: {
53
+ requirements: Number(asRecord(asRecord(selfRaw?.score).dimensions).requirements ?? 0),
54
+ },
55
+ mechanical_score: scores.mechanical,
56
+ review_score: scores.adversarial,
57
+ critical_count: criticalCount,
58
+ gate_pass: scores.gate_pass,
59
+ gate_details: gates,
60
+ modifications_required: !(scores.composite > ctx.targetScore &&
61
+ scores.gate_pass &&
62
+ criticalCount === 0),
63
+ gaps,
64
+ composite_score: scores.composite,
65
+ };
66
+ await writeReqPackScoreSnapshot(ctx, review, adversarial, sampleTrace, scores);
67
+ logger_1.logger.info(`第 ${round} 轮需求包评分:综合 ${scores.composite.toFixed(1)} / 10 ` +
68
+ `(机械 ${scores.mechanical.toFixed(1)} + 对抗 ${scores.adversarial.toFixed(1)} + 抽样 ${scores.sample_trace.toFixed(1)})`);
69
+ return review;
70
+ }
71
+ async function runAcpReviewPhase(ctx, acp, cost, round, template, phaseKey, signal) {
72
+ const gates = await (0, gate_checker_1.runMechanicalGates)(ctx.harnessRoot, { scope: 'requirements', verbose: ctx.verbose });
73
+ const prompt = await (0, phase_prompt_loader_1.loadPhasePrompt)(template, buildPromptVars(ctx, round, gates), ctx.promptDir);
74
+ cost.recordCall(`${phaseKey}_round_${round}`);
75
+ const result = await acp.call({
76
+ workDir: ctx.harnessRoot,
77
+ phase: phaseKey,
78
+ prompt: (0, phase_prompt_loader_1.wrapAcpPrompt)(prompt, signal),
79
+ timeoutMs: ctx.acpTimeoutMs,
80
+ });
81
+ await fs_extra_1.default.writeFile(node_path_1.default.join(ctx.harnessRoot, 'auto-review', `${phaseKey}-round-${round}.log`), [result.output, result.error ? `\n[stderr]\n${result.error}` : ''].join(''), 'utf8');
82
+ if (!result.success) {
83
+ throw new Error(`${template} Agent 调用失败:${result.error ?? 'unknown error'}`);
84
+ }
85
+ }
86
+ function buildPromptVars(ctx, round, gates) {
87
+ return {
88
+ HARNESS_ROOT: ctx.harnessRoot,
89
+ AUTO_GOAL: ctx.goal ?? '未提供构建目的,请根据 harness 内容判断',
90
+ ROUND: round,
91
+ DOCTOR_EXIT: gates.doctor_exit,
92
+ VERIFY_EXIT: gates.verify_exit,
93
+ FILE_COMPLETENESS_PCT: gates.file_completeness_pct.toFixed(1),
94
+ SAMPLE_FRAGMENTS_PATH: node_path_1.default.join(ctx.harnessRoot, 'auto-review', 'sample-fragments.json'),
95
+ };
96
+ }
97
+ function parseAdversarialScore(raw) {
98
+ const score = asRecord(raw.score);
99
+ const adv = asRecord(score.adversarial);
100
+ const coverage = Number(adv.coverage ?? 0);
101
+ const fidelity = Number(adv.fidelity ?? 0);
102
+ const completeness = Number(adv.completeness ?? 0);
103
+ const average = Number(adv.average ?? (coverage + fidelity + completeness) / 3);
104
+ const issues = Array.isArray(raw.issues)
105
+ ? raw.issues
106
+ : [];
107
+ return { coverage, fidelity, completeness, average, issues };
108
+ }
109
+ function parseSampleTraceScore(raw) {
110
+ const score = asRecord(raw.score);
111
+ const st = asRecord(score.sample_trace);
112
+ const traceability = Number(st.traceability ?? 0);
113
+ const fidelity = Number(st.fidelity ?? 0);
114
+ const usability = Number(st.usability ?? 0);
115
+ const average = Number(st.average ?? (traceability + fidelity + usability) / 3);
116
+ const items = Array.isArray(st.items)
117
+ ? st.items
118
+ : [];
119
+ return { traceability, fidelity, usability, average, items };
120
+ }
121
+ function countCriticalIssues(adversarial, gaps) {
122
+ const fromAdv = adversarial.issues.filter((i) => i.severity === 'critical').length;
123
+ const fromGaps = gaps.filter((g) => g.severity === 'critical').length;
124
+ return fromAdv + fromGaps;
125
+ }
126
+ async function writeReqPackScoreSnapshot(ctx, review, adversarial, sampleTrace, scores) {
127
+ const snapshot = {
128
+ round: review.round,
129
+ evaluated_at: new Date().toISOString(),
130
+ scope: 'requirements',
131
+ mechanical_score: scores.mechanical,
132
+ adversarial_score: scores.adversarial,
133
+ sample_trace_score: scores.sample_trace,
134
+ composite_score: scores.composite,
135
+ target_score: ctx.targetScore,
136
+ gate_pass: scores.gate_pass,
137
+ critical_count: review.critical_count,
138
+ adversarial_details: adversarial,
139
+ sample_trace_details: sampleTrace,
140
+ gate_details: review.gate_details,
141
+ };
142
+ await fs_extra_1.default.writeFile(node_path_1.default.join(ctx.harnessRoot, 'auto-review', 'auto-score.json'), JSON.stringify(snapshot, null, 2), 'utf8');
143
+ }
144
+ function dryRunReqPackReview(round, gates) {
145
+ return {
146
+ round,
147
+ report_path: 'dry-run',
148
+ review_score: 0,
149
+ dimension_scores: {},
150
+ critical_count: 0,
151
+ gaps: [],
152
+ gate_pass: false,
153
+ gate_details: gates,
154
+ mechanical_score: 0,
155
+ composite_score: 0,
156
+ modifications_required: true,
157
+ };
158
+ }
159
+ function asRecord(value) {
160
+ return typeof value === 'object' && value !== null ? value : {};
161
+ }
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generateSampleFragments = generateSampleFragments;
7
+ exports.writeSampleFragmentsFile = writeSampleFragmentsFile;
8
+ const fs_extra_1 = __importDefault(require("fs-extra"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const MIN_EXCERPT_CHARS = 50;
11
+ const MAX_EXCERPT_CHARS = 200;
12
+ const NUMERIC_PATTERN = /\d+(\.\d+)?(%|ms|s|Hz|MHz|GB|MB|KB)?/;
13
+ const ENUM_PATTERN = /\b(ON|OFF|TRUE|FALSE|ENABLE|DISABLE|是|否|打开|关闭)\b/i;
14
+ const TIMING_PATTERN = /\b(延迟|超时|timeout|delay|ms|秒|分钟|时序|周期)\b/i;
15
+ const SIGNAL_PRIORITY = {
16
+ numeric: 5,
17
+ timing: 4,
18
+ enum: 4,
19
+ table_row: 3,
20
+ list_item: 2,
21
+ paragraph: 1,
22
+ };
23
+ function classifyLine(text) {
24
+ const trimmed = text.trim();
25
+ if (!trimmed || trimmed.startsWith('```') || trimmed === '---') {
26
+ return { signal_type: 'paragraph', priority: 0 };
27
+ }
28
+ if (trimmed.startsWith('|') && trimmed.split('|').filter(Boolean).length >= 2) {
29
+ return { signal_type: 'table_row', priority: SIGNAL_PRIORITY.table_row };
30
+ }
31
+ if (/^[-*+]\s/.test(trimmed) || /^\d+\.\s/.test(trimmed)) {
32
+ return { signal_type: 'list_item', priority: SIGNAL_PRIORITY.list_item };
33
+ }
34
+ if (NUMERIC_PATTERN.test(trimmed)) {
35
+ return { signal_type: 'numeric', priority: SIGNAL_PRIORITY.numeric };
36
+ }
37
+ if (TIMING_PATTERN.test(trimmed)) {
38
+ return { signal_type: 'timing', priority: SIGNAL_PRIORITY.timing };
39
+ }
40
+ if (ENUM_PATTERN.test(trimmed)) {
41
+ return { signal_type: 'enum', priority: SIGNAL_PRIORITY.enum };
42
+ }
43
+ if (/^#{1,6}\s/.test(trimmed)) {
44
+ return { signal_type: 'list_item', priority: SIGNAL_PRIORITY.list_item + 1 };
45
+ }
46
+ return { signal_type: 'paragraph', priority: SIGNAL_PRIORITY.paragraph };
47
+ }
48
+ function buildExcerpt(lines, startIdx) {
49
+ let excerpt = '';
50
+ let endIdx = startIdx;
51
+ for (let i = startIdx; i < lines.length && excerpt.length < MAX_EXCERPT_CHARS; i++) {
52
+ const line = lines[i]?.trim();
53
+ if (!line)
54
+ continue;
55
+ const next = excerpt ? `${excerpt}\n${line}` : line;
56
+ if (next.length > MAX_EXCERPT_CHARS && excerpt.length >= MIN_EXCERPT_CHARS)
57
+ break;
58
+ excerpt = next;
59
+ endIdx = i;
60
+ if (excerpt.length >= MIN_EXCERPT_CHARS && excerpt.length >= MAX_EXCERPT_CHARS * 0.6)
61
+ break;
62
+ }
63
+ if (excerpt.length < MIN_EXCERPT_CHARS)
64
+ return null;
65
+ return { excerpt, endIdx };
66
+ }
67
+ async function collectCandidates(mdDir) {
68
+ const candidates = [];
69
+ if (!(await fs_extra_1.default.pathExists(mdDir)))
70
+ return candidates;
71
+ const files = (await fs_extra_1.default.readdir(mdDir)).filter((f) => f.endsWith('.md'));
72
+ for (const file of files) {
73
+ const abs = node_path_1.default.join(mdDir, file);
74
+ const content = await fs_extra_1.default.readFile(abs, 'utf8');
75
+ const lines = content.split(/\r?\n/);
76
+ for (let i = 0; i < lines.length; i++) {
77
+ const { signal_type, priority } = classifyLine(lines[i] ?? '');
78
+ if (priority === 0)
79
+ continue;
80
+ candidates.push({
81
+ file: node_path_1.default.join('requirements/md', file).replace(/\\/g, '/'),
82
+ lineNo: i + 1,
83
+ text: lines[i] ?? '',
84
+ signal_type,
85
+ priority,
86
+ });
87
+ }
88
+ }
89
+ return candidates;
90
+ }
91
+ function shuffle(items) {
92
+ const copy = [...items];
93
+ for (let i = copy.length - 1; i > 0; i--) {
94
+ const j = Math.floor(Math.random() * (i + 1));
95
+ [copy[i], copy[j]] = [copy[j], copy[i]];
96
+ }
97
+ return copy;
98
+ }
99
+ async function generateSampleFragments(harnessRoot, count = 7) {
100
+ const mdDir = node_path_1.default.join(harnessRoot, 'requirements', 'md');
101
+ const candidates = await collectCandidates(mdDir);
102
+ if (candidates.length === 0)
103
+ return [];
104
+ const sorted = [...candidates].sort((a, b) => b.priority - a.priority);
105
+ const highPriority = sorted.filter((c) => c.priority >= 3);
106
+ const pool = shuffle(highPriority.length >= count ? highPriority : sorted);
107
+ const selected = pool.slice(0, Math.min(count, pool.length));
108
+ const fragments = [];
109
+ const usedKeys = new Set();
110
+ for (const cand of selected) {
111
+ const abs = node_path_1.default.join(harnessRoot, cand.file);
112
+ if (!(await fs_extra_1.default.pathExists(abs)))
113
+ continue;
114
+ const lines = (await fs_extra_1.default.readFile(abs, 'utf8')).split(/\r?\n/);
115
+ const startIdx = cand.lineNo - 1;
116
+ const built = buildExcerpt(lines, startIdx);
117
+ if (!built)
118
+ continue;
119
+ const key = `${cand.file}:${cand.lineNo}`;
120
+ if (usedKeys.has(key))
121
+ continue;
122
+ usedKeys.add(key);
123
+ fragments.push({
124
+ id: `SF-${String(fragments.length + 1).padStart(3, '0')}`,
125
+ file: cand.file,
126
+ start_line: cand.lineNo,
127
+ end_line: built.endIdx + 1,
128
+ excerpt: built.excerpt,
129
+ signal_type: cand.signal_type,
130
+ });
131
+ }
132
+ return fragments;
133
+ }
134
+ async function writeSampleFragmentsFile(harnessRoot, count = 7) {
135
+ const fragments = await generateSampleFragments(harnessRoot, count);
136
+ const outDir = node_path_1.default.join(harnessRoot, 'auto-review');
137
+ await fs_extra_1.default.ensureDir(outDir);
138
+ await fs_extra_1.default.writeJson(node_path_1.default.join(outDir, 'sample-fragments.json'), {
139
+ generated_at: new Date().toISOString(),
140
+ count: fragments.length,
141
+ fragments,
142
+ }, { spaces: 2 });
143
+ return fragments;
144
+ }
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.dedupePhases = dedupePhases;
7
+ exports.dedupePhaseFields = dedupePhaseFields;
8
+ exports.repairBuildStateYaml = repairBuildStateYaml;
9
+ exports.quoteUnsafeNextAction = quoteUnsafeNextAction;
7
10
  exports.readBuildState = readBuildState;
8
11
  exports.writeBuildState = writeBuildState;
9
12
  exports.phaseStatus = phaseStatus;
@@ -60,32 +63,145 @@ function dedupePhases(rawYaml) {
60
63
  }
61
64
  return result.join('\n');
62
65
  }
66
+ const PHASE_FIELD_REGEX = /^ ([A-Za-z_][\w-]*):/;
67
+ /**
68
+ * Within each phase block, drop duplicate field keys (e.g. a second
69
+ * `completed_at:` appended by ACP text-editing). Keeps the **last**
70
+ * occurrence so the most recent agent edit wins.
71
+ */
72
+ function dedupeFieldsInPhaseBlock(blockLines) {
73
+ if (blockLines.length === 0)
74
+ return blockLines;
75
+ const spans = [];
76
+ for (let i = 0; i < blockLines.length; i++) {
77
+ const match = blockLines[i].match(PHASE_FIELD_REGEX);
78
+ if (!match)
79
+ continue;
80
+ if (spans.length > 0)
81
+ spans[spans.length - 1].end = i;
82
+ spans.push({ key: match[1], start: i, end: blockLines.length });
83
+ }
84
+ const lastByKey = new Map();
85
+ spans.forEach((span, idx) => lastByKey.set(span.key, idx));
86
+ const out = [];
87
+ spans.forEach((span, idx) => {
88
+ if (lastByKey.get(span.key) === idx) {
89
+ out.push(...blockLines.slice(span.start, span.end));
90
+ }
91
+ });
92
+ return out;
93
+ }
94
+ /**
95
+ * Strip duplicate field keys inside each phase block under `phases:`.
96
+ */
97
+ function dedupePhaseFields(rawYaml) {
98
+ const lines = rawYaml.split('\n');
99
+ const phaseKeyRegex = /^ (\S+):$/;
100
+ const result = [];
101
+ let inPhases = false;
102
+ let phaseHeader = null;
103
+ let blockLines = [];
104
+ const flushPhase = () => {
105
+ if (phaseHeader === null)
106
+ return;
107
+ result.push(phaseHeader);
108
+ result.push(...dedupeFieldsInPhaseBlock(blockLines));
109
+ phaseHeader = null;
110
+ blockLines = [];
111
+ };
112
+ for (const line of lines) {
113
+ if (/^phases:$/.test(line)) {
114
+ inPhases = true;
115
+ result.push(line);
116
+ continue;
117
+ }
118
+ if (!inPhases) {
119
+ result.push(line);
120
+ continue;
121
+ }
122
+ if (/^\S/.test(line) && !/^ /.test(line)) {
123
+ flushPhase();
124
+ inPhases = false;
125
+ result.push(line);
126
+ continue;
127
+ }
128
+ const keyMatch = line.match(phaseKeyRegex);
129
+ if (keyMatch) {
130
+ flushPhase();
131
+ phaseHeader = line;
132
+ continue;
133
+ }
134
+ if (phaseHeader !== null) {
135
+ blockLines.push(line);
136
+ }
137
+ else {
138
+ result.push(line);
139
+ }
140
+ }
141
+ flushPhase();
142
+ return result.join('\n');
143
+ }
144
+ /** Apply all text-level repairs for agent-corrupted build-state YAML. */
145
+ function repairBuildStateYaml(raw) {
146
+ return quoteUnsafeNextAction(dedupePhaseFields(dedupePhases(raw)));
147
+ }
148
+ /**
149
+ * Quote unquoted next_action scalars that contain colons.
150
+ * Agent text-edits sometimes write "H1: xxx" without quotes, which breaks YAML.
151
+ */
152
+ function quoteUnsafeNextAction(rawYaml) {
153
+ return rawYaml.replace(/^next_action:\s+(?!['">|])(.+)$/gm, (_match, value) => {
154
+ const trimmed = value.trimEnd();
155
+ if (!trimmed.includes(':')) {
156
+ return _match;
157
+ }
158
+ const escaped = trimmed.replace(/'/g, "''");
159
+ return `next_action: '${escaped}'`;
160
+ });
161
+ }
162
+ function parseBuildStateBody(raw) {
163
+ const body = raw.replace(/^#.*\n/gm, '');
164
+ const state = (js_yaml_1.default.load(body) ?? {});
165
+ state.phases = state.phases ?? {};
166
+ return state;
167
+ }
168
+ async function persistBuildStateRaw(statePath, raw) {
169
+ const tmpPath = statePath + '.tmp';
170
+ await fs_extra_1.default.writeFile(tmpPath, raw, 'utf8');
171
+ await fs_extra_1.default.rename(tmpPath, statePath);
172
+ }
63
173
  async function readBuildState(harnessRoot) {
64
174
  const statePath = node_path_1.default.join(harnessRoot, '.harness-build-state.yaml');
65
175
  const raw = await fs_extra_1.default.readFile(statePath, 'utf8');
66
- const body = raw.replace(/^#.*\n/gm, '');
67
176
  try {
68
- const state = (js_yaml_1.default.load(body) ?? {});
69
- state.phases = state.phases ?? {};
70
- return state;
177
+ return parseBuildStateBody(raw);
71
178
  }
72
179
  catch (err) {
73
- // Auto-repair duplicate phase keys (ACP agent text-editing artifact)
74
- if (err instanceof js_yaml_1.default.YAMLException && err.message.includes('duplicated mapping key')) {
75
- const fixed = dedupePhases(raw);
76
- // Persist the fixed content so subsequent reads also succeed
77
- await fs_extra_1.default.writeFile(statePath, fixed, 'utf8');
78
- const fixedBody = fixed.replace(/^#.*\n/gm, '');
79
- const state = (js_yaml_1.default.load(fixedBody) ?? {});
80
- state.phases = state.phases ?? {};
180
+ if (!(err instanceof js_yaml_1.default.YAMLException)) {
181
+ throw err;
182
+ }
183
+ const fixed = repairBuildStateYaml(raw);
184
+ if (fixed === raw) {
185
+ throw err;
186
+ }
187
+ try {
188
+ const state = parseBuildStateBody(fixed);
189
+ await persistBuildStateRaw(statePath, fixed);
81
190
  return state;
82
191
  }
83
- throw err;
192
+ catch {
193
+ throw err;
194
+ }
84
195
  }
85
196
  }
86
197
  async function writeBuildState(harnessRoot, state) {
87
198
  const statePath = node_path_1.default.join(harnessRoot, '.harness-build-state.yaml');
88
- await fs_extra_1.default.writeFile(statePath, STATE_HEADER + js_yaml_1.default.dump(state, { lineWidth: 100, noRefs: true, sortKeys: false }), 'utf8');
199
+ const tmpPath = statePath + '.tmp';
200
+ const content = STATE_HEADER + js_yaml_1.default.dump(state, { lineWidth: 100, noRefs: true, sortKeys: false });
201
+ // Write to temp file first, then atomic rename to prevent partial writes
202
+ // on process kill. On the same filesystem, rename is atomic.
203
+ await fs_extra_1.default.writeFile(tmpPath, content, 'utf8');
204
+ await fs_extra_1.default.rename(tmpPath, statePath);
89
205
  }
90
206
  function phaseStatus(state, phase) {
91
207
  return state.phases?.[phase]?.status;
@@ -11,6 +11,10 @@ class CostTracker {
11
11
  totalCalls = 0;
12
12
  estimatedCostUsd = 0;
13
13
  phaseCosts = new Map();
14
+ budget80Warned = false;
15
+ budget95Warned = false;
16
+ calls80Warned = false;
17
+ calls95Warned = false;
14
18
  constructor(maxTotalCalls, budgetUsd, alertThreshold) {
15
19
  this.maxTotalCalls = maxTotalCalls;
16
20
  this.budgetUsd = budgetUsd;
@@ -24,11 +28,35 @@ class CostTracker {
24
28
  if (cost > this.alertThreshold) {
25
29
  logger_1.logger.warn(`[成本] ${phase} 本次约 $${cost.toFixed(2)},累计 $${this.estimatedCostUsd.toFixed(2)}`);
26
30
  }
31
+ // Budget pre-warnings (80% → 95% → hard limit)
32
+ if (this.budgetUsd !== null) {
33
+ const usagePct = (this.estimatedCostUsd / this.budgetUsd) * 100;
34
+ if (usagePct >= 80 && usagePct < 95 && !this.budget80Warned) {
35
+ this.budget80Warned = true;
36
+ logger_1.logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),接近上限`);
37
+ }
38
+ if (usagePct >= 95 && !this.budget95Warned) {
39
+ this.budget95Warned = true;
40
+ logger_1.logger.warn(`[成本] 预算已使用 ${usagePct.toFixed(0)}%($${this.estimatedCostUsd.toFixed(2)} / $${this.budgetUsd.toFixed(2)}),即将达到上限`);
41
+ }
42
+ }
43
+ // Call count pre-warnings (80% → 95% → hard limit)
44
+ if (this.maxTotalCalls > 0) {
45
+ const callsPct = (this.totalCalls / this.maxTotalCalls) * 100;
46
+ if (callsPct >= 80 && callsPct < 95 && !this.calls80Warned) {
47
+ this.calls80Warned = true;
48
+ logger_1.logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),接近上限`);
49
+ }
50
+ if (callsPct >= 95 && !this.calls95Warned) {
51
+ this.calls95Warned = true;
52
+ logger_1.logger.warn(`[成本] ACP 调用次数 ${this.totalCalls}/${this.maxTotalCalls}(${callsPct.toFixed(0)}%),即将达到上限`);
53
+ }
54
+ }
27
55
  if (this.totalCalls > this.maxTotalCalls) {
28
- throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls}`);
56
+ throw new Error(`ACP 调用次数 ${this.totalCalls} 已超过上限 ${this.maxTotalCalls},累计费用 $${this.estimatedCostUsd.toFixed(2)}`);
29
57
  }
30
58
  if (this.budgetUsd !== null && this.estimatedCostUsd > this.budgetUsd) {
31
- throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}`);
59
+ throw new Error(`ACP 估算费用 $${this.estimatedCostUsd.toFixed(2)} 已超过预算 $${this.budgetUsd.toFixed(2)}(${this.totalCalls} 次调用)`);
32
60
  }
33
61
  event_bus_1.dashboardEventBus.emitCostUpdated(this.estimatedCostUsd, this.totalCalls);
34
62
  }