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
package/src/docs-debt.js CHANGED
@@ -15,6 +15,7 @@ import { join, relative } from 'node:path'
15
15
  import { safeGit } from './git-helper.js'
16
16
  import { parseModuleMapSimple } from './modules.js'
17
17
  import { runDocsCheck, readDocsCheckConfig, parseDecisionEntries, anchorFilePath } from './docs-check.js'
18
+ import { decisionChangeQualifier } from './knowledge-match.js'
18
19
 
19
20
  /** safeGit 超时(大仓 git log 慢时降级该模块,不挂起 Wave 渲染) */
20
21
  const GIT_TIMEOUT_MS = 5000
@@ -323,7 +324,7 @@ export function computeDecisionTouches(changedFiles, knowledgeRoot) {
323
324
  for (const c of changed) {
324
325
  // 前缀匹配粒度 = 文件或其子路径(R-03:锚点是文件级非目录级,防同前缀不同文件误报)
325
326
  if (c === anchorPath || c.startsWith(anchorPath + '/')) {
326
- touches.push({ id: e.id, title: e.title, anchorFile: anchorPath, touchedFile: c, file })
327
+ touches.push({ id: e.id, title: e.title, anchorFile: anchorPath, touchedFile: c, file, change: e.change || '' })
327
328
  }
328
329
  }
329
330
  }
