sillyspec 3.32.9 → 3.33.0

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 (176) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  3. package/.claude/skills/sillyspec-explore/SKILL.md +107 -107
  4. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  6. package/.github/workflows/ci.yml +58 -0
  7. package/.husky/pre-push +7 -6
  8. package/AGENTS.md +1 -1
  9. package/assets/command-cards/flow.md +1 -1
  10. package/eval/eval.config.json +3 -3
  11. package/eval/lib/config.mjs +6 -3
  12. package/eval/lib/util.mjs +16 -3
  13. package/eval/lib/workdir.mjs +4 -1
  14. package/eval/results/eval.db +0 -0
  15. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/prompt.txt +10 -0
  16. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stderr.txt +0 -0
  17. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stdout.txt +1 -0
  18. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/prompt.txt +10 -0
  19. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stderr.txt +0 -0
  20. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stdout.txt +1 -0
  21. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/prompt.txt +10 -0
  22. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stderr.txt +0 -0
  23. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stdout.txt +1 -0
  24. package/eval/results/logs/2026-10-15T13-00-05-625Z-004-phase-stats-armB/prompt.txt +10 -0
  25. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/prompt.txt +10 -0
  26. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stderr.txt +0 -0
  27. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stdout.txt +1 -0
  28. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/prompt.txt +10 -0
  29. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stderr.txt +0 -0
  30. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stdout.txt +1 -0
  31. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/prompt.txt +10 -0
  32. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stderr.txt +0 -0
  33. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stdout.txt +1 -0
  34. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/prompt.txt +8 -0
  35. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stderr.txt +0 -0
  36. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stdout.txt +1 -0
  37. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/prompt.txt +8 -0
  38. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stderr.txt +0 -0
  39. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stdout.txt +1 -0
  40. package/eval/results/workdirs/001-slugify__armA/package.json +6 -0
  41. package/eval/results/workdirs/001-slugify__armA/src/slugify.mjs +26 -0
  42. package/eval/results/workdirs/001-slugify__armB/package.json +6 -0
  43. package/eval/results/workdirs/001-slugify__armB/src/slugify.mjs +26 -0
  44. package/eval/results/workdirs/002-each-day__armA/package.json +6 -0
  45. package/eval/results/workdirs/002-each-day__armA/src/dates.mjs +30 -0
  46. package/eval/results/workdirs/002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  47. package/eval/results/workdirs/002-each-day__armB/package.json +6 -0
  48. package/eval/results/workdirs/002-each-day__armB/src/dates.mjs +4 -0
  49. package/eval/results/workdirs/003-word-report__armA/bin/report.mjs +36 -0
  50. package/eval/results/workdirs/003-word-report__armA/data/notes.txt +5 -0
  51. package/eval/results/workdirs/003-word-report__armA/package.json +6 -0
  52. package/eval/results/workdirs/003-word-report__armB/bin/report.mjs +36 -0
  53. package/eval/results/workdirs/003-word-report__armB/data/notes.txt +5 -0
  54. package/eval/results/workdirs/003-word-report__armB/package.json +6 -0
  55. package/eval/results/workdirs/004-phase-stats__armA/data/events.log +16 -0
  56. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/flow.md +44 -0
  57. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-archive.md +27 -0
  58. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-brainstorm.md +34 -0
  59. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-execute.md +30 -0
  60. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-plan.md +28 -0
  61. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-verify.md +30 -0
  62. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/status.md +28 -0
  63. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-archive/SKILL.md +87 -0
  64. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-auto/SKILL.md +54 -0
  65. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -0
  66. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-commit/SKILL.md +67 -0
  67. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-continue/SKILL.md +17 -0
  68. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-doctor/SKILL.md +76 -0
  69. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-execute/SKILL.md +222 -0
  70. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-explore/SKILL.md +107 -0
  71. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-export/SKILL.md +57 -0
  72. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-flow/SKILL.md +62 -0
  73. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-init/SKILL.md +177 -0
  74. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-knowledge/SKILL.md +40 -0
  75. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-plan/SKILL.md +156 -0
  76. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-propose/SKILL.md +16 -0
  77. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-quick/SKILL.md +116 -0
  78. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-resume/SKILL.md +21 -0
  79. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-scan/SKILL.md +79 -0
  80. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-state/SKILL.md +25 -0
  81. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-status/SKILL.md +54 -0
  82. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-verify/SKILL.md +87 -0
  83. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-workspace/SKILL.md +33 -0
  84. package/eval/results/workdirs/004-phase-stats__armB/AGENTS.md +45 -0
  85. package/eval/results/workdirs/004-phase-stats__armB/CLAUDE.md +3 -0
  86. package/eval/results/workdirs/004-phase-stats__armB/data/events.log +16 -0
  87. package/eval/results/workdirs/run-001-slugify__armA/package.json +6 -0
  88. package/eval/results/workdirs/run-001-slugify__armA/src/slugify.mjs +26 -0
  89. package/eval/results/workdirs/run-001-slugify__armB/package.json +6 -0
  90. package/eval/results/workdirs/run-001-slugify__armB/src/slugify.mjs +26 -0
  91. package/eval/results/workdirs/run-002-each-day__armA/package.json +6 -0
  92. package/eval/results/workdirs/run-002-each-day__armA/src/dates.mjs +30 -0
  93. package/eval/results/workdirs/run-002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  94. package/eval/results/workdirs/run-002-each-day__armB/package.json +6 -0
  95. package/eval/results/workdirs/run-002-each-day__armB/src/dates.mjs +4 -0
  96. package/eval/results/workdirs/run-003-word-report__armA/bin/report.mjs +36 -0
  97. package/eval/results/workdirs/run-003-word-report__armA/data/notes.txt +5 -0
  98. package/eval/results/workdirs/run-003-word-report__armA/package.json +6 -0
  99. package/eval/results/workdirs/run-003-word-report__armB/bin/report.mjs +36 -0
  100. package/eval/results/workdirs/run-003-word-report__armB/data/notes.txt +5 -0
  101. package/eval/results/workdirs/run-003-word-report__armB/package.json +6 -0
  102. package/eval/results/workdirs/run-004-phase-stats__armA/bin/phase-stats.mjs +37 -0
  103. package/eval/results/workdirs/run-004-phase-stats__armA/data/events.log +16 -0
  104. package/eval/results/workdirs/run-004-phase-stats__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  105. package/eval/results/workdirs/run-004-phase-stats__armB/data/events.log +16 -0
  106. package/eval/results/workdirs/run-005-orders-export__armA/bin/export-orders.mjs +57 -0
  107. package/eval/results/workdirs/run-005-orders-export__armA/data/.gitattributes +1 -0
  108. package/eval/results/workdirs/run-005-orders-export__armA/data/orders.csv +9 -0
  109. package/eval/results/workdirs/run-005-orders-export__armA/out/summary.json +1 -0
  110. package/eval/results/workdirs/run-005-orders-export__armB/bin/export-orders.mjs +57 -0
  111. package/eval/results/workdirs/run-005-orders-export__armB/data/.gitattributes +1 -0
  112. package/eval/results/workdirs/run-005-orders-export__armB/data/orders.csv +9 -0
  113. package/eval/results/workdirs/run-005-orders-export__armB/out/summary.json +1 -0
  114. package/eval/tasks/004-phase-stats/files/data/events.log +16 -0
  115. package/eval/tasks/004-phase-stats/solution/bin/phase-stats.mjs +37 -0
  116. package/eval/tasks/004-phase-stats/task.json +6 -0
  117. package/eval/tasks/004-phase-stats/verify.mjs +38 -0
  118. package/eval/tasks/005-orders-export/files/data/.gitattributes +1 -0
  119. package/eval/tasks/005-orders-export/files/data/orders.csv +9 -0
  120. package/eval/tasks/005-orders-export/solution/bin/export-orders.mjs +57 -0
  121. package/eval/tasks/005-orders-export/task.json +6 -0
  122. package/eval/tasks/005-orders-export/verify.mjs +28 -0
  123. package/package.json +1 -1
  124. package/quicklog/tmp-fix-review.mjs +21 -0
  125. package/src/ceremony-tier.js +2 -1
  126. package/src/commit-suggest.js +45 -4
  127. package/src/config-schema.js +14 -0
  128. package/src/decision-distill.js +106 -24
  129. package/src/decision-redomain.js +148 -0
  130. package/src/decision-renumber.js +157 -0
  131. package/src/design-facts.js +3 -3
  132. package/src/docs-check.js +30 -15
  133. package/src/docs-debt.js +6 -5
  134. package/src/doctor-diagnostics.js +3 -1
  135. package/src/flow-parity.js +28 -4
  136. package/src/flow.js +76 -13
  137. package/src/forensics.js +236 -0
  138. package/src/fr-index.js +17 -2
  139. package/src/hooks/worktree-guard.js +3 -3
  140. package/src/index.js +41 -6
  141. package/src/knowledge-digest.js +23 -6
  142. package/src/knowledge-graph.js +95 -18
  143. package/src/knowledge-match.js +34 -9
  144. package/src/knowledge-stats.js +12 -13
  145. package/src/knowledge-vector.js +98 -7
  146. package/src/machine-interface.js +26 -0
  147. package/src/module-impact.js +2 -5
  148. package/src/modules.js +31 -0
  149. package/src/quicklog.js +6 -1
  150. package/src/run/archive-distill.js +3 -0
  151. package/src/run/command.js +3 -3
  152. package/src/run/complete-handlers.js +8 -3
  153. package/src/run/complete.js +113 -44
  154. package/src/run/gates.js +100 -103
  155. package/src/run/lint-ownership.js +37 -0
  156. package/src/run/prompt.js +43 -28
  157. package/src/run/stage.js +1 -1
  158. package/src/run/verify-quality-scan.js +10 -1
  159. package/src/sentinel-assertions.js +43 -1
  160. package/src/spec-sync.js +81 -2
  161. package/src/stage-contract.js +26 -3
  162. package/src/stage-review.js +46 -12
  163. package/src/stage-templates.js +2 -0
  164. package/src/stages/brainstorm.js +76 -21
  165. package/src/stages/knowledge.js +56 -5
  166. package/src/stages/plan-postcheck.js +13 -2
  167. package/src/stages/plan.js +19 -0
  168. package/src/sync.js +28 -2
  169. package/src/task-tick.js +12 -0
  170. package/src/test-bindings.js +32 -3
  171. package/src/timeline.js +60 -19
  172. package/src/ui-visual.js +119 -4
  173. package/src/verify-postcheck.js +166 -18
  174. package/src/verify-probes.js +61 -18
  175. package/src/worktree-apply.js +26 -2
  176. package/templates/agents-instruction.md +1 -1
