sillyspec 3.32.9 → 3.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  3. package/.claude/skills/sillyspec-explore/SKILL.md +107 -107
  4. package/.claude/skills/sillyspec-flow/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  6. package/.github/workflows/ci.yml +58 -0
  7. package/.husky/pre-push +7 -6
  8. package/AGENTS.md +1 -1
  9. package/assets/command-cards/flow.md +1 -1
  10. package/eval/eval.config.json +3 -3
  11. package/eval/lib/config.mjs +6 -3
  12. package/eval/lib/util.mjs +16 -3
  13. package/eval/lib/workdir.mjs +4 -1
  14. package/eval/results/eval.db +0 -0
  15. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/prompt.txt +10 -0
  16. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stderr.txt +0 -0
  17. package/eval/results/logs/2026-10-15T12-37-22-986Z-004-phase-stats-armA/stdout.txt +1 -0
  18. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/prompt.txt +10 -0
  19. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stderr.txt +0 -0
  20. package/eval/results/logs/2026-10-15T12-45-37-124Z-004-phase-stats-armB/stdout.txt +1 -0
  21. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/prompt.txt +10 -0
  22. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stderr.txt +0 -0
  23. package/eval/results/logs/2026-10-15T12-56-19-259Z-004-phase-stats-armA/stdout.txt +1 -0
  24. package/eval/results/logs/2026-10-15T13-00-05-625Z-004-phase-stats-armB/prompt.txt +10 -0
  25. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/prompt.txt +10 -0
  26. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stderr.txt +0 -0
  27. package/eval/results/logs/2026-10-15T13-04-51-003Z-004-phase-stats-armA/stdout.txt +1 -0
  28. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/prompt.txt +10 -0
  29. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stderr.txt +0 -0
  30. package/eval/results/logs/2026-10-15T13-06-25-271Z-004-phase-stats-armB/stdout.txt +1 -0
  31. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/prompt.txt +10 -0
  32. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stderr.txt +0 -0
  33. package/eval/results/logs/2026-10-15T13-24-38-308Z-004-phase-stats-armB/stdout.txt +1 -0
  34. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/prompt.txt +8 -0
  35. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stderr.txt +0 -0
  36. package/eval/results/logs/2026-10-15T14-50-01-950Z-005-orders-export-armA/stdout.txt +1 -0
  37. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/prompt.txt +8 -0
  38. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stderr.txt +0 -0
  39. package/eval/results/logs/2026-10-15T14-51-56-316Z-005-orders-export-armB/stdout.txt +1 -0
  40. package/eval/results/workdirs/001-slugify__armA/package.json +6 -0
  41. package/eval/results/workdirs/001-slugify__armA/src/slugify.mjs +26 -0
  42. package/eval/results/workdirs/001-slugify__armB/package.json +6 -0
  43. package/eval/results/workdirs/001-slugify__armB/src/slugify.mjs +26 -0
  44. package/eval/results/workdirs/002-each-day__armA/package.json +6 -0
  45. package/eval/results/workdirs/002-each-day__armA/src/dates.mjs +30 -0
  46. package/eval/results/workdirs/002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  47. package/eval/results/workdirs/002-each-day__armB/package.json +6 -0
  48. package/eval/results/workdirs/002-each-day__armB/src/dates.mjs +4 -0
  49. package/eval/results/workdirs/003-word-report__armA/bin/report.mjs +36 -0
  50. package/eval/results/workdirs/003-word-report__armA/data/notes.txt +5 -0
  51. package/eval/results/workdirs/003-word-report__armA/package.json +6 -0
  52. package/eval/results/workdirs/003-word-report__armB/bin/report.mjs +36 -0
  53. package/eval/results/workdirs/003-word-report__armB/data/notes.txt +5 -0
  54. package/eval/results/workdirs/003-word-report__armB/package.json +6 -0
  55. package/eval/results/workdirs/004-phase-stats__armA/data/events.log +16 -0
  56. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/flow.md +44 -0
  57. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-archive.md +27 -0
  58. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-brainstorm.md +34 -0
  59. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-execute.md +30 -0
  60. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-plan.md +28 -0
  61. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/run-verify.md +30 -0
  62. package/eval/results/workdirs/004-phase-stats__armB/.claude/commands/sillyspec/status.md +28 -0
  63. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-archive/SKILL.md +87 -0
  64. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-auto/SKILL.md +54 -0
  65. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -0
  66. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-commit/SKILL.md +67 -0
  67. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-continue/SKILL.md +17 -0
  68. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-doctor/SKILL.md +76 -0
  69. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-execute/SKILL.md +222 -0
  70. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-explore/SKILL.md +107 -0
  71. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-export/SKILL.md +57 -0
  72. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-flow/SKILL.md +62 -0
  73. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-init/SKILL.md +177 -0
  74. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-knowledge/SKILL.md +40 -0
  75. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-plan/SKILL.md +156 -0
  76. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-propose/SKILL.md +16 -0
  77. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-quick/SKILL.md +116 -0
  78. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-resume/SKILL.md +21 -0
  79. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-scan/SKILL.md +79 -0
  80. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-state/SKILL.md +25 -0
  81. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-status/SKILL.md +54 -0
  82. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-verify/SKILL.md +87 -0
  83. package/eval/results/workdirs/004-phase-stats__armB/.claude/skills/sillyspec-workspace/SKILL.md +33 -0
  84. package/eval/results/workdirs/004-phase-stats__armB/AGENTS.md +45 -0
  85. package/eval/results/workdirs/004-phase-stats__armB/CLAUDE.md +3 -0
  86. package/eval/results/workdirs/004-phase-stats__armB/data/events.log +16 -0
  87. package/eval/results/workdirs/run-001-slugify__armA/package.json +6 -0
  88. package/eval/results/workdirs/run-001-slugify__armA/src/slugify.mjs +26 -0
  89. package/eval/results/workdirs/run-001-slugify__armB/package.json +6 -0
  90. package/eval/results/workdirs/run-001-slugify__armB/src/slugify.mjs +26 -0
  91. package/eval/results/workdirs/run-002-each-day__armA/package.json +6 -0
  92. package/eval/results/workdirs/run-002-each-day__armA/src/dates.mjs +30 -0
  93. package/eval/results/workdirs/run-002-each-day__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  94. package/eval/results/workdirs/run-002-each-day__armB/package.json +6 -0
  95. package/eval/results/workdirs/run-002-each-day__armB/src/dates.mjs +4 -0
  96. package/eval/results/workdirs/run-003-word-report__armA/bin/report.mjs +36 -0
  97. package/eval/results/workdirs/run-003-word-report__armA/data/notes.txt +5 -0
  98. package/eval/results/workdirs/run-003-word-report__armA/package.json +6 -0
  99. package/eval/results/workdirs/run-003-word-report__armB/bin/report.mjs +36 -0
  100. package/eval/results/workdirs/run-003-word-report__armB/data/notes.txt +5 -0
  101. package/eval/results/workdirs/run-003-word-report__armB/package.json +6 -0
  102. package/eval/results/workdirs/run-004-phase-stats__armA/bin/phase-stats.mjs +37 -0
  103. package/eval/results/workdirs/run-004-phase-stats__armA/data/events.log +16 -0
  104. package/eval/results/workdirs/run-004-phase-stats__armB/DRY_RUN_WRONG_ANSWER.mjs +1 -0
  105. package/eval/results/workdirs/run-004-phase-stats__armB/data/events.log +16 -0
  106. package/eval/results/workdirs/run-005-orders-export__armA/bin/export-orders.mjs +57 -0
  107. package/eval/results/workdirs/run-005-orders-export__armA/data/.gitattributes +1 -0
  108. package/eval/results/workdirs/run-005-orders-export__armA/data/orders.csv +9 -0
  109. package/eval/results/workdirs/run-005-orders-export__armA/out/summary.json +1 -0
  110. package/eval/results/workdirs/run-005-orders-export__armB/bin/export-orders.mjs +57 -0
  111. package/eval/results/workdirs/run-005-orders-export__armB/data/.gitattributes +1 -0
  112. package/eval/results/workdirs/run-005-orders-export__armB/data/orders.csv +9 -0
  113. package/eval/results/workdirs/run-005-orders-export__armB/out/summary.json +1 -0
  114. package/eval/tasks/004-phase-stats/files/data/events.log +16 -0
  115. package/eval/tasks/004-phase-stats/solution/bin/phase-stats.mjs +37 -0
  116. package/eval/tasks/004-phase-stats/task.json +6 -0
  117. package/eval/tasks/004-phase-stats/verify.mjs +38 -0
  118. package/eval/tasks/005-orders-export/files/data/.gitattributes +1 -0
  119. package/eval/tasks/005-orders-export/files/data/orders.csv +9 -0
  120. package/eval/tasks/005-orders-export/solution/bin/export-orders.mjs +57 -0
  121. package/eval/tasks/005-orders-export/task.json +6 -0
  122. package/eval/tasks/005-orders-export/verify.mjs +28 -0
  123. package/package.json +1 -1
  124. package/quicklog/tmp-fix-review.mjs +21 -0
  125. package/src/ceremony-tier.js +2 -1
  126. package/src/commit-suggest.js +45 -4
  127. package/src/config-schema.js +14 -0
  128. package/src/decision-distill.js +106 -24
  129. package/src/decision-redomain.js +148 -0
  130. package/src/decision-renumber.js +157 -0
  131. package/src/design-facts.js +3 -3
  132. package/src/docs-check.js +30 -15
  133. package/src/docs-debt.js +6 -5
  134. package/src/doctor-diagnostics.js +3 -1
  135. package/src/flow-parity.js +28 -4
  136. package/src/flow.js +76 -13
  137. package/src/forensics.js +236 -0
  138. package/src/fr-index.js +17 -2
  139. package/src/hooks/worktree-guard.js +3 -3
  140. package/src/index.js +41 -6
  141. package/src/knowledge-digest.js +23 -6
  142. package/src/knowledge-graph.js +95 -18
  143. package/src/knowledge-match.js +34 -9
  144. package/src/knowledge-stats.js +12 -13
  145. package/src/knowledge-vector.js +98 -7
  146. package/src/machine-interface.js +26 -0
  147. package/src/module-impact.js +2 -5
  148. package/src/modules.js +31 -0
  149. package/src/quicklog.js +6 -1
  150. package/src/run/archive-distill.js +3 -0
  151. package/src/run/command.js +3 -3
  152. package/src/run/complete-handlers.js +8 -3
  153. package/src/run/complete.js +113 -44
  154. package/src/run/gates.js +100 -103
  155. package/src/run/lint-ownership.js +37 -0
  156. package/src/run/prompt.js +43 -28
  157. package/src/run/stage.js +1 -1
  158. package/src/run/verify-quality-scan.js +10 -1
  159. package/src/sentinel-assertions.js +43 -1
  160. package/src/spec-sync.js +81 -2
  161. package/src/stage-contract.js +26 -3
  162. package/src/stage-review.js +46 -12
  163. package/src/stage-templates.js +2 -0
  164. package/src/stages/brainstorm.js +76 -21
  165. package/src/stages/knowledge.js +56 -5
  166. package/src/stages/plan-postcheck.js +13 -2
  167. package/src/stages/plan.js +19 -0
  168. package/src/sync.js +28 -2
  169. package/src/task-tick.js +12 -0
  170. package/src/test-bindings.js +32 -3
  171. package/src/timeline.js +60 -19
  172. package/src/ui-visual.js +119 -4
  173. package/src/verify-postcheck.js +166 -18
  174. package/src/verify-probes.js +61 -18
  175. package/src/worktree-apply.js +26 -2
  176. package/templates/agents-instruction.md +1 -1
