sillyspec 3.34.0 → 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('、'),
@@ -66,6 +66,9 @@ const DECISION_HEADER_RE = /^##\s+(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+)
66
66
  /** 底稿模板标题特征词(与 4A provenance 分类同源单一口径):harvestSlot4Decision 产的
67
67
  * process 级流程取舍框架词——不是具体方案否决,deathPath 不应赋予(2026-11-02-series-final-cleanup)。 */
68
68
  const TEMPLATE_TITLE_RE = /设计期风险与放弃方案|风险与死路|design\s*槽\s*4\s*收割|底稿/
69
+ // 叙事基线条目识别(2026-11-04-archive-baseline-entry):原编号 D-000@ 是基线保留号
70
+ // (buildBaselineEntry 恒用 D-000@v1)——排序降位/回放幂等预扫共用此约定。
71
+ const BASELINE_ORIGIN_RE = /^D-000@/
69
72
  const DECISION_FIELD_RE = /^(状态|文件|理由|否决理由|复潮条件|supersedes)\s*[::]\s*(.*)$/
70
73
  // 「锚点:」单独标签精确匹配读入(仿 docs-check.js parseDecisionEntries 先例)——绝不并入
71
74
  // DECISION_FIELD_RE:其 else-if 链末尾的 reason 回填(else if (!cur.reason))会把锚点值
@@ -283,6 +286,21 @@ export function keywordMatchesContext(keyword, contextLower) {
283
286
  return new RegExp(`(^|[^a-z0-9_])${escapeRegex(kw)}([^a-z0-9_]|$)`).test(contextLower)
284
287
  }
