sillyspec 3.32.9 → 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 (45) 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 +1 -1
  6. package/assets/command-cards/flow.md +1 -1
  7. package/package.json +1 -1
  8. package/src/commit-suggest.js +45 -4
  9. package/src/config-schema.js +14 -0
  10. package/src/decision-distill.js +77 -21
  11. package/src/decision-renumber.js +157 -0
  12. package/src/docs-check.js +30 -15
  13. package/src/docs-debt.js +3 -2
  14. package/src/doctor-diagnostics.js +3 -1
  15. package/src/flow-parity.js +18 -2
  16. package/src/flow.js +75 -12
  17. package/src/fr-index.js +15 -1
  18. package/src/hooks/worktree-guard.js +3 -3
  19. package/src/index.js +7 -5
  20. package/src/knowledge-digest.js +23 -6
  21. package/src/knowledge-graph.js +92 -14
  22. package/src/knowledge-match.js +34 -9
  23. package/src/knowledge-stats.js +12 -13
  24. package/src/knowledge-vector.js +98 -7
  25. package/src/machine-interface.js +26 -0
  26. package/src/quicklog.js +6 -1
  27. package/src/run/archive-distill.js +3 -0
  28. package/src/run/command.js +3 -3
  29. package/src/run/complete-handlers.js +8 -3
  30. package/src/run/complete.js +113 -44
  31. package/src/run/gates.js +93 -89
  32. package/src/run/prompt.js +3 -3
  33. package/src/run/stage.js +1 -1
  34. package/src/sentinel-assertions.js +43 -1
  35. package/src/stage-contract.js +26 -3
  36. package/src/stage-review.js +44 -11
  37. package/src/stage-templates.js +2 -0
  38. package/src/stages/brainstorm.js +5 -1
  39. package/src/stages/knowledge.js +56 -5
  40. package/src/sync.js +28 -2
  41. package/src/task-tick.js +12 -0
  42. package/src/timeline.js +60 -19
  43. package/src/verify-postcheck.js +49 -1
  44. package/src/verify-probes.js +61 -18
  45. package/templates/agents-instruction.md +1 -1
@@ -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
@@ -16,7 +16,7 @@
16
16
  `--input` 过门格式(轻量道首次调用即过,勿靠报错学;引号内换行合法,下例可照抄):
17
17
 