@@ -22,6 +22,7 @@
22
22
  * 零输出:无 decisions.md 或无入选条目 → skipped 带原因,不写任何文件、不动 INDEX。
23
23
  */
24
24
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
25
+ import { modulePathMatches } from './modules.js'
25
26
  import { basename, dirname, join } from 'node:path'
26
27
 
27
28
  /** FR-02:有实现影响的五类 type(scope 与未知 type 的 confirmed/accepted 不入选) */
@@ -36,7 +37,29 @@ const UNMAPPED_DOMAIN = 'unmapped'
36
37
  /** 旧格式容错占位值(FR-01:缺锚点/缺 headHash 不阻断) */
37
38
  const NOT_RECORDED = '未记录'
38
39
 
39
- /** 多行/带空白值压成单行字段值(条目行是单行契约) */
40
+ /**
41
+ * 锚点自动回填候选(2026-10-15-decision-anchor-backfill 单一源;收割端 flow-parity 与
42
+ * 蒸馏渲染端共用)。条目正文(答案/理由散文)中的路径形态 token——「恰一个唯一」即高置信
43
+ * 落点,返回该 token;多候选/无 token 返回 null(保守:错锚比无锚更误导 impact 查询与
44
+ * 悬空检查)。收益率探针(2026-10-15):恰一路径口径救 28%(本仓 174/616、平台 150/543)。
45
+ * 过滤黑名单(存量干跑实测坏锚类):知识治理面(.sillyspec/)、运行时面(.runtime/)、
46
+ * 归档与变更工件(changes/、allowed_paths 段)、知识互引(knowledge/、decisions/、fr/)、
47
+ * 消费仓坑文档目录(docs/sillyspec/)、前导 / 路径碎片、遥测日志扩展(.jsonl/.log)、
48
+ * repo:// 跨仓协议前缀剥除。token 须带扩展名收尾(:行号 形态锚点契约原生支持)。
49
+ * @param {string} text 条目正文拼接(答案/理由/question 等)
50
+ * @returns {string|null}
51
+ */
52
+ export function anchorBackfillCandidate(text) {
53
+ const raw = String(text || '').match(/[A-Za-z0-9_\-.\\/:]+/g) || []
54
+ const pathed = [...new Set(raw
55
+ .map((t) => t.replace(/\\/g, '/').replace(/^repo:\/\//, '').replace(/\/+$/, '').replace(/:$/, ''))
56
+ .filter((t) => t.includes('/')
57
+ && /\.[A-Za-z0-9]{1,10}(:\d+)?$/.test(t)
58
+ && !/^(\.sillyspec|\.runtime|changes|knowledge|decisions|fr|docs\/sillyspec|allowed_paths)(\/|$)/.test(t)
59
+ && !t.startsWith('/')
60
+ && !/\.(jsonl|log)$/i.test(t)))]
61
+ return pathed.length === 1 ? pathed[0] : null
62
+ }/** 多行/带空白值压成单行字段值(条目行是单行契约) */
40
63
  function oneLine(s) {
41
64
  return String(s ?? '').replace(/\s*\r?\n\s*/g, ' ').trim()
42
65
  }
@@ -274,7 +297,7 @@ function matchImpactsToModules(impacts, moduleIndex) {
274
297
  const hits = []
275
298
  for (const t of tokens) {
276
299
  for (const { id, p } of modPaths) {
277
- if (t === p || t.startsWith(p + '/')) { if (!hits.includes(id)) hits.push(id); break }
300
+ if (modulePathMatches(t, p)) { if (!hits.includes(id)) hits.push(id); break }
278
301
  }
279
302
  }
280
303
  if (hits.length === 0) {
@@ -313,11 +336,17 @@ function newDomainPreamble(domain) {
313
336
  * 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
314
337
  * 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
315
338
  * docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
316
- function renderBlockLines(entry, headHash, supersedesNote, changeName) {
317
- const lines = [`## ${entry.id} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
339
+ function renderBlockLines(entry, headHash, supersedesNote, changeName, sectionNumber, originId) {
340
+ const lines = [`## ${sectionNumber}@v${entry.version} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
318
341
  lines.push(`状态:${entry.selected}`)
319
342
  if (changeName) lines.push(`变更:${oneLine(changeName)}`)
320
- lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
343
+ // 原编号(2026-10-14-decision-global-id-epoch):epoch 形态溯源字段——变更行后/锚点行前
344
+ // (不破「文件:紧跟锚点」行序红线);legacy 复用段(originId=null)不渲染,形态零变化
345
+ if (originId) lines.push(`原编号:${oneLine(originId)}`)
346
+ // 锚点自动回填(2026-10-15-decision-anchor-backfill):源条目未填锚点时,从条目正文
347
+ // (答案/理由)提取路径形态 token——「恰一个唯一」即高置信落点(正文明确只谈一个文件),
348
+ // 多候选/无 token 保持未记录(保守:错锚比无锚更误导 impact 查询与悬空检查)。
349
+ lines.push(`锚点:${oneLine(entry.anchor) || anchorBackfillCandidate([entry.answer, entry.reason, entry.normalizedRequirement, entry.question].join('\n')) || NOT_RECORDED}`)
321
350
  // 文件行(FR-01/D-001@v1):仅非空渲染——存量条目零迁移,幂等重归档不添空行
322
351
  if (entry.files && entry.files.length > 0) lines.push(`文件:${entry.files.join(', ')}`)
323
352
  lines.push(`最近确认:${oneLine(headHash) || NOT_RECORDED}`)
@@ -336,12 +365,14 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
336
365
  }
337
366
 
338
367
  /** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
339
- * 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
340
- * 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。
368
+ * 段的变更归属取段内 `变更:<name>` 行,「来源:<name>` 兜底(2026-10-14-decision-id-p2-hardening:
369
+ * 08-24 首修形态残留——读出后带来源的 legacy 段参与同变更同号匹配/supersede,重蒸馏在位更新收敛);
370
+ * 无任何限定行的历史条目 → change=null(legacy:升级前落库的条目,不参与新条目的同号匹配——只共存不误删)。
341
371
  * 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
342
372
  * 正则与恒等 id),decisions 调用点零改动。 */
343
373
  export function splitKnowledgeSections(content, opts = {}) {
344
- const sectionRegex = opts.sectionRegex || /^## (D-\d+)@v(\d+)\s*(.*)$/
374
+ // 节头号双形态(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1 并存
375
+ const sectionRegex = opts.sectionRegex || /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
345
376
  const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
346
377
  const sections = []
347
378
  const preamble = []
@@ -359,8 +390,15 @@ export function splitKnowledgeSections(content, opts = {}) {
359
390
  if (cur) sections.push(cur)
360
391
  for (const s of sections) {
361
392
  s.id = buildId(s.number, s.version)
362
- const cm = s.lines.find(l => /^变更[::]/.test(l))
363
- s.change = cm ? cm.replace(/^变更[::]\s*/, '').trim() || null : null
393
+ // 「变更:」优先、「来源:」兜底(行内前缀按命中行剥除;无任何限定行 → null 不互认)
394
+ const cm = s.lines.find(l => /^变更[::]/.test(l)) || s.lines.find(l => /^来源[::]/.test(l))
395
+ s.change = cm ? cm.replace(/^(变更|来源)[::]\s*/, '').trim() || null : null
396
+ // 原编号(2026-10-14-decision-global-id-epoch 溯源字段):「原编号:D-011@v1」→ origin/originNumber;
397
+ // legacy 段无该行 → null(幂等匹配谓词按号兜底)
398
+ const oi = s.lines.find(l => /^原编号[::]/.test(l))
399
+ s.origin = oi ? oi.replace(/^原编号[::]\s*/, '').trim() || null : null
400
+ const on = s.origin ? /^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v?\d+/.exec(s.origin) : null
401
+ s.originNumber = on ? on[1] : null
364
402
  }
365
403
  return { preamble, sections }
366
404
  }
@@ -385,14 +423,23 @@ export function joinKnowledgeFile(preamble, sections) {
385
423
  * 升级前落库)不与任何新条目互认,只共存不误删。
386
424
  * @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
387
425
  */
388
- function applyEntryToSections(sections, entry, headHash) {
426
+ /** 同变更同段匹配谓词(2026-10-14-decision-global-id-epoch 扩展):变更一致 且
427
+ * (段号==条目局部号 或 段.原编号号段==条目局部号)——legacy 段按号匹配、epoch 段按原编号匹配。
428
+ * @returns {{action: 'append'|'update'|'supersede'}|null} null = 同变更同段高版本已落库,迟到低版本不降级。 */
429
+ function sameDecisionNumber(s, entry) {
430
+ return (s.change || null) === (entry.change || null) && (s.number === entry.number || s.originNumber === entry.number)
431
+ }
432
+
433
+ function applyEntryToSections(sections, entry, headHash, sectionNumber, originId) {
389
434
  const change = entry.change || null
390
- const sameNumber = sections.filter(s => s.number === entry.number && (s.change || null) === change)
435
+ const sameNumber = sections.filter(s => sameDecisionNumber(s, entry))
391
436
  if (sameNumber.some(s => s.version > entry.version)) return null
392
437
  let action
393
438
  let supersedesNote = oneLine(entry.supersedes) || null
439
+ const sectionId = `${sectionNumber}@v${entry.version}`
440
+ const originNumber = originId ? (/^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)/.exec(originId) || [])[1] || null : null
394
441
  if (sameNumber.length === 0) {
395
- sections.push({ number: entry.number, version: entry.version, id: entry.id, title: entry.title, change, lines: renderBlockLines(entry, headHash, supersedesNote, change || undefined) })
442
+ sections.push({ number: sectionNumber, version: entry.version, id: sectionId, title: entry.title, change, origin: originId, originNumber, lines: renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId) })
396
443
  action = 'append'
397
444
  } else {
398
445
  const same = sameNumber.find(s => s.version === entry.version)
@@ -400,10 +447,14 @@ function applyEntryToSections(sections, entry, headHash) {
400
447
  if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
401
448
  slot.version = entry.version
402
449
  slot.title = entry.title
403
- slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined)
404
- // 同变更同号旧版本段(< 新版本)就地清除——同文件内同变更同号只留一个段
450
+ slot.number = sectionNumber
451
+ slot.id = sectionId
452
+ slot.origin = originId
453
+ slot.originNumber = originNumber
454
+ slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId)
455
+ // 同变更同段(号或原编号匹配)旧版本就地清除——同文件内同变更同段只留一个段
405
456
  for (let i = sections.length - 1; i >= 0; i--) {
406
- if (sections[i].number === entry.number && (sections[i].change || null) === change && sections[i] !== slot) sections.splice(i, 1)
457
+ if (sameDecisionNumber(sections[i], entry) && sections[i] !== slot) sections.splice(i, 1)
407
458
  }
408
459
  action = same ? 'update' : 'supersede'
409
460
  }
@@ -577,31 +628,62 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
577
628
  }
578
629
 
579
630
  const written = []
631
+ // 域全集(跨文件清理与复用扫描共用;磁盘 ∪ 本批已加载)
632
+ let onDisk = []
633
+ try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
634
+ let allDomains = [...new Set([...onDisk, ...store.keys()])]
635
+ // 发号器(2026-10-14-decision-global-id-epoch):域前缀全域 max+1 只增不复用;mintMax 批内会话
636
+ // 计数防同批撞号(磁盘扫描只做初值);并发蒸馏撞号窗口与 FR 侧发号同病(接受,doctor 可见)。
637
+ const mintMax = new Map()
638
+ const mintFor = (domain) => {
639
+ if (!mintMax.has(domain)) {
640
+ let max = 0
641
+ for (const f of onDisk) {
642
+ let c; try { c = readFileSync(join(decisionsDir, f.endsWith('.md') ? f : `${f}.md`), 'utf8') } catch { continue }
643
+ const re = /^## D-([A-Za-z0-9_\-\u4e00-\u9fff]+?)-(\d+)@v\d+/gm
644
+ let m
645
+ while ((m = re.exec(c)) !== null) if (m[1] === domain) max = Math.max(max, parseInt(m[2], 10))
646
+ }
647
+ mintMax.set(domain, max)
648
+ }
649
+ const next = mintMax.get(domain) + 1
650
+ mintMax.set(domain, next)
651
+ return `D-${domain}-${String(next).padStart(3, '0')}`
652
+ }
580
653
  const targetSets = new Map() // entry → Set(域)(跨文件清理时排除目标文件)
581
654
  for (const entry of batch) {
582
655
  const domains = resolveDomains(entry, idx)
583
656
  targetSets.set(entry, new Set(domains))
657
+ // 号决策(2026-10-14-decision-global-id-epoch):同变更同段存量(全域文件扫描,号或原编号匹配)
658
+ // 复用其号——legacy 段保持局部形态(不补原编号不改前缀,存量零触碰);epoch 段原编号随版本更新;
659
+ // 无匹配 → 发号域=首域(resolveDomains 已 sanitize)批内 max+1,扇出多文件同一号(单一身份)。
660
+ let reuse = null
661
+ for (const d of allDomains) {
662
+ const st = loadDomain(d)
663
+ reuse = st.sections.find(s => sameDecisionNumber(s, entry)) || null
664
+ if (reuse) break
665
+ }
666
+ const sectionNumber = reuse ? reuse.number : mintFor(domains[0])
667
+ const originId = reuse && !reuse.origin ? null : `${entry.number}@v${entry.version}`
584
668
  for (const d of domains) {
585
669
  const st = loadDomain(d)
586
- const res = applyEntryToSections(st.sections, entry, headHash)
670
+ const res = applyEntryToSections(st.sections, entry, headHash, sectionNumber, originId)
587
671
  if (res) {
588
672
  st.dirty = true
589
- written.push({ file: `decisions/${d}.md`, id: entry.id, action: res.action })
673
+ written.push({ file: `decisions/${d}.md`, id: `${sectionNumber}@v${entry.version}`, action: res.action })
590
674
  }
591
675
  }
676
+ allDomains = [...new Set([...allDomains, ...domains])]
592
677
  }
593
678
 
594
- // 跨文件清理:同变更同号 ≤ 新版本的旧段散落其他域文件时移除(同变更同号全局只留最高
595
- // 版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
596
- let onDisk = []
597
- try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
598
- const allDomains = [...new Set([...onDisk, ...store.keys()])]
679
+ // 跨文件清理:同变更同段(号或原编号匹配)≤ 新版本的旧段散落其他域文件时移除(同变更同段
680
+ // 全局只留最高版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
599
681
  for (const entry of batch) {
600
682
  const targets = targetSets.get(entry)
601
683
  for (const d of allDomains) {
602
684
  if (targets.has(d)) continue
603
685
  const st = loadDomain(d)
604
- const removed = st.sections.filter(s => s.number === entry.number && s.version <= entry.version && (s.change || null) === (entry.change || null))
686
+ const removed = st.sections.filter(s => s.version <= entry.version && sameDecisionNumber(s, entry))
605
687
  if (removed.length === 0) continue
606
688
  st.sections = st.sections.filter(s => !removed.includes(s))
607
689
  st.dirty = true
@@ -0,0 +1,148 @@
1
+ /**
2
+ * decision-redomain.js — 决策条目跨域迁移(2026-10-15-decision-redomain)
3
+ *
4
+ * 背景:模块路径 glob 匹配失效(2026-10-15-module-path-glob-match)导致消费仓历史决策
5
+ * 大量落 decisions/unmapped.md(平台仓 442 条实证)——匹配器修复止增量后,存量需机器
6
+ * 通道清偿。redomain.js 只管 fr/ 条目;本模块为 decisions/ 面的孪生实现,复用
7
+ * decision-distill 参数化底座(splitKnowledgeSections/joinKnowledgeFile/syncIndexRoutingLines)。
8
+ *
9
+ * 与 FR 版的形态差异:
10
+ * - 段头 `## D-xxx@vN 标题`(全局号/局部号双形态混存)——默认 sectionRegex 即此格式;
11
+ * id = `<number>@v<version>`(迁域不换号,字段行内「原编号」等机器行随段整体搬)。
12
+ * - 跨变更同号合法共存(D 号是变更内局部序号)——冲突键 = id + 变更(目标域已有
13
+ * 同 id 同变更段 = 迁移语义不明,跳该段并报告,不覆盖)。
14
+ * - 路由行形态与 fr 不同(`- <域>|decision|决策 → [decisions/<域>.md]`)——syncIndexRoutingLines
15
+ * 默认参数即 decisions 面。
16
+ *
17
+ * 幂等:重跑同批——源域已无该段 → 0 条(或 anchor/byChange 无命中报错,与 FR 版同款)。
18
+ */
19
+ import { existsSync, readFileSync, unlinkSync } from 'fs'
20
+ import { join } from 'path'
21
+ import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines } from './decision-distill.js'
22
+ import { writeAtomicSync } from './fs-atomic.js'
23
+
24
+ const DECISION_HEADER_RE = /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
25
+
26
+ /** 目标域文件头(与 decision-distill 落域文件既有头逐字同款——平台真实库实测形态) */
27
+ function newDomainPreamble(domain) {
28
+ return [
29
+ `# 决策知识 — ${domain}`,
30
+ '',
31
+ '> decision-distill 从变更 decisions.md 幂等提炼(「最近确认」= 归档时 HEAD)。条目字段行为 docs-check 机械解析契约,勿手改。',
32
+ '',
33
+ ]
34
+ }
35
+
36
+ /** 段 → {id, number, version, title, change}(id=number@version 即迁域不换号的身份) */
37
+ function sectionMeta(s) {
38
+ const m = DECISION_HEADER_RE.exec(s.lines[0] || '')
39
+ if (!m) return null
40
+ const id = `${m[1]}@v${m[2]}`
41
+ const cm = s.lines.find((l) => /^变更[::]/.test(l)) || s.lines.find((l) => /^来源[::]/.test(l))
42
+ const change = cm ? cm.replace(/^(变更|来源)[::]\s*/, '').trim() || null : null
43
+ return { id, number: m[1], version: parseInt(m[2], 10), title: (m[3] || '').trim(), change }
44
+ }
45
+
46
+ function normalizeDomainArg(v) {
47
+ return String(v ?? '').trim().replace(/^decisions\//i, '').replace(/\.md$/i, '').trim()
48
+ }
49
+
50
+ /** 干跑预览:列出将迁移条目,不落盘(anchor/byChange 同执行侧过滤) */
51
+ export function planDecisionRedomain({ knowledgeRoot, from, to, anchors = null, byChange = null }) {
52
+ from = normalizeDomainArg(from)
53
+ to = normalizeDomainArg(to)
54
+ if (!from || !to) throw new Error('用法:--from <源域> --to <目标域>(域名可带 decisions/ 前缀与 .md 后缀)')
55
+ if (from === to) throw new Error('源域与目标域相同')
56
+ const dir = join(knowledgeRoot, 'decisions')
57
+ const fromPath = join(dir, `${from}.md`)
58
+ if (!existsSync(fromPath)) throw new Error(`源域文件不存在:decisions/${from}.md`)
59
+ const parsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'))
60
+ const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
61
+ const entries = []
62
+ for (const s of parsed.sections) {
63
+ const meta = sectionMeta(s)
64
+ if (!meta) continue
65
+ if (anchorSet && !anchorSet.has(meta.id)) continue
66
+ if (byChange && meta.change !== byChange) continue
67
+ entries.push(meta)
68
+ }
69
+ if (entries.length === 0) {
70
+ throw new Error(anchorSet
71
+ ? `anchor 未命中(${[...anchorSet].join('、')} 不在 decisions/${from}.md)`
72
+ : byChange ? `「变更:${byChange}」条目在 decisions/${from}.md 无命中` : `decisions/${from}.md 无决策条目段`)
73
+ }
74
+ return {
75
+ entries,
76
+ targetExists: existsSync(join(dir, `${to}.md`)),
77
+ sourceWillDelete: parsed.sections.length - entries.length === 0,
78
+ }
79
+ }
80
+
81
+ /** 执行迁移(纯文件手术——段字节整体搬运,id/机器行零改写) */
82
+ export function redomainDecisionEntries({ knowledgeRoot, from, to, anchors = null, byChange = null }) {
83
+ from = normalizeDomainArg(from)
84
+ to = normalizeDomainArg(to)
85
+ if (!from || !to) throw new Error('用法:--from <源域> --to <目标域>')
86
+ if (from === to) throw new Error('源域与目标域相同')
87
+ const dir = join(knowledgeRoot, 'decisions')
88
+ const fromPath = join(dir, `${from}.md`)
89
+ const toPath = join(dir, `${to}.md`)
90
+ if (!existsSync(fromPath)) throw new Error(`源域文件不存在:decisions/${from}.md`)
91
+
92
+ const fromParsed = splitKnowledgeSections(readFileSync(fromPath, 'utf8'))
93
+ const anchorSet = Array.isArray(anchors) && anchors.length > 0 ? new Set(anchors) : null
94
+ const targetCreated = !existsSync(toPath)
95
+ const toParsed = targetCreated
96
+ ? { preamble: newDomainPreamble(to), sections: [] }
97
+ : splitKnowledgeSections(readFileSync(toPath, 'utf8'))
98
+
99
+ const moving = []
100
+ const staying = []
101
+ const skippedConflicts = []
102
+ const movedKeys = new Set() // 批内双键(id|change):同批重复段第二条跳过(2026-10-15 平台存量实证幂等残留)
103
+ for (const s of fromParsed.sections) {
104
+ const meta = sectionMeta(s)
105
+ if (!meta) { staying.push(s); continue } // 非条目段(说明/杂项)留守
106
+ if (anchorSet && !anchorSet.has(meta.id)) { staying.push(s); continue }
107
+ if (byChange && meta.change !== byChange) { staying.push(s); continue }
108
+ // 冲突防线:目标域已有同 id + 同变更段(跨变更同号合法共存,双键才冲突)
109
+ const key = `${meta.id}|${meta.change || ''}`
110
+ const clash = movedKeys.has(key) || toParsed.sections.some((t) => {
111
+ const tm = sectionMeta(t)
112
+ return tm && `${tm.id}|${tm.change || ''}` === key
113
+ })
114
+ if (clash) { skippedConflicts.push(meta.id); staying.push(s); continue }
115
+ movedKeys.add(key)
116
+ moving.push({ meta, section: s })
117
+ }
118
+ if (moving.length === 0 && skippedConflicts.length === 0) {
119
+ throw new Error(anchorSet
120
+ ? `anchor 未命中(${[...anchorSet].join('、')} 不在 decisions/${from}.md)`
121
+ : byChange ? `「变更:${byChange}」条目在 decisions/${from}.md 无命中` : `decisions/${from}.md 无决策条目段`)
122
+ }
123
+ if (anchorSet && skippedConflicts.length + moving.length !== anchorSet.size) {
124
+ const got = new Set([...moving.map((m) => m.meta.id), ...skippedConflicts])
125
+ const miss = [...anchorSet].filter((a) => !got.has(a))
126
+ if (miss.length > 0) throw new Error(`anchor 未命中:${miss.join('、')}(不在 decisions/${from}.md)`)
127
+ }
128
+
129
+ toParsed.sections.push(...moving.map((m) => m.section))
130
+ writeAtomicSync(toPath, joinKnowledgeFile(toParsed.preamble, toParsed.sections))
131
+ const sourceDeleted = staying.length === 0
132
+ if (sourceDeleted) unlinkSync(fromPath)
133
+ else writeAtomicSync(fromPath, joinKnowledgeFile(fromParsed.preamble, staying))
134
+
135
+ // INDEX 路由行:decisions 面默认参数(幂等;缺席行补上)
136
+ const indexPath = join(knowledgeRoot, 'INDEX.md')
137
+ const before = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
138
+ try { syncIndexRoutingLines(knowledgeRoot) } catch { /* INDEX 缺席/同步失败不拦迁移 */ }
139
+ const after = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
140
+
141
+ return {
142
+ moved: moving.map((m) => ({ id: m.meta.id, title: m.meta.title })),
143
+ skippedConflicts,
144
+ sourceDeleted,
145
+ targetCreated,
146
+ indexLineAdded: after !== before,
147
+ }
148
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * decision-renumber.js — 存量决策号一次性迁移(2026-10-14-decision-legacy-renumber)。
3
+ *
4
+ * 背景:epoch 全局化(2026-10-14-decision-global-id-epoch)落地后新蒸馏条目自动发全局号,但存量
5
+ * 701 条 legacy 局部号节段无跨变更承接机制、不会自然退役。用户裁决一次性迁移(翻「存量不回填」
6
+ * 旧案——fr-index D-007 是设计取舍非技术禁忌,翻案理由随本变更蒸馏留痕)。epoch 读面(双形态解析/
7
+ * 原编号字段/originId 匹配)已就位——迁移产物所有消费方零改动即认识。
8
+ *
9
+ * 纯函数两段(CLI 层 src/stages/knowledge.js case 'renumber' 负责 fs 与原子写):
10
+ * planRenumber(knowledgeRoot)——扫描全部域文件产出迁移计划:
11
+ * · legacy 节段(局部号标题 D-\d+ 且无原编号字段)按「文件字母序 × 节段序」发号
12
+ * D-<域>-NNN(域=所在文件名去 .md;NNN=该域全域既有 epoch 号 max+1 起批内递增);
13
+ * · fanout 组(同变更同号同版本跨文件副本)单一身份——全组共用一号,号域=字母序首文件域;
14
+ * · 无变更行条目不分组(同号无变更 ≠ 同一决策——backend.md 双 D-001@v1 无变更行实证是
15
+ * 两条不同决策),逐节独立发号;
16
+ * · epoch 形态节段(全局号标题或原编号在场)零触碰——幂等判定面。
17
+ * applyRenumberPlan(contentByFile, plan)——按计划重写:标题行旧 id → 新全局号、
18
+ * 「原编号:<旧号>」插入变更行后(无变更行则状态行后,再退标题行后)、其余行逐字节保留、
19
+ * 节段次序不变;joinKnowledgeFile 复用既有归一落盘形态。
20
+ */
21
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import { splitKnowledgeSections, joinKnowledgeFile } from './decision-distill.js'
24
+
25
+ const EPOCH_NUMBER_RE = /^D-[A-Za-z0-9_\-\u4e00-\u9fff]+-\d+$/
26
+ const LOCAL_NUMBER_RE = /^D-\d+$/
27
+
28
+ /** 扫描 decisions 域文件集(files 元素含 splitKnowledgeSections 产物,零写盘) */
29
+ function scanDomainFiles(knowledgeRoot) {
30
+ const decisionsDir = join(knowledgeRoot, 'decisions')
31
+ const files = []
32
+ if (!existsSync(decisionsDir)) return files
33
+ for (const f of readdirSync(decisionsDir).sort()) {
34
+ if (!f.endsWith('.md')) continue
35
+ const content = readFileSync(join(decisionsDir, f), 'utf8')
36
+ const { preamble, sections } = splitKnowledgeSections(content)
37
+ files.push({ file: `decisions/${f}`, domain: f.replace(/\.md$/, ''), content, preamble, sections })
38
+ }
39
+ return files
40
+ }
41
+
42
+ /**
43
+ * 纯函数(只读扫描):产出迁移计划。
44
+ * @param {string} knowledgeRoot knowledge 目录
45
+ * @returns {{ files: Array<{file, domain, content, ops: Array<{secIdx, fromId, toNumber}>}>,
46
+ * stats: { legacySections, fanoutGroups, fanoutCopies, mintedNumbers, noChangeEntries, untouchedEpoch, perDomainStart: object } }}
47
+ * perDomainStart = 各发号域的起始号(既有 epoch max+1);ops 为空的文件也保留(content 供 CLI 指纹校验)
48
+ */
49
+ export function planRenumber(knowledgeRoot) {
50
+ const files = scanDomainFiles(knowledgeRoot)
51
+ // 各域既有 epoch 号 max(发号起点——不与已发号冲突)
52
+ const domainMax = new Map()
53
+ let untouchedEpoch = 0
54
+ for (const { sections } of files) {
55
+ for (const s of sections) {
56
+ const m = /^D-([A-Za-z0-9_\-\u4e00-\u9fff]+)-(\d+)$/.exec(s.number)
57
+ if (m) domainMax.set(m[1], Math.max(domainMax.get(m[1]) || 0, parseInt(m[2], 10)))
58
+ }
59
+ }
60
+ const isLegacy = (s) => LOCAL_NUMBER_RE.test(s.number) && !s.origin && !EPOCH_NUMBER_RE.test(s.number)
61
+ for (const { sections } of files) for (const s of sections) if (!isLegacy(s)) untouchedEpoch++
62
+ // fanout 分组:同变更(非空)同号同版本跨文件 → 单一身份(组号=字母序首文件域——files 已按文件名排序)
63
+ const groupKeyOf = (s) => `${s.change}#${s.number}@v${s.version}`
64
+ const groups = new Map()
65
+ files.forEach((f) => f.sections.forEach((s) => {
66
+ if (!isLegacy(s) || !s.change) return
67
+ const k = groupKeyOf(s)
68
+ if (!groups.has(k)) groups.set(k, { domain: f.domain, members: [] })
69
+ groups.get(k).members.push({ file: f.file, secIdx: f.sections.indexOf(s) })
70
+ }))
71
+ const fanoutGroups = [...groups.values()].filter((g) => g.members.length >= 2)
72
+ const fanoutCopies = fanoutGroups.reduce((a, g) => a + g.members.length, 0)
73
+ // 组号预分配(确定性:组键排序)
74
+ const minted = new Map()
75
+ const nextFor = (domain) => {
76
+ const cur = Math.max(domainMax.get(domain) || 0, minted.get(domain) || 0) + 1
77
+ minted.set(domain, cur)
78
+ return `D-${domain}-${String(cur).padStart(3, '0')}`
79
+ }
80
+ const groupNumber = new Map()
81
+ for (const k of [...groups.keys()].sort()) {
82
+ const g = groups.get(k)
83
+ if (g.members.length >= 2) groupNumber.set(k, nextFor(g.domain))
84
+ }
85
+ // 逐文件逐节段:组员复用组号,其余 legacy 独立发号(域=所在文件域)
86
+ const perFileOps = new Map()
87
+ let legacySections = 0
88
+ let mintedNumbers = 0
89
+ let noChangeEntries = 0
90
+ for (const f of files) {
91
+ const ops = []
92
+ f.sections.forEach((s, si) => {
93
+ if (!isLegacy(s)) return
94
+ legacySections++
95
+ if (!s.change) noChangeEntries++
96
+ const fromId = `${s.number}@v${s.version}`
97
+ let toNumber
98
+ if (s.change && groups.get(groupKeyOf(s))?.members.length >= 2) {
99
+ toNumber = groupNumber.get(groupKeyOf(s))
100
+ } else {
101
+ toNumber = nextFor(f.domain)
102
+ mintedNumbers++
103
+ }
104
+ ops.push({ secIdx: si, fromId, toNumber })
105
+ })
106
+ perFileOps.set(f.file, ops)
107
+ }
108
+ mintedNumbers += groupNumber.size
109
+ const perDomainStart = {}
110
+ for (const d of new Set([...files.map((f) => f.domain), ...domainMax.keys(), ...minted.keys()])) {
111
+ perDomainStart[d] = (domainMax.get(d) || 0) + 1
112
+ }
113
+ return {
114
+ files: files.map((f) => ({ file: f.file, domain: f.domain, content: f.content, ops: perFileOps.get(f.file) || [] })),
115
+ stats: { legacySections, fanoutGroups: fanoutGroups.length, fanoutCopies, mintedNumbers, noChangeEntries, untouchedEpoch, perDomainStart },
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 纯函数(零 fs):按计划重写。contentByFile 为计划时点内容(CLI 层先做指纹校验防他侧写入被覆盖)。
121
+ * @param {Map<string,string>} contentByFile file → 全文
122
+ * @param {object} plan planRenumber 产物
123
+ * @returns {Map<string,string>} 仅有 op 的 file → 重写后全文
124
+ */
125
+ export function applyRenumberPlan(contentByFile, plan) {
126
+ const out = new Map()
127
+ for (const fe of plan.files) {
128
+ if (!fe.ops || fe.ops.length === 0) continue
129
+ const content = contentByFile.get(fe.file)
130
+ if (content == null) continue
131
+ const { preamble, sections } = splitKnowledgeSections(content)
132
+ const byIdx = new Map(fe.ops.map((o) => [o.secIdx, o]))
133
+ for (let i = 0; i < sections.length; i++) {
134
+ const op = byIdx.get(i)
135
+ if (!op) continue
136
+ const s = sections[i]
137
+ // 标题行:首个旧 id 出现即节头 id(其余行不动)
138
+ s.lines[0] = s.lines[0].replace(op.fromId, `${op.toNumber}@v${s.version}`)
139
+ // 原编号行:变更行后 > 状态行后 > 标题行后(不破「文件:紧跟锚点」行序红线)
140
+ let at = 1
141
+ const vi = s.lines.findIndex((l) => /^变更[::]/.test(l))
142
+ if (vi > -1) at = vi + 1
143
+ else {
144
+ const si = s.lines.findIndex((l) => /^状态[::]/.test(l))
145
+ if (si > -1) at = si + 1
146
+ }
147
+ s.lines.splice(at, 0, `原编号:${op.fromId}`)
148
+ s.id = `${op.toNumber}@v${s.version}`
149
+ s.number = op.toNumber
150
+ s.origin = op.fromId
151
+ }
152
+ out.set(fe.file, joinKnowledgeFile(preamble, sections))
153
+ }
154
+ return out
155
+ }
156
+
157
+ export default { planRenumber, applyRenumberPlan }
@@ -26,7 +26,7 @@ import { join, relative } from 'node:path'
26
26
  import { parseDecisions } from './decision-distill.js'
27
27
  import { parseFileChangeListDetailed } from './change-list.js'
28
28
  import { parseDesignCoverageByRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
29
- import { parseModuleMapSimple } from './modules.js'
29
+ import { modulePathMatches, parseModuleMapSimple } from './modules.js'
30
30
 
31
31
  // ---------------------------------------------------------------------------
32
32
  // 条目解析(distill parseDecisions 的同源镜像,R-01)
@@ -173,7 +173,7 @@ export function deriveActualModules(filePaths, prefixPairs) {
173
173
  const t = String(f || '').replace(/\\/g, '/').replace(/\/+$/, '')
174
174
  if (!t) continue
175
175
  for (const { id, path: p } of prefixPairs) {
176
- if (t === p || t.startsWith(p + '/')) { actual.add(id); break }
176
+ if (modulePathMatches(t, p)) { actual.add(id); break }
177
177
  }
178
178
  }
179
179
  return actual
@@ -387,7 +387,7 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
387
387
  lines.push('- [ ] frontmatter 字段齐全(author/created_at/scale)')
388
388
  lines.push('- [ ] 引用所有当前版本 D-xxx@vN')
389
389
  lines.push('- [ ] 涉及生命周期关键词时含「生命周期契约表」或紧邻豁免短语')
390
- lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记)')
390
+ lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选,输出须带素材来源声明:token 源/组件来源/入口位置)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记;本仓形态未在 local.yaml ui_prototype 登记的,收口前补登记)')
391
391
  lines.push('- [ ] 不确定的问题标注「⚠️ 自审存疑」')
392
392
  lines.push('')
393
393