285
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
+
286
304
  /** 查询词片抽取:CJK 连续段 bigram + ≥4 字符 ASCII 词(小写;短词与标点数字不参与;
287
305
  * 纯 ASCII 查询不产词片——中文知识库,英文查询维持既有零命中行为)。
288
306
  * snake/kebab 复合词保留整词并额外产出 ≥4 字符拆分片段(FR-02:查询 worktree_apply 时
@@ -376,7 +394,7 @@ export function fallbackByQueryShingles(indexDir, taskContext) {
376
394
  if (all.length === 0) return null
377
395
  const byFile = new Map()
378
396
  for (const h of all) {
379
- if (isParkingFile(h.file)) continue // 停车场文件不参与词片回退(FR-01)
397
+ if (isParkingFile(h.file)) continue // 停车场文件不参与常规词片回退(FR-01)
380
398
  let st = byFile.get(h.file)
381
399
  if (!st) { st = { entries: [], texts: [] }; byFile.set(h.file, st) }
382
400
  st.entries.push(h)
@@ -427,6 +445,93 @@ export function fallbackByQueryShingles(indexDir, taskContext) {
427
445
  }
428
446
  }
429
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
+
430
535
  /**
431
536
  * 用任务上下文匹配知识条目
432
537
  * @param {string} indexDir - knowledge 目录路径
@@ -569,7 +674,17 @@ export function matchByRouting(indexDir, taskContext, opts = {}) {
569
674
  // 一个 bigram 即得高比率)会压过长理由被稀释的相关条目,实测复现;宁用零分平局先验。
570
675
  const contentChars = (s) => String(s || '').replace(/[\d\p{P}\s_]+/gu, ' ')
571
676
  const relScore = (h) => frTitleOverlap(contentChars(taskContext), contentChars(`${h.id} ${h.title}`))
572
- const byOverlapDesc = (a, b) => (relScore(b) - relScore(a)) || ((b.deathPath ? 1 : 0) - (a.deathPath ? 1 : 0))
677
+ // 基线降位(2026-11-04-archive-baseline-entry,FR-03):同文件内叙事基线(原编号 D-000@)
678
+ // 排在人工条目之后——基线是补充叙事(标题=变更标题+首句,bigram 天然富集,relScore 常
679
+ // 虚高),人工条目才是检索正主;跨文件保持既有得分序(降位仅在同文件双条目竞争时生效)。
680
+ const isBaselineEntry = (h) => BASELINE_ORIGIN_RE.test(h.originId || '')
681
+ const byOverlapDesc = (a, b) => {
682
+ if (a.file === b.file) {
683
+ const ab = isBaselineEntry(a), bb = isBaselineEntry(b)
684
+ if (ab !== bb) return ab ? 1 : -1
685
+ }
686
+ return (relScore(b) - relScore(a)) || ((b.deathPath ? 1 : 0) - (a.deathPath ? 1 : 0))
687
+ }
573
688
  const decisionHits = [
574
689
  ...allDecisionHits.filter((h) => h.status === 'rejected' || h.deathPath).sort(byOverlapDesc),
575
690
  ...allDecisionHits.filter((h) => h.status !== 'rejected' && !h.deathPath).sort(byOverlapDesc),
@@ -594,5 +709,33 @@ export function matchKnowledge(indexDir, taskContext, opts = {}) {
594
709
  const r = matchByRouting(indexDir, taskContext, opts)
595
710
  if (r.matched) return r
596
711
  const fb = fallbackByQueryShingles(indexDir, taskContext)
597
- return fb || r
712
+ if (fb) return fb
713
+ // 停车场兜底通道(4D 解禁,2026-11-03):非停车场全部零命中时逐条匹配停车场 rejected/死路
714
+ // ——cap 3,标记 parking:true(不进 DF 分母、不解禁路由行);底稿资格门已确保模板不获 deathPath。
715
+ // 保留 parkingFiltered 披露(tier-denoise FR-01 契约)。
716
+ const shingles = queryShingles(taskContext)
717
+ if (shingles.length > 0) {
718
+ const all = parseDecisionEntries(indexDir)
719
+ const parkingHits = all.filter(h =>
720
+ isParkingFile(h.file) && (h.status === 'rejected' || h.deathPath) &&
721
+ shingles.some(sh => asciiWordHit(sh, `${h.title} ${h.reason || ''}`))
722
+ ).slice(0, 3)
723
+ if (parkingHits.length > 0) {
724
+ const parkingFiles = [...new Set(all.filter(h => isParkingFile(h.file)).map(h => h.file))]
725
+ return {
726
+ matched: true,
727
+ entries: [{
728
+ category: 'Decisions',
729
+ keywords: ['parking'],
730
+ file: 'decisions/unmapped.md',
731
+ anchor: '',
732
+ display: `decisions/unmapped.md(停车场兜底命中 ${parkingHits.length} 条 rejected/死路)`,
733
+ }],
734
+ report: 'Status: parking-fallback matched',
735
+ json: { matched: true, entry_count: 1, parking_fallback: true, parking: true, ...(parkingFiles.length > 0 ? { parkingFiltered: parkingFiles } : {}) },
736
+ decisionHits: parkingHits.map(h => ({ ...h, parking: true })),
737
+ }
738
+ }
739
+ }
740
+ return r
598
741
  }
@@ -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
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * md-fence.js — Markdown 代码围栏感知扫描(2026-11-04-fence-aware-task-rows)。
3
+ *
4
+ * 定位:给「逐行正则解析 Markdown 结构」的模块(task-tick 等)提供围栏状态——
5
+ * 代码围栏内的示例任务行(如文档示例 `- [ ] task-99:`)不得被当真实结构行。
6
+ * 依据:superpowers #2436(未闭合围栏把 177 行任务吞成 1159 行五任务简报)同型事故。
7
+ *
8
+ * 语法取 CommonMark 实用子集:
9
+ * - 开栏:行首 ≤3 空格缩进的 ``` 或 ~~~(可带信息串);
10
+ * - 闭栏:同字符、长度不小于开栏、行内仅空白的栏线(闭栏行本身计入围栏);
11
+ * - 开栏期间异字符栏线(``` 内的 ~~~ 或反之)视为内容,不互闭;
12
+ * - 未闭合:按「开栏即入栏直到文件尾」处理(CommonMark 语义,fail-safe 方向)。
13
+ *
14
+ * 纯函数无状态;行索引与 String.split('\n') 对齐(CRLF 的 \r 留在行尾,\s*$ 吸收)。
15
+ */
16
+
17
+ /** 开栏行:≤3 空格缩进 + ≥3 个 ` 或 ~ + 任意信息串。 */
18
+ const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/
19
+
20
+ /** 闭栏行:≤3 空格缩进 + 同字符 ≥3 个 + 行内仅空白(\s*$ 同时吸收 CRLF 的 \r)。 */
21
+ const FENCE_CLOSE_RE = /^ {0,3}(`{3,}|~{3,})\s*$/
22
+
23
+ /**
24
+ * 扫描 Markdown 文本的围栏状态。
25
+ * @param {string} md 文本(LF 或 CRLF)
26
+ * @returns {{ inFence: boolean[], unclosed: boolean, fenceStartLine: number|null }}
27
+ * inFence 按行(split('\n') 口径)标记是否处于围栏内(含开/闭栏行本身);
28
+ * unclosed=文件尾仍有未闭合开栏;fenceStartLine=该未闭合开栏的 1 基行号(未闭合为 false 时 null)。
29
+ */
30
+ export function fenceScan(md) {
31
+ const lines = String(md || '').split('\n')
32
+ const inFence = new Array(lines.length).fill(false)
33
+ let open = null
34
+ let currentStart = 0
35
+ let unclosed = false
36
+ let fenceStartLine = null
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const line = lines[i]
39
+ if (open) {
40
+ const closeMatch = FENCE_CLOSE_RE.exec(line)
41
+ if (closeMatch && closeMatch[1][0] === open.ch && closeMatch[1].length >= open.len) {
42
+ inFence[i] = true
43
+ open = null
44
+ currentStart = 0
45
+ } else {
46
+ inFence[i] = true
47
+ }
48
+ continue
49
+ }
50
+ const openMatch = FENCE_OPEN_RE.exec(line)
51
+ if (openMatch) {
52
+ open = { ch: openMatch[1][0], len: openMatch[1].length }
53
+ currentStart = i + 1
54
+ inFence[i] = true
55
+ }
56
+ }
57
+ if (open) {
58
+ unclosed = true
59
+ fenceStartLine = currentStart
60
+ }
61
+ return { inFence, unclosed, fenceStartLine }
62
+ }
63
+
64
+ export default { fenceScan }