18
18
  ```bash
19
- sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>
19
+ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景
20
20
 
21
21
  成功标准:
22
22
  - <可验证标准>"
@@ -11,7 +11,7 @@ description: 轻量变更(默认快道):需求明确的小/中改动两步
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
  - <可验证标准>"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.32.9",
3
+ "version": "3.32.10",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -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。
@@ -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
@@ -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 }
package/src/docs-check.js CHANGED
@@ -1038,12 +1038,20 @@ export function readDecisionRulesConfig(projectRoot) {
1038
1038
  }
1039
1039
  }
1040
1040
 
1041
- /** 豁免键匹配(本变更范围先支持键匹配,非子串):规则级 `decisions.<id>.<kind>` 精确命中,
1042
- * 或条目级 `decisions.<id>` 伞形命中(一条豁免该决策全部规则)。无 decisions. 前缀的键不参与。 */
1043
- function isDecisionExempt(knownFailures, entryId, kind) {
1044
- const ruleKey = `decisions.${entryId}.${kind}`
1045
- const entryKey = `decisions.${entryId}`
1046
- return knownFailures.some(k => k === ruleKey || k === entryKey)
1041
+ /** 豁免键匹配(键匹配非子串):域限定键 `decisions.<域>/<id>.<kind>`(规则级精确)与
1042
+ * `decisions.<域>/<id>`(条目级伞形)——2026-10-14-decision-id-p2-hardening:裸键无域限定,
1043
+ * 一条 decisions.<id> 可静默豁免全库同号条目(D-001@v1 ×296 潜伏面),域限定键收窄命中面;
1044
+ * 裸键 `decisions.<id>[.<kind>]` 保留兼容(消费仓既有配置不失效)。id 与 originId(原编号,
1045
+ * 2026-10-14-decision-legacy-renumber)双匹配——存量迁移后旧号键继续命中。
1046
+ * 无 decisions. 前缀的键不参与。导出(测试面先例 anchorFilePath)。 */
1047
+ export function isDecisionExempt(knownFailures, entryId, kind, domain = null, originId = null) {
1048
+ const ids = originId ? [entryId, originId] : [entryId]
1049
+ const keys = []
1050
+ for (const id of ids) {
1051
+ keys.push(`decisions.${id}.${kind}`, `decisions.${id}`)
1052
+ if (domain) keys.push(`decisions.${domain}/${id}.${kind}`, `decisions.${domain}/${id}`)
1053
+ }
1054
+ return knownFailures.some(k => keys.includes(k))
1047
1055
  }
1048
1056
 
1049
1057
  /**
@@ -1051,25 +1059,32 @@ function isDecisionExempt(knownFailures, entryId, kind) {
1051
1059
  * 的写入契约对偶:producer=decision-distill → consumer=本规则族;字段行非列表项,顶层
1052
1060
  * 「状态:/锚点:/最近确认:」标签精确匹配,防误吞正文)。旧格式字段缺失容错为 null。
1053
1061
  * @param {string} md 域文件全文
1054
- * @returns {Array<{ id: string, title: string, status: string|null, anchor: string|null, lastConfirmed: string|null }>}
1062
+ * @returns {Array<{ id: string, title: string, status: string|null, anchor: string|null, lastConfirmed: string|null, change: string|null }>}
1055
1063
  */
1056
1064
  export function parseDecisionEntries(md) {
1057
1065
  const entries = []
1058
1066
  let cur = null
1059
1067
  const flush = () => { if (cur) entries.push(cur) }
1060
1068
  for (const line of String(md || '').replace(/\r\n?/g, '\n').split('\n')) {
1061
- const h = line.match(/^##\s+(D-\d+)(?:@v(\d+))?\s*(.*)$/)
1069
+ const h = line.match(/^##\s+(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)(?:@v(\d+))?\s*(.*)$/)
1062
1070
  if (h) {
1063
1071
  flush()
1064
1072
  const version = h[2] ? parseInt(h[2], 10) : 1
1065
- cur = { id: `${h[1]}@v${version}`, title: h[3].trim(), status: null, anchor: null, lastConfirmed: null }
1073
+ cur = { id: `${h[1]}@v${version}`, title: h[3].trim(), status: null, anchor: null, lastConfirmed: null, change: null, originId: null }
1066
1074
  continue
1067
1075
  }
1068
1076
  if (!cur) continue
1069
- const f = line.match(/^(状态|锚点|最近确认)\s*[::]\s*(.*)$/)
1077
+ // 变更/来源/原编号:标签读入(变更:2026-10-14-supersedes-mislink-and-change-display 加法字段——
1078
+ // docs-debt 触碰事实行渲染变更名限定词消歧需读入;来源:2026-10-14-decision-id-p2-hardening legacy
1079
+ // 兜底——变更行无条件覆盖在前、来源仅补缺;原编号:2026-10-14-decision-legacy-renumber——存量迁移
1080
+ // 后条目 id 为全局号,消费仓旧豁免键(decisions.D-002@vN)经原编号继续命中;旧条目缺行容错 null)
1081
+ const f = line.match(/^(状态|锚点|最近确认|变更|来源|原编号)\s*[::]\s*(.*)$/)
1070
1082
  if (!f) continue
1071
1083
  if (f[1] === '状态') cur.status = f[2].trim().split(/[\s((]/)[0] || null
1072
1084
  else if (f[1] === '锚点') cur.anchor = f[2].trim()
1085
+ else if (f[1] === '变更') cur.change = f[2].trim() || null
1086
+ else if (f[1] === '来源') cur.change = cur.change || (f[2].trim() || null)
1087
+ else if (f[1] === '原编号') cur.originId = f[2].trim() || null
1073
1088
  else cur.lastConfirmed = f[2].trim()
1074
1089
  }
1075
1090
  flush()
@@ -1148,8 +1163,8 @@ export async function runDecisionRules(opts) {
1148
1163
  let implementedTotal = 0
1149
1164
  const treeCache = new Map() // resolveCandidates 全树扫描复用(同 docs check 主链路口径)
1150
1165
 
1151
- const emit = (id, domain, file, kind, extra, message) => {
1152
- if (isDecisionExempt(failures, id, kind)) {
1166
+ const emit = (id, domain, file, kind, extra, message, originId = null) => {
1167
+ if (isDecisionExempt(failures, id, kind, domain, originId)) {
1153
1168
  exempted.push({ key: `decisions.${id}.${kind}`, id, kind })
1154
1169
  return
1155
1170
  }
@@ -1169,10 +1184,10 @@ export async function runDecisionRules(opts) {
1169
1184
  // 规则 1:锚点存在性(未记录 → 补录提示;路径不存在 → 失效提示)
1170
1185
  if (!anchorPath) {
1171
1186
  emit(e.id, domain, rel, 'anchor', { anchor: e.anchor || null },
1172
- `「${e.id}」锚点未记录(${rel})——旧格式条目,补「锚点:src/…」后可机械校验(advisory)`)
1187
+ `「${e.id}」锚点未记录(${rel})——旧格式条目,补「锚点:src/…」后可机械校验(advisory)`, e.originId)
1173
1188
  } else if (resolveCandidates(projectRoot, anchorPath, treeCache).length === 0) {
1174
1189
  emit(e.id, domain, rel, 'anchor', { anchor: e.anchor },
1175
- `「${e.id}」锚点文件不存在:${anchorPath}(${rel})——决策落点已漂移,人工复核(advisory)`)
1190
+ `「${e.id}」锚点文件不存在:${anchorPath}(${rel})——决策落点已漂移,人工复核(advisory)`, e.originId)
1176
1191
  }
1177
1192
  // 规则 2:behind 复核(「最近确认」后锚定模块源码前进数超阈值 → 决策待复核;
1178
1193
  // 「最近确认」未记录/域无源码集(无 map 无锚点)→ 无法计算,静默跳过——advisory 不产噪声)
@@ -1185,7 +1200,7 @@ export async function runDecisionRules(opts) {
1185
1200
  const { behind } = computeModuleBehind(domain, e.lastConfirmed, { projectRoot, srcPaths })
1186
1201
  if (behind !== null && behind > threshold) {
1187
1202
  emit(e.id, domain, rel, 'behind', { behind, threshold },
1188
- `「${e.id}」决策待复核:锚定模块 ${domain} 在最近确认 ${e.lastConfirmed} 后源码已前进 ${behind} commit,超阈值 ${threshold}(${rel})——复核后更新「最近确认」`)
1203
+ `「${e.id}」决策待复核:锚定模块 ${domain} 在最近确认 ${e.lastConfirmed} 后源码已前进 ${behind} commit,超阈值 ${threshold}(${rel})——复核后更新「最近确认」`, e.originId)
1189
1204
  }
1190
1205
  }
1191
1206
  }