sillyspec 3.34.1 → 3.34.2

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.
@@ -22,7 +22,7 @@
22
22
  * (uncategorized.md 可能 CRLF,主仓知识文件为 LF)。
23
23
  */
24
24
 
25
- import { appendFileSync, existsSync, readFileSync } from 'fs'
25
+ import { appendFileSync, existsSync, readFileSync, readdirSync, rmSync } from 'fs'
26
26
  import { basename, dirname, join } from 'path'
27
27
  import { writeAtomicSync } from './fs-atomic.js'
28
28
  import { appendKnowledgeHit } from './knowledge-hits.js'
@@ -30,11 +30,16 @@ import { appendKnowledgeHit } from './knowledge-hits.js'
30
30
  // ── 常量与内部工具 ──
31
31
 
32
32
  // 目标文件(INDEX 相对 POSIX 路径)→ INDEX.md 分类段标题。
33
- // decisions/ 目录按前缀判定为 Decisions;四类之外的目标文件无处落路由行 → unknown_category。
33
+ // decisions/ 目录按前缀判定为 Decisions;五类之外的目标文件无处落路由行 → unknown_category。
34
+ // gotchas 双目标(2026-11-07-knowledge-k-shape-minimal):工具/测试坑知识此前被迫落
35
+ // known-issues 语义错位(2026-11-06 六条合入已现此妥协)——INDEX 无对应段时由
36
+ // insertRouteLine 既有 EOF 追加语义兜底,不假设段名在场。
34
37
  const CATEGORY_SECTIONS = {
35
38
  'known-issues.md': 'Known Issues',
36
39
  'patterns.md': 'Patterns',
37
40
  'conventions.md': 'Conventions',
41
+ 'sillyspec-gotchas.md': 'SillySpec Gotchas',
42
+ 'testing-gotchas.md': 'Testing Gotchas',
38
43
  }
39
44
 
40
45
  // 标题行 qlId 标注形态:`## ql-20260604-001-7a4c | <标题>` 的首 token
@@ -295,7 +300,177 @@ export function classifyUncategorizedEntry({ knowledgeDir, qlId, targetFile, sec
295
300
  }
296
301
  }
297
302
 
