sillyspec 3.32.8 → 3.32.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  2. package/.claude/skills/sillyspec-execute/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +2 -2
  5. package/AGENTS.md +5 -4
  6. package/assets/command-cards/flow.md +9 -4
  7. package/package.json +1 -1
  8. package/src/change-list.js +13 -0
  9. package/src/commit-guard.js +70 -2
  10. package/src/commit-suggest.js +45 -4
  11. package/src/config-schema.js +14 -0
  12. package/src/cross-repo-reconcile.js +54 -1
  13. package/src/decision-distill.js +77 -21
  14. package/src/decision-renumber.js +157 -0
  15. package/src/deleted-ledger.js +75 -0
  16. package/src/design-facts.js +5 -3
  17. package/src/dispatch/backends/local-agent.js +1 -1
  18. package/src/dispatch/backends/sillyhub-mcp.js +1 -1
  19. package/src/docs-check.js +30 -15
  20. package/src/docs-debt.js +3 -2
  21. package/src/doctor-diagnostics.js +3 -1
  22. package/src/flow-draft.js +24 -2
  23. package/src/flow-parity.js +32 -2
  24. package/src/flow.js +427 -69
  25. package/src/fr-index.js +15 -1
  26. package/src/hooks/worktree-guard.js +3 -3
  27. package/src/index.js +22 -10
  28. package/src/knowledge-digest.js +23 -6
  29. package/src/knowledge-graph.js +92 -14
  30. package/src/knowledge-match.js +34 -9
  31. package/src/knowledge-stats.js +12 -13
  32. package/src/knowledge-vector.js +98 -7
  33. package/src/machine-interface.js +26 -0
  34. package/src/progress/change-registry.js +7 -0
  35. package/src/progress.js +67 -0
  36. package/src/quicklog.js +6 -1
  37. package/src/redomain.js +27 -0
  38. package/src/route-hindsight.js +34 -2
  39. package/src/run/archive-distill.js +3 -0
  40. package/src/run/command.js +40 -44
  41. package/src/run/complete-handlers.js +8 -3
  42. package/src/run/complete.js +129 -63
  43. package/src/run/gates.js +94 -90
  44. package/src/run/prompt.js +13 -3
  45. package/src/run/shared.js +19 -0
  46. package/src/run/stage.js +1 -1
  47. package/src/scope-audit.js +2 -2
  48. package/src/sentinel-assertions.js +43 -1
  49. package/src/stage-contract.js +26 -3
  50. package/src/stage-review.js +44 -11
  51. package/src/stage-templates.js +2 -0
  52. package/src/stages/brainstorm.js +24 -12
  53. package/src/stages/execute.js +1 -1
  54. package/src/stages/explore.js +3 -5
  55. package/src/stages/knowledge.js +56 -5
  56. package/src/sync.js +98 -2
  57. package/src/task-tick.js +12 -0
  58. package/src/timeline.js +60 -19
  59. package/src/verify-postcheck.js +83 -6
  60. package/src/verify-probes.js +61 -18
  61. package/templates/agents-instruction.md +5 -4
  62. package/templates/prompts/testcase-design.md +5 -0
  63. package/src/stages/brainstorm-auto.js +0 -286
@@ -80,8 +80,8 @@ brainstorm 完成时按 design.md frontmatter 的 `scale` 分叉:
80
80
 
81
81
  brainstorm 在 `tier=independent` 规模下除 design.md 等四件套外,还需产出一个 stage 级 `review.json`,CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
82
82
 
83
- - 路径:`.sillyspec/.runtime/stage-reviews/brainstorm-review-<stage-review-run-id>/review.json`(目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定)。marker 文件 `.runtime/current-stage-review-run-id-brainstorm-<变更名>`。
84
- - **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id(必须 `review-` 前缀)、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage brainstorm` 一步生成。
83
+ - 路径:`.sillyspec/.runtime/stage-reviews/brainstorm-<run-id>/review.json`——**目录名 = `brainstorm-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `brainstorm-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-brainstorm-<变更名>`。
84
+ - **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage brainstorm` 一步生成。
85
85
  - 字段(`schemaVersion:1`,`reviewType=design` —— 区别于 execute 的 `acceptance`):
86
86
 
87
87
  ```json
@@ -106,8 +106,8 @@ execute 完成时,每个 task 必须有 `review.json` 且 verdict 通过,否
106
106
 
107
107
  execute 还有**第二道**独立的 stage 级审查:除逐 task review.json 外,整个 execute 阶段完成还需一个 stage 级 `review.json`(在 "完成确认"/acceptance 步骤产出)。CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
108
108
 
109
- - 路径:`.sillyspec/.runtime/stage-reviews/execute-review-<stage-review-run-id>/review.json`(目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定)。marker 文件 `.runtime/current-stage-review-run-id-execute-<变更名>`。
110
- - **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id(必须 `review-` 前缀)、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage execute` 一步生成。
109
+ - 路径:`.sillyspec/.runtime/stage-reviews/execute-<run-id>/review.json`——**目录名 = `execute-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `execute-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-execute-<变更名>`。
110
+ - **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage execute` 一步生成。
111
111
  - 字段(`schemaVersion:1`,`reviewType=acceptance` —— 区别于 brainstorm/plan 的 `"design"`):
112
112
 
113
113
  ```json
@@ -16,7 +16,7 @@ description: 轻量变更(默认快道)——需求明确的小/中改动两
16
16
  ### ① flow start(立项 + 锁基线)
17
17
 
18
18
  ```bash
19
- sillyspec flow start --change <变更名> --input "<动机与背景>
19
+ sillyspec flow start --change <变更名> --input "<一句中文概括(首行即变更标题);随后动机与背景
20
20
 
21
21
  成功标准:
22
22
  - <可验证标准>"
@@ -49,8 +49,8 @@ plan 的步骤是动态的:`generate_plan`(生成分级计划)→ `review_
49
49
 
50
50
  `review_plan` 步骤在 `tier=independent` 时产出一个 stage 级 `review.json`,CLI `Stage Review Gate` 硬校验其 schema 与 `docHash` 真实性。
51
51
 
