svharness 0.14.2 → 0.14.7

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 (35) hide show
  1. package/README.md +50 -3
  2. package/dist/commands/apply.js +9 -2
  3. package/dist/commands/convert.js +39 -4
  4. package/dist/commands/doctor/check-requirements-fidelity.js +96 -0
  5. package/dist/commands/doctor/check-requirements.js +33 -1
  6. package/dist/commands/doctor/check-specs-depth.js +165 -0
  7. package/dist/commands/doctor/index.js +4 -0
  8. package/dist/commands/init.js +27 -4
  9. package/dist/config/merge-options.js +5 -0
  10. package/dist/config/normalize.js +5 -1
  11. package/dist/core/apply-project-entry.js +8 -1
  12. package/dist/core/harness-yaml-baseline.js +68 -0
  13. package/dist/core/markdown-sheet-split.js +109 -0
  14. package/dist/core/markdown-table-cleanup.js +151 -0
  15. package/dist/core/next-steps.js +15 -2
  16. package/dist/core/repomix-apply-hint.js +68 -0
  17. package/dist/core/repomix-pack.js +5 -0
  18. package/dist/index.js +9 -0
  19. package/package.json +1 -1
  20. package/templates/_shared/apply-skills/harness-apply-skills-main.md +2 -0
  21. package/templates/_shared/build-rules/harness-build-rule-chinese-only.md +2 -2
  22. package/templates/_shared/build-rules/harness-build-rule-orchestrator-flow.md +5 -2
  23. package/templates/_shared/build-rules/harness-build-rule-pre-seal-review.md +7 -1
  24. package/templates/_shared/build-rules/harness-build-rule-requirements-extraction.md +10 -1
  25. package/templates/_shared/build-rules/harness-build-rule-specs-schema.md +8 -4
  26. package/templates/_shared/build-skills/harness-build-skill-pre-seal-review.md +3 -0
  27. package/templates/_shared/build-skills/harness-build-skill-spec-builder.md +48 -7
  28. package/templates/_shared/meta/AGENTS_APPLY.md.ejs +5 -0
  29. package/templates/_shared/meta/harness.yaml.ejs +2 -0
  30. package/templates/_shared/skeleton/requirements/yaml/schema.json +75 -0
  31. package/templates/_shared/skeleton/specs/behavior/schema.json +8 -8
  32. package/templates/_shared/skeleton/specs/interfaces/schema.json +4 -4
  33. package/templates/_shared/skeleton/specs/signals/schema.json +12 -1
  34. package/templates/_shared/skeleton/specs/ui/schema.json +5 -4
  35. package/templates/svharness.config.example.yaml +3 -0
package/README.md CHANGED
@@ -109,13 +109,14 @@ Harness 是一个 **项目本地的知识层**,由两大部分组成:
109
109
  ├── requirements/ # 正式需求(参与条目化)
110
110
  │ ├── raw/ # S20_collect_inputs 用户入口
111
111
  │ ├── md/ # convert 产物
112
- │ └── yaml/ # S40 条目化产出
112
+ │ └── yaml/ # S40 条目化产出(含 schema.json)
113
113
  ├── references/ # 参考资料(不参与条目化)
114
114
  │ ├── raw/
115
115
  │ ├── md/
116
116
  │ └── yaml/
117
117
  └── baseline/
118
118
  ├── code/ # 参考代码快照
119
+ ├── repomix/ # 可选:`--repomix` 时生成的单文件 XML 快照
119
120
  └── wiki/ # 架构 wiki
120
121
 
121
122
  ├── agent-env/ # Agent 运行期环境
@@ -185,8 +186,8 @@ harness-build-{skill|rule}-<semantic-name>
185
186
  | **S10_wiki** | 构建 baseline wiki(仅当有 baseline 时出现,项目经验底座) | `baseline/wiki/` |
186
187
  | **S20_collect_inputs** | 添加相关文件:需求/参考文档 + 额外运行期资源征询(`--extra-skills` 单入口,可混放 skills/rules) | `requirements/raw/` + `references/raw/` + `agent-env/_incoming/skills/` + `task_list.md` |
187
188
  | **S30_convert_docs** | 非 Markdown 原始文档转 Markdown | `requirements/md/` + `references/md/` |
188
- | **S40_extract_requirements** | 条目化需求 + 覆盖率门禁 | `requirements/yaml/*.yaml` + `requirements/coverage-report.yaml` |
189
- | **S50_generate_specs** | 生成规格 + REQ→spec 覆盖 | `specs/<layer>/<module>.<layer>.yaml` + schema 校验 + 覆盖率结果 |
189
+ | **S40_extract_requirements** | 条目化需求 + 覆盖率门禁 + 保真门禁(`title/source_excerpt/description`) | `requirements/yaml/*.yaml` + `requirements/yaml/schema.json` + `requirements/coverage-report.yaml` |
190
+ | **S50_generate_specs** | 生成规格 + REQ→spec 覆盖 + 深度门禁(非 index-only) | `specs/<layer>/<module>.<layer>.yaml` + schema 校验 + 覆盖率结果 |
190
191
  | **S60_process_references** | references 处理(`svharness convert` + 结构化索引 + 用户确认落地) | `references/md/` |
191
192
  | **S61_confirm_baseline_extraction** | 确认是否自动从 baseline 提取 skills/rules(表单确认) | `.harness-build-state.yaml`(`phases.S61_confirm_baseline_extraction.baseline_auto_extract`) |
192
193
  | **S65_customize_agent_env** | agent-env 定制(extra-skills/extra-rules 冲突建议与重命名建议 → 用户确认 → 写入) | `agent-env/rules/` + `agent-env/skills/` |
