svharness 0.14.5 → 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.
package/README.md CHANGED
@@ -116,6 +116,7 @@ Harness 是一个 **项目本地的知识层**,由两大部分组成:
116
116
  │ └── yaml/
117
117
  └── baseline/
118
118
  ├── code/ # 参考代码快照
119
+ ├── repomix/ # 可选:`--repomix` 时生成的单文件 XML 快照
119
120
  └── wiki/ # 架构 wiki
120
121
 
121
122
  ├── agent-env/ # Agent 运行期环境
@@ -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 应:
@@ -435,6 +469,10 @@ svharness doctor --harness ./my-app-harness --mode pre-seal --format json --repo
435
469
 
436
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 生成质量的稳定性。
437
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
+
438
476
  > **架构约束**:CLI 只是 HTTP 客户端,**不 spawn / 不装 Python / 不管进程**。服务端代码集中在 `svharnessbuild/markitdown_serve/` 便于仓内维护,但**不随 npm 包分发**,部署方式详见 [markitdown_serve/README.md](./markitdown_serve/README.md)。
439
477
 
440
478
  ```bash
@@ -464,6 +502,8 @@ svharness convert --verbose
464
502
  | `--timeout-sec <n>` | 单请求超时秒数 | `120`(2 分钟) |
465
503
  | `--type <type>` | 目标子目录:`requirements`(正式需求)\| `references`(参考资料) | `requirements` |
466
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`(默认拆分) |
467
507
  | `-y, --yes` | flag,跳过交互确认 | `false` |
468
508
  | `--verbose` | flag,显示详细日志 | `false` |
469
509
 
@@ -703,6 +743,9 @@ node bin\cli.js build --harness-name demo --arch android-compose --agent codecha
703
743
  ### 发布
704
744
 