52
- - 路径:`.sillyspec/.runtime/stage-reviews/plan-review-<stage-review-run-id>/review.json`(目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定)。marker 文件 `.runtime/current-stage-review-run-id-plan-<变更名>`。
53
- - **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id(必须 `review-` 前缀)、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage plan` 一步生成。
52
+ - 路径:`.sillyspec/.runtime/stage-reviews/plan-<run-id>/review.json`——**目录名 = `plan-` + marker 值原样**(run-id 本身已带 `review-` 前缀:marker 值 `review-2026-10-15-001431` → 目录 `plan-review-2026-10-15-001431`。勿再拼一层 review-,也勿拿 marker 值当完整目录名)。目录可能不存在需手建;run-id 由该步 `--done` prompt 输出指定;marker 文件 `.runtime/current-stage-review-run-id-plan-<变更名>`。
53
+ - **run-id / marker 由 CLI 自动生成注入**(review step prompt 渲染时 echo 完整目录路径 + 写 marker;撞 gate 报缺 review.json 时 gate 也 echo 完整路径 + 写 marker)。直接用 CLI 给的路径写 review.json,**勿手算 run-id、勿手写 marker**。卡住时用 `sillyspec register-stage-review --change <名> --stage plan` 一步生成。
54
54
  - 字段(`schemaVersion:1`,`reviewType=plan` —— plan 阶段主审查文档是 `plan.md`;execute 阶段才是 `acceptance`):
55
55
 
56
56
  ```json
package/AGENTS.md CHANGED
@@ -7,15 +7,16 @@
7
7
  ## 选道(动手前先对号入座)
8
8
  | 需求形态 | 走法 |
9
9
  |---|---|
10
- | 需求已含决策(改什么、成功标准说得清) | **轻量变更(默认快道)**:`flow start` → 直接干活 → `flow done`(2 次协议调用;操作细节见 `/sillyspec:flow` skill 与 flow start 输出) |
11
- | 需求不清晰 / 需要方案探索 | 头脑风暴预段:`run brainstorm --change <名>` → 完成后 `flow start --change <名>` 收编续跑(design 以头脑风暴版为准) |
10
+ | 需求已含决策(改什么、成功标准说得清——成功标准须来自**用户原话或既有文档转写**;要靠你推断拟出的标准=需求不清晰) | **轻量变更(默认快道)**:`flow start` → 直接干活 → `flow done`(2 次协议调用;操作细节见 `/sillyspec:flow` skill 与 flow start 输出) |
11
+ | 用户显式要求极速(「极速/别验证/只要 spec 和代码」类原话)且需求已含决策 | **轻量变更极速版**:同上行加 `--turbo`(`flow start --change <名> --input "<...>" --turbo`)——**仅用户显式触发,禁止你自行启用**;收口跳过实测与重仪式门(hunk/dirty/ui 探针/评审),spec 结构校验/tasks 全勾/ledger/patch 冻结/distill/归档照常(跳的是验证不是证据);升厚即失效恢复全量仪式;收口输出显式标注「不含测试客观核验」。预段组合极速:头脑风暴预段走轻量跑判定+单轮合并问询+burst 一次 --done,收编时按用户要求带 --turbo |
12
+ | 需求不清晰 / 需要方案探索(含「成功标准要你替用户拟」的形态——自己拟标准再自答是把默认共识伪装成共识) | 头脑风暴预段:`run brainstorm --change <名>` → 完成后 `flow start --change <名>` 收编续跑(design 以头脑风暴版为准)。上个轻量变更被 route-hindsight 标记时全新 start 会被同意门拦下要求显式选道:确认已含决策带 `--no-prestage` 声明留痕,否则先走本行预段 |
12
13
  | 大改动:跨模块取舍 / Wave 计划编排 / 多阶段治理 / 设计期人机对抗 | 完整流程五阶段:`run brainstorm → plan → execute → verify → archive`(每阶段一次渲染 + 一次 --done 收口;细节见各阶段 skill 与 CLI 输出) |
13
14
  | 代码已先写好(倒推收尾) | 不回头补流程装样子:`flow start --change <YYYY-MM-DD-名> --input` 按下方过门格式写(动机=已做改动的描述,成功标准=已验证达标的行为)→ `flow done` 一步收口 |
14
15
 
15
16
  `--input` 过门格式(轻量道首次调用即过,勿靠报错学;引号内换行合法,下例可照抄):
16
17
 
17
18
  ```bash
18
- sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
19
+ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景
19
20
 
20
21
  成功标准:
21
22
  - <可验证标准>"
@@ -31,7 +32,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
31
32
  ## 核心规则
32
33
  1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
33
34
  2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md(如有))或现有代码依据,无依据不改。
34
- 3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。
35
+ 3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。测试按层适用:纯逻辑先写测试,行为契约断言用户可见行为,视觉样式与纯配置/文档不写测试(记「不适用:理由」,验收靠规范与人工)。
35
36
  4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`。以落盘文件与测试结果为准,不信口头"已完成"。
36
37
  5. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
37
38
  6. **git hook 拦截提交时禁止跳过**(如 `.husky/pre-push`),修复问题后再提交。
@@ -4,23 +4,26 @@ description: 轻量变更(默认快道):需求明确的小/中改动两步
4
4
  ---
5
5
  # 何时用
6
6
 
7
- 需求已含决策的改动:改什么、成功标准说得清(能列 1-3 条可验证标准)。需求不清晰先走 brainstorm(完成后 flow start 收编续跑);跨模块取舍/Wave 编排/设计期人机对抗走完整五阶段(run-brainstorm 等卡)。
7
+ 需求已含决策的改动:改什么、成功标准说得清(能列 1-3 条可验证标准),且成功标准来自用户原话/既有文档转写——要靠你替用户拟标准=需求不清晰,先走 brainstorm(完成后 flow start 收编续跑);跨模块取舍/Wave 编排/设计期人机对抗走完整五阶段(run-brainstorm 等卡)。
8
8
 
9
9
  # 生命周期速查
10
10
 