@@ -21,7 +21,7 @@
21
21
  import { existsSync, readFileSync, readdirSync } from 'fs'
22
22
  import { join } from 'path'
23
23
  import { parseKnowledgeIndex, parseDecisionEntries, anchorFilePaths } from './knowledge-match.js'
24
- import { parseModuleMapSimple } from './modules.js'
24
+ import { modulePathMatches, parseModuleMapSimple } from './modules.js'
25
25
 
26
26
  // ── 本体常量(design.md 总体方案 §1 的代码化) ──
27
27
 
@@ -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')
@@ -244,9 +273,8 @@ export function buildKnowledgeGraph(specRoot) {
244
273
  const moduleOf = (p) => {
245
274
  const v = posix(p)
246
275
  for (const [mid, mp] of modulePathIndex) {
247
- // 模块 paths 是目录/glob 语义:剥尾斜杠后按「相等 或 path/ 前缀」匹配
248
- const stem = posix(mp).replace(/\/+$/, '')
249
- if (v === stem || v.startsWith(stem + '/')) return mid
276
+ // 模块 paths 目录/glob 语义——modulePathMatches 统一入口(**/*.tsx 等扫描卡形态可命中)
277
+ if (modulePathMatches(v, mp)) return mid
250
278
  }
251
279
  return null
252
280
  }
@@ -542,6 +570,27 @@ export function graphChangelogDanglings(graph, { existsFn = existsSync } = {}) {
542
570
  return out
543
571
  }
544
572
 
573
+ /** 登记仓前缀(graph 惰性缓存):<specRoot>/projects/*.yaml 的 name: 字段——本 spec 树管理
574
+ * 多仓时(register-repo 机制),跨仓交付路径(backend/**、frontend/**…)按前缀归到登记仓名下,
575
+ * 不再落 _unmapped 大簇(2026-10-15-graph-registry-domain-split:真图 2183 未挂模块文件节点
576
+ * 实测 1220 为跨仓前缀、421 为本仓 test 面)。无 projects 目录/解析失败 = 空集(fail-soft)。 */
577
+ function registryProjectPrefixes(graph) {
578
+ if (graph._projPrefixes) return graph._projPrefixes
579
+ const out = []
580
+ try {
581
+ const dir = join(String(graph.root || ''), 'projects')
582
+ if (existsSync(dir)) {
583
+ for (const f of readdirSync(dir)) {
584
+ if (!f.endsWith('.yaml')) continue
585
+ const m = /^name:\s*([\w-]+)\s*$/m.exec(readFileSync(join(dir, f), 'utf8'))
586
+ if (m) out.push(m[1])
587
+ }
588
+ }
589
+ } catch { /* projects 缺席/不可读 = 无登记前缀 */ }
590
+ graph._projPrefixes = out
591
+ return out
592
+ }
593
+
545
594
  /** 节点 → 簇域(lite 总览聚类键 type×domain;缺省占位防散簇)。 */
546
595
  function graphNodeDomain(graph, n) {
547
596
  switch (n.type) {
@@ -550,7 +599,20 @@ function graphNodeDomain(graph, n) {
550
599
  case 'file': {
551
600
  // module-files 精确挂接反查(多挂取最长模块 id——窄域优先)
552
601
  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'
602
+ if (mods.length > 0) return mods.sort((a, b) => b.length - a.length)[0]
603
+ // 登记仓前缀归属(2026-10-15-graph-registry-domain-split):跨仓交付路径按 projects 登记
604
+ // 名归簇(最长前缀优先、大小写不敏感——登记名 SillyHub vs 路径 sillyhub/ 实证);本仓
605
+ // 测试面归 _tests——残余 _unmapped 只剩真无归属面(module-map 覆盖缺口,可行动)
606
+ const id = String(n.id)
607
+ const idLower = id.toLowerCase()
608
+ let best = ''
609
+ for (const p of registryProjectPrefixes(graph)) {
610
+ const pl = p.toLowerCase()
611
+ if (idLower.startsWith(`${pl}/`) && pl.length > best.length) best = p
612
+ }
613
+ if (best) return best
614
+ if (/^(test|tests)\//.test(id)) return '_tests'
615
+ return '_unmapped'
554
616
  }
555
617
  case 'module':
556
618
  return n.id.replace(/^module:/, '')
@@ -573,12 +635,18 @@ export function graphSummary(graph, { existsFn = existsSync, clustersLimit = 0 }
573
635
  const orphans = graphOrphans(graph).length
574
636
  const dangling = graphDangling(graph, { existsFn })
575
637
  const danglingRefs = dangling.length
638
+ // 跨仓口径分流(2026-10-15-graph-dangling-crossrepo-split):知识库集中管理仓、代码分仓存放
639
+ // 是本体系常态拓扑——跨仓强边锚点(顶级目录本仓不存在)永不可在本仓解析却全数计入 dangling_refs,
640
+ // 健康数字长期失真(本仓实证 2537 中 2472 为跨仓注记)。主计数与守恒口径不动(消费方零回归),
641
+ // 增加分流字段:breakdown.cross_repo=跨仓注记数、dangling_refs_local=本仓可行动悬空数。
642
+ const danglingCrossRepo = dangling.filter((d) => d.crossRepo).length
576
643
  // 分强度计数(附加字段——dangling_refs 是两类之和,消费方对账 doctor 时按 breakdown 拆)
577
644
  const danglingBreakdown = dangling.reduce((a, d) => {
578
645
  if (EDGE_STRENGTH[d.edge.type] === 'strong') a.strong_anchors++
579
646
  else if (EDGE_STRENGTH[d.edge.type] === 'medium') a.medium_doc_refs++
580
647
  return a
581
648
  }, { strong_anchors: 0, medium_doc_refs: 0 })
649
+ danglingBreakdown.cross_repo = danglingCrossRepo
582
650
  const moduleDocGaps = graphModuleDocGaps(graph).length
583
651
  const changelogDanglings = graphChangelogDanglings(graph, { existsFn }).length
584
652
  // clusters:type×domain 聚簇,representatives 度数 top-5(同度按 id 字典序稳序)
@@ -612,7 +680,10 @@ export function graphSummary(graph, { existsFn = existsSync, clustersLimit = 0 }
612
680
  module_doc_gaps: moduleDocGaps,
613
681
  changelog_danglings: changelogDanglings,
614
682
  dangling_refs: danglingRefs,
615
- // dangling_refs 构成(附加字段,向后兼容):doctor graph-dangling-anchor(强)+ graph-doc-dangling-ref(中)之和
683
+ // 本仓可行动悬空(附加字段,跨仓口径分流):dangling_refs − 跨仓注记——治理动作只看这个数
684
+ dangling_refs_local: danglingRefs - danglingCrossRepo,
685
+ // dangling_refs 构成(附加字段,向后兼容):doctor graph-dangling-anchor(强)+ graph-doc-dangling-ref(中)之和;
686
+ // cross_repo=跨仓注记数(顶级目录本仓不存在——目标活在独立仓,单列不对账)
616
687
  dangling_refs_breakdown: danglingBreakdown,
617
688
  clusters,
618
689
  }
@@ -791,7 +862,7 @@ export function impactFromDecisionsMd(graph, text) {
791
862
  // 输出沿知识命令族全 JSON 约定({ok,...data});人类可读摘要行内嵌 summary 字段。
792
863
  // ═══════════════════════════════════════════════════════════════
793
864
 
794
- const GRAPH_USAGE = '用法:sillyspec knowledge graph <summary|nodes|dump|neighbors|path|impact|orphans|dangling> [锚点...] [--search <模糊>] [--limit N] [--edges <边型>] [--depth N];dump 需 --layout'
865
+ 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
866
 
796
867
  export async function cmdKnowledgeGraph(dir, args, opts = {}) {
797
868
  const out = (ok, data, error) => console.log(JSON.stringify({ ok, ...(data || {}) , ...(error ? { error } : {}) }, null, 2))
@@ -822,7 +893,7 @@ export async function cmdKnowledgeGraph(dir, args, opts = {}) {
822
893
  stats: s,
823
894
  summary: [
824
895
  `节点 ${s.nodes} · 边 ${s.edges}`,
825
- `孤儿 ${s.orphans} · 模块文档缺口 ${s.module_doc_gaps} · changelog 悬空 ${s.changelog_danglings} · 悬空引用 ${s.dangling_refs}`,
896
+ `孤儿 ${s.orphans} · 模块文档缺口 ${s.module_doc_gaps} · changelog 悬空 ${s.changelog_danglings} · 悬空引用 ${s.dangling_refs}(本仓 ${s.dangling_refs_local} · 跨仓注记 ${s.dangling_refs_breakdown.cross_repo}——目标仓不在本仓,单列不对账)`,
826
897
  `簇 ${s.clusters.length} 个(最大 ${s.clusters[0]?.key ?? '—'} × ${s.clusters[0]?.count ?? 0})`,
827
898
  ],
828
899
  })
@@ -907,12 +978,18 @@ export async function cmdKnowledgeGraph(dir, args, opts = {}) {
907
978
  orphans: list.map((f) => ({ id: f.node.id, type: f.node.type, kind: f.kind })),
908
979
  })
909
980
  }
910
- // dangling
911
- const list = graphDangling(graph)
981
+ // dangling(--local 过滤跨仓注记:治理动作只看本仓可解析面——跨仓目标活在独立仓)
982
+ const localOnly = args.includes('--local')
983
+ const allDangling = graphDangling(graph)
984
+ const crossRepoCount = allDangling.filter((d) => d.crossRepo).length
985
+ const list = localOnly ? allDangling.filter((d) => !d.crossRepo) : allDangling
912
986
  return out(true, {
913
- query: { sub },
987
+ query: { sub, local: localOnly || undefined },
914
988
  count: list.length,
915
- summary: list.map((d) => `⚠️ ${d.edge.type}(${d.strength}) ${d.edge.s} → ${d.missing}`),
989
+ summary: list.map((d) => `⚠️ ${d.edge.type}(${d.strength}) ${d.edge.s} → ${d.missing}${d.crossRepo ? ' 〔跨仓注记〕' : ''}`),
916
990
  dangling: list.map((d) => ({ edge: d.edge, missing: d.missing, strength: d.strength })),
991
+ // 口径分流(附加字段):本仓可行动数 + 跨仓注记数单列不对账;--local 时 count 即本仓数
992
+ dangling_refs_local: allDangling.length - crossRepoCount,
993
+ cross_repo: crossRepoCount,
917
994
  })
918
995
  }
@@ -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
 
@@ -18,20 +18,65 @@
18
18
  * "change": "2026-09-26-thin-agent-tasks", "score": 0.83 } ] }
19
19
  * anchor = 决策条目 id(## D-xxx@vN);change = 条目「变更:」字段——同号条目跨变更常见
20
20
  * (runtime.md 七个 D-001@v1 先例),change 在场时消歧;缺省时同号条目全量带回(候选诚实)。
21
+ * epoch 注记(2026-10-14-decision-global-id-epoch):新蒸馏条目 anchor 为全局号形态
22
+ * D-<域>-NNN@vN——等值匹配语义不变;平台重建索引窗口内向量召回对新形态条目降级
23
+ * (本地词面回退不受影响),重索引后恢复。
21
24
  *
22
25
  * 降级纪律:任何失败(未连接/开关关闭/网络/404/5xx/超时/响应形态不符)静默降级本地层
23
26
  * ——检索是 advisory 面且高频(每方案步 --done),失败刷屏即狼来了;debug 排障可设
24
27
  * SILLYSPEC_KNOWLEDGE_VECTOR_DEBUG=1 打单行stderr。
28
+ *
29
+ * 开关契约(2026-10-15-vector-capability-autodetect 终态;前身 vector-gate-tighten 缺省关是
30
+ * 「端点不存在+无发现机制」时代的正确补丁,平台端点落地后 auto 成为诚实缺省):
31
+ * 缺省(未写键/auto)跟随平台能力自动探测——能力缓存 .runtime/vector-capability.json
32
+ * (404→unavailable 重试窗 1h 内跳过;成功→available;按平台 URL 匹配;跨进程持久),
33
+ * 平台部署端点后最迟 1h 全仓自动启用,消费仓零配置。local.yaml on=强制开跳过探测、
34
+ * off=强制关(用户逃生阀);env SILLYSPEC_KNOWLEDGE_VECTOR 同语义强制。
35
+ * 404 另有进程级死信短路(端点死信不再空转探测)。
25
36
  */
26
- import { readFileSync } from 'fs'
37
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
27
38
  import { join, dirname } from 'path'
28
39
  import { readPlatformConfig } from './sync.js'
29
40
  import { matchByRouting, fallbackByQueryShingles, parseDecisionEntries } from './knowledge-match.js'
30
41
 
31
42
  const VECTOR_TIMEOUT_MS = 3000
32
43
  const ENDPOINT_PATH = '/api/spec/knowledge/vector-search'
44
+ /** 能力缓存重试窗:unavailable 记录 1h 内不再探测(平台部署后最迟 1h 自动启用)。 */
45
+ const CAPABILITY_RETRY_MS = 60 * 60 * 1000
46
+
47
+ // ── 平台能力探测缓存(2026-10-15-vector-capability-autodetect)────────────────
48
+ // .runtime/ 已 gitignored 且被 daemon spec 上传排除(UPLOAD_EXCLUDE_TOP_BASE),是仓内
49
+ // 机器态的标准落点(knowledge-hits 同款)。写盘全 fail-soft;按平台 URL 匹配(换平台即失效)。
50
+
51
+ function capabilityCachePath(cwd) {
52
+ return join(cwd, '.sillyspec', '.runtime', 'vector-capability.json')
53
+ }
54
+
55
+ function readCapabilityCache(cwd) {
56
+ try {
57
+ const obj = JSON.parse(readFileSync(capabilityCachePath(cwd), 'utf-8'))
58
+ const cfg = readPlatformConfig(cwd)
59
+ if (!cfg || !cfg.url) return null
60
+ if (obj.url !== String(cfg.url).replace(/\/+$/, '')) return null // 换平台 → 缓存失效
61
+ if (typeof obj.available !== 'boolean' || typeof obj.probedAt !== 'number') return null
62
+ return { available: obj.available, probedAt: obj.probedAt }
63
+ } catch { /* 无缓存/坏 JSON = 未探测 */ }
64
+ return null
65
+ }
66
+
67
+ function writeCapabilityCache(cwd, platformUrl, available) {
68
+ try {
69
+ const p = capabilityCachePath(cwd)
70
+ const cur = readCapabilityCache(cwd)
71
+ if (cur && cur.available === available && Date.now() - cur.probedAt < CAPABILITY_RETRY_MS) return // 值未变且未过期:零写盘
72
+ const dir = join(p, '..')
73
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
74
+ writeFileSync(p, JSON.stringify({ url: platformUrl, available, probedAt: Date.now() }), 'utf-8')
75
+ } catch { /* 缓存写失败不影响检索本体(fail-soft) */ }
76
+ }
33
77
 
34
- /** local.yaml knowledge.vector_search 开关(行级扫描 knowledge: 段下 vector_search: off)。 */
78
+ /** local.yaml knowledge.vector_search 开关(行级扫描 knowledge: 段下 vector_search 值;
79
+ * 未写键/无 yaml → null=未表态——2026-10-15-vector-gate-tighten 起缺省关,显式落盘才启用)。 */
35
80
  function vectorSwitchFromLocalYaml(cwd) {
36
81
  try {
37
82
  const lines = readFileSync(join(cwd, '.sillyspec', 'local.yaml'), 'utf-8').split(/\r?\n/)
@@ -43,16 +88,55 @@ function vectorSwitchFromLocalYaml(cwd) {
43
88
  const kv = line.match(/^\s+vector_search\s*:\s*(\S+)/)
44
89
  if (kv) return kv[1].toLowerCase()
45
90
  }
46
- } catch { /* 无 local.yaml = 缺省 auto */ }
47
- return 'auto'
91
+ } catch { /* 无 local.yaml = 未表态 */ }
92
+ return null
48
93
  }
49
94
 
95
+ /**
96
+ * 向量层开关(2026-10-15-vector-capability-autodetect 终态契约;前身 2026-10-15-vector-gate-tighten
97
+ * 缺省关是「端点不存在+无发现机制」时代的正确补丁,端点落地后 auto 成为诚实缺省):
98
+ * env SILLYSPEC_KNOWLEDGE_VECTOR=on|auto → 强制启用;off(其他值保守视为 off)→ 强制关;
99
+ * local.yaml knowledge.vector_search: on → 强制启用;off → 强制关(用户显式逃生阀);
100
+ * auto/未写键 → **跟随平台能力自动探测**:读能力缓存(.runtime/vector-capability.json,
101
+ * {url,available,probedAt})——unavailable 未过重试窗(1h)=关,否则乐观试(探测结果由
102
+ * platformVectorRecall 落缓存,404→unavailable / 成功→available;跨进程持久)。
103
+ * 「端点部署本身就是开关」——平台部署后最迟 1h 内全仓自动启用,消费仓零配置。
104
+ */
50
105
  function vectorEnabled(cwd) {
51
106
  const env = String(process.env.SILLYSPEC_KNOWLEDGE_VECTOR || '').toLowerCase()
52
- if (env === 'off' || env === 'auto') return env === 'auto'
53
- return vectorSwitchFromLocalYaml(cwd) !== 'off'
107
+ if (env === 'on' || env === 'auto') return true
108
+ if (env) return false
109
+ const sw = vectorSwitchFromLocalYaml(cwd)
110
+ if (sw === 'off') return false
111
+ if (sw === 'on') return true
112
+ // absent/auto → 能力缓存判定(无平台配置=没得探测=关)
113
+ if (!readPlatformConfig(cwd)) return false
114
+ const cap = readCapabilityCache(cwd)
115
+ if (cap && !cap.available && Date.now() - cap.probedAt < CAPABILITY_RETRY_MS) return false
116
+ return true
54
117
  }
55
118
 
119
+ /**
120
+ * 向量能力提示(2026-10-15-vector-capability-autodetect 语义随迁;前身 vector-switch-discoverability
121
+ * 的「未开请配置」提示随缺省 auto 退役——auto 模式下没有可执行配置,提示转为信息性):
122
+ * 仅当 auto 模式(无显式键/键=auto)且能力缓存记 unavailable 时给一行信息——「平台部署后
123
+ * 自动启用,无需本仓配置」。强制关(yaml off/env off=用户显式选择)与已启用 → ''(零噪音)。
124
+ * 平台未连接 → ''。
125
+ */
126
+ export function vectorSwitchHint(cwd) {
127
+ if (!readPlatformConfig(cwd)) return ''
128
+ if (vectorEnabled(cwd)) return ''
129
+ const cap = readCapabilityCache(cwd)
130
+ if (cap && !cap.available) {
131
+ return `ℹ️ 平台向量语义检索未就绪(自动探测缓存生效中)——平台部署 vector-search 端点后最迟 1 小时内本仓自动启用,无需配置`
132
+ }
133
+ return ''
134
+ }
135
+
136
+ /** 404 死信端点(进程级,按平台 URL 记):端点未实现=今天真实平台状态,标记后本进程不再
137
+ * 对同 URL 空转探测(CLI 短进程生命周期天然重置;测试各用独立端口互不污染)。 */
138
+ const vectorDeadEndpoints = new Set()
139
+
56
140
  /**
57
141
  * 平台向量召回(纯 HTTP 层)。未连接/失败/形态不符 → null(调用方降级)。
58
142
  * @returns {Promise<{results: Array<{spec_path: string, anchor: string, score: number}>}|null>}
@@ -60,19 +144,26 @@ function vectorEnabled(cwd) {
60
144
  export async function platformVectorRecall({ cwd, query, limit = 10, timeoutMs = VECTOR_TIMEOUT_MS }) {
61
145
  const cfg = readPlatformConfig(cwd)
62
146
  if (!cfg || !cfg.url || !cfg.token) return null
147
+ const base = String(cfg.url).replace(/\/+$/, '')
148
+ if (vectorDeadEndpoints.has(base)) return null
63
149
  const q = String(query || '').trim().slice(0, 4000)
64
150
  if (!q) return null
65
151
  try {
66
- const res = await fetch(`${String(cfg.url).replace(/\/+$/, '')}${ENDPOINT_PATH}`, {
152
+ const res = await fetch(`${base}${ENDPOINT_PATH}`, {
67
153
  method: 'POST',
68
154
  headers: { Authorization: `Bearer ${cfg.token}`, 'Content-Type': 'application/json' },
69
155
  body: JSON.stringify({ query: q, limit }),
70
156
  signal: AbortSignal.timeout(timeoutMs),
71
157
  })
72
158
  if (!res.ok) {
159
+ if (res.status === 404) {
160
+ vectorDeadEndpoints.add(base) // 端点未实现——本进程短路后续探测
161
+ writeCapabilityCache(cwd, base, false) // 跨进程持久:重试窗内不再探测(平台部署后自动启用)
162
+ }
73
163
  if (process.env.SILLYSPEC_KNOWLEDGE_VECTOR_DEBUG) console.error(`[knowledge-vector] ${res.status} ${ENDPOINT_PATH}——降级本地`)
74
164
  return null
75
165
  }
166
+ writeCapabilityCache(cwd, base, true) // 端点在场——缓存 available(后续进程零探测直用)
76
167
  const body = await res.json().catch(() => null)
77
168
  const results = body && Array.isArray(body.results)
78
169
  ? body.results.filter((r) => r && typeof r.spec_path === 'string' && typeof r.anchor === 'string')
@@ -187,10 +187,25 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
187
187
  // 各 spawn 2 个 git 进程(gate execute 一次省 ≈ 60-200ms on Windows)。
188
188
  const sharedEvidence = stage === 'execute' ? checkExecuteCodeEvidence(cwd, changeName) : undefined;
189
189
 
190
+ // ── gate 预检实况注入(2026-10-14-gate-verify-apiface-preflight FR-01~FR-03):facts.apiFace
191
+ // 唯一生产者是 run verify --done 收尾前置 backfill(run/gates.js),gate 路径不回填且 --init
192
+ // 刻意不落键 → 锚点核对 parseSet 空/旧 → design接口表# 锚点恒空指(平台 6 轮 gate 实证)。
193
+ // 此处以同一解析器实况解析 design.md 经 context 注入 validator(与 --done backfill 产物同形
194
+ // 同源,零落盘守 D-002 只读契约);design.md 缺失/不可读 helper 返 null、解析异常 fail-soft
195
+ // ——两种态都不注入,退回 facts 口径(现状行为;评审 P1:空面注入会覆写有效 facts 反致误阻断)。──
196
+ let apiFaceLive = null;
197
+ if (stage === 'verify') {
198
+ try {
199
+ const { computeApiFaceFromDesign } = await import('./verify-probes.js');
200
+ apiFaceLive = computeApiFaceFromDesign(join(specRoot, 'changes', changeName, 'design.md'));
201
+ } catch { /* fail-soft:不注入退回 facts 口径 */ }
202
+ }
203
+
190
204
  // ── a. artifacts:阶段产物校验(全部阶段)──