705
745
  ```bash
746
+ npm version 0.14.6
747
+ npm run build
748
+
706
749
  npm login
707
750
  npm publish --access public # prepublishOnly 自动执行 build
708
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;
@@ -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
  };
@@ -71,6 +71,7 @@ function pickBuildSection(raw) {
71
71
  'force',
72
72
  'yes',
73
73
  'verbose',
74
+ 'repomix',
74
75
  'generateWiki',
75
76
  'wikiTasksOnly',
76
77
  ]) {
@@ -107,10 +108,13 @@ function pickConvertSection(raw) {
107
108
  if (raw[k] !== undefined)
108
109
  s[k] = Number(raw[k]);
109
110
  }
110
- for (const k of ['force', 'yes', 'verbose']) {
111
+ for (const k of ['force', 'yes', 'verbose', 'splitSheets']) {
111
112
  if (raw[k] !== undefined)
112
113
  s[k] = Boolean(raw[k]);
113
114
  }
115
+ if (raw.splitSheetsSuffix !== undefined && raw.splitSheetsSuffix !== null) {
116
+ s.splitSheetsSuffix = String(raw.splitSheetsSuffix).trim();
117
+ }
114
118
  return s;
115
119
  }
116
120
  function pickDefaults(raw) {
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.writeApplyProjectEntry = writeApplyProjectEntry;
7
7
  const fs_extra_1 = __importDefault(require("fs-extra"));
8
8
  const node_path_1 = __importDefault(require("node:path"));
9
+ const repomix_apply_hint_1 = require("./repomix-apply-hint");
9
10
  const logger_1 = require("../utils/logger");
10
11
  function toPosix(p) {
11
12
  return p.replace(/\\/g, '/');
@@ -81,7 +82,13 @@ async function writeApplyProjectEntry(input) {
81
82
  : bridgeHint.length > 0
82
83
  ? `${raw.trimEnd()}\n\n${bridgeHint}`
83
84
  : raw;
84
- const rewritten = rewriteEntryReferences(withBridgeHint, input.harnessDirName);
85
+ const repomixHint = await (0, repomix_apply_hint_1.buildRepomixApplyHintReplacement)({
86
+ harnessRoot: input.harnessRoot,
87
+ harnessDirName: input.harnessDirName,
88
+ blockquote: true,
89
+ });
90
+ const withRepomixHint = (0, repomix_apply_hint_1.applyRepomixHintPlaceholder)(withBridgeHint, repomixHint, repomix_apply_hint_1.REPOMIX_APPLY_HINT_PLACEHOLDER);
91
+ const rewritten = rewriteEntryReferences(withRepomixHint, input.harnessDirName);
85
92
  await fs_extra_1.default.outputFile(dest, rewritten, 'utf8');
86
93
  logger_1.logger.success(`已写入项目根 AI 入口:${rel}(由 AGENTS_APPLY.md 重命名)`);
87
94
  return rel;
@@ -0,0 +1,68 @@
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.readHarnessYamlDoc = readHarnessYamlDoc;
7
+ exports.getBaselineRepomixPackFromDoc = getBaselineRepomixPackFromDoc;
8
+ exports.setBaselineRepomixPack = setBaselineRepomixPack;
9
+ const fs_extra_1 = __importDefault(require("fs-extra"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const js_yaml_1 = __importDefault(require("js-yaml"));
12
+ const yaml_safe_path_1 = require("../utils/yaml-safe-path");
13
+ const REPOMIX_PACK_KEY = 'repomix_pack';
14
+ const REPOMIX_PACK_COMMENT = ' # repomix_pack: baseline/repomix/repomix-pack.xml # 仅 --repomix 成功后由 CLI 写入';
15
+ async function readHarnessYamlDoc(harnessRoot) {
16
+ const yamlPath = node_path_1.default.join(harnessRoot, 'harness.yaml');
17
+ if (!(await fs_extra_1.default.pathExists(yamlPath)))
18
+ return undefined;
19
+ try {
20
+ const raw = await fs_extra_1.default.readFile(yamlPath, 'utf8');
21
+ return js_yaml_1.default.load((0, yaml_safe_path_1.preprocessHarnessYamlText)(raw));
22
+ }
23
+ catch {
24
+ return undefined;
25
+ }
26
+ }
27
+ function getBaselineRepomixPackFromDoc(doc) {
28
+ if (!doc || typeof doc.baseline !== 'object' || doc.baseline === null)
29
+ return undefined;
30
+ const baseline = doc.baseline;
31
+ const v = baseline[REPOMIX_PACK_KEY];
32
+ if (typeof v !== 'string' || !v.trim())
33
+ return undefined;
34
+ return v.trim().replace(/\\/g, '/');
35
+ }
36
+ /**
37
+ * Set or clear `baseline.repomix_pack` in harness.yaml (line edit to preserve comments).
38
+ */
39
+ async function setBaselineRepomixPack(harnessRoot, repomixPackRel) {
40
+ const yamlPath = node_path_1.default.join(harnessRoot, 'harness.yaml');
41
+ if (!(await fs_extra_1.default.pathExists(yamlPath))) {
42
+ throw new Error(`harness.yaml 不存在:${yamlPath}`);
43
+ }
44
+ let text = await fs_extra_1.default.readFile(yamlPath, 'utf8');
45
+ const rel = repomixPackRel?.trim().replace(/\\/g, '/') ?? null;
46
+ const activeLine = rel ? ` repomix_pack: ${rel}` : REPOMIX_PACK_COMMENT;
47
+ const activePattern = /^\s*repomix_pack:\s*.+$/m;
48
+ const commentPattern = /^\s*#\s*repomix_pack:.*$/m;
49
+ if (activePattern.test(text)) {
50
+ text = text.replace(activePattern, activeLine);
51
+ }
52
+ else if (commentPattern.test(text)) {
53
+ text = text.replace(commentPattern, activeLine);
54
+ }
55
+ else if (rel) {
56
+ const wikiLine = /(^\s*wiki:\s*baseline\/wiki\/.*$)/m;
57
+ if (wikiLine.test(text)) {
58
+ text = text.replace(wikiLine, `$1\n${activeLine}`);
59
+ }
60
+ else {
61
+ const baselineBlock = /(^baseline:\s*\n(?:^\s+.+\n)*)/m;
62
+ if (baselineBlock.test(text)) {
63
+ text = text.replace(baselineBlock, (block) => `${block}${activeLine}\n`);
64
+ }
65
+ }
66
+ }
67
+ await fs_extra_1.default.outputFile(yamlPath, text, 'utf8');
68
+ }
@@ -0,0 +1,109 @@
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.XLSX_SPLIT_EXTS = void 0;
7
+ exports.slugifySheetTitle = slugifySheetTitle;
8
+ exports.splitSpreadsheetMarkdownBySheet = splitSpreadsheetMarkdownBySheet;
9
+ exports.writeSheetSplitOutput = writeSheetSplitOutput;
10
+ /**
11
+ * Split spreadsheet-derived Markdown by level-2 headings (`##`).
12
+ *
13
+ * MarkItDown emits one `## SheetName` block per Excel worksheet. This pass
14
+ * writes each block to its own `.md` file under `<basename><suffix>/`.
15
+ */
16
+ const fs_extra_1 = __importDefault(require("fs-extra"));
17
+ const node_path_1 = __importDefault(require("node:path"));
18
+ /** Source extensions that trigger sheet split during `svharness convert`. */
19
+ exports.XLSX_SPLIT_EXTS = new Set(['.xlsx', '.xls']);
20
+ const INVALID_FILENAME_CHARS = /[<>:"/\\|?*\x00-\x1f]/g;
21
+ /**
22
+ * Turn a sheet title into a safe filename: `{index}_{slug}.md`.
23
+ */
24
+ function slugifySheetTitle(title, index) {
25
+ let name = title.trim();
26
+ name = name.replace(INVALID_FILENAME_CHARS, '_');
27
+ name = name.replace(/\s+/g, '_');
28
+ name = name.replace(/^[._]+|[._]+$/g, '');
29
+ if (!name) {
30
+ name = 'section';
31
+ }
32
+ return `${String(index).padStart(2, '0')}_${name}.md`;
33
+ }
34
+ /**
35
+ * Split markdown at `##` headings (one section per sheet).
36
+ */
37
+ function splitSpreadsheetMarkdownBySheet(markdown) {
38
+ const charsBefore = Buffer.byteLength(markdown, 'utf8');
39
+ const lines = markdown.split(/\r?\n/);
40
+ const anchors = [];
41
+ for (let i = 0; i < lines.length; i++) {
42
+ const match = /^## (.+)$/.exec(lines[i]);
43
+ if (match) {
44
+ anchors.push({ title: match[1].trim(), startLine: i });
45
+ }
46
+ }
47
+ if (anchors.length === 0) {
48
+ return {
49
+ sections: [],
50
+ stats: { sectionsFound: 0, charsBefore, charsAfter: charsBefore },
51
+ };
52
+ }
53
+ const sections = [];
54
+ for (let i = 0; i < anchors.length; i++) {
55
+ const endLine = i + 1 < anchors.length ? anchors[i + 1].startLine : lines.length;
56
+ const body = lines.slice(anchors[i].startLine, endLine).join('\n').trimEnd() + '\n';
57
+ const filename = slugifySheetTitle(anchors[i].title, i + 1);
58
+ sections.push({
59
+ title: anchors[i].title,
60
+ body,
61
+ filename,
62
+ });
63
+ }
64
+ const charsAfter = sections.reduce((sum, s) => sum + Buffer.byteLength(s.body, 'utf8'), 0);
65
+ return {
66
+ sections,
67
+ stats: {
68
+ sectionsFound: sections.length,
69
+ charsBefore,
70
+ charsAfter,
71
+ },
72
+ };
73
+ }
74
+ function buildSplitIndex(sourceName, sections) {
75
+ const lines = [
76
+ `# Split index: ${sourceName}\n`,
77
+ `\nTotal sections: ${sections.length}\n\n`,
78
+ ];
79
+ for (const section of sections) {
80
+ lines.push(`- [${section.title}](${section.filename})\n`);
81
+ }
82
+ return lines.join('');
83
+ }
84
+ /**
85
+ * Persist split sections to `outputDir`. Returns absolute paths written.
86
+ */
87
+ async function writeSheetSplitOutput(sections, outputDir, sourceName, opts) {
88
+ const writeIndex = opts?.writeIndex !== false;
89
+ const force = !!opts?.force;
90
+ await fs_extra_1.default.ensureDir(outputDir);
91
+ const written = [];
92
+ for (const section of sections) {
93
+ const filePath = node_path_1.default.join(outputDir, section.filename);
94
+ if (!force && (await fs_extra_1.default.pathExists(filePath))) {
95
+ throw new Error(`sheet split output exists (pass --force): ${filePath}`);
96
+ }
97
+ await fs_extra_1.default.writeFile(filePath, section.body, 'utf8');
98
+ written.push(filePath);
99
+ }
100
+ if (writeIndex) {
101
+ const indexPath = node_path_1.default.join(outputDir, 'README.md');
102
+ if (!force && (await fs_extra_1.default.pathExists(indexPath))) {
103
+ throw new Error(`sheet split index exists (pass --force): ${indexPath}`);
104
+ }
105
+ await fs_extra_1.default.writeFile(indexPath, buildSplitIndex(sourceName, sections), 'utf8');
106
+ written.push(indexPath);
107
+ }
108
+ return written;
109
+ }
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ /**
3
+ * Post-process Markdown emitted from spreadsheet conversions (`.xlsx` / `.xls` / `.csv`).
4
+ *
5
+ * MarkItDown / pandas represent empty Excel cells as the literal "NaN" in pipe
6
+ * tables. This pass:
7
+ * 1. Replaces NaN cells with empty cells
8
+ * 2. Drops data rows that are entirely empty or only contain a numeric index
9
+ * 3. Drops columns whose data rows are all empty and whose header is blank or
10
+ * "Unnamed: N" (pandas default for empty header cells)
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.XLSX_CONVERT_EXTS = void 0;
14
+ exports.cleanupSpreadsheetMarkdown = cleanupSpreadsheetMarkdown;
15
+ /** Source extensions that trigger table cleanup during `svharness convert`. */
16
+ exports.XLSX_CONVERT_EXTS = new Set([
17
+ '.xlsx',
18
+ '.xls',
19
+ '.csv',
20
+ ]);
21
+ const UNNAMED_HEADER = /^Unnamed: \d+$/;
22
+ function isTableRow(line) {
23
+ const trimmed = line.trim();
24
+ return trimmed.startsWith('|') && trimmed.endsWith('|');
25
+ }
26
+ function parseTableRow(line) {
27
+ const inner = line.trim().replace(/^\|/, '').replace(/\|$/, '');
28
+ return inner.split('|').map((cell) => cell.trim());
29
+ }
30
+ function formatTableRow(cells) {
31
+ return `| ${cells.join(' | ')} |`;
32
+ }
33
+ function normalizeCell(cell) {
34
+ if (cell === 'NaN' || cell === 'nan') {
35
+ return '';
36
+ }
37
+ return cell;
38
+ }
39
+ function isSparseTemplateRow(row) {
40
+ const nonEmpty = row.filter((cell) => cell !== '');
41
+ if (nonEmpty.length === 0) {
42
+ return true;
43
+ }
44
+ // Excel 预留行常在首列保留序号(2、3、…),其余单元格为空。
45
+ if (nonEmpty.length === 1 && /^\d+\.?\d*$/.test(nonEmpty[0])) {
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ function isSeparatorRow(cells) {
51
+ return (cells.length > 0 &&
52
+ cells.every((cell) => {
53
+ const c = cell.trim();
54
+ return c === '' || /^:?-{3,}:?$/.test(c);
55
+ }));
56
+ }
57
+ function padRow(row, width) {
58
+ const out = row.slice();
59
+ while (out.length < width) {
60
+ out.push('');
61
+ }
62
+ return out;
63
+ }
64
+ function cleanupTableBlock(lines) {
65
+ if (lines.length === 0) {
66
+ return { lines, nanCellsReplaced: 0, rowsRemoved: 0, columnsRemoved: 0 };
67
+ }
68
+ let nanCellsReplaced = 0;
69
+ const rawRows = lines.map(parseTableRow);
70
+ const colCount = Math.max(...rawRows.map((row) => row.length), 0);
71
+ if (colCount === 0) {
72
+ return { lines, nanCellsReplaced: 0, rowsRemoved: 0, columnsRemoved: 0 };
73
+ }
74
+ const rows = rawRows.map((row) => padRow(row, colCount).map((cell) => {
75
+ const normalized = normalizeCell(cell);
76
+ if (normalized !== cell) {
77
+ nanCellsReplaced += 1;
78
+ }
79
+ return normalized;
80
+ }));
81
+ const hasSeparator = rows.length > 1 && isSeparatorRow(rows[1]);
82
+ const header = rows[0];
83
+ const dataStart = hasSeparator ? 2 : 1;
84
+ const dataRowsBefore = rows.slice(dataStart);
85
+ const dataRows = dataRowsBefore.filter((row) => !isSparseTemplateRow(row));
86
+ const rowsRemoved = dataRowsBefore.length - dataRows.length;
87
+ const keepCols = [];
88
+ for (let col = 0; col < colCount; col += 1) {
89
+ const headerCell = header[col] ?? '';
90
+ const dataEmpty = dataRows.every((row) => (row[col] ?? '') === '');
91
+ if (dataEmpty && (headerCell === '' || UNNAMED_HEADER.test(headerCell))) {
92
+ continue;
93
+ }
94
+ keepCols.push(col);
95
+ }
96
+ if (keepCols.length === 0) {
97
+ keepCols.push(0);
98
+ }
99
+ const columnsRemoved = colCount - keepCols.length;
100
+ const pickCols = (row) => keepCols.map((col) => padRow(row, colCount)[col] ?? '');
101
+ const out = [];
102
+ out.push(formatTableRow(pickCols(header)));
103
+ if (hasSeparator || dataRows.length > 0) {
104
+ out.push(formatTableRow(keepCols.map(() => '---')));
105
+ }
106
+ for (const row of dataRows) {
107
+ out.push(formatTableRow(pickCols(row)));
108
+ }
109
+ return { lines: out, nanCellsReplaced, rowsRemoved, columnsRemoved };
110
+ }
111
+ /**
112
+ * Clean spreadsheet-derived Markdown document-wide (table blocks only).
113
+ */
114
+ function cleanupSpreadsheetMarkdown(markdown) {
115
+ const charsBefore = Buffer.byteLength(markdown, 'utf8');
116
+ const inputLines = markdown.split(/\r?\n/);
117
+ const outputLines = [];
118
+ let nanCellsReplaced = 0;
119
+ let rowsRemoved = 0;
120
+ let columnsRemoved = 0;
121
+ let index = 0;
122
+ while (index < inputLines.length) {
123
+ const line = inputLines[index];
124
+ if (!isTableRow(line)) {
125
+ outputLines.push(line);
126
+ index += 1;
127
+ continue;
128
+ }
129
+ const block = [];
130
+ while (index < inputLines.length && isTableRow(inputLines[index])) {
131
+ block.push(inputLines[index]);
132
+ index += 1;
133
+ }
134
+ const cleaned = cleanupTableBlock(block);
135
+ nanCellsReplaced += cleaned.nanCellsReplaced;
136
+ rowsRemoved += cleaned.rowsRemoved;
137
+ columnsRemoved += cleaned.columnsRemoved;
138
+ outputLines.push(...cleaned.lines);
139
+ }
140
+ const result = outputLines.join('\n');
141
+ return {
142
+ markdown: result,
143
+ stats: {
144
+ nanCellsReplaced,
145
+ rowsRemoved,
146
+ columnsRemoved,
147
+ charsBefore,
148
+ charsAfter: Buffer.byteLength(result, 'utf8'),
149
+ },
150
+ };
151
+ }
@@ -60,11 +60,24 @@ function printNextSteps(input) {
60
60
  lines.push('4. 随时用下面的命令查看进度: ' +
61
61
  picocolors_1.default.cyan('cat .harness-build-state.yaml'));
62
62
  lines.push('');
63
- if (input.hasSource) {
64
- lines.push(picocolors_1.default.bold('Baseline Repomix(XML,默认)'));
63
+ if (input.repomixGenerated) {
64
+ lines.push(picocolors_1.default.bold('Baseline Repomix(XML'));
65
65
  lines.push(' 输出文件: ' + picocolors_1.default.cyan((0, repomix_pack_1.repomixPackRelFile)()));
66
66
  lines.push('');
67
67
  }
68
+ else if (input.hasSource) {
69
+ lines.push(picocolors_1.default.bold('Baseline Repomix(可选,默认关闭)'));
70
+ lines.push(' 适用:大基线整库鸟瞰、只接受单文件上下文的工具、封存留档;' +
71
+ '日常 S40/S50 以 ' +
72
+ picocolors_1.default.cyan('baseline/code/') +
73
+ ' 为准即可');
74
+ lines.push(' 启用:重新 build 并加 ' +
75
+ picocolors_1.default.cyan('--repomix') +
76
+ '(须保留 --baseline)→ ' +
77
+ picocolors_1.default.cyan((0, repomix_pack_1.repomixPackRelFile)()));
78
+ lines.push(' 说明:svharnessbuild README「Repomix」');
79
+ lines.push('');
80
+ }
68
81
  if (wikiMode === 'tasks') {
69
82
  lines.push(picocolors_1.default.bold('Baseline wiki 任务清单已生成(默认 tasks-only 模式)'));
70
83
  lines.push(' 清单位置: ' + picocolors_1.default.cyan('baseline/wiki/TASKS.md'));
@@ -0,0 +1,68 @@
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.REPOMIX_APPLY_HINT_PLACEHOLDER = void 0;
7
+ exports.resolveRepomixPackRel = resolveRepomixPackRel;
8
+ exports.renderRepomixApplyHintMarkdown = renderRepomixApplyHintMarkdown;
9
+ exports.buildRepomixApplyHintReplacement = buildRepomixApplyHintReplacement;
10
+ exports.applyRepomixHintPlaceholder = applyRepomixHintPlaceholder;
11
+ const fs_extra_1 = __importDefault(require("fs-extra"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ const harness_yaml_baseline_1 = require("./harness-yaml-baseline");
14
+ const repomix_pack_1 = require("./repomix-pack");
15
+ exports.REPOMIX_APPLY_HINT_PLACEHOLDER = '__REPOMIX_APPLY_HINT__';
16
+ /**
17
+ * Resolve Repomix pack path relative to harness root: harness.yaml field first, then file existence.
18
+ */
19
+ async function resolveRepomixPackRel(harnessRoot) {
20
+ const doc = await (0, harness_yaml_baseline_1.readHarnessYamlDoc)(harnessRoot);
21
+ const fromYaml = (0, harness_yaml_baseline_1.getBaselineRepomixPackFromDoc)(doc);
22
+ if (fromYaml) {
23
+ const abs = node_path_1.default.join(harnessRoot, fromYaml);
24
+ if (await fs_extra_1.default.pathExists(abs))
25
+ return fromYaml;
26
+ }
27
+ const defaultRel = (0, repomix_pack_1.repomixPackRelFile)();
28
+ const defaultAbs = node_path_1.default.join(harnessRoot, defaultRel);
29
+ if (await fs_extra_1.default.pathExists(defaultAbs))
30
+ return defaultRel;
31
+ return undefined;
32
+ }
33
+ function renderRepomixApplyHintMarkdown(input) {
34
+ const harnessScoped = `./${input.harnessDirName}/${input.packRel.replace(/\\/g, '/')}`;
35
+ const codeScoped = `./${input.harnessDirName}/baseline/code/`;
36
+ const body = [
37
+ '**Baseline Repomix(可选快照)**',
38
+ '',
39
+ `- **路径**:\`${input.packRel}\`(项目根视角:\`${harnessScoped}\`)`,
40
+ '- **用途**:将 `baseline/code/` 打成单文件 XML,便于整库鸟瞰、单文件 attach;与 `baseline/wiki/` 互补。',
41
+ '- **优先级**:**权威参考实现仍为** `baseline/code/`(行级引用、specs 追溯、守则不变);Repomix **不得**替代对具体文件的精读,也不得作为 specs 契约来源。',
42
+ `- **读取**:优先 Read 上述 XML;上下文过大时只读相关片段,或退回 \`${codeScoped}<repo-relative>\`。`,
43
+ '- **典型场景**:架构摸底、模块边界初扫;实现细节与交付校验仍以 specs + `baseline/code/` 为准。',
44
+ ];
45
+ if (input.blockquote) {
46
+ return ['> Repomix 快照(build 已启用 `--repomix`)', ...body.map((l) => (l ? `> ${l}` : '>'))].join('\n');
47
+ }
48
+ return ['## Baseline Repomix(可选快照)', '', ...body].join('\n');
49
+ }
50
+ async function buildRepomixApplyHintReplacement(input) {
51
+ const packRel = await resolveRepomixPackRel(input.harnessRoot);
52
+ if (!packRel)
53
+ return '';
54
+ const block = renderRepomixApplyHintMarkdown({
55
+ harnessDirName: input.harnessDirName,
56
+ packRel,
57
+ blockquote: input.blockquote,
58
+ });
59
+ return `${block}\n\n`;
60
+ }
61
+ function applyRepomixHintPlaceholder(content, hint, placeholder = exports.REPOMIX_APPLY_HINT_PLACEHOLDER) {
62
+ if (content.includes(placeholder)) {
63
+ return content.replace(new RegExp(placeholder, 'g'), hint.trimEnd() ? hint : '');
64
+ }
65
+ if (!hint.trim())
66
+ return content;
67
+ return `${content.trimEnd()}\n\n${hint}`;
68
+ }
@@ -6,6 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.REPOMIX_PACK_FILENAME = exports.REPOMIX_BASELINE_REL_DIR = void 0;
7
7
  exports.repomixPackRelFile = repomixPackRelFile;
8
8
  exports.runRepomixPackBaseline = runRepomixPackBaseline;
9
+ /**
10
+ * Optional baseline snapshot via Repomix (single XML under `baseline/repomix/`).
11
+ * Enabled only when `svharness build --repomix` is passed with `--baseline`.
12
+ * Complements `baseline/code/` (per-file references); see svharnessbuild README § Repomix.
13
+ */
9
14
  const fs_extra_1 = __importDefault(require("fs-extra"));
10
15
  const node_path_1 = __importDefault(require("node:path"));
11
16
  const repomix_1 = require("repomix");
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ function buildSectionFromOpts(opts, harnessName) {
55
55
  force: opts.force,
56
56
  yes: opts.yes,
57
57
  verbose: opts.verbose,
58
+ repomix: opts.repomix,
58
59
  generateWiki: opts.generateWiki,
59
60
  wikiTasksOnly: opts.wikiTasksOnly,
60
61
  wikiLang: opts.wikiLang === 'en' ? 'en' : opts.wikiLang === 'zh' ? 'zh' : undefined,
@@ -87,6 +88,7 @@ async function runBuildAction(opts, cmd) {
87
88
  force: !!merged.force,
88
89
  yes: !!merged.yes,
89
90
  verbose: !!merged.verbose,
91
+ repomix: !!merged.repomix,
90
92
  generateWiki: !!merged.generateWiki,
91
93
  wikiTasksOnly: !!merged.wikiTasksOnly,
92
94
  wikiLang: merged.wikiLang === 'en' ? 'en' : merged.wikiLang === 'zh' ? 'zh' : undefined,
@@ -120,6 +122,7 @@ function attachBuildOptions(cmd) {
120
122
  .option('--arch <arch>', '架构模板:' + (0, validate_args_1.listSupportedArches)().join(' | '), DEFAULT_ARCH)
121
123
  .option('--agent <agent>', '目标 Agent:' + (0, validate_args_1.listSupportedAgents)().join(' | '), DEFAULT_AGENT)
122
124
  .option('--baseline <path|url>', '【可选】基线源码路径或 Git 仓库地址')
125
+ .option('--repomix', '【可选,需 --baseline】生成 baseline/repomix/repomix-pack.xml:大基线整库鸟瞰、单文件上下文工具、封存留档;日常 build 默认关闭,详见 README「Repomix」')
123
126
  .option('--requirements <path>', '【可选】需求文档输入路径(文件或目录)')
124
127
  .option('--references <path>', '【可选】参考资料输入路径(文件或目录)')
125
128
  .option('--extra-skills <path...>', '【可选】额外运行期资源(skills/rules 混放,先入 _incoming)')
@@ -231,6 +234,8 @@ function main() {
231
234
  .option('--timeout-sec <n>', '【默认 120】超时秒数', (v) => Number(v))
232
235
  .option('--type <type>', 'requirements | references')
233
236
  .option('--force', '覆盖已存在同名 .md')
237
+ .option('--split-sheets-suffix <suffix>', 'xlsx/xls 按 sheet(##)拆分时的子目录后缀', '_split')
238
+ .option('--no-split-sheets', '不将 xlsx/xls 合并 md 按 ## 拆分为多文件')
234
239
  .option('-y, --yes', '跳过交互确认')
235
240
  .option('--verbose', '显示详细日志')
236
241
  .action(async (opts, cmd) => {
@@ -246,6 +251,8 @@ function main() {
246
251
  timeoutSec: opts.timeoutSec,
247
252
  type: opts.type,
248
253
  force: opts.force,
254
+ splitSheetsSuffix: opts.splitSheetsSuffix,
255
+ noSplitSheets: opts.noSplitSheets,
249
256
  yes: opts.yes,
250
257
  verbose: opts.verbose,
251
258
  }, loaded?.config.convert, loaded?.config.defaults, cmd);
@@ -259,6 +266,8 @@ function main() {
259
266
  timeoutSec: merged.timeoutSec,
260
267
  type: merged.type,
261
268
  force: !!merged.force,
269
+ splitSheets: merged.noSplitSheets ? false : merged.splitSheets,
270
+ splitSheetsSuffix: merged.splitSheetsSuffix,
262
271
  yes: !!merged.yes,
263
272
  verbose: !!merged.verbose,
264
273
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svharness",
3
- "version": "0.14.5",
3
+ "version": "0.14.7",
4
4
  "description": "CLI scaffolder for SDD-Driven Agent-Agnostic Coding Framework (harness)",
5
5
  "bin": {
6
6
  "svharness": "./bin/cli.js",
@@ -21,6 +21,8 @@ description: >
21
21
  4. 不修改 `__HARNESS_ROOT_REL__` 下的任何文件;harness 作为只读知识源。
22
22
  5. 入口链路以项目根 AI 入口文档为准:`AGENTS.md` 或 `CLAUDE.md`。
23
23
 
24
+ __REPOMIX_APPLY_HINT__
25
+
24
26
  ## 典型触发
25
27
 
26
28
  - 应用 harness-apply-skills-main 完成 xxx 功能开发
@@ -28,6 +28,7 @@
28
28
  - `description` 必须覆盖 `source_excerpt` 里的条件、数值、枚举、时序,不得退化为标题短语。
29
29
  - 推荐使用长度启发式审查:`description` 明显短于 `source_excerpt`(如 < 50%)时标记风险并复核。
30
30
  - `title` 仅用于索引,禁止用 `title` 替代完整需求描述。
31
+ - `source_file` 必须指向 Agent 可读文本:优先 `requirements/md/<同名>.md`(convert 产物),或 `requirements/raw/` 下的 md/txt/代码文件;禁止引用 xlsx/pdf/docx 等难解析格式。
31
32
 
32
33
  5. **覆盖率报告是 S40 必备产物**
33
34
  - 必须生成 `requirements/coverage-report.yaml`。
@@ -28,7 +28,7 @@ specs/
28
28
  - `source_excerpt`: 源文档原文摘录(可多行)
29
29
  - `description`: 完整需求陈述(不得弱于 source_excerpt)
30
30
  - `source_anchor`: 可追踪源锚点(如 `SyRD-*`、章节+行号)
31
- - `source_file`: 源文件路径(通常在 `requirements/raw/`)
31
+ - `source_file`: Agent 可读的文本源路径——`requirements/md/<同名>.md`(convert 产物)或 `requirements/raw/` 下的 md/txt/代码文件;禁止引用 xlsx/pdf/docx 等难解析格式
32
32
  - `source_section`: 源章节或表格区域
33
33
  - `aggregates`: 聚合锚点列表(仅在显式聚合时)
34
34
  - `aggregation_reason`: 聚合原因(聚合时必填)
@@ -53,7 +53,9 @@ items:
53
53
  允许整理语序,但信息集合不得缩小。
54
54
  acceptance: "如何验证该需求满足"
55
55
  source_anchor: "SyRD-..."
56
- source_file: "requirements/raw/<file>"
56
+ # AI 可读文本源(二选一,禁止引用 xlsx/pdf/docx 等难解析格式):
57
+ source_file: "requirements/md/<file>.md" # 原始为 pdf/xlsx/docx 等 → 引用 convert 产物
58
+ # source_file: "requirements/raw/<file>.md" # 或 raw 下本身可读的 md/txt/代码文件
57
59
  source_section: "01_Function Spec"
58
60
  aggregates: []
59
61
  waived: false
@@ -68,7 +70,11 @@ items:
68
70
  - `source_excerpt` 必须来自源文档原文;禁止写成「见上文」「同前」等替代语。
69
71
  - `description` 必须覆盖 `source_excerpt` 的关键约束;不得删减条件、数值、枚举、时序。
70
72
  - `traces_to` 初始可为空,阶段 B 生成 specs 后回填。
71
- - `source_doc` 与 `source_file` 必须引用真实源文件。
73
+ - `source_doc` 记录原始入库路径(`requirements/raw/<原始文件名>`),用于资产追溯。
74
+ - `source_file` 必须指向 **Agent 可直接读取的文本源**,路径二选一:
75
+ - `requirements/md/<同名>.md` —— 原始为 pdf/xlsx/docx 等时,引用 S30 convert 后的 Markdown;
76
+ - `requirements/raw/<file>` —— 原始本身即为 `.md`、`.txt` 或源代码文件(如 `.kt`、`.xml`、`.proto` 等)。
77
+ - **禁止**将 `requirements/raw/` 下的 xlsx、pdf、docx、图片等 AI 难处理格式写入 `source_file`。
72
78
  - 聚合仅在用户确认后允许,且必须带 `aggregates + aggregation_reason`。
73
79
  - 产出后必须校验 `requirements/yaml/schema.json`(若存在);失败不得进入 S50。
74
80
 
@@ -93,7 +99,8 @@ items:
93
99
  extraction_strategy: md_primary
94
100
  generated_at: "..."
95
101
  sources:
96
- - file: "requirements/raw/<file>"
102
+ - file: "requirements/md/<file>.md" # 或 requirements/raw/<可读文本文件>
103
+ source_doc: "requirements/raw/<原始文件>" # 可选,保留原始资产路径
97
104
  anchors_total: 100
98
105
  anchors_mapped: 96
99
106
  anchors_waived: 4
@@ -100,6 +100,7 @@ __BUILD_MAIN_BRIDGE_HINT__
100
100
  |------|------|------|
101
101
  | `baseline/wiki/` | 项目知识摘要,优先建立上下文 | 只读 |
102
102
  | `baseline/code/` | 权威参考实现 | 只读 |
103
+ | `baseline/repomix/repomix-pack.xml` | 可选:Repomix 整库 XML 快照(仅 build 启用 `--repomix` 时存在;见 `harness.yaml` → `baseline.repomix_pack`) | 只读 |
103
104
  | `specs/signals/` | 信号/协议规格 | 只读 |
104
105
  | `specs/ui/` | UI 规格 | 只读 |
105
106
  | `specs/behavior/` | 行为规格 | 只读 |
@@ -108,6 +109,10 @@ __BUILD_MAIN_BRIDGE_HINT__
108
109
 
109
110
  > `baseline/code/` 是“参考实现”的权威来源。
110
111
  > 你仍需阅读并修改目标项目业务源码来完成交付,但不得把 `baseline/code/` 之外的路径当作规范样本来源。
112
+ >
113
+ > 若存在 `baseline/repomix/repomix-pack.xml`,清单字段为 `harness.yaml` → `baseline.repomix_pack`;`svharness apply` 会在项目根入口与本 skill 中注入详细用法。
114
+
115
+ __REPOMIX_APPLY_HINT__
111
116
 
112
117
  ### 4.3 编码执行
113
118
 
@@ -43,6 +43,7 @@ references: # 参考资料(不参与条目化)
43
43
  baseline:
44
44
  code: baseline/code/ # 基线代码快照(参考样本)
45
45
  wiki: baseline/wiki/ # 基线工程 wiki
46
+ # repomix_pack: baseline/repomix/repomix-pack.xml # 仅 --repomix 成功后由 CLI 写入
46
47
 
47
48
  # Agent 运行时环境(规则、技能、工具、记忆)。
48
49
  # incoming_* 仅构建期:`svharness build --extra-skills` 将资源暂存于此并生成 manifest,S65 确认后分流写入 rules/skills。
@@ -49,7 +49,11 @@
49
49
  "description": { "type": "string", "minLength": 1 },
50
50
  "acceptance": { "type": "string", "minLength": 1 },
51
51
  "source_anchor": { "type": "string", "minLength": 1 },
52
- "source_file": { "type": "string", "minLength": 1 },
52
+ "source_file": {
53
+ "type": "string",
54
+ "minLength": 1,
55
+ "description": "Agent 可读文本源:requirements/md/<同名>.md(convert 产物)或 requirements/raw/ 下的 md/txt/代码文件;禁止 xlsx/pdf/docx 等"
56
+ },
53
57
  "source_section": { "type": "string", "minLength": 1 },
54
58
  "aggregates": {
55
59
  "type": "array",
@@ -20,6 +20,9 @@ build:
20
20
  referencesNote: 设计稿与接口文档
21
21
  extraSkills:
22
22
  - ./team-skills/custom-skill
23
+ # Repomix:将 baseline/code 打成单文件 baseline/repomix/repomix-pack.xml(默认 false)
24
+ # 适合:大基线整库鸟瞰、外部单文件上下文工具、封存留档;日常 harness-build 不必开
25
+ repomix: false
23
26
  generateWiki: false
24
27
  wikiLang: zh
25
28
  force: false