11
11
  ```bash
12
12
  export SILLYSPEC_SESSION_ID=<agent名+任务名> # 每条命令都带(shell 状态不持久;多会话并行的所有权判定依赖它)
13
13
  # ① 立项 + 锁基线(--input 引号内换行合法,可照抄;缺「成功标准」条目会被清晰度门拦下):
14
- sillyspec flow start --change <变更名> --input "<动机与背景>
14
+ sillyspec flow start --change <变更名> --input "<一句中文概括(首行即变更标题);随后动机与背景
15
15
 
16
16
  成功标准:
17
17
  - <可验证标准>"
18
- # ② 直接干活:改代码写测试;填 design.md 四节(空节 flow done 拒收;列改动文件用独立「## 文件变更清单」节);
18
+ # 极速变体:用户显式说「极速/别验证/只要 spec 和代码」类原话时才加 --turbo(仅用户显式触发,
19
+ # 禁止你自行启用)——flow-state 留痕,收口跳过实测与重仪式门(hunk/dirty/ui 探针/评审),
20
+ # spec 结构校验、tasks 全勾、ledger、patch 冻结、distill、归档照常;升厚即失效恢复全量仪式。
21
+ # ② 直接干活:改代码写测试(按层适用——纯逻辑先写、行为契约断用户所见、样式/纯配置不写);填 design.md 四节(空节 flow done 拒收;列改动文件用独立「## 文件变更清单」节);
19
22
  # tasks.md 是你的工作队列——机器种子改写为实现步骤(每行做什么+怎么验证,保持 `- [ ] task-NN:` 行形态),
20
23
  # 执行循环:Working on task N/M → 做一件 → 测试绿后当场勾一格(sillyspec task tick --change <名> --task task-NN
21
24
  # 即时回显进度与下一任务)→ 下一行;每格勾选要有对应提交(消息带 task-NN),一把勾收口被拒。
22
25
  # 交付代码显式 pathspec 提交(patch 冻结面=baseline..HEAD 提交面,未提交不进审计件)
23
- # ③ 收口(CLI 亲自实测 test/lint + 独立评审定档 + patch 留档):
26
+ # ③ 收口(CLI 亲自实测 test/lint + 独立评审定档 + patch 留档;--turbo 变更自动跳过实测与重仪式门):
24
27
  sillyspec flow done --change <变更名>
25
28
  # 断点恢复 / 随时查进度:
26
29
  sillyspec flow status --change <变更名>
@@ -29,8 +32,10 @@ sillyspec flow status --change <变更名>
29
32
  # 防坑清单
30
33
 
31
34
  - --input 的「成功标准:」必须独立成行,其后每行一条 `- <可验证标准>`——格式不对 CLI 会拦下给两选一(头脑风暴预段 / 确认输入已含决策),照提示走即可。
35
+ - 预段同意门:上个轻量变更被 route-hindsight 标记「疑似该走预段未走」时,全新 start 会被拦下要求显式选道——确认需求已含决策(成功标准来自用户原话/文档转写)加 `--no-prestage` 声明留痕;要靠你拟标准就走 brainstorm 预段,完成后收编(标记自动消费)。
32
36
  - 触及 src/test 的改动 flow done 会亲自实测 local.yaml 的 test/lint(实测失败阻断收口,修好重跑即可)——想省一轮就先自跑。
33
37
  - 轻量→完整转道是用户决策:征得同意带 `--upgrade-thick` 重启;实测失败自动升厚。
38
+ - 极速模式(--turbo)触发判据:仅用户显式极速原话(「极速/别验证/只要 spec 和代码」类)才携带——你不得自行启用(实证核验是默认价值,fast 是用户让渡的例外);跳过面只覆盖验证仪式,spec 面与证据面(patch/distill)全保留;收口输出会显式标注「本次收口不含测试客观核验」。预段组合极速:头脑风暴预段走轻量跑判定(需求已收敛直书结论)+ 单轮合并问询 + burst 一次 --done,收编时若用户要求极速则 start 带 --turbo。
34
39
  - 显式 pathspec 提交(`git commit -m "..." -- 文件…`),禁 git add -A。
35
40
 
36
41
  # 边界声明
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.32.8",
3
+ "version": "3.32.10",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -13,6 +13,9 @@ export function normalizePath(raw) {
13
13
  .replace(/`/g, '')
14
14
  .replace(/\s*([^)]*)\s*$/, '')
15
15
  .replace(/\s*\([^)]*\)\s*$/, '')
16
+ // 未闭合尾括号残片兜底剥除(2026-10-13-declared-paren-strip:冒号剥等上游截断产生的
17
+ // 「路径黏连(交付」形态——闭合剥救不回,缺陷档案 flow-diff-declared-face-paren-pollution)
18
+ .replace(/\s*[((][^))]*$/, '')
16
19
  .replace(/\\/g, '/')
17
20
  .replace(/\/+$/, '')
18
21
  .trim()
@@ -330,6 +333,16 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false, repoK
330
333
  const preCross = splitCrossRepoPrefix(normalizePath(listItem[1]), repoKeys)
331
334
  let rawItem = listItem[1]
332
335
  if (!preCross) {
336
+ // 括号注记剥除前置于冒号剥(2026-10-13-declared-paren-strip,缺陷档案实证:注记
337
+ // 「(交付:说明)」的冒号在括号内,冒号剥先跑把行砍成「路径(交付」未闭合残片——
338
+ // 循环剥行尾完整括号组(嵌套/多组)+ 未闭合残片兜底,路径中段括号不动)。
339
+ let _prev
340
+ do {
341
+ _prev = rawItem
342
+ rawItem = rawItem
343
+ .replace(/\s*[((][^))]*[))]\s*$/, '')
344
+ .replace(/\s*[((][^))]*$/, '')
345
+ } while (rawItem !== _prev)
333
346
  // 坑 brainstorm-gate-agent-unavailable-and-list-path-parse 坑2:列表项「路径:描述」
334
347
  // 整行当路径 existsSync 假阴性(文件存在却报幻觉路径)——剥首个中/英冒号及之后描述。
335
348
  // 仅列表分支剥、且冒号前段须 looksLikePath:normalizePath 全局剥会毁 Windows 绝对路径
@@ -13,6 +13,10 @@
13
13
  * 即警告「疑似扫入他者文件」。
14
14
  * S2 跨变更目录:staged 里 .sillyspec/changes/<name>/(含 archive/)命中 ≥2 个不同
15
15
  * name → 警告「正常提交只携带一个变更的面」。
16
+ * S3 pathspec 提交暂存区旁路(2026-10-13-commit-guard-s3,055080e4 三十八行夹带实证):
17
+ * `git commit -- <路径>` 取工作树内容构造临时索引(GIT_INDEX_FILE 指给钩子),
18
+ * 暂存区净版被绕过——两索引同路径 blob 不一致 = 提交将携带未暂存工作树内容
19
+ * (共享仓多会话时即并行编辑夹带)。普通全量提交零开销跳过。
16
20
  * 命中即 stderr 警告 + appendWriteAudit 留痕(via: 'commit-guard')。
17
21
  *
