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
package/src/fr-index.js CHANGED
@@ -21,6 +21,7 @@ import { readChangeTrace, upsertFrBindings, applySupersededToEntryLines, testAnc
21
21
  import { suggestDomainFromFiles, snapDomainToDictionary } from './knowledge-digest.js'
22
22
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
23
23
  import { basename, dirname, join } from 'path';
24
+ import { execFileSync } from 'node:child_process';
24
25
  import { writeAtomicSync } from './fs-atomic.js';
25
26
  import {
26
27
  splitKnowledgeSections,
@@ -73,6 +74,19 @@ export function frCoverageFiles({ archiveRoot, changeName }) {
73
74
  }
74
75
  }
75
76
  } catch { /* 源2 fail-soft */ }
77
+ try {
78
+ // 源3(2026-10-15-rot-coverage-git-source):thin 归档常无交付表无 patch 文件清单——覆盖面
79
+ // 从 git 历史恢复:提交主题含变更名的提交的 name-only 文件集(--grep 子串匹配,变更名含
80
+ // 日期前缀足够特异;剔除 .sillyspec/ 自身工件面)。只读确定性;调用侧 covCache 每运行缓存,
81
+ // 非 git 仓/无记录 fail-soft=源缺(六顽固化 unknown 来源实测全部可恢复 1~23 文件)。
82
+ const gitOut = execFileSync('git', ['log', '--all', '--name-only', '--pretty=format:COMMIT', '--grep', String(changeName || '')],
83
+ { cwd: archiveRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
84
+ for (const f of String(gitOut).split(/\r?\n/)) {
85
+ const v = f.trim().replace(/\\/g, '/');
86
+ if (!v || v.startsWith('COMMIT') || v.startsWith('.sillyspec/')) continue;
87
+ out.add(v);
88
+ }
89
+ } catch { /* 源3 fail-soft(非 git 仓/无记录) */ }
76
90
  return [...out].filter((p) => !p.startsWith('.sillyspec/'));
77
91
  }
78
92
 
@@ -873,7 +887,7 @@ export function markSkeletonThin(knowledgeRoot) {
873
887
  * active FR × 触碰文件 → hits(覆盖相交)/ skip(覆盖可判无交集)/ unknown(覆盖源缺失)。
874
888
  * 判据逐字不变——三消费方(rot/测试门/注入排序)共用同一实现,防口径漂移。
875
889
  * covCache 供调用方跨查询复用归档读取。 */
876
- function partitionActiveByCoverage({ archiveRoot, frs, changed, covCache = new Map() }) {
890
+ export function partitionActiveByCoverage({ archiveRoot, frs, changed, covCache = new Map() }) {
877
891
  const hits = []
878
892
  const unknownSources = new Set()
879
893
  let skip = 0
@@ -61,7 +61,7 @@ const STAGE_HINTS = {
61
61
  '你需要先启动一个任务流程才能修改源码(调用对应的 sillyspec skill):',
62
62
  '',
63
63
  ' 轻量变更/BUG修复(默认快道,skill sillyspec-flow)——--input 引号内换行合法,下例可照抄:',
64
- ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>',
64
+ ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景',
65
65
  '',
66
66
  ' 成功标准:',
67
67
  ' - <可验证标准>"',
@@ -80,7 +80,7 @@ const STAGE_HINTS = {
80
80
  'verify': [
81
81
  '当前在 verify(验证)阶段,只做代码审查和测试验证,不修改源码。',
82
82
  '如需修改,请先回到 execute 阶段,或收口后开轻量变更(--input 引号内换行合法,可照抄):',
83
- ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>',
83
+ ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景',
84
84
  '',
85
85
  ' 成功标准:',
86
86
  ' - <可验证标准>"',
@@ -88,7 +88,7 @@ const STAGE_HINTS = {
88
88
  'archive': [
89
89
  '当前在 archive(归档)阶段,不修改源码。',
90
90
  '如需修改,请开启新变更(--input 引号内换行合法,可照抄):',
91
- ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>',
91
+ ' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景',
92
92
  '',
93
93
  ' 成功标准:',
94
94
  ' - <可验证标准>"',
package/src/index.js CHANGED
@@ -171,7 +171,7 @@ SillySpec CLI — 规范驱动开发工具包
171
171
  示例:
172
172
  sillyspec init
173
173
  sillyspec run brainstorm --change 2026-07-03-add-login
174
- sillyspec flow start --change 2026-07-03-my-fix --input "<动机与背景+独立一行『成功标准:』+每行一条『- 可验证标准』>" # 轻量变更(默认快道,2 次调用;变更名须 YYYY-MM-DD-<简短描述> 日期前缀)
174
+ sillyspec flow start --change 2026-07-03-my-fix --input "<一句中文概括(首行即变更标题)+动机与背景详述+独立一行『成功标准:』+每行一条『- 可验证标准』>" # 轻量变更(默认快道,2 次调用;变更名须 YYYY-MM-DD-<简短描述> 日期前缀)
175
175
  sillyspec run verify --done --output "验证通过,测试全绿"
176
176
  sillyspec run archive --done --confirm --output "归档完成"
177
177
  sillyspec run plan --reopen --from-step 2 # 修订 plan,从第 2 步重做
@@ -226,7 +226,7 @@ function refuseRetiredQuickFreshStart(stageArgs) {
226
226
  console.error('❌ quick 通道已退役,不再接受新会话(v3.30.0 起)。')
227
227
  // 可照抄实例(2026-10-05-input-teach-copyable):引号内换行合法,行级 trim 容忍缩进
228
228
  console.error(' 新工作请走轻量变更(--input 引号内换行合法,下例可照抄):')
229
- console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<动机与背景>')
229
+ console.error(' sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
230
230
  console.error('')
231
231
  console.error(' 成功标准:')
232
232
  console.error(' - <可验证标准>"')
@@ -3382,17 +3382,19 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
3382
3382
  // --from <域> --to <域> [--anchor <FR-id> 可重复];干跑缺省列条目,--write 落盘。
3383
3383
  // 身份铁律:迁域不换号(绑定/supersede 链靠 ID 寻址);平台伪域卡「一键迁移」的 CLI 端。
3384
3384
  if (has('--redomain')) {
3385
- const fromD = flag('--from')
3386
- const toD = flag('--to')
3385
+ const { planRedomain, redomainFrEntries, normalizeDomainArg } = await import('./redomain.js')
3386
+ // 入参归一化(2026-10-14-redomain-domain-arg-normalize):--from/--to 容忍
3387
+ // flow done 提示语原样形态(fr/auto-backend、auto-backend.md),回显/报错统一裸域名。
3388
+ const fromD = normalizeDomainArg(flag('--from'))
3389
+ const toD = normalizeDomainArg(flag('--to'))
3387
3390
  // --by-change(坑 fr-domain-suggest-typo-and-no-split-migration 缺陷2):unmapped 等
3388
3391
  // 混合池按「变更:<名>」字段分批迁移——整域迁移会错置大部分条目,来源变更才是分批键。
3389
3392
  const byChangeD = flag('--by-change')
3390
- if (!fromD || !toD) { fail('用法: sillyspec tests --redomain --from <源域> --to <目标域> [--anchor <FR-id>] [--by-change <变更名>] [--write]'); break }
3393
+ if (!fromD || !toD) { fail('用法: sillyspec tests --redomain --from <源域> --to <目标域> [--anchor <FR-id>] [--by-change <变更名>] [--write]\n 域名可带 fr/ 前缀与 .md 后缀(fr/auto-backend ≍ auto-backend.md ≍ auto-backend)'); break }
3391
3394
  // 多 --anchor 收集
3392
3395
  const anchorList = []
3393
3396
  for (let i = 0; i < filteredArgs.length - 1; i++) if (filteredArgs[i] === '--anchor') anchorList.push(filteredArgs[i + 1])
3394
3397
  try {
3395
- const { planRedomain, redomainFrEntries } = await import('./redomain.js')
3396
3398
  if (!has('--write')) {
3397
3399
  // 预览与落盘同口径透传 byChange(坑 redomain-plan-preview-by-change-ignored,
3398
3400
  // 2026-10-02 实证:76338b4d 收编在途工作时此行漏透传——预览列整域、落盘只迁
@@ -4397,7 +4399,15 @@ SillySpec platform — SillyHub 平台同步
4397
4399
  const syncChangeName = syncChangeIdx >= 0 && args[syncChangeIdx + 1] && !String(args[syncChangeIdx + 1]).startsWith("--") ? args[syncChangeIdx + 1] : null;
4398
4400
  if (syncChangeName) {
4399
4401
  // manual:用户显式同步,change_deleted 回执噪音闸旁路(显式动作的回执必须可见)
4400
- await syncModule.sync(syncChangeName, dir, { manual: true });
4402
+ // 失败必须 exit 1(2026-10-13-sync-ledger-tombstone,事故 2026-10-13-new-change-e5a27813):
4403
+ // 此前忽略返回值恒 exit 0——变更不存在/未连接平台都「假成功」,daemon RPC
4404
+ // (sillyspec.sync_change)据退出码回执,假成功掩盖行缺失。成功路径(含
4405
+ // change_deleted 预期回执、台账墓碑补推)errors 为空,exit 0 语义不变。
4406
+ const syncResult = await syncModule.sync(syncChangeName, dir, { manual: true });
4407
+ if (syncResult && Array.isArray(syncResult.errors) && syncResult.errors.length > 0) {
4408
+ console.error(`❌ platform sync 失败(${syncChangeName}): ${syncResult.errors.join(';')}`);
4409
+ process.exit(1);
4410
+ }
4401
4411
  } else {
4402
4412
  // 全树 spec 文件同步(2026-10-11-sync-conflict-tri-fix FR-03):不带 --change
4403
4413
  // 此前报「需要指定变更名称」——归档/历史变更无活跃会话标签时无从下手。现走
@@ -5489,7 +5499,7 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5489
5499
  console.error(` 事件流路径:${join(tRuntimeRoot, `watcher-events-${tChange}.jsonl`)};活跃变更跑任意 flow/run 命令会拉起 watcher`);
5490
5500
  process.exit(2);
5491
5501
  }
5492
- const { changeDir, tasksMd } = loadChangeTasks(tSpecBase, tChange);
5502
+ const { changeDir, tasksMd, tickLog } = loadChangeTasks(tSpecBase, tChange);
5493
5503
  const tasks = tasksMd ? parseTaskLines(tasksMd) : null;
5494
5504
  let tier = null;
5495
5505
  if (changeDir) {
@@ -5499,6 +5509,8 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5499
5509
  } catch { /* 缺 flow-state(头脑风暴预段等)按未知 */ }
5500
5510
  }
5501
5511
  const { gitQuiet } = await import('./git-helper.js');
5512
+ // 锚收敛(2026-10-14-timeline-ticktime-anchor-scope):change 参进锚解析——消息不含本变更
5513
+ // 名的提交不参与任务行锚定(跨变更 token 撞车防线);事件时间轴不受影响。
5502
5514
  const anchors = resolveCommitAnchors(tRes.events, (hash) => {
5503
5515
  const out = gitQuiet(dir, ['log', '-1', '--format=%h%x1f%aI%x1f%B', hash]);
5504
5516
  if (typeof out !== 'string' || !out.trim()) return null;
@@ -5506,8 +5518,8 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5506
5518
  const rest = out.slice(sep + 1);
5507
5519
  const sep2 = rest.indexOf('\x1f');
5508
5520
  return { hash: out.slice(0, sep), dateISO: rest.slice(0, sep2), message: rest.slice(sep2 + 1) };
5509
- });
5510
- console.log(renderTimeline({ change: tChange, events: tRes.events, tasks, anchors, birthTs: readBirthTs(changeDir), tier }));
5521
+ }, tChange);
5522
+ console.log(renderTimeline({ change: tChange, events: tRes.events, tasks, anchors, birthTs: readBirthTs(changeDir), tier, tickLog }));
5511
5523
  if (tSourceNote) console.log(tSourceNote);
5512
5524
  break;
5513
5525
  }
@@ -152,6 +152,14 @@ export function collectKnowledgeDigest({ specBase, projectRoot }) {
152
152
  mk('inbox', 'warn', `知识收件箱积压(${totals.inbox} 条 > 20)`, totals.inbox,
153
153
  inboxTitles.slice(0, 5).join(';') + (inboxTitles.length > 5 ? ' 等' : ''),
154
154
  'sillyspec knowledge inbox 逐条 classify 清账')
155
+ } else if (totals.inbox > 0) {
156
+ // 低量收件箱可见性(2026-10-15-classify-surface):>20 才报警导致 1~20 条常态化静默——
157
+ // classify 遥测零消费实证(工具在、入口隐身)。info 级提示给可执行命令(classify 零使用
158
+ // 的接入点);不进 healthy 判定否决——info 信号 healthy 语义见下(healthy=signals 全空,
159
+ // info 在场仍算 healthy=false?否——info 级降噪:healthy 只看 warn 级)。
160
+ mk('inbox', 'info', `知识收件箱 ${totals.inbox} 条待归类`, totals.inbox,
161
+ inboxTitles.slice(0, 5).join(';') + (inboxTitles.length > 5 ? ' 等' : ''),
162
+ 'sillyspec knowledge inbox 看清单 → knowledge classify --ql <id> --file <known-issues.md|patterns.md|conventions.md> [--dry-run] 逐条归类')
155
163
  }
156
164
  if (totals.pseudo > 0) {
157
165
  mk('pseudo-domain', 'warn', `伪域在库(auto-*/unmapped 共 ${totals.pseudo} 条)`, totals.pseudo,
@@ -166,7 +174,9 @@ export function collectKnowledgeDigest({ specBase, projectRoot }) {
166
174
 
167
175
  return {
168
176
  generated_at: new Date().toISOString(),
169
- healthy: signals.length === 0,
177
+ // healthy 只看 warn 级(2026-10-15-classify-surface):info 是提示不是超阈——
178
+ // 低量收件箱提示不该把治理卡打成人人自危的告警面;平台 governance 消费同语义。
179
+ healthy: !signals.some((s) => s.level === 'warn'),
170
180
  signals,
171
181
  totals,
172
182
  }
@@ -176,15 +186,22 @@ export function collectKnowledgeDigest({ specBase, projectRoot }) {
176
186
  export function renderKnowledgeDigestText(d) {
177
187
  const lines = []
178
188
  lines.push(`📬 知识资产治理摘要(${d.generated_at.slice(0, 16).replace('T', ' ')})`)
179
- if (d.healthy) {
180
- lines.push('✅ 三类信号全部在阈内(inbox/伪域/坏绑定)——安静即健康态,无需动作')
181
- } else {
182
- lines.push(`⚠️ ${d.signals.length} 类信号超阈:`)
183
- for (const s of d.signals) {
189
+ const warns = d.signals.filter((s) => s.level === 'warn')
190
+ const infos = d.signals.filter((s) => s.level === 'info')
191
+ if (warns.length > 0) {
192
+ lines.push(`⚠️ ${warns.length} 类信号超阈:`)
193
+ for (const s of warns) {
184
194
  lines.push(` [${s.kind}] ${s.title}`)
185
195
  if (s.detail) lines.push(` 明细:${s.detail}`)
186
196
  if (s.suggestion) lines.push(` 处置:${s.suggestion}`)
187
197
  }
198
+ } else {
199
+ lines.push('✅ 超阈信号零(inbox/伪域/坏绑定)——健康态')
200
+ }
201
+ for (const s of infos) {
202
+ lines.push(`ℹ️ [${s.kind}] ${s.title}`)
203
+ if (s.detail) lines.push(` 明细:${s.detail}`)
204
+ if (s.suggestion) lines.push(` 处置:${s.suggestion}`)
188
205
  }
189
206
  lines.push(`底数:收件箱 ${d.totals.inbox}|伪域条目 ${d.totals.pseudo}|坏绑定 ${d.totals.unresolvedBindings}`)
190
207
  return lines.join('\n')
@@ -103,6 +103,7 @@ export function buildKnowledgeGraph(specRoot) {
103
103
 
104
104
  // ── ② 决策库(parseDecisionEntries:去重键 file+id+change 跨变更同号不折叠) ──
105
105
  const decisionHits = existsSync(join(knowledgeDir, 'INDEX.md')) ? parseDecisionEntries(knowledgeDir) : []
106
+ const supersedesRefs = []
106
107
  for (const h of decisionHits) {
107
108
  const id = `decision:${h.file}#${h.id}${h.change ? `@${h.change}` : ''}`
108
109
  addNode(id, 'decision', `${h.id} ${h.title || ''}`.trim(), {
@@ -123,17 +124,45 @@ export function buildKnowledgeGraph(specRoot) {
123
124
  else addNode(h.change, 'change', h.change)
124
125
  addEdge(id, h.change, 'from-change')
125
126
  }
126
- // supersedes:同域 D-id 解析(「supersedes:无(…)」等非 id 值被正则白名单滤掉;跨变更同号时取同变更优先,再退首个)
127
- const supIds = String(h.supersedesText || '').split(/[,,、\s]+/).filter((s) => /^D-\d+@v\d+$/.test(s))
127
+ // supersedes:同域 D-id 解析(「supersedes:无(…)」等非 id 值被正则白名单滤掉)。目标必须同变更
128
+ // (「变更:」行一致,含双方均无该行的 legacy 同源)——D 号是变更内局部序号,跨变更同号互不相干;
129
+ // v2 蒸馏落地时同变更 v1 段已被就地替换,此时无目标即不建边(2026-10-14-supersedes-mislink-and-change-display:
130
+ // 旧「再退同文件首个」回退几乎必然连到他变更同号条目,实测 33 条误连,回退已除)。epoch 形态段
131
+ // (2026-10-14-decision-global-id-epoch)id 为全局号——局部号引用经「原编号」字段匹配(originId)。
132
+ const supIds = String(h.supersedesText || '').split(/[,,、\s]+/).filter((s) => /^D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+$/.test(s))
128
133
  for (const sid of supIds) {
129
- const target = decisionHits.find((x) => x.id === sid && x.file === h.file && (x.change || '') === (h.change || ''))
130
- || decisionHits.find((x) => x.id === sid && x.file === h.file)
134
+ const target = decisionHits.find((x) => (x.id === sid || x.originId === sid) && x.file === h.file && (x.change || '') === (h.change || ''))
131
135
  if (target) {
132
- const tid = `decision:${target.file}#${target.id}${target.change ? `@${target.change}` : ''}`
133
- addEdge(id, tid, 'supersedes')
136
+ supersedesRefs.push([id, `decision:${target.file}#${target.id}${target.change ? `@${target.change}` : ''}`])
137
+ continue
138
+ }
139
+ // 幽灵版本节点(2026-10-15-supersedes-ghost-nodes):同号版本取代是蒸馏就地替换(旧段被删,
140
+ // 跨文件同谓词同删),自身旧版本引用的目标从不在场——真实库实证 39 条引用 0 条可解析、
141
+ // 703 决策节点全图仅 1 条 supersedes 边(边体系闲置根因)。合成口径**只限自身版本链**:
142
+ // sid 基号 === 本条目 id 基号(legacy 同号自链)或 === originId 基号(epoch 原编号自链,
143
+ // 迁移后真实库 36/36 全为此形态);跨号/跨变更缺失引用不合成(2026-10-14-supersedes-mislink
144
+ // 的跨变更误连防护不因幽灵形态复活:为他号凭空铸造节点=误连换皮)。ghost 只带 supersedes
145
+ // 入边(非孤儿)、不参与 anchors/dangling 面(supersedes 不在悬空检测边型集),impact 闭包
146
+ // 经传递边自然延伸。
147
+ const ghostBase = (s) => (/^D-((?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v\d+$/.exec(String(s || '')) || [])[1]
148
+ const selfBase = ghostBase(h.id)
149
+ const originBase = h.originId ? ghostBase(h.originId) : null
150
+ const sidBase = ghostBase(sid)
151
+ if ((selfBase && sidBase === selfBase) || (originBase && sidBase === originBase)) {
152
+ const gid = `decision:${h.file}#${sid}${h.change ? `@${h.change}` : ''}`
153
+ if (!nodes.has(gid)) {
154
+ addNode(gid, 'decision', `${sid}(已被 ${h.id} 取代)`, {
155
+ ghost: true, status: 'superseded', supersededBy: h.id,
156
+ domain: String(h.file || '').replace(/^decisions\//, '').replace(/\.md$/, ''),
157
+ })
158
+ }
159
+ supersedesRefs.push([id, gid])
134
160
  }
135
161
  }
136
162
  }
163
+ // 目标节点可能晚于引用者定义(同文件行序)——addEdge 要求两端节点在场,引用者先行遍历会静默丢边;
164
+ // 边统一在节点面建齐后补挂(2026-10-14-supersedes-mislink-and-change-display 测试实证)
165
+ for (const [s, t] of supersedesRefs) addEdge(s, t, 'supersedes')
137
166
 
138
167
  // ── ③ FR 索引(fr/<域>.md:变更/状态/superseded_by/取代链/测试绑定子块) ──
139
168
  const frDir = join(knowledgeDir, 'fr')
@@ -542,6 +571,27 @@ export function graphChangelogDanglings(graph, { existsFn = existsSync } = {}) {
542
571
  return out
543
572
  }
544
573
 
574
+ /** 登记仓前缀(graph 惰性缓存):<specRoot>/projects/*.yaml 的 name: 字段——本 spec 树管理
575
+ * 多仓时(register-repo 机制),跨仓交付路径(backend/**、frontend/**…)按前缀归到登记仓名下,
576
+ * 不再落 _unmapped 大簇(2026-10-15-graph-registry-domain-split:真图 2183 未挂模块文件节点
577
+ * 实测 1220 为跨仓前缀、421 为本仓 test 面)。无 projects 目录/解析失败 = 空集(fail-soft)。 */
578
+ function registryProjectPrefixes(graph) {
579
+ if (graph._projPrefixes) return graph._projPrefixes
580
+ const out = []
581
+ try {
582
+ const dir = join(String(graph.root || ''), 'projects')
583
+ if (existsSync(dir)) {
584
+ for (const f of readdirSync(dir)) {
585
+ if (!f.endsWith('.yaml')) continue
586
+ const m = /^name:\s*([\w-]+)\s*$/m.exec(readFileSync(join(dir, f), 'utf8'))
587
+ if (m) out.push(m[1])
588
+ }
589
+ }
590
+ } catch { /* projects 缺席/不可读 = 无登记前缀 */ }
591
+ graph._projPrefixes = out
592
+ return out
593
+ }
594
+
545
595
  /** 节点 → 簇域(lite 总览聚类键 type×domain;缺省占位防散簇)。 */
546
596
  function graphNodeDomain(graph, n) {
547
597
  switch (n.type) {
@@ -550,7 +600,20 @@ function graphNodeDomain(graph, n) {
550
600
  case 'file': {
551
601
  // module-files 精确挂接反查(多挂取最长模块 id——窄域优先)
552
602
  const mods = graph.edges.filter((e) => e.type === 'module-files' && e.t === n.id).map((e) => e.s.replace(/^module:/, ''))
553
- return mods.sort((a, b) => b.length - a.length)[0] || '_unmapped'
603
+ if (mods.length > 0) return mods.sort((a, b) => b.length - a.length)[0]
604
+ // 登记仓前缀归属(2026-10-15-graph-registry-domain-split):跨仓交付路径按 projects 登记
605
+ // 名归簇(最长前缀优先、大小写不敏感——登记名 SillyHub vs 路径 sillyhub/ 实证);本仓
606
+ // 测试面归 _tests——残余 _unmapped 只剩真无归属面(module-map 覆盖缺口,可行动)
607
+ const id = String(n.id)
608
+ const idLower = id.toLowerCase()
609
+ let best = ''
610
+ for (const p of registryProjectPrefixes(graph)) {
611
+ const pl = p.toLowerCase()
612
+ if (idLower.startsWith(`${pl}/`) && pl.length > best.length) best = p
613
+ }
614
+ if (best) return best
615
+ if (/^(test|tests)\//.test(id)) return '_tests'
616
+ return '_unmapped'
554
617
  }
555
618
  case 'module':
556
619
  return n.id.replace(/^module:/, '')
@@ -573,12 +636,18 @@ export function graphSummary(graph, { existsFn = existsSync, clustersLimit = 0 }
573
636
  const orphans = graphOrphans(graph).length
574
637
  const dangling = graphDangling(graph, { existsFn })
575
638
  const danglingRefs = dangling.length
639
+ // 跨仓口径分流(2026-10-15-graph-dangling-crossrepo-split):知识库集中管理仓、代码分仓存放
640
+ // 是本体系常态拓扑——跨仓强边锚点(顶级目录本仓不存在)永不可在本仓解析却全数计入 dangling_refs,
641
+ // 健康数字长期失真(本仓实证 2537 中 2472 为跨仓注记)。主计数与守恒口径不动(消费方零回归),
642
+ // 增加分流字段:breakdown.cross_repo=跨仓注记数、dangling_refs_local=本仓可行动悬空数。
643
+ const danglingCrossRepo = dangling.filter((d) => d.crossRepo).length
576
644
  // 分强度计数(附加字段——dangling_refs 是两类之和,消费方对账 doctor 时按 breakdown 拆)
577
645
  const danglingBreakdown = dangling.reduce((a, d) => {
578
646
  if (EDGE_STRENGTH[d.edge.type] === 'strong') a.strong_anchors++
579
647
  else if (EDGE_STRENGTH[d.edge.type] === 'medium') a.medium_doc_refs++
580
648
  return a
581
649
  }, { strong_anchors: 0, medium_doc_refs: 0 })
650
+ danglingBreakdown.cross_repo = danglingCrossRepo
582
651
  const moduleDocGaps = graphModuleDocGaps(graph).length
583
652
  const changelogDanglings = graphChangelogDanglings(graph, { existsFn }).length
584
653
  // clusters:type×domain 聚簇,representatives 度数 top-5(同度按 id 字典序稳序)
@@ -612,7 +681,10 @@ export function graphSummary(graph, { existsFn = existsSync, clustersLimit = 0 }
612
681
  module_doc_gaps: moduleDocGaps,
613
682
  changelog_danglings: changelogDanglings,
614
683
  dangling_refs: danglingRefs,
615
- // dangling_refs 构成(附加字段,向后兼容):doctor graph-dangling-anchor(强)+ graph-doc-dangling-ref(中)之和
684
+ // 本仓可行动悬空(附加字段,跨仓口径分流):dangling_refs − 跨仓注记——治理动作只看这个数
685
+ dangling_refs_local: danglingRefs - danglingCrossRepo,
686
+ // dangling_refs 构成(附加字段,向后兼容):doctor graph-dangling-anchor(强)+ graph-doc-dangling-ref(中)之和;
687
+ // cross_repo=跨仓注记数(顶级目录本仓不存在——目标活在独立仓,单列不对账)
616
688
  dangling_refs_breakdown: danglingBreakdown,
617
689
  clusters,
618
690
  }
@@ -791,7 +863,7 @@ export function impactFromDecisionsMd(graph, text) {
791
863
  // 输出沿知识命令族全 JSON 约定({ok,...data});人类可读摘要行内嵌 summary 字段。
792
864
  // ═══════════════════════════════════════════════════════════════
793
865
 
794
- const GRAPH_USAGE = '用法:sillyspec knowledge graph <summary|nodes|dump|neighbors|path|impact|orphans|dangling> [锚点...] [--search <模糊>] [--limit N] [--edges <边型>] [--depth N];dump 需 --layout'
866
+ const GRAPH_USAGE = '用法:sillyspec knowledge graph <summary|nodes|dump|neighbors|path|impact|orphans|dangling> [锚点...] [--search <模糊>] [--limit N] [--edges <边型>] [--depth N] [--local];dump 需 --layout;dangling --local 只列本仓悬空(跨仓注记单列不对账)'
795
867
 
796
868
  export async function cmdKnowledgeGraph(dir, args, opts = {}) {
797
869
  const out = (ok, data, error) => console.log(JSON.stringify({ ok, ...(data || {}) , ...(error ? { error } : {}) }, null, 2))
@@ -822,7 +894,7 @@ export async function cmdKnowledgeGraph(dir, args, opts = {}) {
822
894
  stats: s,
823
895
  summary: [
824
896
  `节点 ${s.nodes} · 边 ${s.edges}`,
825
- `孤儿 ${s.orphans} · 模块文档缺口 ${s.module_doc_gaps} · changelog 悬空 ${s.changelog_danglings} · 悬空引用 ${s.dangling_refs}`,
897
+ `孤儿 ${s.orphans} · 模块文档缺口 ${s.module_doc_gaps} · changelog 悬空 ${s.changelog_danglings} · 悬空引用 ${s.dangling_refs}(本仓 ${s.dangling_refs_local} · 跨仓注记 ${s.dangling_refs_breakdown.cross_repo}——目标仓不在本仓,单列不对账)`,
826
898
  `簇 ${s.clusters.length} 个(最大 ${s.clusters[0]?.key ?? '—'} × ${s.clusters[0]?.count ?? 0})`,
827
899
  ],
828
900
  })
@@ -907,12 +979,18 @@ export async function cmdKnowledgeGraph(dir, args, opts = {}) {
907
979
  orphans: list.map((f) => ({ id: f.node.id, type: f.node.type, kind: f.kind })),
908
980
  })
909
981
  }
910
- // dangling
911
- const list = graphDangling(graph)
982
+ // dangling(--local 过滤跨仓注记:治理动作只看本仓可解析面——跨仓目标活在独立仓)
983
+ const localOnly = args.includes('--local')
984
+ const allDangling = graphDangling(graph)
985
+ const crossRepoCount = allDangling.filter((d) => d.crossRepo).length
986
+ const list = localOnly ? allDangling.filter((d) => !d.crossRepo) : allDangling
912
987
  return out(true, {
913
- query: { sub },
988
+ query: { sub, local: localOnly || undefined },
914
989
  count: list.length,
915
- summary: list.map((d) => `⚠️ ${d.edge.type}(${d.strength}) ${d.edge.s} → ${d.missing}`),
990
+ summary: list.map((d) => `⚠️ ${d.edge.type}(${d.strength}) ${d.edge.s} → ${d.missing}${d.crossRepo ? ' 〔跨仓注记〕' : ''}`),
916
991
  dangling: list.map((d) => ({ edge: d.edge, missing: d.missing, strength: d.strength })),
992
+ // 口径分流(附加字段):本仓可行动数 + 跨仓注记数单列不对账;--local 时 count 即本仓数
993
+ dangling_refs_local: allDangling.length - crossRepoCount,
994
+ cross_repo: crossRepoCount,
917
995
  })
918
996
  }
@@ -61,7 +61,7 @@ function isDecisionRoute(entry) {
61
61
  return /^decisions\//.test(String(entry.file || '').replace(/\\/g, '/'))
62
62
  }
63
63
 
64
- const DECISION_HEADER_RE = /^##\s+(D-\d+@v\d+)\s*(.*)$/
64
+ const DECISION_HEADER_RE = /^##\s+(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+)\s*(.*)$/
65
65
  const DECISION_FIELD_RE = /^(状态|文件|理由|否决理由|复潮条件|supersedes)\s*[::]\s*(.*)$/
66
66
  // 「锚点:」单独标签精确匹配读入(仿 docs-check.js parseDecisionEntries 先例)——绝不并入
67
67
  // DECISION_FIELD_RE:其 else-if 链末尾的 reason 回填(else if (!cur.reason))会把锚点值
@@ -69,6 +69,12 @@ const DECISION_FIELD_RE = /^(状态|文件|理由|否决理由|复潮条件|supe
69
69
  const DECISION_ANCHOR_RE = /^锚点\s*[::]\s*(.*)$/
70
70
  // 变更:单独标签(变更名限定——D-xxx 是变更内局部序号,跨变更同号互不相干,去重键依赖它)
71
71
  const DECISION_CHANGE_RE = /^变更\s*[::]\s*(.*)$/
72
+ // 来源:兜底标签(2026-10-14-decision-id-p2-hardening):2026-08-24 首修形态残留(后被「变更:」
73
+ // 取代,现存 20 行)——变更行无条件覆盖在前,来源仅补缺(任意行序下变更优先)
74
+ const DECISION_ORIGIN_RE = /^来源\s*[::]\s*(.*)$/
75
+ // 原编号:溯源标签(2026-10-14-decision-global-id-epoch):epoch 形态条目的局部号——graph
76
+ // supersedes 经它把「supersedes:D-011@v1」解析到同变更全局号段
77
+ const DECISION_ORIGINID_RE = /^原编号\s*[::]\s*(.*)$/
72
78
 
73
79
  /** 「文件:」值切分 + 逐项反斜杠归一 POSIX(与 decision-distill 的 entry.files 契约对齐:
74
80
  * split /[,,、\s]+/ + replace(/\\/g,'/'))。 */
@@ -133,7 +139,7 @@ function parseDecisionFile(filePath, file) {
133
139
  const h = line.match(DECISION_HEADER_RE)
134
140
  if (h) {
135
141
  flush()
136
- cur = { file, id: h[1], title: h[2].trim(), status: '', reason: '', revisitWhen: '', files: [], anchor: '', change: '', supersedesText: '' }
142
+ cur = { file, id: h[1], title: h[2].trim(), status: '', reason: '', revisitWhen: '', files: [], anchor: '', change: '', supersedesText: '', originId: '' }
137
143
  continue
138
144
  }
139
145
  if (!cur) continue
@@ -144,6 +150,10 @@ function parseDecisionFile(filePath, file) {
144
150
  // 域文件跨变更同号条目常见(D-xxx 是变更内局部序号),去重键须含变更名否则先到条目遮蔽后来者)
145
151
  const c = line.match(DECISION_CHANGE_RE)
146
152
  if (c) { cur.change = c[1].trim(); continue }
153
+ const o = line.match(DECISION_ORIGIN_RE)
154
+ if (o) { if (!cur.change) cur.change = o[1].trim(); continue }
155
+ const oi = line.match(DECISION_ORIGINID_RE)
156
+ if (oi) { cur.originId = oi[1].trim(); continue }
147
157
  const f = line.match(DECISION_FIELD_RE)
148
158
  if (!f) continue
149
159
  const value = f[2].trim()
@@ -170,20 +180,25 @@ function parseDecisionFile(filePath, file) {
170
180
  export function parseDecisionEntries(indexDir, indexEntries = null) {
171
181
  const routes = (indexEntries || parseKnowledgeIndex(indexDir)).filter(isDecisionRoute)
172
182
  const hits = []
183
+ // 路由行按文件去重单解析(2026-10-14-decision-id-p2-hardening):多条路由行指向同一域文件只解析
184
+ // 一次——此前靠条目级折叠兜完全重复,legacy 不折叠后从源头消除重复
185
+ const parsed = new Set()
173
186
  for (const route of routes) {
187
+ const fileKey = String(route.file || '').replace(/\\/g, '/')
188
+ if (parsed.has(fileKey)) continue
189
+ parsed.add(fileKey)
174
190
  const filePath = join(indexDir, route.file)
175
191
  if (!existsSync(filePath)) continue // 路由行失效(域文件已删)→ 跳过不阻断
176
192
  hits.push(...parseDecisionFile(filePath, route.file))
177
193
  }
178
- // 多条路由行指向同一域文件时按 file+id+变更 去重(完全重复折叠);键含变更名——
179
- // 跨变更同号条目(D-xxx 是变更内局部序号,域文件内同 id 不同变更常见)不再被先到者
180
- // 遮蔽(坑 decision-cross-change-id-shadow,2026-09-11 dogfood 实证:runtime.md 七个
181
- // D-001@v1 分属七变更,旧键只放行首个——同号 rejected 决策在 {DECISION_HITS} 防复潮
182
- // 注入同样被遮蔽)。无「变更:」行的 legacy 条目 change='' 与同号 legacy 条目互折叠
183
- // (维持旧行为,不放大历史条目数)。
194
+ // 带 change 条目按 file+id+change 去重(坑 decision-cross-change-id-shadow,2026-09-11 dogfood 实证:
195
+ // 域文件跨变更同号条目常见,键须含变更名否则先到遮蔽——runtime.md 七个 D-001@v1 分属七变更)。
196
+ // 无「变更:/来源:」行的 legacy 条目不再互折叠(2026-10-14-decision-id-p2-hardening:旧互折叠致
197
+ // 同号 legacy 互相遮蔽——实测 2 条 implemented 条目永久隐身;路由单解析后完全重复已从源头消除)。
184
198
  const seen = new Set()
185
199
  return hits.filter(h => {
186
- const key = `${h.file}#${h.id}#${h.change || ''}`
200
+ if (!h.change) return true
201
+ const key = `${h.file}#${h.id}#${h.change}`
187
202
  if (seen.has(key)) return false
188
203
  seen.add(key)
189
204
  return true
@@ -221,6 +236,16 @@ export function matchDecisionsByFiles(indexDir, files) {
221
236
  return result
222
237
  }
223
238
 
239
+ /** 决策展示行变更名限定词(2026-10-14-supersedes-mislink-and-change-display):D-xxx 是变更内
240
+ * 局部序号,跨变更同号条目常见(知识库 D-001@v1 ×296、其中 192 条同题)——四处展示面
241
+ * ({DECISION_HITS}/轻量道防复潮/knowledge-gate/decision-touch)经本函数统一渲染限定词消歧;
242
+ * change 缺失(legacy 无「变更:」行)固定降级「变更未记录」——不编造、不省略。
243
+ * 单一源防四处文案漂移(与 deathPathNote 同址同规约)。 */
244
+ export function decisionChangeQualifier(change) {
245
+ const c = String(change || '').trim()
246
+ return c ? `变更 ${c}` : '变更未记录'
247
+ }
248
+
224
249
  /** 死路注记短句提取(消费方渲染用):理由里「死路:…」到首个句读为止;无标记时截前 60 字。
225
250
  * 单一源供 flow 知识注入段与 complete.js knowledge-gate 回显共用(2026-09-28-knowledge-inject-ranking)。 */
226
251
  export function deathPathNote(reason) {
@@ -99,6 +99,10 @@ export function buildHitMatrix(knowledgeDir, runtimeDir, { sinceDays = 30 } = {}
99
99
  }
100
100
 
101
101
  // ── FR 索引实验聚合(2026-09-18-fr-index-l1 后续钩子·L3 裁决仪表盘)──
102
+ // L3 已裁决冻结(2026-10-15-fr-chain-l3-adjudication D-001@v1):证伪三条件全中(承接引用率
103
+ // 1/452≈0% / fr-supersede 窗口 2 次 / fr-duplicate-warning 24 次零候选),条款明文合法结局——
104
+ // 活规格树不建,fr 索引降级保留检索/注入面;本区指标转历史观察,候选视图退役为冻结注记。
105
+ const FR_L3_ADJUDICATED_NOTE = '已裁决(2026-10-15 D-001@v1):L3 冻结不建,fr 索引保留检索/注入面——本区指标转历史观察(复潮条件见裁决条目)'
102
106
 
103
107
  /**
104
108
  * 聚合 L1 四类 fr-* 遥测事件 + knowledge/fr/ 索引面统计——L3 裁决(design 实验证伪条款)
@@ -314,7 +318,7 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
314
318
  if (frIndex.present) {
315
319
  const e = frIndex.events
316
320
  const i = frIndex.index
317
- lines.push(`🔬 FR 索引实验(窗口内事件 / 索引面全量——L3 裁决指标,证伪条款见 2026-09-18-fr-index-l1 design)`)
321
+ lines.push(`🔬 FR 索引实验(窗口内事件 / 索引面全量——L3 裁决指标,证伪条款见 2026-09-18-fr-index-l1 design)|${FR_L3_ADJUDICATED_NOTE}`)
318
322
  lines.push(` 注入 fr-inject ${e.frInject} 次(${e.frInjectChanges} 变更) | 取代链 fr-supersede ${e.frSupersede} 次(${e.frSupersedeChanges} 变更) | 重复拦截 fr-duplicate-warning ${e.frDuplicateWarning} 次(${e.frDuplicateWarningCandidates || e.frDuplicateCandidates || 0} 候选)`)
319
323
  lines.push(` 腐烂 suspect fr-rot-suspect ${e.frRotSuspect || 0} 次(${e.frRotSuspectChanges || 0} 变更${e.frRotSuspectByDomain && e.frRotSuspectByDomain.length > 0 ? `,按域 ${e.frRotSuspectByDomain.map(d => `${d.domain}×${d.count}`).join(',')}` : ''})`)
320
324
  if (e.frUnreferenced.length > 0) {
@@ -325,10 +329,8 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
325
329
  lines.push(` 承接引用率:${frIndex.supersedeRate === null ? '—(索引空)' : `${(frIndex.supersedeRate * 100).toFixed(0)}%(${e.frSupersedeChanges}/${i.sourceChanges},分子窗口内/分母全量——裁决用大窗口)`}`)
326
330
  const cand = frIndex.adjudicationCandidates || []
327
331
  if (cand.length > 0) {
328
- lines.push(' 裁决候选 Top-10(suspect×unref 条目级聚合——候选非裁决,确认无人承接→承接翻链或人工退休):')
329
- for (const c of cand.slice(0, 10)) {
330
- lines.push(` - ${c.id} suspect×${c.suspect} unref×${c.unreferenced}${c.change ? `(来源 ${c.change})` : ''}`)
331
- }
332
+ // L3 冻结后候选视图退役(数据面 adjudicationCandidates 保留 JSON 可查)——显示冻结注记
333
+ lines.push(` 裁决候选视图已退役|${FR_L3_ADJUDICATED_NOTE}`)
332
334
  }
333
335
  } else {
334
336
  lines.push('(FR 索引面不存在——knowledge/fr/ 目录缺失或无条目;归档完整流程变更后此处可查)')
@@ -375,12 +377,12 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
375
377
 
376
378
  lines.push(`遥测计数(窗口内):注入 ${result.totalInjects} 次 | 归类 ${result.totalClassifies} 次${lastEventAt ? ` | 数据截至 ${lastEventAt.slice(0, 10)}` : ''}`)
377
379
 
378
- // FR 索引实验(L3 裁决仪表盘——证伪条款出数面;裁决时建议 --since-days 大窗口读全口径)
380
+ // FR 索引实验(L3 裁决仪表盘——证伪条款出数面;L3 已裁决冻结 2026-10-15,指标转历史观察)
379
381
  if (frIndex.present) {
380
382
  const e = frIndex.events
381
383
  const i = frIndex.index
382
384
  lines.push('')
383
- lines.push(`🔬 FR 索引实验(窗口内事件 / 索引面全量——L3 裁决指标,证伪条款见 2026-09-18-fr-index-l1 design)`)
385
+ lines.push(`🔬 FR 索引实验(窗口内事件 / 索引面全量——L3 裁决指标,证伪条款见 2026-09-18-fr-index-l1 design)|${FR_L3_ADJUDICATED_NOTE}`)
384
386
  lines.push(` 注入 fr-inject ${e.frInject} 次(${e.frInjectChanges} 变更) | 取代链 fr-supersede ${e.frSupersede} 次(${e.frSupersedeChanges} 变更) | 重复拦截 fr-duplicate-warning ${e.frDuplicateWarning} 次(${e.frDuplicateCandidates} 候选)`)
385
387
  if (e.frUnreferenced.length > 0) {
386
388
  const top = e.frUnreferenced.slice(0, 5).map((u) => `${u.domain}×${u.count}`).join(',')
@@ -388,14 +390,11 @@ export async function cmdKnowledgeStats(dir, args, opts = {}) {
388
390
  }
389
391
  lines.push(` 索引面:${i.entries} 条(active ${i.active} / superseded ${i.superseded})| 来源变更 ${i.sourceChanges} 个 | 域 ${i.domains.join(', ') || '—'}`)
390
392
  lines.push(` 承接引用率:${frIndex.supersedeRate === null ? '—(索引空)' : `${(frIndex.supersedeRate * 100).toFixed(0)}%(${e.frSupersedeChanges}/${i.sourceChanges},分子窗口内/分母全量——裁决用大窗口)`}`)
391
- // 裁决候选视图(2026-10-03-fr-governance-telemetry):条目级 id 聚合(事件 frIds/ids,帽 20 抽样
392
- // 为下界)。候选非裁决——高 suspect 天然来自热文件;处置出口:确认无人承接 → 承接翻链或人工退休。
393
+ // 裁决候选视图退役(L3 冻结,2026-10-15-fr-chain-l3-adjudication D-001@v1)——数据面
394
+ // adjudicationCandidates 保留 JSON 可查,显示面只留冻结注记。
393
395
  const cand = frIndex.adjudicationCandidates || []
394
396
  if (cand.length > 0) {
395
- lines.push(' 裁决候选 Top-10(suspect×unref 条目级聚合——候选非裁决,确认无人承接→承接翻链或人工退休):')
396
- for (const c of cand.slice(0, 10)) {
397
- lines.push(` - ${c.id} suspect×${c.suspect} unref×${c.unreferenced}${c.change ? `(来源 ${c.change})` : ''}`)
398
- }
397
+ lines.push(` 裁决候选视图已退役|${FR_L3_ADJUDICATED_NOTE}`)
399
398
  }
400
399
  }
401
400