@@ -344,7 +345,7 @@ export function renderDecisionTouchFacts(touches) {
344
345
  const shown = touches.slice(0, DECISION_TOUCH_RENDER_LIMIT)
345
346
  const lines = [`[decision-touch] 本次变更触碰 ${touches.length} 条决策锚点(改动可能使该决策需复核):`]
346
347
  for (const t of shown) {
347
- lines.push(` - ${t.id}(${t.title || '无标题'},锚点 ${t.anchorFile})← 触碰文件 ${t.touchedFile}(${t.file})`)
348
+ lines.push(` - ${t.id}(${t.title || '无标题'},锚点 ${t.anchorFile})← 触碰文件 ${t.touchedFile}(${t.file}|${decisionChangeQualifier(t.change)})`)
348
349
  }
349
350
  if (touches.length > DECISION_TOUCH_RENDER_LIMIT) {
350
351
  lines.push(` …另有 ${touches.length - DECISION_TOUCH_RENDER_LIMIT} 条`)
@@ -946,7 +946,9 @@ function detectDecisionDrift(cwd) {
946
946
  let count = 0
947
947
  for (const f of readdirSync(decDir)) {
948
948
  if (!f.endsWith('.md')) continue
949
- count += (readFileSync(join(decDir, f), 'utf8').match(/^## D-\d+@\d+/gm) || []).length
949
+ // 双形态计数(2026-10-14-decision-global-id-epoch):局部 D-011@v1 与全局 D-<域>-042@v1;
950
+ // 旧正则 @\d+ 漏 @v 前缀恒零——随本单修正
951
+ count += (readFileSync(join(decDir, f), 'utf8').match(/^## D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+/gm) || []).length
950
952
  }
951
953
  return { ...base, findings: [`decisions 库 ${count} 条(behind 精确复核在 verify evidence-auto 与 docs-check 决策规则族)`], pass: true, severity: CHECK_SEVERITY.PASSED }
952
954
  } catch (e) {
@@ -31,6 +31,7 @@ import { readV2SectionAnswer } from './flow-review.js'
31
31
  import { DESIGN_QUESTIONS } from './flow-draft.js'
32
32
  import { pathMatches } from './change-list.js'
33
33
  import { classifyToolScaffold, filterDeliverableFiles } from './worktree-apply.js'
34
+ import { deriveChangeTitle } from './quicklog.js'
34
35
 
35
36
  /**
36
37
  * 模块文档对账(advisory)+ 结构化落盘面(2026-09-27-thin-module-scope-persist)。
@@ -133,6 +134,19 @@ export function evaluateModuleGate({ moduleScope, ignore } = {}) {
133
134
  return { block: maps.length > 0 && dirs.length > 0, dirs, maps }
134
135
  }
135
136
 
137
+ /**
138
+ * ⑥-0 motiveFromInput:模块变更索引摘要提取(2026-10-15-title-label-skip)。与变更标题
139
+ * 同源走 deriveChangeTitle——此前 flow.js 裸取 --input 首行,「动机与背景:」段头行整行
140
+ * 入摘要(stages.changelog 实证)。段头行跳过/标签剥离随 deriveChangeTitle 单源;提取为
141
+ * 空回退变更名(可辨识不达意优于空串;appendChangeIndexEntries 侧另有 '轻量变更' 二级兜底)。
142
+ * @returns {string} 摘要(≤50 字)
143
+ */
144
+ export function motiveFromInput(input, change = '') {
145
+ let derived = ''
146
+ try { derived = deriveChangeTitle(input) } catch { derived = '' }
147
+ return derived || String(change || '')
148
+ }
149
+
136
150
  /**
137
151
  * ⑥ appendChangeIndexEntries:命中模块变更索引机械回写(二档)。零语义判断——日期/变更名/
138
152
  * 动机首行全是已知量,与 decision-distill 降 noAI 同性质。目标判定遵守 split-changelog 契约
@@ -309,7 +323,9 @@ export function harvestSlot4Decision({ changeDir, change }) {
309
323
  '---',
310
324
  `# 决策记录(Decisions)— ${change}`,
311
325
  '',
312
- `## D-001@v1: 风险与死路(design 槽4 收割)`,
326
+ // 标题自解释(2026-10-15-harvest-title-clarify):读者面是 decisions 文档与 knowledge,
327
+ // 机制出处(槽4 收割)由 frontmatter author: flow-machine-draft 承载,标题只说内容与性质。
328
+ `## D-001@v1: 设计期风险与放弃方案(底稿)`,
313
329
  `- 类型:process`,
314
330
  `- 状态:confirmed`,
315
331
  // 类型/状态字段(2026-09-26-slot4-distill-fix):蒸馏链入选要 status∈{confirmed|accepted|rejected}
@@ -326,7 +342,7 @@ export function harvestSlot4Decision({ changeDir, change }) {
326
342
  return { harvested: true }
327
343
  }
328
344
 
329
- export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary }
345
+ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary, motiveFromInput }
330
346
 
331
347
  /**
332
348
  * patch 冻结面的提交面过滤(2026-09-27-tool-debt-cleanup 抽出为纯函数供单测锁定):
package/src/flow.js CHANGED
@@ -224,6 +224,23 @@ export async function extractRoutingInputPaths(cwd, specBase, input) {
224
224
  })
225
225
  }
226
226
 
227
+ /**
228
+ * 轻量道知识注入查询串瘦身(2026-10-15-thin-query-slim):--input 的「成功标准:」段是验收
229
+ * 判据语料(文件路径/测试名/断言词——对知识匹配是纯噪音,且路径词会过触路由关键词把命中面
230
+ * 撑爆:本仓实证整段 input 匹配出 76 条目、58 个 fr 域文件全命中)。查询只取动机段——
231
+ * 「成功标准:」标记前正文;标记缺席=无标准段,全文照旧(兼容旧形态);标记前为空白则退全 input。
232
+ * @param {string} change 变更名(查询首行,既有口径)
233
+ * @param {string} input --input 全文
234
+ * @returns {string} 瘦身后的查询串
235
+ */
236
+ export function thinKnowledgeQuery(change, input) {
237
+ const raw = String(input || '')
238
+ const markerIdx = raw.search(/成功标准[::]/)
239
+ const motivation = markerIdx >= 0 ? raw.slice(0, markerIdx) : raw
240
+ const body = motivation.trim() || raw.trim()
241
+ return `${change || ''}\n${body}`
242
+ }
243
+
227
244
  /**
228
245
  * 轻量道知识注入段(2026-09-25-thin-fr-inject-parity):默认快道读取面对齐——
229
246
  * {FR_INDEX_DIGEST}/{DECISION_HITS}/execute 知识命中报告此前只在 run 族装配,轻量道 fresh
@@ -295,10 +312,11 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
295
312
  }
296
313
  }
297
314
  const { matchKnowledgeHybrid } = await import('./knowledge-vector.js')
298
- const { deathPathNote } = await import('./knowledge-match.js')
315
+ const { deathPathNote, decisionChangeQualifier } = await import('./knowledge-match.js')
299
316
  // scope 遍历召回(2026-10-08-knowledge-graph,FR-04):touched=机器算的图查询键
300
317
  // (filesOverride ∪ design 交付表——上方既有变量),词面三层零命中时沿强边保底防复潮
301
- const km = await matchKnowledgeHybrid(knowledgeRoot, `${change}\n${input || ''}`, { cwd: dirname(specBase), scopeFiles: touched })
318
+ const kmQuery = thinKnowledgeQuery(change, input)
319
+ const km = await matchKnowledgeHybrid(knowledgeRoot, kmQuery, { cwd: dirname(specBase), scopeFiles: touched })
302
320
  // 防复潮面 = 死路注记 ∪ 有主题重叠的 rejected(2026-09-28-knowledge-inject-ranking 起 rejected∪死路;
303
321
  // 2026-09-28-knowledge-gate-denoise 收紧:score 零的 rejected 与查询零主题重叠——空标题条目靠状态
304
322
  // 蹭进回显是行为实测三例的噪音源,不再注入)
@@ -306,20 +324,51 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
306
324
  summary.rejectedDecisions = rejected.length
307
325
  summary.knowledgeEntries = km.matched ? (km.entries || []).length : 0
308
326
  if (rejected.length > 0) {
309
- lines.push(` ⚠️ 否决决策/死路注记(历史已否决或已记死路,防复潮——除非复潮条件满足勿重提,复潮须在本变更 decisions.md 记新版本):`)
327
+ lines.push(` ⚠️ 否决决策/死路注记(历史已否决或已记死路,防复潮——除非复潮条件满足勿重提,复潮须在本变更 decisions.md 记新版本;跨变更引用带变更名 变更名#D-xxx@vN):`)
310
328
  for (const h of rejected.slice(0, 5)) {
311
329
  const note = h.status === 'rejected' ? (h.reason || '(未记录)') : `死路——${deathPathNote(h.reason)}`
312
- lines.push(` - ${h.id} ${h.title}(${h.file})${h.status === 'rejected' ? '否决理由' : '⚰️'}:${note}`)
330
+ lines.push(` - ${h.id} ${h.title}(${h.file}|${decisionChangeQualifier(h.change)})${h.status === 'rejected' ? '否决理由' : '⚰️'}:${note}`)
313
331
  }
314
332
  }
315
333
  if (km.matched && (km.entries || []).length > 0) {
316
334
  const src = km.entries.slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)).join(';')
317
335
  lines.push(` 📚 知识命中(INDEX 关键词,按需 Read):${src}`)
318
336
  }
337
+ // 遥测导线(2026-10-15-thin-inject-telemetry):轻量道注入此前不落 hits.jsonl——thin 常态化后
338
+ // inject 事件流断流(本仓 10-09 后归零实证),knowledge stats 从未命中判定被盲区污染。
339
+ // 分级字段(A2-lite):injectedFiles=正文实际注入(thin 面恒空——只出按需 Read 行)、
340
+ // mentionedFiles=显式展示给 agent 的锚点(📚 行 top-3)。既有 matchedFiles 口径不变
341
+ // (knowledge-stats 消费面零回归)。仅命中面非空才落盘;fail-soft(R-04)不阻断注入。
342
+ const thinMatchedFiles = km.matched ? (km.entries || []).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)) : []
343
+ if (thinMatchedFiles.length > 0 || rejected.length > 0) {
344
+ try {
345
+ const { appendKnowledgeHit } = await import('./knowledge-hits.js')
346
+ appendKnowledgeHit(join(specBase, '.runtime'), {
347
+ type: 'inject',
348
+ change,
349
+ query: kmQuery,
350
+ matchedFiles: thinMatchedFiles,
351
+ injectedFiles: [],
352
+ mentionedFiles: thinMatchedFiles.slice(0, 3),
353
+ rejectedCount: rejected.length,
354
+ source: 'thin-start',
355
+ })
356
+ } catch { /* 遥测 fail-soft(R-04):hits 落盘失败不影响注入本体 */ }
357
+ }
319
358
  if (domains.length === 0 && rejected.length === 0 && !km.matched) {
320
359
  lines.length = 0
321
360
  lines.push(`🧠 知识注入(轻量道读取面):语料未命中知识库(无触达域依据、无否决命中、INDEX 无对齐条目)——可自行查 knowledge/INDEX.md。`)
322
361
  }
362
+ // 向量开关发现性提示(2026-10-15-vector-switch-discoverability):零命中=正需要语义召回
363
+ // 而未获得的时刻——平台已连接但开关未开(缺省关)时给一行可执行提示;有命中/未连接/
364
+ // 已开启零新增(vectorSwitchHint 单源,同款提示也挂 knowledge search hint 键)。
365
+ if (!km.matched && rejected.length === 0) {
366
+ try {
367
+ const { vectorSwitchHint } = await import('./knowledge-vector.js')
368
+ const hint = vectorSwitchHint(dirname(specBase))
369
+ if (hint) lines.push(` ${hint}`)
370
+ } catch { /* 提示 fail-open */ }
371
+ }
323
372
  // 模块卡欠账提示前移(2026-10-12-thin-module-gate-and-feed 三档):触碰命中模块 behind>0
324
373
  // 才出事实行——动手前看到「你要动的卡已落后」比收口时警告有效;computeDocsDebt(厚道
325
374
  // execute {DOCS_DEBT})同源,空债/无图零输出,异常走外层 catch fail-open 不阻断 start。
@@ -1425,11 +1474,22 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1425
1474
  // 采样去重(2026-10-07-thin-tasks-v3):task tick 直写的精确事件(source:'task-tick')为
1426
1475
  // 权威口径,watcher 3s 采样合并跳被去重——CLI 快速连续 tick 不再误伤;观测旁路缺席
1427
1476
  // (无流/读失败)→ 跳过(fail-open,watcher 非真相源);--allow-batch-tick 显式旁路留痕。
1477
+ // 侧车突发回退(2026-10-14-tick-burst-sidecar-gate):CLI 收口端快连点对事件流不可见(单格
1478
+ // 精确跳),空结果时回退 task-ticks.jsonl 毫秒时戳判突发——CLI 与直接 Edit 两条路径同判。
1428
1479
  try {
1429
1480
  const { readWatcherEvents } = await import('./watcher.js')
1430
- const { detectBatchCheckCadence, resolveBatchTickAction } = await import('./sentinel-assertions.js')
1481
+ const { detectBatchCheckCadence, detectTickBurst, resolveBatchTickAction } = await import('./sentinel-assertions.js')
1431
1482
  const stream = readWatcherEvents({ runtimeRoot, change })
1432
- const batch = stream.exists ? detectBatchCheckCadence(stream.events) : null
1483
+ let batch = stream.exists ? detectBatchCheckCadence(stream.events) : null
1484
+ if (!batch) {
1485
+ // 侧车突发回退(2026-10-14-tick-burst-sidecar-gate):事件流空结果时读 change 目录
1486
+ // task-ticks.jsonl(CLI tick 毫秒时戳事实源,随归档进 git、不受观测盲窗影响)判快连点;
1487
+ // 事件流结果优先不双重报告;侧车缺失/坏文件按无证据静默(fail-open)。
1488
+ try {
1489
+ const { parseTickLog } = await import('./timeline.js')
1490
+ batch = detectTickBurst(parseTickLog(readFileSync(join(changeDir, 'task-ticks.jsonl'), 'utf8')))
1491
+ } catch { /* 侧车缺失/不可读 → 无侧车证据(fail-open) */ }
1492
+ }
1433
1493
  if (batch) {
1434
1494
  // 代勾计数取「本拍实时代勾 ∨ 持久化代勾」较大者(二轮 P2-B:重入漂移防护);决策纯函数
1435
1495
  // 单源(resolveBatchTickAction)四态与豁免优先序见其 JSDoc——代勾须解释整跳才降级
@@ -1454,7 +1514,8 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1454
1514
  console.warn(`⚠️ 勾选节奏:单拍多格勾选(${batch.detail},${at})——${why},降级提醒不拒`)
1455
1515
  }
1456
1516
  } else if (v.action === 'reject') {
1457
- console.error(`🚫 单拍勾选拒收:事件流观测到一拍勾选 ${batch.detail}(${at})——勾选纪律要求逐格(做一件→勾一格→下一个),勾选任务 ${_sentinelNonMirrorTasks} 格`)
1517
+ const _via = batch.source === 'tick-burst' ? '侧车时戳观测到' : '事件流观测到'
1518
+ console.error(`🚫 单拍勾选拒收:${_via}一拍勾选 ${batch.detail}(${at})——勾选纪律要求逐格(做一件→勾一格→下一个),勾选任务 ${_sentinelNonMirrorTasks} 格`)
1458
1519
  console.error(' 出口:①节奏违例已既成——认知后重跑带 --allow-batch-tick 显式留痕过门(平台时间线可见旁路);②疑观测误判→sillyspec doctor 核对事件流')
1459
1520
  appendTelemetry({ sentinel: 'batch-tick', jump: batch.detail })
1460
1521
  reportMidFail('ledger')
@@ -2272,8 +2333,10 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
2272
2333
  // 回读块保证在场(patch 本轮算出或 change-patch.json 回读)。
2273
2334
  try {
2274
2335
  if (moduleScope && Array.isArray(moduleScope.modules) && moduleScope.modules.length > 0) {
2275
- const { appendChangeIndexEntries } = await import('./flow-parity.js')
2276
- const motive = String(st.input || '').split('\n').map((s) => s.trim()).find(Boolean) || ''
2336
+ const { appendChangeIndexEntries, motiveFromInput } = await import('./flow-parity.js')
2337
+ // 摘要与变更标题同源(2026-10-15-title-label-skip):段头行(动机与背景:等)跳过取次行,
2338
+ // 空提取回退变更名——此前裸取首行致「动机与背景:」整行入摘要(stages.changelog 实证)。
2339
+ const motive = motiveFromInput(st.input, change)
2277
2340
  const wr = appendChangeIndexEntries({ specBase, modules: moduleScope.modules, change, summary: motive, date: new Date().toISOString().slice(0, 10) })
2278
2341
  changelogAppended = wr.appended
2279
2342
  if (wr.appended.length > 0) console.log(`📖 模块变更索引回写:${wr.appended.length} 个目标(${wr.appended.join('、')})——sidecar 优先/卡内节次之,随归档窄化入暂存`)
@@ -2600,7 +2663,7 @@ export async function cmdFlow(args, cwd, specDir = null, opts = {}) {
2600
2663
  console.error(`❌ ${e.message}`)
2601
2664
  // 可照抄实例(2026-10-05-input-teach-copyable):引号内换行合法,行级 trim 容忍缩进
2602
2665
  console.error(' 重试(--input 引号内换行合法,可照抄):')
2603
- console.error(' sillyspec flow start --change <YYYY-MM-DD-简短描述> --input "<动机与背景>')
2666
+ console.error(' sillyspec flow start --change <YYYY-MM-DD-简短描述> --input "<一句中文概括(首行即变更标题);随后动机与背景')
2604
2667
  console.error('')
2605
2668
  console.error(' 成功标准:')
2606
2669
  console.error(' - <可验证标准>"')
@@ -2829,9 +2892,9 @@ export async function cmdFlow(args, cwd, specDir = null, opts = {}) {
2829
2892
  console.log(`✅ flow amend-draft 留痕重锚:${r.reanchored.join('、')}——ledger amendment 审计在案;editRatio=${maxRatio}(段级最大;基准=首版原文,AGENT 槽不计入)`)
2830
2893
  return r
2831
2894
  }
2832
- console.error('用法: sillyspec flow start --change <名> --input "<动机与背景+独立一行『成功标准:』+每行一条『- 可验证标准』>" [--thick|--with-tasks] [--no-prestage(hindsight 标记在场时声明需求已含决策,留痕放行)] | sillyspec flow done --change <名> | sillyspec flow status --change <名> [--json] | sillyspec flow diff --change <名> [--json](只读文件统计)')
2895
+ console.error('用法: sillyspec flow start --change <名> --input "<一句中文概括(首行即变更标题)+动机与背景详述+独立一行『成功标准:』+每行一条『- 可验证标准』>" [--thick|--with-tasks] [--no-prestage(hindsight 标记在场时声明需求已含决策,留痕放行)] | sillyspec flow done --change <名> | sillyspec flow status --change <名> [--json] | sillyspec flow diff --change <名> [--json](只读文件统计)')
2833
2896
  console.error(' --input 可照抄形态(引号内换行合法):')
2834
- console.error(' sillyspec flow start --change <名> --input "<动机与背景>')
2897
+ console.error(' sillyspec flow start --change <名> --input "<一句中文概括(首行即变更标题);随后动机与背景')
2835
2898
  console.error('')
2836
2899
  console.error(' 成功标准:')
2837
2900
  console.error(' - <可验证标准>"')
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(' - <可验证标准>"')
@@ -5499,7 +5499,7 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5499
5499
  console.error(` 事件流路径:${join(tRuntimeRoot, `watcher-events-${tChange}.jsonl`)};活跃变更跑任意 flow/run 命令会拉起 watcher`);
5500
5500
  process.exit(2);
5501
5501
  }
5502
- const { changeDir, tasksMd } = loadChangeTasks(tSpecBase, tChange);
5502
+ const { changeDir, tasksMd, tickLog } = loadChangeTasks(tSpecBase, tChange);
5503
5503
  const tasks = tasksMd ? parseTaskLines(tasksMd) : null;
5504
5504
  let tier = null;
5505
5505
  if (changeDir) {
@@ -5509,6 +5509,8 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5509
5509
  } catch { /* 缺 flow-state(头脑风暴预段等)按未知 */ }
5510
5510
  }
5511
5511
  const { gitQuiet } = await import('./git-helper.js');
5512
+ // 锚收敛(2026-10-14-timeline-ticktime-anchor-scope):change 参进锚解析——消息不含本变更
5513
+ // 名的提交不参与任务行锚定(跨变更 token 撞车防线);事件时间轴不受影响。
5512
5514
  const anchors = resolveCommitAnchors(tRes.events, (hash) => {
5513
5515
  const out = gitQuiet(dir, ['log', '-1', '--format=%h%x1f%aI%x1f%B', hash]);
5514
5516
  if (typeof out !== 'string' || !out.trim()) return null;
@@ -5516,8 +5518,8 @@ SillySpec pull — 拉取服务器 spec 快照到本地(X2 / FR-07)
5516
5518
  const rest = out.slice(sep + 1);
5517
5519
  const sep2 = rest.indexOf('\x1f');
5518
5520
  return { hash: out.slice(0, sep), dateISO: rest.slice(0, sep2), message: rest.slice(sep2 + 1) };
5519
- });
5520
- 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 }));
5521
5523
  if (tSourceNote) console.log(tSourceNote);
5522
5524
  break;
5523
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
  }