298
- // ── CLI 入口(形态对齐 src/stages/knowledge.js 的 cmdSearch:JSON 输出 + args.indexOf 解析) ──
303
+ // ── proposed 收口通道(2026-11-07-knowledge-accept-channel)─────────────────────
304
+
305
+ /**
306
+ * 待审知识生效目标(--file 可省时的 category 自动路由白名单,INDEX 相对 basename)。
307
+ * 与 CATEGORY_SECTIONS 同源——decisions/ 不收 proposed(决策走蒸馏管道,不走人工合入)。
308
+ */
309
+ const ACCEPT_TARGETS = new Set(['known-issues', 'patterns', 'conventions', 'sillyspec-gotchas', 'testing-gotchas'])
310
+
311
+ /**
312
+ * K 形态结构完整度启发式(2026-11-07-knowledge-k-shape-minimal FR-02):有效知识最小
313
+ * 结构 = 「问题」段 + 解法类段(解法/规则/模式/约定任一)。标题措辞启发式检测——
314
+ * advisory 不阻断(历史形态多元,硬门留完整 K 契约变更裁决)。
315
+ */
316
+ function structuralWarningsFor(body) {
317
+ const warnings = []
318
+ // 行首锚定已足(\b 在纯中文后失效——中文非 \w,词边界断言恒 false)
319
+ if (!/^#{1,3}\s*问题/m.test(body)) warnings.push('缺「问题」段标题(现象/根因宜以「## 问题」开头)')
320
+ if (!/^#{1,3}\s*(解法|规则|模式|约定)/m.test(body)) warnings.push('缺解法类段标题(「## 解法/规则/模式/约定」任一——可执行结论是有效知识的最小结构)')
321
+ return warnings
322
+ }
323
+
324
+ /**
325
+ * 合并单个 proposed/ 待审条目到正式知识文件(proposed 收口通道 FR-01~04)。
326
+ *
327
+ * 复用 classifyUncategorizedEntry 的全部迁移原语(追加块/duplicate 判定/INDEX 路由行/
328
+ * keywords 解析/EOL 检测),差异仅在源侧:读 proposed/<slug>.md(frontmatter category +
329
+ * `#` 标题 + 正文,剥 cmdPropose 生成的尾注块)→ 删除整个源文件(区别于 classify 删
330
+ * uncategorized 段)。落盘顺序对齐 classify:先追加后 INDEX 后删源(中断最坏态可幂等收敛)。
331
+ *
332
+ * 复活防线(2026-11-07-sync-proposed-no-revive):accept 删本地源文件后,下次 spec-sync
333
+ * 按「服务器有本地无 → delete」自然下发删除,平台待审区同步清位——无复活面。
334
+ */
335
+ function acceptProposedEntry({ knowledgeDir, id, targetFile, keywords, sectionTitle, dryRun, runtimeDir } = {}) {
336
+ const fail = (code, extra = {}) => ({
337
+ ok: false, moved: false, appendedTo: null, indexUpdated: false, anchor: '',
338
+ error: { code, ...extra },
339
+ })
340
+
341
+ // ① id 归一:proposed/<slug> 或裸 slug → 目录内文件名(拒绝路径穿越形态)
342
+ let slug = String(id || '').trim().replace(/\\/g, '/')
343
+ if (slug.startsWith('proposed/')) slug = slug.slice('proposed/'.length)
344
+ if (!knowledgeDir || !existsSync(knowledgeDir)) return fail('knowledge_dir_missing', { path: knowledgeDir || null })
345
+ if (!slug || slug.includes('/') || slug.includes('..') || slug === '.') return fail('proposed_invalid_id', { id })
346
+ const srcPath = join(knowledgeDir, 'proposed', `${slug}.md`)
347
+ if (!existsSync(srcPath)) return fail('proposed_not_found', { id })
348
+
349
+ // ② 解析 proposed 文件:frontmatter(category/proposed_at) + `#` 标题 + 正文(剥尾注块)
350
+ const norm = readFileSync(srcPath, 'utf8').replace(/\r\n/g, '\n')
351
+ const fm = {}
352
+ const fmBlock = norm.match(/^---\n([\s\S]*?)\n---\n/)
353
+ if (fmBlock) {
354
+ for (const line of fmBlock[1].split('\n')) {
355
+ const m = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/)
356
+ if (m) fm[m[1].toLowerCase()] = m[2].trim()
357
+ }
358
+ }
359
+ const titleMatch = norm.match(/^#\s+(.+)$/m)
360
+ if (!titleMatch) return fail('proposed_invalid', { id, reason: '缺少 # 标题行' })
361
+ const entryTitle = titleMatch[1].trim()
362
+ let body = norm.slice(norm.indexOf(titleMatch[0]) + titleMatch[0].length)
363
+ // 剥 cmdPropose 生成的尾注分隔块('---\n> This is a proposed knowledge entry...')
364
+ body = body.replace(/\n+---\s*\n>\s*This is a proposed knowledge entry[\s\S]*$/, '').trim()
365
+
366
+ const finalTitle = String(sectionTitle || '').trim() || entryTitle
367
+ const anchor = finalTitle
368
+ // K 形态结构完整度 advisory(2026-11-07-knowledge-k-shape-minimal FR-02):缺段列警告
369
+ // 不阻断——dry-run 与正式路径同携(见两处返回的 structuralWarnings 键)。
370
+ const structuralWarnings = structuralWarningsFor(body)
371
+
372
+ // ③ 目标文件:--file 显式优先 > frontmatter category 自动路由(FR-02)
373
+ let file = normalizeTargetFile(targetFile || '')
374
+ if (!file) {
375
+ const cat = String(fm.category || '').trim().replace(/\.md$/, '')
376
+ if (ACCEPT_TARGETS.has(cat)) file = `${cat}.md`
377
+ else return fail('target_file_required', {
378
+ category: fm.category || null,
379
+ message: 'category 无效或缺失,须显式 --file(known-issues/patterns/conventions/sillyspec-gotchas/testing-gotchas 五目标)',
380
+ })
381
+ }
382
+ const targetPath = join(knowledgeDir, file)
383
+ if (!existsSync(targetPath)) return fail('target_file_missing', { targetFile: file, path: targetPath })
384
+
385
+ // ④ 追加块 + duplicate 幂等(口径逐字对齐 classifyUncategorizedEntry ②段)
386
+ const targetRaw = readFileSync(targetPath, 'utf8')
387
+ const targetEol = detectEol(targetRaw)
388
+ const dupRe = new RegExp(`^##\\s+${escapeRegExp(finalTitle)}\\s*$`, 'm')
389
+ const duplicate = dupRe.test(targetRaw.replace(/\r\n/g, '\n'))
390
+ let appendBlock = null
391
+ let skippedReason
392
+ if (!duplicate) {
393
+ const b = body ? body.replace(/\n/g, targetEol) : ''
394
+ appendBlock = ['', `## ${finalTitle}`, '', ...(b ? [b] : []), ''].join(targetEol)
395
+ if (targetRaw && !targetRaw.endsWith('\n')) appendBlock = targetEol + appendBlock
396
+ } else {
397
+ skippedReason = 'duplicate-title'
398
+ }
399
+
400
+ // ⑤ INDEX 路由行(口径对齐 classifyUncategorizedEntry ③段)
401
+ const category = categoryForTarget(file)
402
+ if (!category) return fail('unknown_category', { targetFile: file })
403
+ const indexPath = join(knowledgeDir, 'INDEX.md')
404
+ if (!existsSync(indexPath)) return fail('index_missing', { path: 'knowledge/INDEX.md' })
405
+ const indexRaw = readFileSync(indexPath, 'utf8')
406
+ const indexEol = detectEol(indexRaw)
407
+ const indexNorm = indexRaw.replace(/\r\n/g, '\n')
408
+ const kws = resolveKeywords(keywords, finalTitle)
409
+ const display = `${file}#${anchor}`
410
+ const routeLine = `- ${kws.join('|')} → [${display}](${display})`
411
+ const routeExists = indexNorm.split('\n').some((l) => l.includes(`](${display})`))
412
+ const newIndexNorm = routeExists ? null : insertRouteLine(indexNorm, category, routeLine)
413
+
414
+ if (dryRun) {
415
+ return {
416
+ ok: true, dryRun: true, moved: false, appendedTo: file, indexUpdated: false,
417
+ anchor, title: finalTitle, keywords: kws, skippedReason: skippedReason || undefined,
418
+ ...(structuralWarnings.length > 0 ? { structuralWarnings } : {}),
419
+ plan: {
420
+ appendToTarget: !duplicate,
421
+ targetSectionHeading: `## ${finalTitle}`,
422
+ indexRouteLine: routeExists ? null : routeLine,
423
+ removeFromProposed: `knowledge/proposed/${slug}.md`,
424
+ },
425
+ }
426
+ }
427
+
428
+ // 落盘顺序(对齐 classify 约束:先追加后删除,条目不丢内容、可手工搬运 revert)
429
+ if (appendBlock !== null) appendFileSync(targetPath, appendBlock, 'utf8')
430
+ if (newIndexNorm !== null) writeAtomicSync(indexPath, newIndexNorm.split('\n').join(indexEol))
431
+ rmSync(srcPath, { force: true })
432
+
433
+ const rt = runtimeDir || join(dirname(knowledgeDir), '.runtime')
434
+ appendKnowledgeHit(rt, {
435
+ type: 'accept',
436
+ targetFile: file,
437
+ title: finalTitle,
438
+ skippedReason: skippedReason || undefined,
439
+ })
440
+
441
+ return {
442
+ ok: true, moved: true, appendedTo: file,
443
+ indexUpdated: newIndexNorm !== null,
444
+ anchor, title: finalTitle, keywords: kws,
445
+ skippedReason: skippedReason || undefined,
446
+ ...(structuralWarnings.length > 0 ? { structuralWarnings } : {}),
447
+ }
448
+ }
449
+
450
+ /** 列出 proposed/ 待审清单(inbox 消费段 FR-05):slug + 标题 + 积压天数。空目录返回 []。 */
451
+ export function listProposedPending(knowledgeDir) {
452
+ const propDir = join(knowledgeDir, 'proposed')
453
+ if (!existsSync(propDir)) return []
454
+ const now = Date.now()
455
+ return readdirSync(propDir)
456
+ .filter((f) => f.endsWith('.md'))
457
+ .map((f) => {
458
+ const slug = f.replace(/\.md$/, '')
459
+ let title = slug
460
+ let ageDays = null
461
+ try {
462
+ const norm = readFileSync(join(propDir, f), 'utf8').replace(/\r\n/g, '\n')
463
+ title = norm.match(/^#\s+(.+)$/m)?.[1]?.trim() || slug
464
+ const at = norm.match(/^proposed_at:\s*(.+)$/m)?.[1]?.trim()
465
+ const ts = at ? Date.parse(at) : NaN
466
+ if (Number.isFinite(ts)) ageDays = Math.max(0, Math.floor((now - ts) / 86400000))
467
+ } catch { /* 单文件解析失败按 slug 兜底展示 */ }
468
+ return { slug, title, ageDays }
469
+ })
470
+ .sort((a, b) => (b.ageDays ?? 0) - (a.ageDays ?? 0))
471
+ }
472
+
473
+
299
474
 
300
475
  function output(ok, data, error) {
301
476
  const result = { ok, ...data }
@@ -413,12 +588,24 @@ export async function cmdKnowledgeInbox(dir, args, opts = {}) {
413
588
  qlId: e.qlIdPrefix || null,
414
589
  summary: String(e.body || '').replace(/\s+/g, ' ').slice(0, 80),
415
590
  }))
591
+ // proposed 待审清单(2026-11-07-knowledge-accept-channel FR-05):json 面恒挂键;文本面
592
+ // 非空渲染——渲染件在空收件箱早退**之前**(评审 P1 修复:早退吞清单致默认人读路径
593
+ // 违反 FR-05)。
594
+ const proposed = listProposedPending(knowledgeDir)
595
+ const renderProposed = () => {
596
+ if (proposed.length === 0) return
597
+ console.log(`📬 待审提议(proposed/ ${proposed.length} 条)——确认后 knowledge accept --id proposed/<slug> 合并生效:`)
598
+ proposed.forEach((p, i) => {
599
+ console.log(` ${i + 1}. 《${p.title}》${p.ageDays !== null ? `(积压 ${p.ageDays} 天)` : ''}`)
600
+ })
601
+ }
416
602
  if (wantJson) {
417
- output(true, { inbox: { count: entries.length, baseline, over, items } })
603
+ output(true, { inbox: { count: entries.length, baseline, over, items, proposed } })
418
604
  return
419
605
  }
420
606
  if (entries.length === 0) {
421
607
  console.log('📚 知识收件箱:空——uncategorized 无待审条目')
608
+ renderProposed()
422
609
  return
423
610
  }
424
611
  console.log(`📚 知识收件箱:待审 ${entries.length} 条${baseline !== null ? `(基线 ${baseline}${over ? ',已超 ⚠️' : ''})` : ''}`)
@@ -427,4 +614,43 @@ export async function cmdKnowledgeInbox(dir, args, opts = {}) {
427
614
  if (it.summary) console.log(` ${it.summary}`)
428
615
  })
429
616
  console.log(' 归类:sillyspec knowledge classify --title "…" --file conventions|patterns|known-issues.md')
617
+ renderProposed()
618
+ }
619
+
620
+ /**
621
+ * `sillyspec knowledge accept` 子命令入口(2026-11-07-knowledge-accept-channel):
622
+ * 合并单个 proposed/ 待审条目到正式知识文件(用户确认后由 agent/人执行)。
623
+ * 参数解析形态对齐 cmdSearch(args.indexOf + flag-as-value 防护)。
624
+ */
625
+ export async function cmdKnowledgeAccept(dir, args, opts = {}) {
626
+ const base = opts.specDir || join(dir, '.sillyspec')
627
+ const knowledgeDir = join(base, 'knowledge')
628
+ const grab = (flag) => {
629
+ const i = args.indexOf(flag)
630
+ const v = i >= 0 ? args[i + 1] : ''
631
+ return v && !v.startsWith('--') ? v : ''
632
+ }
633
+ const id = grab('--id')
634
+ const targetFile = grab('--file')
635
+ const sectionTitle = grab('--section')
636
+ const kwRaw = grab('--keywords')
637
+ const dryRun = args.includes('--dry-run')
638
+ if (!id) {
639
+ output(false, {}, '--id is required(proposed/<slug>)')
640
+ return
641
+ }
642
+ const r = acceptProposedEntry({
643
+ knowledgeDir,
644
+ id,
645
+ targetFile: targetFile || undefined,
646
+ keywords: kwRaw ? kwRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined,
647
+ sectionTitle: sectionTitle || undefined,
648
+ dryRun,
649
+ })
650
+ if (r.ok) {
651
+ const { error: _e, ...data } = r
652
+ output(true, data)
653
+ } else {
654
+ output(false, {}, r.error)
655
+ }
430
656
  }