@@ -274,6 +275,7 @@ svharness build \
274
275
  | `--extra-skills <path...>` | 可选 | 额外运行期资源输入(文件/目录/glob,可混放 skills/rules);build 阶段先拷贝到 `agent-env/_incoming/skills/` 并生成 `agent-env/_incoming/manifest.yaml`,S65 再分流写入 `skills/` 与 `rules/` | — |
275
276
  | `--baseline-branch <name>` | 可选 | git 基线的分支名(仅 git 模式有效) | `main` |
276
277
  | `--baseline-max-file-kb <kb>` | 可选 | 基线拷贝单文件大小上限(KB) | `1024` |
278
+ | `--repomix` | 可选 flag | 将 `baseline/code` 打成**单文件** Repomix XML;**须同时提供 `--baseline`**。适用场景见下文 [Repomix](#repomix-repomix) | `false` |
277
279
  | `--convert-endpoint <url>` | 可选 | build 自动 convert 使用的 markitdown 服务基址;省略时读环境变量 `SVHARNESS_MARKITDOWN_ENDPOINT` | `http://markitdown.desaysz.site` |
278
280
  | `--convert-concurrency <n>` | 可选 | build 自动 convert 并发上传数 | `3` |
279
281
  | `--convert-max-file-mb <n>` | 可选 | build 自动 convert 单文件大小上限(MB) | `50` |
@@ -307,6 +309,36 @@ svharness build \
307
309
 
308
310
  > Wiki 生成失败不会回滚 harness 骨架,仅输出警告。
309
311
 
312
+ #### Repomix(`--repomix`){#repomix-repomix}
313
+
314
+ **产物**:`baseline/repomix/repomix-pack.xml` —— 用 [Repomix](https://github.com/yamadashy/repomix) 把 `baseline/code/` 目录树压缩成**一份带行号的 XML**,便于整包喂给上下文窗口有限的模型,或离线归档/比对。
315
+
316
+ **默认关闭的原因**:harness 主流程已依赖 `baseline/code/`(逐文件引用、wiki-writer、knowledge-builder、S61 自动提取等)。Repomix 是**辅助快照**,不是 specs / requirements 的契约来源,且大仓库打包耗时长、产物体积大。
317
+
318
+ **建议开启 `--repomix` 的场景**:
319
+
320
+ | 场景 | 说明 |
321
+ |------|------|
322
+ | 基线体量较大,需要「整库一览」 | 希望 Agent **一次 attach 单文件** 做架构鸟瞰、模块边界梳理、依赖关系初扫,而不是在对话里反复 `@` 上百个路径 |
323
+ | 外部工具只吃单文件上下文 | 使用只支持粘贴/上传**单个**上下文文件的分析器、审计脚本或二次 LLM 流水线 |
324
+ | 离线交付或版本留档 | 需要把某次 `--baseline` 快照固化为**可 diff 的单文件**(例如随 harness 封存、发给评审方) |
325
+ | 与 `baseline/wiki/` 互补 | wiki 偏「说明文档」;Repomix 偏「源码字面快照」。做反向提取前的**代码面**快速摸底时可同时保留两者 |
326
+
327
+ **通常不必开启的场景**:
328
+
329
+ - 基线很小,或 Agent 已能稳定按路径阅读 `baseline/code/`(**默认路径即可**)
330
+ - 只关心 harness-build 标准阶段(S40/S50/S85):规格与审查以 `requirements/`、`specs/`、`baseline/code` 为准,**不依赖** Repomix XML
331
+ - 基线含大量二进制/生成物:Repomix 对图片等帮助有限,且会拉长 build 时间(失败仅告警,不回滚骨架)
332
+
333
+ **用法**:
334
+
335
+ ```bash
336
+ svharness build --harness-name my-app --baseline ./src --repomix
337
+ # 或配置:build.repomix: true(须同时配置 build.baseline)
338
+ ```
339
+
340
+ > Repomix 步骤在 baseline 拷贝**之后**执行;打包失败不会回滚 harness 骨架。已构建的 harness 可带 `--force` 重新 `build` 并补上 `--repomix`。
341
+
310
342
  ### Agent 适配器
311
343
 
312
344
  | Agent | skill 目录 | 扩展名 | 额外处理 |
@@ -341,6 +373,8 @@ svharness apply --harness ../my-app-harness --target ./to-apply-project --clone
341
373
  - `--inject-build-main-bridge` 开启时:在 `<target>/<adapter.skillsDir>/harness-build-skills-bridge/` 写入桥接 skill(仅用于构建流二次改造)
342
374
  - 目标 `.gitignore` 会在文件尾部 append 注入路径(幂等去重)
343
375
 
376
+ **Repomix 与 apply**:若 build 时启用了 `--repomix` 且产物存在,CLI 会回写 `harness.yaml` → `baseline.repomix_pack`,并在 `apply` 时向 `harness-apply-skills-main` 与项目根 `AGENTS.md`/`CLAUDE.md` 注入 Repomix 路径与用法(权威参考实现仍为 `baseline/code/`)。详见上文 [Repomix(`--repomix`)](#repomix-repomix)。
377
+
344
378
  #### references 内容引用 → apply_skill_registry(S60,由 Agent 写入)
345
379
 
346
380
  S60(`harness-build-skill-references-intake`)属于 **build 阶段**。若某份 references 属于**内容引用**(apply 时要按 `references/md/` 原文指导开发),Agent 应:
@@ -426,11 +460,19 @@ svharness doctor --harness ./my-app-harness --mode pre-seal --format json --repo
426
460
  - **门禁**:`overall_score >= 5.0`(C 级)、`critical_count == 0`、报告 frontmatter `gate_pass: true`、用户表单确认
427
461
 
428
462
  `doctor` 另含 `check-review-report`(warning):S85 已 DONE 时校验报告 frontmatter 与 state 中 `review_gate_pass` 一致。数量覆盖率阻断仍由 S40/S50 表单门禁负责。
463
+ 同时新增:
464
+
465
+ - `check-requirements-fidelity`:检查 `source_excerpt` 缺失、`description` 缩略与占位语义。
466
+ - `check-specs-depth`:检查 index-only 信号、enum 缺失 `enum_values`、behavior 空 `guard/action`。
429
467
 
430
468
  ### `convert` —— 文档 → Markdown 预处理(对接 S20_collect_inputs)
431
469
 
432
470
  把本地原始需求文档(`.pdf / .docx / .pptx / .xlsx / .html / .epub / .txt / .csv / .json / ...`)通过**云端部署**的 `markitdown_serve`(FastAPI + Microsoft MarkItDown)批量转为 Markdown,产物统一落到 `<harness>/<type>/md/`(`type` 为 `requirements` 或 `references`),直接喂给下游 `S40_extract_requirements` 条目化流程,显著提升 specs 生成质量的稳定性。
433
471
 
472
+ > **表格清洗(xlsx/xls/csv)**:源文件为 `.xlsx`、`.xls` 或 `.csv` 时,转换完成后自动清理 Markdown 表格中的 `NaN`、全空行、以及数据全空且表头为 `Unnamed: N` 的列(CLI 与服务端双端生效)。对已存在的产物 md 可执行:`node scripts/cleanup-spreadsheet-md.js <dirOrFile>`。
473
+
474
+ > **Sheet 拆分(xlsx/xls)**:多 sheet 工作簿转换后,MarkItDown 以 `## SheetName` 分隔各 sheet。CLI 默认在保留合并 md 的同时,将各 sheet 写入 `<basename>_split/`(含 `README.md` 索引);`--no-split-sheets` 可关闭。已有合并 md 可执行:`node scripts/split-spreadsheet-md.js <dirOrFile>`。
475
+
434
476
  > **架构约束**:CLI 只是 HTTP 客户端,**不 spawn / 不装 Python / 不管进程**。服务端代码集中在 `svharnessbuild/markitdown_serve/` 便于仓内维护,但**不随 npm 包分发**,部署方式详见 [markitdown_serve/README.md](./markitdown_serve/README.md)。
435
477
 
436
478
  ```bash
@@ -460,6 +502,8 @@ svharness convert --verbose
460
502
  | `--timeout-sec <n>` | 单请求超时秒数 | `120`(2 分钟) |
461
503
  | `--type <type>` | 目标子目录:`requirements`(正式需求)\| `references`(参考资料) | `requirements` |
462
504
  | `--force` | flag,覆盖已存在同名 `.md`(默认自动追加 `-1`、`-2` 后缀) | `false` |
505
+ | `--split-sheets-suffix <suffix>` | xlsx/xls 按 sheet 拆分的子目录后缀 | `_split` |
506
+ | `--no-split-sheets` | flag,不将 xlsx/xls 合并 md 按 `##` 拆分为多文件 | `false`(默认拆分) |
463
507
  | `-y, --yes` | flag,跳过交互确认 | `false` |
464
508
  | `--verbose` | flag,显示详细日志 | `false` |
465
509
 
@@ -699,6 +743,9 @@ node bin\cli.js build --harness-name demo --arch android-compose --agent codecha
699
743
  ### 发布
700
744
 
701
745
  ```bash
746
+ npm version 0.14.6
747
+ npm run build
748
+
702
749
  npm login
703
750
  npm publish --access public # prepublishOnly 自动执行 build
704
751
  ```
@@ -10,6 +10,7 @@ const js_yaml_1 = __importDefault(require("js-yaml"));
10
10
  const prompts_1 = __importDefault(require("prompts"));
11
11
  const adapters_1 = require("../adapters");
12
12
  const apply_project_entry_1 = require("../core/apply-project-entry");
13
+ const repomix_apply_hint_1 = require("../core/repomix-apply-hint");
13
14
  const validate_args_1 = require("../utils/validate-args");
14
15
  const logger_1 = require("../utils/logger");
15
16
  const version_1 = require("../utils/version");
@@ -322,9 +323,14 @@ async function injectThinMainSkill(input) {
322
323
  await fs_extra_1.default.remove(dstDir);
323
324
  }
324
325
  const raw = await fs_extra_1.default.readFile(templatePath, 'utf8');
325
- const rendered = raw
326
+ const repomixHint = await (0, repomix_apply_hint_1.buildRepomixApplyHintReplacement)({
327
+ harnessRoot: input.harnessRoot,
328
+ harnessDirName: input.harnessDirName,
329
+ blockquote: false,
330
+ });
331
+ const rendered = (0, repomix_apply_hint_1.applyRepomixHintPlaceholder)(raw
326
332
  .replace(/__HARNESS_ROOT_REL__/g, `./${input.harnessDirName}`)
327
- .replace(/__HARNESS_DIR_NAME__/g, input.harnessDirName);
333
+ .replace(/__HARNESS_DIR_NAME__/g, input.harnessDirName), repomixHint, repomix_apply_hint_1.REPOMIX_APPLY_HINT_PLACEHOLDER);
328
334
  const content = input.adapter.transform
329
335
  ? input.adapter.transform(rendered, `${DISPATCHER_SKILL_NAME}.md`)
330
336
  : rendered;
@@ -707,6 +713,7 @@ async function runApply(opts) {
707
713
  const thinMainPath = await injectThinMainSkill({
708
714
  templatesRoot,
709
715
  targetRoot,
716
+ harnessRoot: effectiveHarnessRoot,
710
717
  adapter,
711
718
  harnessDirName,
712
719
  force: !!opts.force,
@@ -27,6 +27,8 @@ const logger_1 = require("../utils/logger");
27
27
  const validate_args_1 = require("../utils/validate-args");
28
28
  const markitdown_client_1 = require("../core/markitdown-client");
29
29
  const doc_intake_paths_1 = require("../core/doc-intake-paths");
30
+ const markdown_table_cleanup_1 = require("../core/markdown-table-cleanup");
31
+ const markdown_sheet_split_1 = require("../core/markdown-sheet-split");
30
32
  /**
31
33
  * Extensions recognised by Microsoft MarkItDown (v0.x). Anything outside this
32
34
  * whitelist is skipped client-side to avoid a round-trip that is guaranteed to
@@ -85,7 +87,12 @@ async function runConvert(opts) {
85
87
  configRows.push({ label: 'harness ', value: v.harnessRoot });
86
88
  configRows.push({ label: 'type ', value: v.type });
87
89
  }
88
- configRows.push({ label: 'output dir ', value: outDir }, { label: 'endpoint ', value: v.endpoint }, { label: 'concurrency', value: String(v.concurrency) }, { label: 'max file ', value: `${v.maxFileMB} MB` }, { label: 'timeout ', value: `${v.timeoutSec}s` }, { label: 'force ', value: opts.force ? 'yes' : 'no' });
90
+ configRows.push({ label: 'output dir ', value: outDir }, { label: 'endpoint ', value: v.endpoint }, { label: 'concurrency', value: String(v.concurrency) }, { label: 'max file ', value: `${v.maxFileMB} MB` }, { label: 'timeout ', value: `${v.timeoutSec}s` }, { label: 'force ', value: opts.force ? 'yes' : 'no' }, {
91
+ label: 'split sheets',
92
+ value: opts.splitSheets === false
93
+ ? 'no'
94
+ : `yes (${opts.splitSheetsSuffix ?? '_split'})`,
95
+ });
89
96
  logger_1.logger.configBox('svharness convert', configRows);
90
97
  // 1. Collect candidate files.
91
98
  const candidates = await collectFiles(v.input, cwd);
@@ -321,13 +328,41 @@ async function uploadOne(source, outDir, used, v, opts) {
321
328
  const basename = node_path_1.default.basename(source, node_path_1.default.extname(source));
322
329
  const outPath = await resolveOutputPath(outDir, basename, used, !!opts.force);
323
330
  used.add(outPath);
331
+ const splitSheets = opts.splitSheets !== false;
332
+ const splitSuffix = opts.splitSheetsSuffix ?? '_split';
324
333
  logger_1.logger.debug(`upload ${source} -> ${outPath}`);
325
334
  try {
326
335
  const resp = await (0, markitdown_client_1.postConvertWithRetry)(v.endpoint, source, v.timeoutSec * 1000);
327
- await fs_extra_1.default.writeFile(outPath, resp.markdown, 'utf8');
328
- const bytes = Buffer.byteLength(resp.markdown, 'utf8');
336
+ const ext = node_path_1.default.extname(source).toLowerCase();
337
+ let markdown = resp.markdown;
338
+ if (markdown_table_cleanup_1.XLSX_CONVERT_EXTS.has(ext)) {
339
+ const cleaned = (0, markdown_table_cleanup_1.cleanupSpreadsheetMarkdown)(markdown);
340
+ markdown = cleaned.markdown;
341
+ const { stats } = cleaned;
342
+ if (stats.nanCellsReplaced > 0 ||
343
+ stats.rowsRemoved > 0 ||
344
+ stats.columnsRemoved > 0) {
345
+ logger_1.logger.debug(`spreadsheet cleanup ${node_path_1.default.basename(source)}: ` +
346
+ `NaN=${stats.nanCellsReplaced} rows-=${stats.rowsRemoved} cols-=${stats.columnsRemoved} ` +
347
+ `bytes ${stats.charsBefore}->${stats.charsAfter}`);
348
+ }
349
+ }
350
+ await fs_extra_1.default.writeFile(outPath, markdown, 'utf8');
351
+ const bytes = Buffer.byteLength(markdown, 'utf8');
352
+ const outputs = [outPath];
353
+ if (splitSheets && markdown_sheet_split_1.XLSX_SPLIT_EXTS.has(ext)) {
354
+ const split = (0, markdown_sheet_split_1.splitSpreadsheetMarkdownBySheet)(markdown);
355
+ if (split.sections.length >= 2) {
356
+ const splitDir = node_path_1.default.join(outDir, `${basename}${splitSuffix}`);
357
+ const splitWritten = await (0, markdown_sheet_split_1.writeSheetSplitOutput)(split.sections, splitDir, node_path_1.default.basename(source), { writeIndex: true, force: !!opts.force });
358
+ outputs.push(...splitWritten);
359
+ logger_1.logger.debug(`sheet split ${node_path_1.default.basename(source)}: ${split.sections.length} sections -> ${splitDir}`);
360
+ logger_1.logger.success(`${node_path_1.default.basename(source)} -> ${node_path_1.default.basename(outPath)} + ${split.sections.length} sheets in ${node_path_1.default.basename(splitDir)}/ (${bytes}B)`);
361
+ return { source, output: outPath, outputs, status: 'ok', bytes };
362
+ }
363
+ }
329
364
  logger_1.logger.success(`${node_path_1.default.basename(source)} -> ${node_path_1.default.basename(outPath)} (${bytes}B)`);
330
- return { source, output: outPath, status: 'ok', bytes };
365
+ return { source, output: outPath, outputs, status: 'ok', bytes };
331
366
  }
332
367
  catch (err) {
333
368
  const e = err;
@@ -0,0 +1,96 @@
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.checkRequirementsFidelity = checkRequirementsFidelity;
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const js_yaml_1 = __importDefault(require("js-yaml"));
10
+ const utils_1 = require("./utils");
11
+ const TRUNCATION_HINT_RE = /(详见原文|见上文|同前|tbd|placeholder|…|\.{3,})/i;
12
+ const MIN_EXCERPT_LEN = 20;
13
+ const WARN_RATIO = 0.5;
14
+ const ERROR_RATIO = 0.25;
15
+ function normalizeLength(v) {
16
+ if (typeof v !== 'string')
17
+ return 0;
18
+ return v.replace(/\s+/g, '').length;
19
+ }
20
+ async function checkRequirementsFidelity(ctx) {
21
+ const id = 'requirements-fidelity';
22
+ const findings = [];
23
+ const yamlDir = node_path_1.default.join(ctx.harnessRoot, 'requirements', 'yaml');
24
+ const reqYamlFiles = (await (0, utils_1.listRealFilesRecursive)(yamlDir, { extensions: ['.yaml', '.yml'] })).filter((f) => !f.endsWith('schema.json'));
25
+ for (const yamlFile of reqYamlFiles) {
26
+ let parsed;
27
+ try {
28
+ parsed = js_yaml_1.default.load(await fs_extra_1.default.readFile(yamlFile, 'utf8'));
29
+ }
30
+ catch {
31
+ continue;
32
+ }
33
+ const items = parsed?.items;
34
+ if (!Array.isArray(items))
35
+ continue;
36
+ for (const [idx, item] of items.entries()) {
37
+ const obj = (item ?? {});
38
+ const itemId = typeof obj.id === 'string' ? obj.id : `#${idx + 1}`;
39
+ const excerpt = typeof obj.source_excerpt === 'string' ? obj.source_excerpt : '';
40
+ const desc = typeof obj.description === 'string' ? obj.description : '';
41
+ const excerptLen = normalizeLength(excerpt);
42
+ const descLen = normalizeLength(desc);
43
+ const ratio = excerptLen > 0 ? descLen / excerptLen : 1;
44
+ const itemPath = `${(0, utils_1.rel)(ctx.harnessRoot, yamlFile)}:${itemId}`;
45
+ if (excerptLen === 0) {
46
+ findings.push({
47
+ checkId: id,
48
+ severity: 'error',
49
+ message: 'source_excerpt 为空,无法证明需求保留了源文档信息',
50
+ path: itemPath,
51
+ });
52
+ }
53
+ else if (excerptLen < MIN_EXCERPT_LEN) {
54
+ findings.push({
55
+ checkId: id,
56
+ severity: 'warning',
57
+ message: `source_excerpt 过短(${excerptLen}),建议补充完整原文摘录`,
58
+ path: itemPath,
59
+ });
60
+ }
61
+ if (!desc.trim()) {
62
+ findings.push({
63
+ checkId: id,
64
+ severity: 'error',
65
+ message: 'description 为空,缺少完整需求陈述',
66
+ path: itemPath,
67
+ });
68
+ }
69
+ else if (ratio < ERROR_RATIO) {
70
+ findings.push({
71
+ checkId: id,
72
+ severity: ctx.mode === 'pre-seal' ? 'error' : 'warning',
73
+ message: `description 相对 source_excerpt 明显缩略(ratio=${ratio.toFixed(2)})`,
74
+ path: itemPath,
75
+ });
76
+ }
77
+ else if (ratio < WARN_RATIO) {
78
+ findings.push({
79
+ checkId: id,
80
+ severity: 'warning',
81
+ message: `description 可能缩略 source_excerpt(ratio=${ratio.toFixed(2)})`,
82
+ path: itemPath,
83
+ });
84
+ }
85
+ if (TRUNCATION_HINT_RE.test(excerpt) || TRUNCATION_HINT_RE.test(desc)) {
86
+ findings.push({
87
+ checkId: id,
88
+ severity: 'warning',
89
+ message: '检测到截断/占位语义(如 详见原文、TBD、placeholder)',
90
+ path: itemPath,
91
+ });
92
+ }
93
+ }
94
+ }
95
+ return { id, title: '需求保真度', findings };
96
+ }
@@ -7,6 +7,8 @@ exports.checkRequirements = checkRequirements;
7
7
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
9
  const js_yaml_1 = __importDefault(require("js-yaml"));
10
+ const ajv_1 = __importDefault(require("ajv"));
11
+ const ajv_formats_1 = __importDefault(require("ajv-formats"));
10
12
  const utils_1 = require("./utils");
11
13
  async function checkRequirements(ctx) {
12
14
  const id = 'requirements';
@@ -35,6 +37,24 @@ async function checkRequirements(ctx) {
35
37
  }
36
38
  }
37
39
  else {
40
+ const schemaPath = node_path_1.default.join(yamlDir, 'schema.json');
41
+ let validate;
42
+ if (await fs_extra_1.default.pathExists(schemaPath)) {
43
+ try {
44
+ const schema = JSON.parse(await fs_extra_1.default.readFile(schemaPath, 'utf8'));
45
+ const ajv = new ajv_1.default({ allErrors: true, strict: false });
46
+ (0, ajv_formats_1.default)(ajv);
47
+ validate = ajv.compile(schema);
48
+ }
49
+ catch (err) {
50
+ findings.push({
51
+ checkId: id,
52
+ severity: 'error',
53
+ message: `requirements/yaml/schema.json 无效:${err.message}`,
54
+ path: 'requirements/yaml/schema.json',
55
+ });
56
+ }
57
+ }
38
58
  for (const file of yamlFiles) {
39
59
  try {
40
60
  const raw = await fs_extra_1.default.readFile(file, 'utf8');
@@ -47,7 +67,19 @@ async function checkRequirements(ctx) {
47
67
  });
48
68
  continue;
49
69
  }
50
- js_yaml_1.default.load(raw);
70
+ const parsed = js_yaml_1.default.load(raw);
71
+ if (validate && !validate(parsed)) {
72
+ const detail = (validate.errors ?? [])
73
+ .slice(0, 3)
74
+ .map((e) => `${e.instancePath || '/'} ${e.message}`)
75
+ .join('; ');
76
+ findings.push({
77
+ checkId: id,
78
+ severity: 'error',
79
+ message: `requirements schema 校验失败:${detail}`,
80
+ path: (0, utils_1.rel)(ctx.harnessRoot, file),
81
+ });
82
+ }
51
83
  }
52
84
  catch (err) {
53
85
  findings.push({
@@ -0,0 +1,165 @@
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.checkSpecsDepth = checkSpecsDepth;
7
+ const fs_extra_1 = __importDefault(require("fs-extra"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const js_yaml_1 = __importDefault(require("js-yaml"));
10
+ const utils_1 = require("./utils");
11
+ function asArray(v) {
12
+ return Array.isArray(v) ? v : [];
13
+ }
14
+ async function getEnabledLayers(harnessRoot) {
15
+ const { doc } = await (0, utils_1.readHarnessYaml)(harnessRoot);
16
+ const layers = asArray(doc?.specs?.layers);
17
+ const enabled = new Set();
18
+ for (const l of layers) {
19
+ const name = l?.name;
20
+ if (name === 'signals' || name === 'behavior' || name === 'interfaces' || name === 'ui') {
21
+ enabled.add(name);
22
+ }
23
+ }
24
+ if (enabled.size === 0) {
25
+ enabled.add('signals');
26
+ enabled.add('behavior');
27
+ enabled.add('interfaces');
28
+ enabled.add('ui');
29
+ }
30
+ return enabled;
31
+ }
32
+ async function getDepthThresholds(harnessRoot) {
33
+ const defaults = {
34
+ warnThreshold: 0.5,
35
+ failThreshold: 0.8,
36
+ };
37
+ const { doc } = await (0, utils_1.readHarnessYaml)(harnessRoot);
38
+ const arch = doc?.arch?.trim();
39
+ const candidates = [
40
+ arch ? node_path_1.default.join(harnessRoot, 'agent-env', 'review-profiles', `${arch}.yaml`) : '',
41
+ node_path_1.default.join(harnessRoot, 'agent-env', 'review-profiles', '_default.yaml'),
42
+ ].filter(Boolean);
43
+ for (const file of candidates) {
44
+ if (!(await fs_extra_1.default.pathExists(file)))
45
+ continue;
46
+ try {
47
+ const parsed = js_yaml_1.default.load(await fs_extra_1.default.readFile(file, 'utf8'));
48
+ const heuristics = (parsed?.heuristics ?? {});
49
+ const fail = Number(heuristics.index_signal_id_ratio_threshold);
50
+ if (!Number.isNaN(fail) && fail > 0 && fail <= 1) {
51
+ return {
52
+ warnThreshold: Math.max(0.1, Math.min(0.95, fail * 0.7)),
53
+ failThreshold: fail,
54
+ };
55
+ }
56
+ }
57
+ catch {
58
+ // ignore invalid profile and fall back
59
+ }
60
+ }
61
+ return defaults;
62
+ }
63
+ async function scanSignalsDepth(ctx, findings, warnThreshold, failThreshold) {
64
+ const signalsDir = node_path_1.default.join(ctx.harnessRoot, 'specs', 'signals');
65
+ const files = (await (0, utils_1.listRealFilesRecursive)(signalsDir, { extensions: ['.yaml', '.yml'] })).filter((f) => !f.endsWith('schema.json'));
66
+ let totalSignals = 0;
67
+ let indexOnlySignals = 0;
68
+ for (const file of files) {
69
+ let parsed;
70
+ try {
71
+ parsed = js_yaml_1.default.load(await fs_extra_1.default.readFile(file, 'utf8'));
72
+ }
73
+ catch {
74
+ continue;
75
+ }
76
+ const signals = asArray(parsed?.signals);
77
+ for (const sig of signals) {
78
+ totalSignals++;
79
+ const id = typeof sig.id === 'string' ? sig.id : '';
80
+ const hasDescription = typeof sig.description === 'string' && sig.description.trim().length >= 8;
81
+ const hasEnumValues = Array.isArray(sig.enum_values) && sig.enum_values.length > 0;
82
+ const hasLength = typeof sig.length_bytes === 'number';
83
+ const isEnum = sig.type === 'enum';
84
+ const maybeIndexOnly = /^CMDID_[A-Z0-9_]+$/.test(id) && !hasDescription && !hasLength && (!isEnum || !hasEnumValues);
85
+ if (maybeIndexOnly) {
86
+ indexOnlySignals++;
87
+ }
88
+ if (isEnum && !hasEnumValues) {
89
+ findings.push({
90
+ checkId: 'specs-depth',
91
+ severity: 'warning',
92
+ message: `enum 信号缺少 enum_values(${id || 'unknown-id'})`,
93
+ path: (0, utils_1.rel)(ctx.harnessRoot, file),
94
+ });
95
+ }
96
+ }
97
+ }
98
+ if (totalSignals === 0)
99
+ return;
100
+ const ratio = indexOnlySignals / totalSignals;
101
+ if (ratio >= failThreshold) {
102
+ findings.push({
103
+ checkId: 'specs-depth',
104
+ severity: 'error',
105
+ message: `signals 索引式占比过高(${indexOnlySignals}/${totalSignals}=${ratio.toFixed(2)})`,
106
+ path: 'specs/signals',
107
+ });
108
+ }
109
+ else if (ratio >= warnThreshold) {
110
+ findings.push({
111
+ checkId: 'specs-depth',
112
+ severity: 'warning',
113
+ message: `signals 索引式占比偏高(${indexOnlySignals}/${totalSignals}=${ratio.toFixed(2)})`,
114
+ path: 'specs/signals',
115
+ });
116
+ }
117
+ }
118
+ async function scanBehaviorDepth(ctx, findings) {
119
+ const behaviorDir = node_path_1.default.join(ctx.harnessRoot, 'specs', 'behavior');
120
+ const files = (await (0, utils_1.listRealFilesRecursive)(behaviorDir, { extensions: ['.yaml', '.yml'] })).filter((f) => !f.endsWith('schema.json'));
121
+ for (const file of files) {
122
+ let parsed;
123
+ try {
124
+ parsed = js_yaml_1.default.load(await fs_extra_1.default.readFile(file, 'utf8'));
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ const transitions = asArray(parsed?.transitions);
130
+ for (const t of transitions) {
131
+ const trigger = typeof t.trigger === 'string' ? t.trigger : 'unknown-trigger';
132
+ const guard = typeof t.guard === 'string' ? t.guard.trim() : '';
133
+ const action = typeof t.action === 'string' ? t.action.trim() : '';
134
+ if (!guard || /^见需求|^tbd|placeholder/i.test(guard)) {
135
+ findings.push({
136
+ checkId: 'specs-depth',
137
+ severity: 'warning',
138
+ message: `transition.guard 为空或占位(${trigger})`,
139
+ path: (0, utils_1.rel)(ctx.harnessRoot, file),
140
+ });
141
+ }
142
+ if (!action || /^见需求|^tbd|placeholder/i.test(action)) {
143
+ findings.push({
144
+ checkId: 'specs-depth',
145
+ severity: 'warning',
146
+ message: `transition.action 为空或占位(${trigger})`,
147
+ path: (0, utils_1.rel)(ctx.harnessRoot, file),
148
+ });
149
+ }
150
+ }
151
+ }
152
+ }
153
+ async function checkSpecsDepth(ctx) {
154
+ const id = 'specs-depth';
155
+ const findings = [];
156
+ const enabledLayers = await getEnabledLayers(ctx.harnessRoot);
157
+ const { warnThreshold, failThreshold } = await getDepthThresholds(ctx.harnessRoot);
158
+ if (enabledLayers.has('signals')) {
159
+ await scanSignalsDepth(ctx, findings, warnThreshold, failThreshold);
160
+ }
161
+ if (enabledLayers.has('behavior')) {
162
+ await scanBehaviorDepth(ctx, findings);
163
+ }
164
+ return { id, title: 'Specs 深度启发式', findings };
165
+ }
@@ -45,7 +45,9 @@ const check_empty_dirs_1 = require("./check-empty-dirs");
45
45
  const check_convert_pairing_1 = require("./check-convert-pairing");
46
46
  const check_requirements_1 = require("./check-requirements");
47
47
  const check_requirements_coverage_1 = require("./check-requirements-coverage");
48
+ const check_requirements_fidelity_1 = require("./check-requirements-fidelity");
48
49
  const check_specs_1 = require("./check-specs");
50
+ const check_specs_depth_1 = require("./check-specs-depth");
49
51
  const check_references_1 = require("./check-references");
50
52
  const check_skills_tasks_1 = require("./check-skills-tasks");
51
53
  const check_memory_1 = require("./check-memory");
@@ -74,7 +76,9 @@ async function runDoctorChecks(input) {
74
76
  check_convert_pairing_1.checkConvertPairing,
75
77
  check_requirements_1.checkRequirements,
76
78
  check_requirements_coverage_1.checkRequirementsCoverage,
79
+ check_requirements_fidelity_1.checkRequirementsFidelity,
77
80
  check_specs_1.checkSpecs,
81
+ check_specs_depth_1.checkSpecsDepth,
78
82
  check_references_1.checkReferences,
79
83
  check_skills_tasks_1.checkSkillsTasks,
80
84
  check_memory_1.checkMemory,
@@ -20,6 +20,8 @@ const build_project_entry_1 = require("../core/build-project-entry");
20
20
  const project_ignore_1 = require("../core/project-ignore");
21
21
  const harness_name_1 = require("../utils/harness-name");
22
22
  const yaml_safe_path_1 = require("../utils/yaml-safe-path");
23
+ const harness_yaml_baseline_1 = require("../core/harness-yaml-baseline");
24
+ const repomix_apply_hint_1 = require("../core/repomix-apply-hint");
23
25
  const repomix_pack_1 = require("../core/repomix-pack");
24
26
  const extra_assets_intake_1 = require("../core/extra-assets-intake");
25
27
  const baseline_copy_1 = require("../utils/baseline-copy");
@@ -100,6 +102,10 @@ async function runInit(opts) {
100
102
  // modes. Wiki generation consumes this directory by default so it always
101
103
  // reflects the baseline the user asked for (not the caller's cwd).
102
104
  const hasBaseline = !!validated.baseline;
105
+ const enableRepomix = !!opts.repomix && hasBaseline;
106
+ if (opts.repomix && !hasBaseline) {
107
+ logger_1.logger.warn('--repomix 需要同时提供 --baseline,已忽略 Repomix 打包');
108
+ }
103
109
  const baselineCodeDir = node_path_1.default.join(targetRoot, 'baseline', 'code');
104
110
  const wikiSourceRoot = node_path_1.default.resolve(opts.wikiSource ?? (hasBaseline ? baselineCodeDir : cwd));
105
111
  // Derive the final wiki mode:
@@ -182,7 +188,9 @@ async function runInit(opts) {
182
188
  if (hasBaseline) {
183
189
  configItems.push({
184
190
  label: 'Repomix 基线包',
185
- value: `默认生成(${(0, repomix_pack_1.repomixPackRelFile)()})`,
191
+ value: enableRepomix
192
+ ? `启用(${(0, repomix_pack_1.repomixPackRelFile)()})`
193
+ : '关闭(默认;大基线整库快照/单文件工具/留档时再 --repomix)',
186
194
  });
187
195
  }
188
196
  logger_1.logger.configBox('配置确认', configItems);
@@ -247,7 +255,7 @@ async function runInit(opts) {
247
255
  const hasExtraAssets = (opts.extraSkills?.length ?? 0) > 0;
248
256
  const stepExtraAssets = hasExtraAssets ? ++stepCursor : 0;
249
257
  const stepBaseline = hasBaseline ? ++stepCursor : 0;
250
- const stepRepomix = hasBaseline ? ++stepCursor : 0;
258
+ const stepRepomix = enableRepomix ? ++stepCursor : 0;
251
259
  const stepWiki = wikiEnabled ? ++stepCursor : 0;
252
260
  const stepState = ++stepCursor;
253
261
  const totalSteps = stepCursor;
@@ -362,8 +370,9 @@ async function runInit(opts) {
362
370
  logger_1.logger.info('项目 ignore 文件已包含 build 注入路径,未追加新行');
363
371
  }
364
372
  }
365
- // 6a. Repomix XML pack of baseline code (default when --baseline; non-fatal on failure).
366
- if (hasBaseline) {
373
+ // 6a. Repomix XML pack of baseline code (opt-in via --repomix; non-fatal on failure).
374
+ let repomixGenerated = false;
375
+ if (enableRepomix) {
367
376
  logger_1.logger.section(`步骤 ${stepRepomix}/${totalSteps} - 生成 baseline Repomix 包`);
368
377
  try {
369
378
  const packRootFs = wikiSourceRoot;
@@ -381,6 +390,19 @@ async function runInit(opts) {
381
390
  onLog: (m) => logger_1.logger.info(` ${m}`),
382
391
  });
383
392
  logger_1.logger.success(`baseline Repomix 已生成:${node_path_1.default.relative(targetRoot, outAbs)}`);
393
+ repomixGenerated = true;
394
+ await (0, harness_yaml_baseline_1.setBaselineRepomixPack)(targetRoot, (0, repomix_pack_1.repomixPackRelFile)());
395
+ logger_1.logger.info(`已更新 harness.yaml:baseline.repomix_pack`);
396
+ const agentsApplyPath = node_path_1.default.join(targetRoot, 'AGENTS_APPLY.md');
397
+ if (await fs_extra_1.default.pathExists(agentsApplyPath)) {
398
+ const repomixHint = await (0, repomix_apply_hint_1.buildRepomixApplyHintReplacement)({
399
+ harnessRoot: targetRoot,
400
+ harnessDirName,
401
+ blockquote: true,
402
+ });
403
+ const agentsApplyRaw = await fs_extra_1.default.readFile(agentsApplyPath, 'utf8');
404
+ await fs_extra_1.default.outputFile(agentsApplyPath, (0, repomix_apply_hint_1.applyRepomixHintPlaceholder)(agentsApplyRaw, repomixHint, repomix_apply_hint_1.REPOMIX_APPLY_HINT_PLACEHOLDER), 'utf8');
405
+ }
384
406
  }
385
407
  catch (err) {
386
408
  logger_1.logger.warn(`Repomix 生成失败(不回滚 harness 骨架):${err.message}`);
@@ -563,6 +585,7 @@ async function runInit(opts) {
563
585
  adapter: adapterForNext,
564
586
  agent: validated.agent,
565
587
  hasSource: !!validated.baseline,
588
+ repomixGenerated,
566
589
  wikiMode,
567
590
  });
568
591
  }
@@ -77,6 +77,7 @@ function mergeBuildOptions(cli, configSection, defaults, cmd) {
77
77
  force: pickBool('force', cli.force, cfg.force, defaults?.force, cmd),
78
78
  yes: pickBool('yes', cli.yes, cfg.yes, defaults?.yes, cmd),
79
79
  verbose: pickBool('verbose', cli.verbose, cfg.verbose, defaults?.verbose, cmd),
80
+ repomix: pickBool('repomix', cli.repomix, cfg.repomix, undefined, cmd),
80
81
  generateWiki: pickBool('generateWiki', cli.generateWiki, cfg.generateWiki, undefined, cmd),
81
82
  wikiTasksOnly: pickBool('wikiTasksOnly', cli.wikiTasksOnly, cfg.wikiTasksOnly, undefined, cmd),
82
83
  wikiLang: pickString('wikiLang', cli.wikiLang, cfg.wikiLang, cmd),
@@ -113,6 +114,10 @@ function mergeConvertOptions(cli, configSection, defaults, cmd) {
113
114
  timeoutSec: pickNumber('timeoutSec', cli.timeoutSec, cfg.timeoutSec, cmd),
114
115
  type: pickString('type', cli.type, cfg.type, cmd),
115
116
  force: pickBool('force', cli.force, cfg.force, defaults?.force, cmd),
117
+ splitSheets: cli.noSplitSheets
118
+ ? false
119
+ : pickBool('splitSheets', cli.splitSheets, cfg.splitSheets, undefined, cmd),
120
+ splitSheetsSuffix: pickString('splitSheetsSuffix', cli.splitSheetsSuffix, cfg.splitSheetsSuffix, cmd),
116
121
  yes: pickBool('yes', cli.yes, cfg.yes, defaults?.yes, cmd),
117
122
  verbose: pickBool('verbose', cli.verbose, cfg.verbose, defaults?.verbose, cmd),
118
123
  };