18
22
  * fail-open 硬约束(钩子挂在所有会话共享的 .husky/ 上,守卫自身绝不拦任何人的提交):
@@ -20,7 +24,7 @@
20
24
  */
21
25
  import { execFileSync } from 'child_process'
22
26
  import { readFileSync, existsSync } from 'fs'
23
- import { join } from 'path'
27
+ import { join, resolve } from 'path'
24
28
  import { pathToFileURL } from 'url'
25
29
  import { appendWriteAudit } from './write-audit.js'
26
30
 
@@ -75,6 +79,43 @@ export function analyzeStagedFace(stagedFiles, qlPatches) {
75
79
  return warnings
76
80
  }
77
81
 
82
+ /**
83
+ * S3 纯函数①:解析 `git ls-files -s` 输出为条目数组(CRLF/噪声行容错)。
84
+ * 行形态:`<mode> <sha> <stage>\t<path>`。
85
+ */
86
+ export function parseLsFiles(output) {
87
+ const out = []
88
+ for (const line of String(output || '').split(/\r?\n/)) {
89
+ const m = line.match(/^(\d+) ([0-9a-f]{40}) (\d+)\t(.+)$/)
90
+ if (m) out.push({ mode: m[1], sha: m[2], stage: Number(m[3]), path: m[4] })
91
+ }
92
+ return out
93
+ }
94
+
95
+ /**
96
+ * S3 纯函数②:两索引逐路径比对(2026-10-13-commit-guard-s3)。temp=钩子 GIT_INDEX_FILE
97
+ * 指向的临时索引(pathspec 提交将入库的内容),real=真实暂存区(用户 add 过的净版)。
98
+ * 不一致(异 sha / 仅临时在场)= 提交将携带未暂存工作树内容——暂存净版被 pathspec 语义
99
+ * 绕过(055080e4 实证形态)。仅真实在场(staged 但不在 pathspec)不告:该文件不会被
100
+ * 本提交带走。冲突态(stage>0)不判。
101
+ */
102
+ export function analyzeIndexDivergence(tempEntries, realEntries) {
103
+ const real = new Map()
104
+ for (const e of realEntries) if (e && e.stage === 0 && e.path) real.set(e.path, e.sha)
105
+ const files = []
106
+ for (const e of tempEntries) {
107
+ if (!e || e.stage !== 0 || !e.path) continue
108
+ const rs = real.get(e.path)
109
+ if (rs === undefined || rs !== e.sha) files.push(e.path)
110
+ }
111
+ if (files.length === 0) return []
112
+ return [{
113
+ signal: 'index_divergence_on_pathspec_commit',
114
+ files,
115
+ message: `pathspec 提交将携带未暂存的工作树内容(暂存区净版被绕过——git commit -- <路径> 取工作树内容):${files.slice(0, 5).join('、')}${files.length > 5 ? ' 等' : ''}——共享仓此形态会夹带并行会话在途编辑;核对 git diff -- <路径> 后 add 最新净版再提交,或等并行会话先提交该文件`,
116
+ }]
117
+ }
118
+
78
119
  function main() {
79
120
  const cwd = process.cwd()
80
121
  let staged = []
@@ -102,7 +143,34 @@ function main() {
102
143
  qlPatches[f] = JSON.parse(readFileSync(join(cwd, f), 'utf8'))
103
144
  } catch { /* 单个 json 损坏 → 跳过该信号源,不连坐 */ }
104
145
  }