@@ -17,6 +17,7 @@
17
17
  import { existsSync, readFileSync, readdirSync } from 'fs'
18
18
  import { join } from 'path'
19
19
  import { readFrBindings, resolveTestFileRel, testAnchorFile } from './test-bindings.js'
20
+ import { listProposedPending } from './knowledge-classify.js'
20
21
 
21
22
  /** 伪域判定:auto- 前缀(scaffold 时代机器蒸馏域)或 unmapped 池 */
22
23
  const isPseudoDomain = (domain) => /^auto[-_]/.test(domain) || domain === 'unmapped'
@@ -161,6 +162,24 @@ export function collectKnowledgeDigest({ specBase, projectRoot }) {
161
162
  inboxTitles.slice(0, 5).join(';') + (inboxTitles.length > 5 ? ' 等' : ''),
162
163
  'sillyspec knowledge inbox 看清单 → knowledge classify --ql <id> --file <known-issues.md|patterns.md|conventions.md> [--dry-run] 逐条归类')
163
164
  }
165
+ // ③ proposed 待审积压(2026-11-07-knowledge-accept-channel FR-05):此前 proposed 积压
166
+ // 零信号(本仓 6 条 46 天无人知)——与收件箱同款双档(info 可见 / 超阈 warn),阈值 6 取
167
+ // 本仓当前积压实证量级。
168
+ let proposedPending = []
169
+ const propDir = join(knowledgeRoot, 'proposed')
170
+ if (existsSync(propDir)) {
171
+ proposedPending = listProposedPending(knowledgeRoot)
172
+ totals.proposed = proposedPending.length
173
+ }
174
+ if (totals.proposed >= 6) {
175
+ mk('proposed', 'warn', `待审知识积压(proposed ${totals.proposed} 条 ≥ 6)`, totals.proposed,
176
+ proposedPending.slice(0, 5).map((p) => p.title).join(';') + (proposedPending.length > 5 ? ' 等' : ''),
177
+ 'sillyspec knowledge inbox 看清单 → 确认后 knowledge accept --id proposed/<slug> 合并生效')
178
+ } else if (totals.proposed > 0) {
179
+ mk('proposed', 'info', `待审知识 ${totals.proposed} 条(proposed)`, totals.proposed,
180
+ proposedPending.slice(0, 5).map((p) => p.title).join(';') + (proposedPending.length > 5 ? ' 等' : ''),
181
+ '确认后 knowledge accept --id proposed/<slug> 合并生效')
182
+ }
164
183
  if (totals.pseudo > 0) {
165
184
  mk('pseudo-domain', 'warn', `伪域在库(auto-*/unmapped 共 ${totals.pseudo} 条)`, totals.pseudo,
166
185
  [...pseudoByDomain.entries()].map(([d, n]) => `${d} ${n}`).join('、'),
@@ -286,6 +286,21 @@ export function keywordMatchesContext(keyword, contextLower) {
286
286
  return new RegExp(`(^|[^a-z0-9_])${escapeRegex(kw)}([^a-z0-9_]|$)`).test(contextLower)
287
287
  }
288
288
 
289
+ /** 检索结果 → 命中通道单源判定(routing/vector/fallback/scope,2026-11-05-knowledge-search-
290
+ * observability FR-04):注入遥测 channel 字段与 search 面通道行同口径。放本叶子模块(而非
291
+ * knowledge-vector.js)——stages/execute.js 静态导入本函数,若从 knowledge-vector 导入会形成
292
+ * stages/index→execute→knowledge-vector→…→stages/index 的 TDZ 环(execute.js 头注释 2026-09-14
293
+ * 实证同型坑)。json 判别键与各层构造方一致(fallbackByQueryShingles/buildResultFromPlatform/
294
+ * buildScopeRecallResult 的 json 标记)。 */
295
+ export function resultChannel(result) {
296
+ const json = result && result.json
297
+ if (!json) return 'routing'
298
+ if (json.fallback) return 'fallback'
299
+ if (json.vector) return 'vector'
300
+ if (json.scope) return 'scope'
301
+ return 'routing'
302
+ }
303
+
289
304
  /** 查询词片抽取:CJK 连续段 bigram + ≥4 字符 ASCII 词(小写;短词与标点数字不参与;
290
305
  * 纯 ASCII 查询不产词片——中文知识库,英文查询维持既有零命中行为)。
291
306
  * snake/kebab 复合词保留整词并额外产出 ≥4 字符拆分片段(FR-02:查询 worktree_apply 时
@@ -430,6 +445,93 @@ export function fallbackByQueryShingles(indexDir, taskContext) {
430
445
  }
431
446
  }
432
447
 
448
+ /**
449
+ * 正文词片兜底(2026-11-06-knowledge-body-recall FR-01):普通知识文件 `##` 段落语料的
450
+ * 查询词片召回——四层检索既有语料均不含普通文件正文(根因见该变更 design),本通道补位。
451
+ *
452
+ * - 语料:INDEX 引用的普通知识文件(排除 decisions/fr 路由行——各有专用通道;排除停车场
453
+ * 文件),按 `## ` 切段,段落=锚点级命中单元。
454
+ * - 词片:复用 queryShingles(无新分词逻辑;纯 ASCII 查询零词片=零产出,既有语义边界)。
455
+ * - 窗判定(D-002 段落覆盖数口径,非 decisions 通道的绝对次数窗):词片**覆盖的段落数**
456
+ * n_segs ∈ [2, max(2, ceil(8% × 该文件段落总数))] 才算「跨段复现且非泛在」——绝对次数
457
+ * 窗在正文语料会误杀稀有词(实测 5% 次数窗上限恒 2,正文出现 3-5 次的稀有词被误判泛在,
458
+ * 依据 .sillyspec/.runtime/logs/bigram-stats.log:泛在词「目录」段覆盖 19.3%、「会话」
459
+ * 11.9% vs 稀有词「重开」1.8%、「问题」3.7%,8% 取分界带)。
460
+ * - 已修复段落过滤(FR-04):段落含 `- 状态:已修复/已解决/已退役` 状态行时跳过(口径对齐
461
+ * isResolvedRoute 的正文状态行判据)。
462
+ * - 无可命中段落返回 null(保持调用方零命中语义,对齐 fallbackByQueryShingles 契约)。
463
+ */
464
+ export function fallbackByBodyShingles(indexDir, taskContext) {
465
+ const shingles = queryShingles(taskContext)
466
+ if (shingles.length === 0) return null
467
+ const entries = parseKnowledgeIndex(indexDir)
468
+ if (entries.length === 0) return null
469
+
470
+ const byFile = new Map()
471
+ for (const e of entries) {
472
+ const file = String(e.file || '').replace(/\\/g, '/')
473
+ // 专用通道文件(decisions/fr 路由行)与停车场不入正文语料
474
+ if (/^decisions\//.test(file) || /^fr\//.test(file) || isParkingFile(file)) continue
475
+ if (!byFile.has(file)) byFile.set(file, [])
476
+ byFile.get(file).push(e)
477
+ }
478
+
479
+ const hits = []
480
+ for (const [file] of byFile) {
481
+ let content
482
+ try {
483
+ content = readFileSync(join(indexDir, file), 'utf8').replace(/\r\n?/g, '\n')
484
+ } catch { continue /* 文件不可读跳过(fail-soft,对齐 isResolvedRoute 读取容错) */ }
485
+ // `## ` 切段;段头之后的正文参与词片判定
486
+ const blocks = content.split(/^## /m).slice(1)
487
+ if (blocks.length === 0) continue
488
+ const segs = blocks.map((b) => {
489
+ const lines = b.split('\n')
490
+ return { heading: lines[0].trim(), text: b.toLowerCase() }
491
+ })
492
+ // 已修复段落过滤(FR-04)
493
+ const active = segs.filter((s) => !/^\s*-\s*状态[::]\s*(已修复|已解决|已退役)/m.test(s.text))
494
+ const ceil = Math.max(2, Math.ceil(active.length * 0.08))
495
+ // 词片 → 覆盖段落数(段落覆盖口径);窗内词片按段落聚合
496
+ const segHitCount = new Map()
497
+ const inWindow = new Set()
498
+ for (const sh of shingles) {
499
+ let n = 0
500
+ for (const s of active) if (s.text.includes(sh)) n++
501
+ if (n >= 2 && n <= ceil) {
502
+ inWindow.add(sh)
503
+ for (const s of active) if (s.text.includes(sh)) segHitCount.set(s, (segHitCount.get(s) || 0) + 1)
504
+ }
505
+ }
506
+ if (inWindow.size === 0) continue
507
+ const entry = byFile.get(file)[0]
508
+ for (const s of active) {
509
+ const score = segHitCount.get(s) || 0
510
+ if (score === 0) continue
511
+ hits.push({
512
+ file,
513
+ anchor: s.heading,
514
+ keywords: [...inWindow].filter((sh) => s.text.includes(sh)),
515
+ display: `${s.heading}(正文词片命中)`,
516
+ category: entry.category || '',
517
+ score,
518
+ })
519
+ }
520
+ }
521
+ if (hits.length === 0) return null
522
+ hits.sort((a, b) => b.score - a.score)
523
+ const top = hits.slice(0, 10)
524
+ return {
525
+ matched: true,
526
+ entries: top,
527
+ report: `Status: body fallback matched (${top.length} segments in ${byFile.size} files)`,
528
+ json: {
529
+ matched: true, entry_count: top.length, fallback: true, bodyFallback: true,
530
+ entries: top.map((e) => ({ file: e.file, anchor: e.anchor, keywords: e.keywords, category: e.category })),
531
+ },
532
+ }
533
+ }
534
+
433
535
  /**
434
536
  * 用任务上下文匹配知识条目
435
537
  * @param {string} indexDir - knowledge 目录路径
@@ -38,9 +38,21 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
38
38
  import { runtimeStatePath } from './runtime-paths.js'
39
39
  import { join, dirname } from 'path'
40
40
  import { readPlatformConfig } from './sync.js'
41
- import { matchByRouting, fallbackByQueryShingles, parseDecisionEntries, isParkingFile } from './knowledge-match.js'
41
+ import { matchByRouting, fallbackByQueryShingles, fallbackByBodyShingles, parseDecisionEntries, isParkingFile, resultChannel } from './knowledge-match.js'
42
+
43
+ export { resultChannel }
44
+
45
+ /** 向量召回 HTTP 预算(ms)。缺省 5000 覆盖平台 embedding worker 冷启 1~3s + 查询嵌入
46
+ * (2026-11-04-knowledge-vector-async-refresh 平台侧修复的配套议题——旧缺省 3000 在
47
+ * worker 闲置自灭后的首查必超时白降级一次);env SILLYSPEC_KNOWLEDGE_VECTOR_TIMEOUT_MS
48
+ * 正整数覆盖,非法值回落缺省(2026-11-05-knowledge-search-observability FR-01)。 */
49
+ const VECTOR_TIMEOUT_MS_DEFAULT = 5000
50
+
51
+ export function resolveVectorTimeoutMs(env = process.env) {
52
+ const raw = Number(env.SILLYSPEC_KNOWLEDGE_VECTOR_TIMEOUT_MS)
53
+ return Number.isFinite(raw) && Number.isInteger(raw) && raw > 0 ? raw : VECTOR_TIMEOUT_MS_DEFAULT
54
+ }
42
55
 
43
- const VECTOR_TIMEOUT_MS = 3000
44
56
  const ENDPOINT_PATH = '/api/spec/knowledge/vector-search'
45
57
  /** 能力缓存重试窗:unavailable 记录 1h 内不再探测(平台部署后最迟 1h 自动启用)。 */
46
58
  const CAPABILITY_RETRY_MS = 60 * 60 * 1000
@@ -142,7 +154,7 @@ const vectorDeadEndpoints = new Set()
142
154
  * 平台向量召回(纯 HTTP 层)。未连接/失败/形态不符 → null(调用方降级)。
143
155
  * @returns {Promise<{results: Array<{spec_path: string, anchor: string, score: number}>}|null>}
144
156
  */
145
- export async function platformVectorRecall({ cwd, query, limit = 10, timeoutMs = VECTOR_TIMEOUT_MS }) {
157
+ export async function platformVectorRecall({ cwd, query, limit = 10, timeoutMs = resolveVectorTimeoutMs() }) {
146
158
  const cfg = readPlatformConfig(cwd)
147
159
  if (!cfg || !cfg.url || !cfg.token) return null
148
160
  const base = String(cfg.url).replace(/\/+$/, '')
@@ -215,9 +227,23 @@ export function buildResultFromPlatform(indexDir, pvResults) {
215
227
  file,
216
228
  anchor: rs[0].anchor,
217
229
  display: `${file}(平台向量召回)`,
230
+ // search 面真实分透出(FR-03):该文件命中的最高余弦分——buildSearchMatches
231
+ // 对 vector 通道用它替代 keywords∩query 计数(原先恒 0 是错值不只是缺值)
232
+ vectorScore: Math.max(...rs.map((r) => (typeof r.score === 'number' ? r.score : 1))),
218
233
  })
219
234
  }
220
235
  if (decisionHits.length === 0) return null
236
+ // 跨文件同键去重(FR-02,2026-11-05-knowledge-search-observability):epoch 迁移/蒸馏
237
+ // fanout 遗留同 (id, change) 条目在多个域文件存副本(实测 798 条中 104 重复槽位,top10
238
+ // 曾被 5 条副本挤占)——键含 change||''(无 change 的同号跨变更是诚实形态不误并),保分高
239
+ const byKey = new Map()
240
+ for (const h of decisionHits) {
241
+ const key = `${h.id}\u0000${h.change || ''}`
242
+ const cur = byKey.get(key)
243
+ if (!cur || h.score > cur.score) byKey.set(key, h)
244
+ }
245
+ decisionHits.length = 0
246
+ decisionHits.push(...byKey.values())
221
247
  decisionHits.sort((a, b) =>
222
248
  (b.score - a.score) ||
223
249
  (((b.status === 'rejected' || b.deathPath) ? 1 : 0) - ((a.status === 'rejected' || a.deathPath) ? 1 : 0))
@@ -254,6 +280,31 @@ async function fuseBaseWithScope(base, indexDir, scopeFiles) {
254
280
  } catch { return base }
255
281
  }
256
282
 
283
+ /**
284
+ * 路由命中结果的正文词片并联融合(2026-11-06-knowledge-body-recall FR-02):弱命中不短路
285
+ * 的根因修复——路由命中(哪怕泛词 tag 撞词的弱命中)此前直接 return,正文写了相关词的普通
286
+ * 知识文件段落永久不可见。body 兜底命中按 `file#anchor` 键去重后**追加**顶层 entries 尾部
287
+ * (路由命中原序不动;`json.entries` 不同步——全仓零消费点,仅在 json 面挂 bodyFallback 判
288
+ * 定键);本路径零网络(「路由命中零网络」契约保持)。fail-soft:body 兜底异常原样返回 base。
289
+ */
290
+ function fuseRoutingWithBodyFallback(base, indexDir, taskContext) {
291
+ try {
292
+ const body = fallbackByBodyShingles(indexDir, taskContext)
293
+ if (!body) return base
294
+ const seen = new Set(base.entries.map((e) => `${String(e.file || '').replace(/\\/g, '/')}#${e.anchor || ''}`))
295
+ const additions = body.entries.filter((e) => {
296
+ const key = `${String(e.file || '').replace(/\\/g, '/')}#${e.anchor || ''}`
297
+ if (seen.has(key)) return false
298
+ seen.add(key)
299
+ return true
300
+ })
301
+ if (additions.length === 0) return base
302
+ base.entries = [...base.entries, ...additions]
303
+ base.json = { ...(base.json || {}), bodyFallback: true }
304
+ return base
305
+ } catch { return base }
306
+ }
307
+
257
308
  /**
258
309
  * 异步三层检索入口(四消费方使用:flow 注入段/complete 门/prompt {DECISION_HITS}/
259
310
  * knowledge search CLI):路由 → 平台向量(已连接且开关开)→ scope 遍历 → 本地词片 → 空。
@@ -269,7 +320,9 @@ export async function matchKnowledgeHybrid(indexDir, taskContext, opts = {}) {
269
320
  // 融合(2026-11-02-knowledge-fusion):路由命中后仍跑 scope——热文件触达的 rejected/手工条目
270
321
  // 原先被 early-return 永久遮住。只追加 scopeDecisionHits/scopeEntries(不重排路由结果);
271
322
  // scope 构建失败原样返回路由结果;仍不跑平台向量(路由命中零网络)。
272
- return fuseBaseWithScope(r, indexDir, scopeFiles)
323
+ // 正文词片并联(2026-11-06-knowledge-body-recall FR-02):同一 early-return 也遮正文——
324
+ // body 兜底先行并联(零网络),scope 融合照旧。
325
+ return fuseBaseWithScope(fuseRoutingWithBodyFallback(r, indexDir, taskContext), indexDir, scopeFiles)
273
326
  }
274
327
  const cwd = opts.cwd || dirname(dirname(String(indexDir)))
275
328
  if (vectorEnabled(cwd)) {
@@ -289,5 +342,6 @@ export async function matchKnowledgeHybrid(indexDir, taskContext, opts = {}) {
289
342
  } catch { /* 图构建 fail-soft——降级词片层 */ }
290
343
  }
291
344
  const fb = fallbackByQueryShingles(indexDir, taskContext)
292
- return fb || r
345
+ // 零命中链末端补跑(FR-01):decisions 词片回退优先,无果时普通文件正文兜底接管
346
+ return fb || fallbackByBodyShingles(indexDir, taskContext) || r
293
347
  }
@@ -15,8 +15,10 @@
15
15
  * - 标记文件 route-hindsight.json:per-repo 单条,每次标记整文件覆盖——提示语义即
16
16
  * 「上个轻量变更」。
17
17
  *
18
- * 指标四元组(口径):
19
- * designRewriteRatio / tasksRewriteRatio:终稿 vs 首版快照的行级改写比——复用
18
+ * 指标口径(判定三元组 + 观测一路):
19
+ * designRewriteRatio(判定 >0.5)/ tasksRewriteRatio(仅观测,判定已退役
20
+ * 2026-11-08-hindsight-tasks-ratio-retire——种子=v3 成功标准转写,横幅明文要求改写,
21
+ * 正常与不清不可分离):终稿 vs 首版快照的行级改写比——复用
20
22
  * flow-draft.computeEditRatio 同款 LCS 口径(改写行/首版行数,纯增行不计入;CRLF/LF
21
23
  * 归一后比对)。diff 面先做结构归一:剔 frontmatter 块、HTML 注释标记行(<!--)、空行
22
24
  * ——惰性脚手架行(机器稿里约占半数)会把真实换血稀释到阈值以下(实测整写机器段仅得
@@ -39,11 +41,15 @@ import { writeAtomicSync } from './fs-atomic.js'
39
41
  import { computeEditRatio } from './flow-draft.js'
40
42
  import { runtimeStatePath } from './runtime-paths.js'
41
43
 
42
- /** 超阈判定常量(可调;口径:比例两路为严格大于——恰等于阈值不算超,保守防误标 R-01;
43
- * 计数两路为大于等于——2 维/2 次即系统性信号)。 */
44
+ /** 超阈判定常量(可调;口径:比例路为严格大于——恰等于阈值不算超,保守防误标 R-01;
45
+ * 计数两路为大于等于——2 维/2 次即系统性信号)。
46
+ * tasksRewriteRatio 判定退役(2026-11-08-hindsight-tasks-ratio-retire):种子自
47
+ * 2026-10-07-thin-tasks-v3 起为成功标准逐条转写(验收句≠实现步骤,文体必然不同),
48
+ * 且 flow start 横幅明文要求「机器种子按真实实现路径改写、增删改随意」——正常改写与
49
+ * 需求不清的改写不可区分(0.875/0.8 两连误标实证),阈值无分离度。计算面保留为观测
50
+ * (computeHindsightMetrics 照算照返回),未来有证据可重定口径后再恢复判定。 */
44
51
  export const HINDSIGHT_THRESHOLDS = {
45
52
  designRewriteRatio: 0.5, // design 机器稿改写比 > 0.5(50% 首版行被改写)
46
- tasksRewriteRatio: 0.6, // tasks 机器预填稿改写率 > 0.6
47
53
  blindDims: 2, // 评审盲维命中(dimensionNotes='finding')≥ 2 维
48
54
  testFailures: 2, // 收口实测失败记录 ≥ 2 次
49
55
  }
@@ -229,9 +235,8 @@ export function markHindsight({ cwd, specBase, change, metrics }) {
229
235
  if (num(m.designRewriteRatio) > HINDSIGHT_THRESHOLDS.designRewriteRatio) {
230
236
  reasons.push(`design 机器稿改写比 ${num(m.designRewriteRatio)} > ${HINDSIGHT_THRESHOLDS.designRewriteRatio}`)
231
237
  }
232
- if (num(m.tasksRewriteRatio) > HINDSIGHT_THRESHOLDS.tasksRewriteRatio) {
233
- reasons.push(`tasks 机器预填稿改写率 ${num(m.tasksRewriteRatio)} > ${HINDSIGHT_THRESHOLDS.tasksRewriteRatio}`)
234
- }
238
+ // tasksRewriteRatio 判定退役(2026-11-08-hindsight-tasks-ratio-retire)——metrics 字段
239
+ // 仍随标记文件落盘(观测面,向后兼容读取),不再参与判定与 reasons。
235
240
  if (num(m.blindDims) >= HINDSIGHT_THRESHOLDS.blindDims) {
236
241
  reasons.push(`评审盲维命中 ${num(m.blindDims)} 维 ≥ ${HINDSIGHT_THRESHOLDS.blindDims}`)
237
242
  }