191
205
  const r = runValidators(stage, cwd, changeName, {
192
206
  projectName: progress.project,
193
207
  specRoot,
208
+ ...(apiFaceLive ? { apiFaceLive } : {}),
194
209
  ...(sharedEvidence ? { evidence: sharedEvidence } : {}),
195
210
  });
196
211
  checks.push({
@@ -752,10 +767,21 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
752
767
 
753
768
  // ── d. artifacts:当前阶段产物校验 ──
754
769
  // runValidators 需要 stage:用 progress.currentStage(若为空串则校验器内部处理)。
770
+ // verify 阶段同款实况注入(2026-10-14-gate-verify-apiface-preflight):与 runGate 同因——
771
+ // facts.apiFace 唯一生产者是 --done backfill,derive 不回填 → 锚点核对恒空指;零落盘 +
772
+ // fail-soft(缺失返 null/异常两态都不注入退回 facts 口径),守 D-002 只读契约。
755
773
  case 'artifacts': {
774
+ let apiFaceLive = null;
775
+ if (currentStage === 'verify') {
776
+ try {
777
+ const { computeApiFaceFromDesign } = await import('./verify-probes.js');
778
+ apiFaceLive = computeApiFaceFromDesign(join(specRoot, 'changes', changeName, 'design.md'));
779
+ } catch { /* fail-soft:不注入退回 facts 口径 */ }
780
+ }
756
781
  const r = runValidators(currentStage, cwd, changeName, {
757
782
  projectName: progress.project,
758
783
  specRoot,
784
+ ...(apiFaceLive ? { apiFaceLive } : {}),
759
785
  });
760
786
  data = { ok: r.ok, errors: r.errors, warnings: r.warnings };
761
787
  ok = r.ok;
@@ -14,6 +14,7 @@
14
14
  * 归属分类同一套 module-map 前缀匹配,仅输入来源不同。
15
15
  */
16
16
  import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
17
+ import { modulePathMatches } from './modules.js'
17
18
  import { join } from 'path'
18
19
  import { resolveVerifyChangedFiles } from './verify-postcheck.js'
19
20
  import { resolveSpecDir } from './run/shared.js'
@@ -87,11 +88,7 @@ function classifyFile(posixPath, modulePaths) {
87
88
  for (const [moduleId, paths] of modulePaths) {
88
89
  for (const p of paths) {
89
90
  // 目录条目以 / 结尾 → 前缀匹配;文件条目 → 精确或子路径(与模块上下文索引读侧口径一致)
90
- if (p.endsWith('/')) {
91
- if (posixPath.startsWith(p)) return moduleId
92
- } else if (posixPath === p || posixPath.startsWith(p + '/')) {
93
- return moduleId
94
- }
91
+ if (modulePathMatches(posixPath, p)) return moduleId
95
92
  }
96
93
  }
97
94
  return null
package/src/modules.js CHANGED
@@ -2,6 +2,37 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { gitQuiet } from './git-helper.js';
4
4
  import { nowWallClock } from './datetime.js';
5
+ import { globMatch } from './change-list.js';
6
+
7
+ /**
8
+ * 模块 paths 条目 × 文件路径 的统一归属判定(2026-10-15-module-path-glob-match)。
9
+ *
10
+ * 背景:sillyspec-scan 生成的 module-map(消费仓)大量使用 glob 形态 paths——
11
+ * `app/modules/daemon/**`、`frontend/src/components/daemon/*.tsx`、多段通配与文件通配的
12
+ * 组合(`**` 与 `*.tsx` 同串)、括号路径等(平台仓实测 146 条)——而历史各消费点只做朴素
13
+ * `file === p || startsWith(p + '/')`,glob 条目恒不命中(恰好只有精确文件路径能命中),
14
+ * 导致消费仓全链路域路由失效:交付文件匹配不上模块 → 三级兜底全灭 → FR 铸伪域
15
+ * (auto-backend 等)、决策落 unmapped。
16
+ *
17
+ * 语义(本仓无 glob map 时与旧各点逐字等价——零回归):
18
+ * - 尾部 `/` 的目录条目:startsWith(旧 module-impact 目录分支语义)
19
+ * - 其余条目:相等 或 `p/` 前缀(旧 fr-index/ceremony-tier 等语义)
20
+ * - 含 `*`/`?` 的 glob 条目:追加 globMatch(双星跨零或多段、单星段内、单问号单字符;
21
+ * change-list.js 既有完备实现单一源复用)
22
+ * @param {string} filePath 仓根相对文件路径(含反斜杠容忍)
23
+ * @param {string} rawPattern module-map 的 paths/core_files 条目
24
+ * @returns {boolean}
25
+ */
26
+ export function modulePathMatches(filePath, rawPattern) {
27
+ const f = String(filePath || '').replace(/\\/g, '/');
28
+ const raw = String(rawPattern || '').replace(/\\/g, '/');
29
+ if (!f || !raw) return false;
30
+ if (raw.endsWith('/')) return f.startsWith(raw);
31
+ const p = raw.replace(/\/+$/, '');
32
+ if (!p) return false;
33
+ if (f === p || f.startsWith(p + '/')) return true;
34
+ return globMatch(f, p);
35
+ }
5
36
 
6
37
 
7
38
  /**