105
- const warnings = analyzeStagedFace(staged, qlPatches)
146
+ let warnings = analyzeStagedFace(staged, qlPatches)
147
+ // S3:pathspec 提交的暂存区旁路检测(fail-open——任何异常静默跳过该信号,绝不拦提交)。
148
+ // ⚠ 两个实证语义:①rev-parse --git-path index 跟随 GIT_INDEX_FILE(钩子内返回临时索引
149
+ // 路径自身)——真索引解析必须剥掉该环境变量;②worktree 内该命令返回**绝对路径**
150
+ // (.git/worktrees/<n>/index)——拼接必须用 resolve(绝对段重置),join(cwd, 绝对) 会拼出
151
+ // 损坏路径 → ls-files 指向不存在索引 exit 0 输出空 → 全文件误判偏差(评审 P1 实证)。
152
+ // 未设或等于真索引 → 跳过(普通全量提交零开销)。
153
+ try {
154
+ const envIdx = process.env.GIT_INDEX_FILE || ''
155
+ if (envIdx) {
156
+ const { GIT_INDEX_FILE: _drop, ...envNoIdx } = process.env
157
+ const realIdxRaw = execFileSync('git', ['rev-parse', '--git-path', 'index'], {
158
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: envNoIdx,
159
+ }).trim()
160
+ const realIdx = resolve(cwd, realIdxRaw) // 绝对段重置语义(worktree 形态关键)
161
+ const norm = (p) => {
162
+ try { return resolve(p).replace(/\\/g, '/').toLowerCase() } catch { return String(p).replace(/\\/g, '/').toLowerCase() }
163
+ }
164
+ if (norm(envIdx) !== norm(realIdx)) {
165
+ const tempLs = execFileSync('git', ['ls-files', '-s'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
166
+ const realLs = execFileSync('git', ['ls-files', '-s'], {
167
+ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
168
+ env: { ...process.env, GIT_INDEX_FILE: realIdx },
169
+ })
170
+ warnings = [...warnings, ...analyzeIndexDivergence(parseLsFiles(tempLs), parseLsFiles(realLs))]
171
+ }
172
+ }
173
+ } catch { /* S3 fail-open:解析/比对异常静默跳过 */ }
106
174
  if (warnings.length === 0) return 0
107
175
  for (const w of warnings) process.stderr.write(`⚠️ [commit-guard] ${w.message}\n`)
108
176
  const specDir = join(cwd, '.sillyspec')
@@ -17,7 +17,7 @@
17
17
  * C. 阶段产出:changed paths 按路径模式归 scan/brainstorm/plan/execute/archive/knowledge
18
18
  */
19
19
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
20
- import { join } from 'path'
20
+ import { join, relative } from 'path'
21
21
  import { DatabaseSync } from 'node:sqlite'
22
22
  import { gitQuiet, safeGit, stageArchiveArtifacts } from './git-helper.js'
23
23
  import { resolveSpecDir } from './run/shared.js'
@@ -117,6 +117,12 @@ export function collectCommitContext({ cwd, specDir = null }) {
117
117
  const stat = gitQuiet(cwd, ['diff', 'HEAD', '--stat']) || (changedPaths.length > 0 ? `(${changedPaths.length} 个文件待首提交)` : '')
118
118
 
119
119
  // ── 来源 A:QUICKLOG(主 quicklog + 各活跃变更归属 quicklog)──
120
+ // 并行会话噪声锚定(坑 probe-noise-and-commit-suggest-quality 坑B,2026-10-15 消费仓实证:
121
+ // 归档提交时他并行 quick 会话的 QUICKLOG 新条目(时间窗过滤粗筛通过)把 feat 建议劫持成
122
+ // 「fix: quick 修复 + 阶段产出」——type 误判 + 凭空 quick 字样。quick 来源现加锚「quicklog
123
+ // 文件属于本次提交变更集」(porcelain 含 staged/unstaged/untracked):不在变更集的 quicklog
124
+ // 条目不参与建议(时间窗保留作首道粗筛,零条目时无建议行为不变)。
125
+ const changedSet = new Set(changedPaths.map(p => String(p).replace(/\\/g, '/')))
120
126
  const quickFiles = []
121
127
  try {
122
128
  for (const f of readdirSync(join(specBase, 'quicklog'))) {
@@ -133,6 +139,7 @@ export function collectCommitContext({ cwd, specDir = null }) {
133
139
  const quickEntries = quickFiles
134
140
  .flatMap(file => parseQuicklogEntries(file).map(e => ({ ...e, file })))
135
141
  .filter(e => !lastCommitKey || e.time > lastCommitKey)
142
+ .filter(e => changedSet.has(relative(cwd, e.file).replace(/\\/g, '/')))
136
143
  .sort((a, b) => (a.time < b.time ? -1 : 1))
137
144
  const doneQuick = quickEntries.filter(e => e.status === '已完成')
138
145
 
@@ -155,6 +162,27 @@ export function collectCommitContext({ cwd, specDir = null }) {
155
162
  }
156
163
  }
157
164
 
165
+ // ── 来源 B2:归档产物任务面(坑 probe-noise-and-commit-suggest-quality 坑B 连带)──
166
+ // apply 后 tasks.md 移到 changes/archive/<变更>/——来源 B 的 mtime 锚对已提交面恒失效
167
+ // (任务勾选随 execute 提交走,mtime 早于上次 commit → hasTasks 恒 false → 归档提交被判
168
+ // fix)。归档目录下 tasks.md 在本次变更集时解析其 [x] 行进 task 面(归档提交的 feat 语义正源)。
169
+ try {
170
+ const archDir = join(specBase, 'changes', 'archive')
171
+ if (existsSync(archDir)) {
172
+ for (const c of readdirSync(archDir)) {
173
+ const tp = join(archDir, c, 'tasks.md')
174
+ try {
175
+ if (!statSync(tp).isFile()) continue
176
+ } catch { continue }
177
+ if (!changedSet.has(relative(cwd, tp).replace(/\\/g, '/'))) continue
178
+ for (const line of readFileSync(tp, 'utf8').split('\n')) {
179
+ const m = line.match(/^[-*]\s*\[[xX]\]\s*(task-\d+)\s*[::]?\s*(.*)/)
180
+ if (m) checkedTasks.push({ change: c, item: `${m[1]}: ${m[2].trim()}` })
181
+ }
182
+ }
183
+ }
184
+ } catch { /* 归档扫描失败静默(来源面缺省,行为回退修复前) */ }
185
+
158
186
  // ── 来源 C:阶段产出(changed paths 按模式归类去重)──
159
187
  const stageSet = new Set()
160
188
  for (const p of changedPaths) {
@@ -195,11 +223,24 @@ export function collectCommitContext({ cwd, specDir = null }) {
195
223
  type = 'feat'
196
224
  subject = `feat: 变更交付 + quick 修复 ${doneQuick.length} 项`
197
225
  } else {
198
- type = 'fix'
199
- subject = `fix: quick 修复 + 阶段产出`
226
+ // 纯阶段产出(坑 probe-noise-and-commit-suggest-quality 坑B,2026-10-15 实证:原
227
+ // 「fix: quick 修复 + 阶段产出」在无任何 quick 证据时凭空捏造 quick 语义且误判 fix——
228
+ // 无 task 无 quick 的归档/知识面提交按 chore 归档语义建议)
229
+ type = 'chore'
230
+ subject = `chore: 阶段产出归档(${stageArtifacts.join('、')})`
200
231
  }
201
232
  if (doneQuick.length >= 2 || hasTasks || hasStages) {
202
- for (const q of doneQuick) bodyLines.push(`- quick ${q.qlId}: ${q.title}`)
233
+ // body 与 subject 去重(坑同上:quick ≤2 条时标题已全列 subject,body 原样重复无信息量)
234
+ const subjectListed = doneQuick.length <= 2 ? new Set(doneQuick.map(q => q.title)) : null
235
+ for (const q of doneQuick) {
236
+ // 标题已列 subject 时 body 仍保留 qlId 行(去重不丢审计锚——标题可省,qlId 是
237
+ // QUICKLOG 对账锚点,既有测试契约钉住 body 含 qlId)
238
+ if (subjectListed && subjectListed.has(q.title)) {
239
+ bodyLines.push(`- quick ${q.qlId}(标题见上)`)
240
+ continue
241
+ }
242
+ bodyLines.push(`- quick ${q.qlId}: ${q.title}`)
243
+ }
203
244
  }
204
245
  for (const t of checkedTasks) bodyLines.push(`- ${t.change} ${t.item}`)
205
246
  for (const s of stageArtifacts) bodyLines.push(`- 阶段产出: ${s}`)
@@ -77,6 +77,14 @@ export const LOCAL_YAML_SCHEMA = {
77
77
  { path: 'commands.knowledge-gate', type: 'boolean', optional: true, status: 'live', readers: ['completeStep (src/run/complete.js)'], desc: '【2026-09-28-unclear-req-to-brainstorm FR-03/D-004】brainstorm 方案步(提出 2-3 种方案)--done 知识检索命中回显开关——缺省 true=开(对 --output 与 decisions.md 条目跑既有检索匹配器,命中 warn 回显「须 evidence 回应或说明不复潮」不阻断);false(或 off)关回显,无命中时输出与现状一致。', example: 'false' },
78
78
  ],
79
79
  },
80
+ {
81
+ id: 'knowledge',
82
+ title: '知识检索(平台向量语义召回)',
83
+ note: '可选段——缺省(未写整段/auto)跟随平台能力自动探测:端点在场自动启用、未部署缓存 1h 内跳过(部署后自动启用,零配置;2026-10-15-platform-vector-search)。off=显式逃生阀强制关,on=强制开跳过探测。能力缓存落 .runtime/vector-capability.json(gitignored);提示触点:knowledge search hint 键、flow 注入零命中行(auto 未就绪时信息性提示)。',
84
+ keys: [
85
+ { path: 'knowledge.vector_search', type: 'string', optional: true, status: 'live', readers: ['vectorSwitchFromLocalYaml (src/knowledge-vector.js)'], desc: '【2026-10-15-vector-capability-autodetect 终态】平台向量语义召回开关——缺省(未写键/auto)跟随平台能力自动探测:端点在场自动启用、未部署缓存 1h 内跳过(部署后自动启用,零配置);off=显式逃生阀强制关、on=强制开跳过探测。env SILLYSPEC_KNOWLEDGE_VECTOR=on|auto/off 同语义强制。四层检索第二层:路由 tag 零命中后平台向量召回(语料=knowledge/decisions 条目,词面不相交条目可语义召回)。', example: 'off' },
86
+ ],
87
+ },
80
88
  {
81
89
  id: 'mcp',
82
90
  title: 'SillyHub MCP 客户端凭据(派发用)',
@@ -387,6 +395,12 @@ commands:
387
395
  # lint_timeout_sec: 600 # commands.lint 超时秒数(缺省 180)
388
396
  # knowledge-gate: false # 方案步 --done 知识检索命中回显开关(缺省 true 开;false 关回显)
389
397
 
398
+ # ── 知识检索(平台向量语义召回)──
399
+ # 缺省(未写整段)跟随平台能力自动探测:端点在场自动启用、未部署自动跳过(部署后自动
400
+ # 启用,零配置)。仅显式逃生阀时才需要写:
401
+ # knowledge:
402
+ # vector_search: off # 强制关(on=强制开跳过探测)
403
+
390
404
  # ── SillyHub MCP 客户端凭据(派发到 worker 用)──
391
405
  # agent 手填,或 sillyspec platform connect 在 mcp 段缺失时同源自动填。
392
406
  # 两键齐全才生效;缺则回退环境变量 SILLYHUB_MCP_URL / SILLYHUB_MCP_TOKEN。
@@ -198,7 +198,7 @@ function collectStatusInto(repoPath, union) {
198
198
  * 纯读 fail-soft:任何异常 → degraded 形态返回(degradedReason=`内核采集异常: <msg首行>`),
199
199
  * 绝不 throw。
200
200
  */
201
- export function collectRepoActual({ repoKey, specBase, cwd, runtimeRoot = null, changeName = null }) {
201
+ export function collectRepoActual({ repoKey, specBase, cwd, runtimeRoot = null, changeName = null, crossBaselines = null }) {
202
202
  try {
203
203
  // 1) 仓注册解析(与 reconcile 旧口径同源:local.yaml repos 段,读失败 → 空注册表落「未注册」)
204
204
  let registry = new Map()
@@ -264,6 +264,59 @@ export function collectRepoActual({ repoKey, specBase, cwd, runtimeRoot = null,
264
264
  }
265
265
  } catch { /* meta 不可读/键缺失 → 回退 B 档 */ }
266
266
 
267
+ // 3c) flow-start-baseline 窗口(2026-10-13-cross-repo-baseline-anchor):thin 手工交付的
268
+ // 跨仓基线锚(flow start 首写者胜记录各注册仓 HEAD)——窗口=基线..工作树,根治 B 档
269
+ // HEAD~1 单笔窗口的多提交盲区(fire-ledger 两笔交付实证:实现+评审修复,首笔整笔
270
+ // 漏窗)。主面=token 归因(窗口内提交消息含变更名的提交文件并集——AGENTS 规则 11
271
+ // 的消息约定跨仓同样适用)∪ 未提交态;零 token 命中回退全窗口(并行工作区噪音
272
+ // 可见不静默——unplanned 行承载,与主仓夹带嫌疑 advisory 同语义)。基线 sha 在该仓
273
+ // 不可达(浅克隆/被 gc)→ 回退 B 档(不回退语义)。
274
+ const _blSha = crossBaselines && /^[0-9a-f]{7,40}$/i.test(String(crossBaselines[repoKey] || '')) ? String(crossBaselines[repoKey]) : null
275
+ if (_blSha) {
276
+ const _ok = gitQuiet(repoPath, ['rev-parse', '--verify', '--quiet', `${_blSha}^{commit}`], { timeout: GIT_TIMEOUT })
277
+ if (_ok !== null && String(_ok).trim()) {
278
+ const _mine = new Set()
279
+ let _tokenHit = false
280
+ const _log = changeName
281
+ ? gitQuiet(repoPath, ['log', '--format=%H', `--grep=${changeName}`, `${_blSha}..HEAD`], { timeout: GIT_TIMEOUT })
282
+ : null
283
+ if (_log !== null) {
284
+ for (const c of String(_log).split('\n').map((s) => s.trim()).filter(Boolean)) {
285
+ // -m --first-parent(评审 P2 清偿):merge 提交不带 -m 时 diff-tree 输出**空**——消息
286
+ // 带变更名的 merge 会假命中 token 却收零文件、并抑制全窗口回退(窄形态劣于 B 档)。
287
+ // --first-parent 取 merge 带入本线的文件面(非 merge 提交单亲,-m 为 no-op)。
288
+ const dt = gitQuiet(repoPath, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-m', '--first-parent', c], { timeout: GIT_TIMEOUT })
289
+ if (dt === null) continue
290
+ let got = false
291
+ for (const f of String(dt).split('\n')) {
292
+ const n = normalizeRepoPath(f)
293
+ if (n) { _mine.add(n); got = true }
294
+ }
295
+ // 空文件列表不计 tokenHit(belt:全空命中回退全窗口而非静默漏面)
296
+ if (got) _tokenHit = true
297
+ }
298
+ }
299
+ const union3 = new Set(_mine)
300
+ if (!_tokenHit) {
301
+ const d1 = gitQuiet(repoPath, ['diff', '--name-only', _blSha], { timeout: GIT_TIMEOUT })
302
+ for (const f of String(d1 || '').split('\n')) {
303
+ const n = normalizeRepoPath(f)
304
+ if (n) union3.add(n)
305
+ }
306
+ }
307
+ const d2 = gitQuiet(repoPath, ['ls-files', '--others', '--exclude-standard'], { timeout: GIT_TIMEOUT })
308
+ for (const f of String(d2 || '').split('\n')) {
309
+ const n = normalizeRepoPath(f)
310
+ if (n && n !== 'meta.json' && !n.startsWith('.sillyspec/')) union3.add(n)
311
+ }
312
+ collectStatusInto(repoPath, union3)
313
+ return { repo: repoKey, repoPath,
314
+ anchor: { source: 'flow-start-baseline', base: _blSha, head: null,
315
+ label: `flow-start 跨仓基线窗口(base=${_blSha.slice(0, 8)}…HEAD+工作树${_tokenHit ? ';token 归因命中(窗口内含变更名提交为主面)' : ';零 token 命中回退全窗口(并行噪音可见)'})` },
316
+ files: [...union3].sort(), degradedReason: null }
317
+ }
318
+ }
319
+
267
320
  // 4) B/C 档:diff HEAD~1..HEAD(现行 reconcile 口径)∪ status;diff 失败仅 status → C 档
268
321
  const union = new Set()
269
322
  const diffOut = gitQuiet(repoPath, ['diff', '--name-only', 'HEAD~1..HEAD'], { timeout: GIT_TIMEOUT })
@@ -313,10 +313,13 @@ function newDomainPreamble(domain) {
313
313
  * 变更:行(2026-08-28,坑 distill-cross-change-supersede):D-xxx 编号是变更内局部序号,
314
314
  * 跨变更同号(两个 D-002)互不相干——条目必须携带变更名限定,消费方(knowledge-match/
315
315
  * docs-check 的字段行解析)只认各自标签,新增字段行是增量安全。 */
316
- function renderBlockLines(entry, headHash, supersedesNote, changeName) {
317
- const lines = [`## ${entry.id} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
316
+ function renderBlockLines(entry, headHash, supersedesNote, changeName, sectionNumber, originId) {
317
+ const lines = [`## ${sectionNumber}@v${entry.version} ${oneLine(entry.title)}`.replace(/\s+$/, '')]
318
318
  lines.push(`状态:${entry.selected}`)
319
319
  if (changeName) lines.push(`变更:${oneLine(changeName)}`)
320
+ // 原编号(2026-10-14-decision-global-id-epoch):epoch 形态溯源字段——变更行后/锚点行前
321
+ // (不破「文件:紧跟锚点」行序红线);legacy 复用段(originId=null)不渲染,形态零变化
322
+ if (originId) lines.push(`原编号:${oneLine(originId)}`)
320
323
  lines.push(`锚点:${oneLine(entry.anchor) || NOT_RECORDED}`)
321
324
  // 文件行(FR-01/D-001@v1):仅非空渲染——存量条目零迁移,幂等重归档不添空行
322
325
  if (entry.files && entry.files.length > 0) lines.push(`文件:${entry.files.join(', ')}`)
@@ -336,12 +339,14 @@ function renderBlockLines(entry, headHash, supersedesNote, changeName) {
336
339
  }
337
340
 
338
341
  /** 知识文件切段:preamble 行 + 条目段(段边界 = 下一节头)。
339
- * 段的变更归属取段内 `变更:<name>` 行;无该行的历史条目 → change=null(legacy:
340
- * 升级前落库的条目,不参与新条目的同号匹配/supersede——只共存不误删)。
342
+ * 段的变更归属取段内 `变更:<name>` 行,「来源:<name>` 兜底(2026-10-14-decision-id-p2-hardening:
343
+ * 08-24 首修形态残留——读出后带来源的 legacy 段参与同变更同号匹配/supersede,重蒸馏在位更新收敛);
344
+ * 无任何限定行的历史条目 → change=null(legacy:升级前落库的条目,不参与新条目的同号匹配——只共存不误删)。
341
345
  * 2026-09-18-fr-index-l1 参数化:sectionRegex/buildId 经参(decisions 缺省不变;fr 侧传 FR 节头
342
346
  * 正则与恒等 id),decisions 调用点零改动。 */
343
347
  export function splitKnowledgeSections(content, opts = {}) {
344
- const sectionRegex = opts.sectionRegex || /^## (D-\d+)@v(\d+)\s*(.*)$/
348
+ // 节头号双形态(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1 并存
349
+ const sectionRegex = opts.sectionRegex || /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
345
350
  const buildId = opts.buildId || ((n, v) => `${n}@v${v}`)
346
351
  const sections = []
347
352
  const preamble = []
@@ -359,8 +364,15 @@ export function splitKnowledgeSections(content, opts = {}) {
359
364
  if (cur) sections.push(cur)
360
365
  for (const s of sections) {
361
366
  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
367
+ // 「变更:」优先、「来源:」兜底(行内前缀按命中行剥除;无任何限定行 → null 不互认)
368
+ const cm = s.lines.find(l => /^变更[::]/.test(l)) || s.lines.find(l => /^来源[::]/.test(l))
369
+ s.change = cm ? cm.replace(/^(变更|来源)[::]\s*/, '').trim() || null : null
370
+ // 原编号(2026-10-14-decision-global-id-epoch 溯源字段):「原编号:D-011@v1」→ origin/originNumber;
371
+ // legacy 段无该行 → null(幂等匹配谓词按号兜底)
372
+ const oi = s.lines.find(l => /^原编号[::]/.test(l))
373
+ s.origin = oi ? oi.replace(/^原编号[::]\s*/, '').trim() || null : null
374
+ const on = s.origin ? /^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v?\d+/.exec(s.origin) : null
375
+ s.originNumber = on ? on[1] : null
364
376
  }
365
377
  return { preamble, sections }
366
378
  }
@@ -385,14 +397,23 @@ export function joinKnowledgeFile(preamble, sections) {
385
397
  * 升级前落库)不与任何新条目互认,只共存不误删。
386
398
  * @returns {{ action: 'append'|'update'|'supersede' }|null} null = 同变更同号高版本已落库,迟到低版本不降级。
387
399
  */
388
- function applyEntryToSections(sections, entry, headHash) {
400
+ /** 同变更同段匹配谓词(2026-10-14-decision-global-id-epoch 扩展):变更一致 且
401
+ * (段号==条目局部号 或 段.原编号号段==条目局部号)——legacy 段按号匹配、epoch 段按原编号匹配。
402
+ * @returns {{action: 'append'|'update'|'supersede'}|null} null = 同变更同段高版本已落库,迟到低版本不降级。 */
403
+ function sameDecisionNumber(s, entry) {
404
+ return (s.change || null) === (entry.change || null) && (s.number === entry.number || s.originNumber === entry.number)
405
+ }
406
+
407
+ function applyEntryToSections(sections, entry, headHash, sectionNumber, originId) {
389
408
  const change = entry.change || null
390
- const sameNumber = sections.filter(s => s.number === entry.number && (s.change || null) === change)
409
+ const sameNumber = sections.filter(s => sameDecisionNumber(s, entry))
391
410
  if (sameNumber.some(s => s.version > entry.version)) return null
392
411
  let action
393
412
  let supersedesNote = oneLine(entry.supersedes) || null
413
+ const sectionId = `${sectionNumber}@v${entry.version}`
414
+ const originNumber = originId ? (/^(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)/.exec(originId) || [])[1] || null : null
394
415
  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) })
416
+ 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
417
  action = 'append'
397
418
  } else {
398
419
  const same = sameNumber.find(s => s.version === entry.version)
@@ -400,10 +421,14 @@ function applyEntryToSections(sections, entry, headHash) {
400
421
  if (!same && !supersedesNote) supersedesNote = `${slot.number}@v${slot.version}`
401
422
  slot.version = entry.version
402
423
  slot.title = entry.title
403
- slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined)
404
- // 同变更同号旧版本段(< 新版本)就地清除——同文件内同变更同号只留一个段
424
+ slot.number = sectionNumber
425
+ slot.id = sectionId
426
+ slot.origin = originId
427
+ slot.originNumber = originNumber
428
+ slot.lines = renderBlockLines(entry, headHash, supersedesNote, change || undefined, sectionNumber, originId)
429
+ // 同变更同段(号或原编号匹配)旧版本就地清除——同文件内同变更同段只留一个段
405
430
  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)
431
+ if (sameDecisionNumber(sections[i], entry) && sections[i] !== slot) sections.splice(i, 1)
407
432
  }
408
433
  action = same ? 'update' : 'supersede'
409
434
  }
@@ -577,31 +602,62 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
577
602
  }
578
603
 
579
604
  const written = []
605
+ // 域全集(跨文件清理与复用扫描共用;磁盘 ∪ 本批已加载)
606
+ let onDisk = []
607
+ try { onDisk = readdirSync(decisionsDir).filter(f => f.endsWith('.md')).map(f => f.replace(/\.md$/, '')) } catch { /* 刚创建,走 store */ }
608
+ let allDomains = [...new Set([...onDisk, ...store.keys()])]
609
+ // 发号器(2026-10-14-decision-global-id-epoch):域前缀全域 max+1 只增不复用;mintMax 批内会话
610
+ // 计数防同批撞号(磁盘扫描只做初值);并发蒸馏撞号窗口与 FR 侧发号同病(接受,doctor 可见)。
611
+ const mintMax = new Map()
612
+ const mintFor = (domain) => {
613
+ if (!mintMax.has(domain)) {
614
+ let max = 0
615
+ for (const f of onDisk) {
616
+ let c; try { c = readFileSync(join(decisionsDir, f.endsWith('.md') ? f : `${f}.md`), 'utf8') } catch { continue }
617
+ const re = /^## D-([A-Za-z0-9_\-\u4e00-\u9fff]+?)-(\d+)@v\d+/gm
618
+ let m
619
+ while ((m = re.exec(c)) !== null) if (m[1] === domain) max = Math.max(max, parseInt(m[2], 10))
620
+ }
621
+ mintMax.set(domain, max)
622
+ }
623
+ const next = mintMax.get(domain) + 1
624
+ mintMax.set(domain, next)
625
+ return `D-${domain}-${String(next).padStart(3, '0')}`
626
+ }
580
627
  const targetSets = new Map() // entry → Set(域)(跨文件清理时排除目标文件)
581
628
  for (const entry of batch) {
582
629
  const domains = resolveDomains(entry, idx)
583
630
  targetSets.set(entry, new Set(domains))
631
+ // 号决策(2026-10-14-decision-global-id-epoch):同变更同段存量(全域文件扫描,号或原编号匹配)
632
+ // 复用其号——legacy 段保持局部形态(不补原编号不改前缀,存量零触碰);epoch 段原编号随版本更新;
633
+ // 无匹配 → 发号域=首域(resolveDomains 已 sanitize)批内 max+1,扇出多文件同一号(单一身份)。
634
+ let reuse = null
635
+ for (const d of allDomains) {
636
+ const st = loadDomain(d)
637
+ reuse = st.sections.find(s => sameDecisionNumber(s, entry)) || null
638
+ if (reuse) break
639
+ }
640
+ const sectionNumber = reuse ? reuse.number : mintFor(domains[0])
641
+ const originId = reuse && !reuse.origin ? null : `${entry.number}@v${entry.version}`
584
642
  for (const d of domains) {
585
643
  const st = loadDomain(d)
586
- const res = applyEntryToSections(st.sections, entry, headHash)
644
+ const res = applyEntryToSections(st.sections, entry, headHash, sectionNumber, originId)
587
645
  if (res) {
588
646
  st.dirty = true
589
- written.push({ file: `decisions/${d}.md`, id: entry.id, action: res.action })
647
+ written.push({ file: `decisions/${d}.md`, id: `${sectionNumber}@v${entry.version}`, action: res.action })
590
648
  }
591
649
  }
650
+ allDomains = [...new Set([...allDomains, ...domains])]
592
651
  }
593
652
 
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()])]
653
+ // 跨文件清理:同变更同段(号或原编号匹配)≤ 新版本的旧段散落其他域文件时移除(同变更同段
654
+ // 全局只留最高版本/目标文件);他者变更的同号段不参与(共存,坑 distill-cross-change-supersede)
599
655
  for (const entry of batch) {
600
656
  const targets = targetSets.get(entry)
601
657
  for (const d of allDomains) {
602
658
  if (targets.has(d)) continue
603
659
  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))
660
+ const removed = st.sections.filter(s => s.version <= entry.version && sameDecisionNumber(s, entry))
605
661
  if (removed.length === 0) continue
606
662
  st.sections = st.sections.filter(s => !removed.includes(s))
607
663
  st.dirty = true