sillyspec 3.29.4 → 3.31.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 (148) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
  5. package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
  6. package/AGENTS.md +38 -20
  7. package/SKILL.md +9 -3
  8. package/assets/command-cards/flow.md +32 -0
  9. package/assets/command-cards/run-archive.md +26 -0
  10. package/assets/command-cards/run-brainstorm.md +33 -0
  11. package/assets/command-cards/run-execute.md +29 -0
  12. package/assets/command-cards/run-plan.md +27 -0
  13. package/assets/command-cards/run-quick.md +17 -0
  14. package/assets/command-cards/run-verify.md +29 -0
  15. package/assets/command-cards/status.md +27 -0
  16. package/package.json +2 -1
  17. package/packages/dashboard/server/executor.js +2 -1
  18. package/packages/dashboard/server/parser.js +7 -7
  19. package/r7-test-env/README-R7-TEST.md +93 -0
  20. package/r7-test-env/demo-project/lib/calc.js +6 -0
  21. package/r7-test-env/demo-project/package.json +1 -0
  22. package/r7-test-env/demo-project/test.js +10 -0
  23. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  24. package/r7-test-env/sillyspec-r7.cmd +3 -0
  25. package/round4/account.mjs +98 -0
  26. package/round4/b1-analyze-task08.mjs +40 -0
  27. package/round4/b1-b1-target-validation.md +32 -0
  28. package/round4/b3a-prompt-audit-result.md +66 -0
  29. package/round4/b3a-prompt-audit.mjs +89 -0
  30. package/round4/brief-autocompact.md +34 -0
  31. package/round4/brief-session-replay.md +50 -0
  32. package/round4/deep-read-synthesis.md +38 -0
  33. package/round4/extract-full.mjs +38 -0
  34. package/round4/extract-timeline.mjs +43 -0
  35. package/round4/find-sessions.mjs +13 -0
  36. package/round4/full-r4-l.md +4707 -0
  37. package/round4/full-r4-o-l.md +4300 -0
  38. package/round4/full-r4-os.md +1681 -0
  39. package/round4/full-r4-sf.md +4255 -0
  40. package/round4/full-r4-sq.md +1734 -0
  41. package/round4/gate-audit-relay-receipt.md +30 -0
  42. package/round4/gate-value-audit.md +52 -0
  43. package/round4/optimization-plan.md +165 -0
  44. package/round4/prereg.md +67 -0
  45. package/round4/prompt-R4-L.md +63 -0
  46. package/round4/prompt-R4-O-L.md +59 -0
  47. package/round4/prompt-R4-O-S.md +56 -0
  48. package/round4/prompt-R4-S-F.md +60 -0
  49. package/round4/prompt-R4-S-Q.md +66 -0
  50. package/round4/r4-final-report.html +347 -0
  51. package/round4/r4-os-timeline.txt +297 -0
  52. package/round4/r4-sf-timeline.txt +738 -0
  53. package/round4/r4-sq-timeline.txt +322 -0
  54. package/round4/r4l-timeline.txt +793 -0
  55. package/round4/r4ol-timeline.txt +729 -0
  56. package/round4/r4sf-timeline.txt +852 -0
  57. package/round4/r5-scenario-matrix.md +46 -0
  58. package/round4/result-S.md +171 -0
  59. package/round4/result-final.md +46 -0
  60. package/round4/result-quality.md +63 -0
  61. package/src/change-list.js +9 -0
  62. package/src/classify-change.js +6 -0
  63. package/src/command-cards.js +74 -0
  64. package/src/commit-guard.js +2 -0
  65. package/src/config-schema.js +64 -12
  66. package/src/contract-matrix.js +26 -5
  67. package/src/db.js +35 -4
  68. package/src/decision-distill.js +9 -3
  69. package/src/diagnostic-codes.js +2 -1
  70. package/src/docs-check.js +1 -1
  71. package/src/doctor-diagnostics.js +44 -2
  72. package/src/flow-draft.js +748 -0
  73. package/src/flow-parity.js +236 -0
  74. package/src/flow-review.js +184 -0
  75. package/src/flow.js +1492 -0
  76. package/src/foreign-declared.js +17 -1
  77. package/src/fr-index.js +396 -22
  78. package/src/fs-atomic.js +5 -1
  79. package/src/git-helper.js +2 -2
  80. package/src/greenfield-bootstrap.js +98 -0
  81. package/src/handoff.js +120 -7
  82. package/src/hunk-attribution.js +191 -0
  83. package/src/index.js +602 -21
  84. package/src/init.js +25 -8
  85. package/src/knowledge-classify.js +47 -1
  86. package/src/knowledge-digest.js +162 -0
  87. package/src/knowledge-quicklog.js +246 -0
  88. package/src/machine-draft.js +128 -0
  89. package/src/machine-interface.js +194 -7
  90. package/src/mcp-server.js +1 -0
  91. package/src/module-impact.js +51 -22
  92. package/src/module-resolve.js +3 -1
  93. package/src/preview-progress.js +160 -0
  94. package/src/progress/change-registry.js +11 -3
  95. package/src/progress/shared.js +3 -2
  96. package/src/progress.js +56 -13
  97. package/src/quicklog.js +44 -6
  98. package/src/redomain.js +147 -0
  99. package/src/review-material-pack.js +204 -1
  100. package/src/run/archive-distill.js +4 -2
  101. package/src/run/bg-sync.js +67 -0
  102. package/src/run/command.js +127 -10
  103. package/src/run/complete-handlers.js +363 -73
  104. package/src/run/complete.js +356 -74
  105. package/src/run/gate-snapshot-ledger.js +213 -0
  106. package/src/run/gate-snapshot.js +260 -38
  107. package/src/run/gates.js +482 -268
  108. package/src/run/green-cache.js +123 -0
  109. package/src/run/junction-rm.js +70 -0
  110. package/src/run/next.js +14 -0
  111. package/src/run/prompt.js +185 -55
  112. package/src/run/quick-audit.js +213 -14
  113. package/src/run/shared.js +123 -4
  114. package/src/run/stage.js +182 -216
  115. package/src/run/test-ledger.js +264 -0
  116. package/src/run/verify-quality-scan.js +84 -7
  117. package/src/scope-audit.js +6 -1
  118. package/src/sentinel-assertions.js +114 -0
  119. package/src/setup.js +3 -3
  120. package/src/spec-sync.js +41 -1
  121. package/src/stage-contract.js +26 -8
  122. package/src/stage-review-checklist.js +2 -0
  123. package/src/stage-review.js +7 -2
  124. package/src/stage-templates.js +157 -0
  125. package/src/stages/archive.js +2 -0
  126. package/src/stages/brainstorm.js +24 -106
  127. package/src/stages/execute.js +222 -85
  128. package/src/stages/knowledge.js +74 -1
  129. package/src/stages/plan-postcheck.js +385 -20
  130. package/src/stages/plan.js +23 -1
  131. package/src/stages/quick.js +10 -2
  132. package/src/stages/verify.js +4 -2
  133. package/src/sync.js +81 -23
  134. package/src/task-done.js +181 -0
  135. package/src/task-review.js +8 -55
  136. package/src/taskcard-frontmatter.js +57 -0
  137. package/src/test-bindings.js +549 -0
  138. package/src/timeline.js +218 -0
  139. package/src/ui-visual.js +175 -0
  140. package/src/verify-draft.js +214 -0
  141. package/src/verify-postcheck.js +919 -159
  142. package/src/verify-probes.js +103 -25
  143. package/src/watcher.js +1031 -0
  144. package/src/worktree-apply.js +44 -4
  145. package/src/worktree-deps.js +22 -8
  146. package/src/worktree-policy.js +42 -0
  147. package/src/worktree.js +102 -15
  148. package/templates/agents-instruction.md +37 -18
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ import { checkbox, confirm, input } from '@inquirer/prompts';
5
5
  import { ProgressManager } from './progress.js';
6
6
  import chalk from 'chalk';
7
7
  import { getVersion } from './version.js';
8
+ import { injectCommandCards } from './command-cards.js';
8
9
  import { gitQuiet } from './git-helper.js';
9
10
  import { renderExample } from './config-schema.js';
10
11
  // 向后兼容:getVersion 已抽到轻量 version.js(避免 index.js 为 --version 静态加载 init.js 的 inquirer 税),
@@ -76,10 +77,11 @@ function copyDirSync(src, dst) {
76
77
 
77
78
 
78
79
 
79
- const VALID_TOOLS = ['claude', 'cursor', 'openclaw', 'codex', 'gemini', 'opencode'];
80
+ const VALID_TOOLS = ['claude', 'zcode', 'cursor', 'openclaw', 'codex', 'gemini', 'opencode'];
80
81
 
81
82
  const TOOL_LABELS = {
82
83
  claude: 'Claude Code',
84
+ zcode: 'ZCode',
83
85
  cursor: 'Cursor',
84
86
  openclaw: 'OpenClaw',
85
87
  codex: 'OpenAI Codex (通过 AGENTS.md)',
@@ -288,14 +290,17 @@ function isTTY() {
288
290
 
289
291
  // ── 核心安装逻辑 ──
290
292
 
291
- function doInstall(projectDir, tools, subprojects = [], specDir = null, options = {}) {
293
+ async function doInstall(projectDir, tools, subprojects = [], specDir = null, options = {}) {
292
294
  // specDir: 规范目录(默认 projectDir/.sillyspec)
293
295
  // projectDir: 源码项目根目录(用于工具检测、指令注入、.gitignore)
294
296
  // options.noSkills: 跳过 skills 复制段(--no-skills;指令注入不受影响)
295
297
  // options.platformMode: 平台模式(cmdInit 收到 platformOpts 非空)——项目内 .sillyspec/
296
298
  // 通常只有 local.yaml(平台 init lease 写、含用户手调 mcp 段),任何清理都会丢失,整体跳过
297
- const { noSkills = false, platformMode = false } = options;
299
+ const { noSkills = false, platformMode = false, forceCards = false } = options;
298
300
  const spec = specDir || join(projectDir, '.sillyspec');
301
+ // 命令卡工具面(2026-09-21-flow-command-cards):仅 zcode/claude 有落点(D-003);
302
+ // noSkills/platformMode 与 skills 同门跳过(Grill P2-4)
303
+ const cardTools = noSkills || platformMode ? [] : tools.filter((t) => t === 'zcode' || t === 'claude');
299
304
 
300
305
  // 外部 specDir 时清理旧版本残留的 cwd/.sillyspec/(防止源码污染)。
301
306
  // ⚠️ 必须保护真实资产:若本地 .sillyspec 含 changes/(非空)、projects/(非空)
@@ -443,11 +448,23 @@ function doInstall(projectDir, tools, subprojects = [], specDir = null, options
443
448
  }
444
449
  }
445
450
 
446
- // 注入 AGENTS.md 完整指引(claude/codex 共用:版本感知幂等三态四分支 + 旧 ## SillySpec 段迁移)
447
- if (tools.includes('claude') || tools.includes('codex')) {
451
+ // 注入 AGENTS.md 完整指引(claude/codex/zcode 共用:版本感知幂等三态四分支 + 旧 ## SillySpec 段迁移;
452
+ // zcode 2026-09-21-flow-command-cards FR-05 入面——AGENTS.md 是跨工具通用标准内容源)
453
+ if (tools.includes('claude') || tools.includes('codex') || tools.includes('zcode')) {
448
454
  injectAgentsInstructions(projectDir);
449
455
  }
450
456
 
457
+ // 流程命令卡注入(2026-09-21-flow-command-cards:zcode→.zcode/commands/sillyspec/、claude→.claude/commands/sillyspec/;
458
+ // 尾部锚行三态幂等。与 skills 复制同门:noSkills/platformMode 跳过(工具目录零污染语义,Grill P2-4);
459
+ // 挂点在 noSkills return 之前——卡与 AGENTS.md 同批,早于 skills 复制不影响)
460
+ if (cardTools.length > 0) {
461
+ const cards = await injectCommandCards(projectDir, { tools: cardTools, force: forceCards, version: getVersion() });
462
+ for (const w of cards.warnings) console.warn(` ⚠️ [命令卡] ${w}`);
463
+ if (cards.written.length + cards.updated.length > 0) {
464
+ console.log(chalk.green(` ✓ 流程命令卡已${cards.written.length ? '生成' : '更新'} ${cards.written.length + cards.updated.length} 张 → ${cardTools.map((t) => (t === 'zcode' ? '.zcode' : '.claude') + '/commands/sillyspec/').join(' + ')}`));
465
+ }
466
+ }
467
+
451
468
  // 注入 CLAUDE.md 指针(claude 专属:@AGENTS.md 导入薄文件,须在 AGENTS.md 之后)
452
469
  if (tools.includes('claude')) {
453
470
  injectClaudePointer(projectDir);
@@ -546,7 +563,7 @@ function showSummary(version, tools, specDir) {
546
563
  // ── 主命令 ──
547
564
 
548
565
  export async function cmdInit(projectDir, options = {}) {
549
- const { tool, tools: toolsOpt, interactive, specDir, noSkills = false, platformOpts = null } = options;
566
+ const { tool, tools: toolsOpt, interactive, specDir, noSkills = false, forceCards = false, platformOpts = null } = options;
550
567
  const version = getVersion();
551
568
  const resolvedSpecDir = specDir ? resolve(specDir) : null;
552
569
 
@@ -654,7 +671,7 @@ export async function cmdInit(projectDir, options = {}) {
654
671
  }
655
672
 
656
673
  console.log('');
657
- await doInstall(projectDir, selectedTools, subprojects, resolvedSpecDir, { noSkills, platformMode: platformOpts != null });
674
+ await doInstall(projectDir, selectedTools, subprojects, resolvedSpecDir, { noSkills, forceCards, platformMode: platformOpts != null });
658
675
  writeInitPlatformPointer(projectDir, resolvedSpecDir, platformOpts);
659
676
  showSummary(version, selectedTools, resolvedSpecDir);
660
677
  return;
@@ -682,7 +699,7 @@ export async function cmdInit(projectDir, options = {}) {
682
699
  tools = detectTools(projectDir);
683
700
  }
684
701
 
685
- await doInstall(projectDir, tools, [], resolvedSpecDir, { noSkills, platformMode: platformOpts != null });
702
+ await doInstall(projectDir, tools, [], resolvedSpecDir, { noSkills, forceCards, platformMode: platformOpts != null });
686
703
  writeInitPlatformPointer(projectDir, resolvedSpecDir, platformOpts);
687
704
 
688
705
  console.log('');
@@ -101,7 +101,7 @@ function resolveKeywords(keywords, title) {
101
101
  * @returns {{ start: number, end: number, headingText: string, qlIdPrefix: string|null,
102
102
  * title: string, body: string, text: string }[]}
103
103
  */
104
- function parseUncategorizedEntries(normalizedContent) {
104
+ export function parseUncategorizedEntries(normalizedContent) {
105
105
  const lines = normalizedContent.split('\n')
106
106
  const boundaryRe = /^#{1,3}\s+\S/
107
107
  const entryHeadingRe = /^#{2,3}\s+\S/
@@ -380,3 +380,49 @@ export async function cmdKnowledgeClassify(dir, args, opts = {}) {
380
380
  }, result.error)
381
381
  }
382
382
  }
383
+
384
+ /**
385
+ * `sillyspec knowledge inbox` 子命令——知识收件箱按需视图(2026-09-23 知识可见性 quick)。
386
+ *
387
+ * 背景:uncategorized 待审条目与 quick 资产尾蒸馏产出此前只落文件、不在聊天面露头,
388
+ * 用户全程无感(2026-09-23 用户反馈「聊天过程中完全不知道」)。收件箱给出待审清单
389
+ * (标题 + ql 前缀 + 一行摘要)与基线状态;--json 结构化供面板/脚本消费。纯读零副作用。
390
+ */
391
+ export async function cmdKnowledgeInbox(dir, args, opts = {}) {
392
+ const base = opts.specDir || join(dir, '.sillyspec')
393
+ const knowledgeDir = join(base, 'knowledge')
394
+ const wantJson = args.includes('--json')
395
+ const uncPath = join(knowledgeDir, 'uncategorized.md')
396
+ let entries = []
397
+ if (existsSync(uncPath)) {
398
+ entries = parseUncategorizedEntries(readFileSync(uncPath, 'utf8').replace(/\r\n/g, '\n'))
399
+ }
400
+ let baseline = null
401
+ try {
402
+ const bp = join(base, 'knowledge-baseline')
403
+ if (existsSync(bp)) {
404
+ const n = parseInt(readFileSync(bp, 'utf8').trim(), 10)
405
+ if (Number.isFinite(n) && n >= 0) baseline = n
406
+ }
407
+ } catch { /* 基线读失败按未启用 */ }
408
+ const over = baseline !== null && entries.length > baseline
409
+ const items = entries.map((e) => ({
410
+ title: e.title,
411
+ qlId: e.qlIdPrefix || null,
412
+ summary: String(e.body || '').replace(/\s+/g, ' ').slice(0, 80),
413
+ }))
414
+ if (wantJson) {
415
+ output(true, { inbox: { count: entries.length, baseline, over, items } })
416
+ return
417
+ }
418
+ if (entries.length === 0) {
419
+ console.log('📚 知识收件箱:空——uncategorized 无待审条目')
420
+ return
421
+ }
422
+ console.log(`📚 知识收件箱:待审 ${entries.length} 条${baseline !== null ? `(基线 ${baseline}${over ? ',已超 ⚠️' : ''})` : ''}`)
423
+ items.forEach((it, i) => {
424
+ console.log(` ${i + 1}. 《${it.title}》${it.qlId ? `(${it.qlId})` : ''}`)
425
+ if (it.summary) console.log(` ${it.summary}`)
426
+ })
427
+ console.log(' 归类:sillyspec knowledge classify --title "…" --file conventions|patterns|known-issues.md')
428
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * knowledge-digest.js — 知识资产治理信号收集器(2026-09-27-knowledge-digest)
3
+ *
4
+ * 现状:通用知识走 propose→平台人工合并闭环,规格资产(FR 索引/绑定)全自动入库零人审——
5
+ * 四类信号沉睡库内无人工出口(R23-full 载体仓收件箱积压 40 条实证)。本模块把沉睡数据
6
+ * 变结构化摘要:`sillyspec knowledge digest`(人读文本)/ `--json`(平台 RPC 消费)。
7
+ *
8
+ * 四信号(阈值内静默、超阈才进摘要——安静即健康态,防仪式化):
9
+ * ① rot 待复核标记:knowledge/fr/*.md 条目内「待复核:」行,按域计数(阈值 100)
10
+ * ② 收件箱积压:knowledge/uncategorized.md 的 `## <qlId> | <标题>` 条目(阈值 20)
11
+ * ③ 伪域落库:auto-* 域文件条目数 + unmapped 池条目数(阈值 0——伪域本不该增长)
12
+ * ④ 绑定路径解析失败:fr/*.md 绑定行经 resolveTestFileRel(与 repair-paths/门禁读侧
13
+ * 同口径单源)无法自仓根解析(阈值 0)
14
+ *
15
+ * 只读扫描,零写入——动作(迁移/清账/repair)由消费方另行执行。
16
+ */
17
+ import { existsSync, readFileSync, readdirSync } from 'fs'
18
+ import { join } from 'path'
19
+ import { readFrBindings, resolveTestFileRel, testAnchorFile } from './test-bindings.js'
20
+
21
+ /** 伪域判定:auto- 前缀(scaffold 时代机器蒸馏域)或 unmapped 池 */
22
+ const isPseudoDomain = (domain) => /^auto[-_]/.test(domain) || domain === 'unmapped'
23
+
24
+ /** 交付路径 → 建议域(落域机械改进的建议器,与 indexRequirements 的告警共用单源) */
25
+ export function suggestDomainFromFiles(files) {
26
+ for (const raw of files || []) {
27
+ const p = String(raw || '').replace(/\\/g, '/')
28
+ let m
29
+ if ((m = /^backend\/app\/modules\/([\w-]+)\//.exec(p))) return m[1] // backend 模块归属最强
30
+ if ((m = /^sillyhub-daemon\//.exec(p))) return 'daemon'
31
+ if ((m = /^frontend\//.exec(p))) return 'frontend'
32
+ if ((m = /^backend\//.exec(p))) return 'backend'
33
+ if ((m = /^([\w-]+)\/src\//.exec(p))) return m[1]
34
+ if ((m = /^([\w-]+)\//.exec(p))) return m[1]
35
+ }
36
+ return null
37
+ }
38
+
39
+ /** 条目级解析:fr/<域>.md 的 `## <id> <标题>` 段头与「待复核:」行 */
40
+ function scanFrFile(text) {
41
+ const entries = []
42
+ let cur = null
43
+ for (const line of text.split(/\r?\n/)) {
44
+ const h = /^## (FR-[\w.-]+-\d+|FR-\d+)\s*(.*)$/.exec(line)
45
+ if (h) { cur = { id: h[1], needsReview: null }; entries.push(cur); continue }
46
+ if (cur) {
47
+ const r = /^待复核:(.+)$/.exec(line)
48
+ if (r) cur.needsReview = r[1].trim()
49
+ }
50
+ }
51
+ return entries
52
+ }
53
+
54
+ /**
55
+ * 收集治理信号(纯函数,只读)。
56
+ * @returns {{ generated_at: string, healthy: boolean, signals: Array<{kind, level, title, count, detail, suggestion?}>, totals: object }}
57
+ */
58
+ export function collectKnowledgeDigest({ specBase, projectRoot }) {
59
+ const knowledgeRoot = join(specBase, 'knowledge')
60
+ const frDir = join(knowledgeRoot, 'fr')
61
+ const signals = []
62
+ const totals = { rot: 0, inbox: 0, pseudo: 0, unresolvedBindings: 0 }
63
+
64
+ const rotByDomain = new Map()
65
+ const pseudoByDomain = new Map()
66
+ const unresolved = []
67
+ const frFiles = existsSync(frDir) ? readdirSync(frDir).filter(f => f.endsWith('.md')).sort() : []
68
+ // unmapped 基线消音(与 fr-index 落库告警同口径):跨仓历史基线不刷信号,只报增量
69
+ let unmappedBaseline = 0
70
+ try {
71
+ const raw = readFileSync(join(specBase, 'local.yaml'), 'utf8')
72
+ const m = raw.match(/^fr_unmapped_baseline:\s*(\d+)\s*$/m)
73
+ if (m) unmappedBaseline = parseInt(m[1], 10)
74
+ } catch { /* 无 local.yaml = 无基线 */ }
75
+
76
+ for (const f of frFiles) {
77
+ const domain = f.replace(/\.md$/, '')
78
+ const text = readFileSync(join(frDir, f), 'utf8')
79
+ const entries = scanFrFile(text)
80
+ const rot = entries.filter(e => e.needsReview).length
81
+ if (rot > 0) { rotByDomain.set(domain, rot); totals.rot += rot }
82
+ if (isPseudoDomain(domain) && entries.length > 0) {
83
+ // 基线内的 unmapped 池不计(历史跨仓基线,非新增堆积);auto-* 伪域无基线语义恒计
84
+ const count = domain === 'unmapped' ? Math.max(0, entries.length - unmappedBaseline) : entries.length
85
+ if (count > 0) { pseudoByDomain.set(domain, count); totals.pseudo += count }
86
+ }
87
+ // 绑定解析失败(与 repair-paths 同口径:先根相对直取,再 resolveTestFileRel)
88
+ for (const e of entries) {
89
+ const rows = readFrBindings({ knowledgeRoot, frId: e.id })
90
+ for (const row of rows) {
91
+ for (const t of row.tests || []) {
92
+ // 锚剥离单源(评审 P1 清偿):testAnchorFile 吃全部分隔形态(#/::/「」/>)——
93
+ // 手搓 split('#').split('::') 漏「」锚致 7/8 假阳性(目标文件全在场),与 repair-paths 口径对拍实证
94
+ const filePart = testAnchorFile(String(t))
95
+ const rel = resolveTestFileRel(filePart, { projectRoot })
96
+ if (!rel && !existsSync(join(projectRoot, filePart.replace(/\\/g, '/')))) {
97
+ totals.unresolvedBindings++
98
+ unresolved.push(`${e.id}:${t}`)
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ // ② 收件箱(uncategorized.md 的 `## <qlId> | <标题>` 行)
106
+ let inboxTitles = []
107
+ const uncPath = join(knowledgeRoot, 'uncategorized.md')
108
+ if (existsSync(uncPath)) {
109
+ inboxTitles = readFileSync(uncPath, 'utf8').split(/\r?\n/)
110
+ .map(l => /^## (.+)$/.exec(l)?.[1]).filter(Boolean)
111
+ totals.inbox = inboxTitles.length
112
+ }
113
+
114
+ const mk = (kind, level, title, count, detail, suggestion) =>
115
+ signals.push({ kind, level, title, count, detail, ...(suggestion ? { suggestion } : {}) })
116
+
117
+ if (totals.rot > 100) {
118
+ mk('rot', 'warn', `rot 待复核批量标记(${totals.rot} 条 > 100)`, totals.rot,
119
+ [...rotByDomain.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([d, n]) => `${d} ${n}`).join('、'),
120
+ '批量复核:文案漂移类可批量承接,行为类逐条对账')
121
+ }
122
+ if (totals.inbox > 20) {
123
+ mk('inbox', 'warn', `知识收件箱积压(${totals.inbox} 条 > 20)`, totals.inbox,
124
+ inboxTitles.slice(0, 5).join(';') + (inboxTitles.length > 5 ? ' 等' : ''),
125
+ 'sillyspec knowledge inbox 逐条 classify 清账')
126
+ }
127
+ if (totals.pseudo > 0) {
128
+ mk('pseudo-domain', 'warn', `伪域在库(auto-*/unmapped 共 ${totals.pseudo} 条)`, totals.pseudo,
129
+ [...pseudoByDomain.entries()].map(([d, n]) => `${d} ${n}`).join('、'),
130
+ '按 suggestDomainFromFiles 建议迁移域(平台信号卡一键/手工 redomain)')
131
+ }
132
+ if (totals.unresolvedBindings > 0) {
133
+ mk('binding-unresolved', 'warn', `绑定路径解析失败(${totals.unresolvedBindings} 个)`, totals.unresolvedBindings,
134
+ unresolved.slice(0, 5).join('、') + (unresolved.length > 5 ? ' 等' : ''),
135
+ 'sillyspec tests repair-paths(干跑预览 → --write 归一)')
136
+ }
137
+
138
+ return {
139
+ generated_at: new Date().toISOString(),
140
+ healthy: signals.length === 0,
141
+ signals,
142
+ totals,
143
+ }
144
+ }
145
+
146
+ /** 人读文本渲染(CLI 出口;平台走 --json) */
147
+ export function renderKnowledgeDigestText(d) {
148
+ const lines = []
149
+ lines.push(`📬 知识资产治理摘要(${d.generated_at.slice(0, 16).replace('T', ' ')})`)
150
+ if (d.healthy) {
151
+ lines.push('✅ 四类信号全部在阈内(rot/inbox/伪域/坏绑定)——安静即健康态,无需动作')
152
+ } else {
153
+ lines.push(`⚠️ ${d.signals.length} 类信号超阈:`)
154
+ for (const s of d.signals) {
155
+ lines.push(` [${s.kind}] ${s.title}`)
156
+ if (s.detail) lines.push(` 明细:${s.detail}`)
157
+ if (s.suggestion) lines.push(` 处置:${s.suggestion}`)
158
+ }
159
+ }
160
+ lines.push(`底数:rot 待复核 ${d.totals.rot}|收件箱 ${d.totals.inbox}|伪域条目 ${d.totals.pseudo}|坏绑定 ${d.totals.unresolvedBindings}`)
161
+ return lines.join('\n')
162
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * knowledge-quicklog.js — quicklog 历史检索面(知识可见性导线)
3
+ *
4
+ * 缺口(2026-09-21 R5R 实证):quick 产出(quicklog 条目 + patch)不在任何检索面——
5
+ * knowledge-match 只扫 INDEX 路由与 decisions;主仓唯一核验过键名的先例
6
+ * (ql-20260920-007-d0dd)因 linked 变更未归档而 distill 缺席,FR 检索面零覆盖,
7
+ * 后续三个独立会话全部猜错同一外部键。本模块把 quicklog 历史拉进注入面:
8
+ * 纯读、fail-open、top-3 一行制——注入的是「发生过什么」的历史事实(条目说的是
9
+ * 当时做了什么),不是权威断言;消费方要用契约细节按 ql-ID 回源读全文或读代码。
10
+ *
11
+ * 与 knowledge-match.js 分工:那边消费 INDEX/decisions(结构化知识),这边消费双信号源——
12
+ * quicklog/QUICKLOG-*.md(append-only 历史)+ changes/archive/<变更>/flow-state.yaml(flow 族归档
13
+ * 变更伪条目,2026-09-23 扩源:flow 变更不产生 quicklog 条目,扩源后检索面才看得见)。
14
+ * 条目头与字段行是 allocateQuicklogEntry
15
+ * 的机械解析契约:`## ql-<id> | <时间> | <标题>` + `文件:` 段(`- path(注记)` 行)。
16
+ * run/prompt.js(execute 确认步 + quick step1)与 stages/execute.js(Wave 孪生)两处
17
+ * 注入点共用本模块——格式等价由 test/knowledge-inject.test.mjs 锁定,改渲染必同步两处。
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
21
+ import { join } from 'path'
22
+
23
+ const QUICKLOG_ENTRY_RE = /^##\s+(ql-[\w-]+)\s*\|\s*([^|]+)\s*\|\s*(.+)$/
24
+ // 文件注记行:`- path(中文括注)` / `- path(ascii)` / 裸 `- path`(allocateQuicklogEntry
25
+ // 均可产出;path 本身不含空白,故止于首个空白或括号)
26
+ const QUICKLOG_FILE_LINE_RE = /^-\s+([^\s((]+)/
27
+ // 文件:段结束边界——出现其他字段标签行即收段(状态/需求/根因/方案/结果/审计/关联变更)
28
+ const QUICKLOG_FIELD_RE = /^(状态|关联变更|需求|根因|方案|结果|审计)\s*[::]/
29
+
30
+ /**
31
+ * 解析 quicklog 全量条目——双信号源(资产三小件①,2026-09-23 扩源):
32
+ * ① quicklog/QUICKLOG-*.md(append-only 历史条目,含按日归档的分片文件);
33
+ * ② .sillyspec/changes/archive/<变更>/flow-state.yaml(flow 族归档变更)——flow 变更不产生
34
+ * quicklog 条目,检索面此前看不见 flow 变更的知识(与 ql-011 治的病同族)。伪条目
35
+ * 三料合成:qlId=变更目录名 / date=目录名日期前缀(缺前缀退 flow-state.yaml mtime)/
36
+ * title=proposal.md 首个 `# ` 标题行(缺文件退变更名);solution 恒空、files 恒空。
37
+ * 双源命中时 quicklog 条目排前(matchQuicklogContext 排序 source 键)。
38
+ * @param {string} specBase - .sillyspec 根
39
+ * @returns {{ qlId: string, date: string, title: string, solution: string, files: string[], source: 'quicklog'|'flow' }[]}
40
+ * 两源都不存在 / 全部不可读 → [](调用方零注入零阻断)
41
+ */
42
+ export function parseQuicklogEntries(specBase) {
43
+ return [...parseQuicklogFileEntries(specBase), ...parseFlowArchiveEntries(specBase)]
44
+ }
45
+
46
+ /** 源①:quicklog/QUICKLOG-*.md 条目解析(原 parseQuicklogEntries 主体,行为不变)。 */
47
+ function parseQuicklogFileEntries(specBase) {
48
+ const dir = join(specBase, 'quicklog')
49
+ let files
50
+ try {
51
+ files = readdirSync(dir).filter((f) => /^QUICKLOG-.*\.md$/i.test(f))
52
+ } catch {
53
+ return []
54
+ }
55
+ const entries = []
56
+ for (const f of files) {
57
+ let content
58
+ try {
59
+ content = readFileSync(join(dir, f), 'utf8')
60
+ } catch {
61
+ continue
62
+ }
63
+ let cur = null
64
+ let inFiles = false
65
+ const flush = () => {
66
+ if (cur) entries.push(cur)
67
+ cur = null
68
+ }
69
+ for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
70
+ const h = line.match(QUICKLOG_ENTRY_RE)
71
+ if (h) {
72
+ flush()
73
+ cur = { qlId: h[1].trim(), date: h[2].trim(), title: h[3].trim(), solution: '', files: [], source: 'quicklog' }
74
+ inFiles = false
75
+ continue
76
+ }
77
+ if (!cur) continue
78
+ if (QUICKLOG_FIELD_RE.test(line)) {
79
+ inFiles = false
80
+ const m = line.match(/^方案\s*[::]\s*(.*)$/)
81
+ if (m) cur.solution = m[1].trim()
82
+ continue
83
+ }
84
+ if (/^文件\s*[::]/.test(line)) {
85
+ inFiles = true
86
+ continue
87
+ }
88
+ if (inFiles) {
89
+ const m = line.match(QUICKLOG_FILE_LINE_RE)
90
+ if (m) cur.files.push(m[1].replace(/\\/g, '/'))
91
+ }
92
+ }
93
+ flush()
94
+ }
95
+ return entries
96
+ }
97
+
98
+ /** 源②:flow 归档变更伪条目——目录内 flow-state.yaml 在场即认(不校验 yaml 内容:伪条目
99
+ * 只需「这曾是一个 flow 变更」的存在信号,字段全来自目录名与 proposal.md,缺件逐项降级)。 */
100
+ function parseFlowArchiveEntries(specBase) {
101
+ const archiveDir = join(specBase, 'changes', 'archive')
102
+ let dirs
103
+ try {
104
+ dirs = readdirSync(archiveDir, { withFileTypes: true })
105
+ } catch {
106
+ return []
107
+ }
108
+ const out = []
109
+ for (const d of dirs) {
110
+ if (!d.isDirectory()) continue
111
+ try {
112
+ const dir = join(archiveDir, d.name)
113
+ if (!existsSync(join(dir, 'flow-state.yaml'))) continue
114
+ const dm = d.name.match(/^(\d{4}-\d{2}-\d{2})-/)
115
+ let date = dm ? dm[1] : ''
116
+ if (!date) {
117
+ try { date = new Date(statSync(join(dir, 'flow-state.yaml')).mtime).toISOString().slice(0, 10) } catch { /* mtime 不可得 → 空日期(排序自然靠后) */ }
118
+ }
119
+ let title = ''
120
+ try {
121
+ const prop = readFileSync(join(dir, 'proposal.md'), 'utf8')
122
+ const h = prop.replace(/\r\n/g, '\n').split('\n').find((l) => /^#\s+/.test(l))
123
+ if (h) title = h.replace(/^#\s+/, '').trim()
124
+ } catch { /* 无 proposal.md → 标题退变更名 */ }
125
+ out.push({ qlId: d.name, date, title: title || d.name, solution: '', files: [], source: 'flow' })
126
+ } catch { /* 单目录读取异常 → 跳过该目录(fail-open,不影响其余源) */ }
127
+ }
128
+ return out
129
+ }
130
+
131
+ // ── 匹配打分 ─────────────────────────────────────────────────────────────────
132
+ // 查询侧 token:ASCII 词(≥3 字符,词边界匹配)+ CJK 连续段(≥2 字,子串匹配)——
133
+ // 与 knowledge-match.keywordMatchesContext 同判法,方向相反(那边条目出关键词查上下文,
134
+ // 这边上下文出 token 查条目——quicklog 条目没有 INDEX 关键词行,只能反向)。
135
+ const ASCII_TOKEN_RE = /[a-z][a-z0-9._-]{2,}/g
136
+ const CJK_RUN_RE = /[\u4e00-\u9fff]{2,}/g
137
+
138
+ function contextTokens(taskContext) {
139
+ const s = String(taskContext || '').toLowerCase()
140
+ const out = new Set()
141
+ for (const t of s.match(ASCII_TOKEN_RE) || []) out.add(t)
142
+ for (const t of s.match(CJK_RUN_RE) || []) out.add(t)
143
+ return [...out]
144
+ }
145
+
146
+ function escapeRegex(s) {
147
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
148
+ }
149
+
150
+ /** 文件命中权重:路径/文件名是强信号(同一文件被改过 ≈ 同域修补) */
151
+ const FILE_HIT_WEIGHT = 5
152
+ /** 命中门槛:≥1 文件命中,或 ≥2 个不同 token 命中(单 token 命中多为噪音词,不注入) */
153
+ const MIN_TOKEN_HITS = 2
154
+
155
+ /** 源排序权重:quicklog 条目 0(排前),flow 伪条目 1——双源命中时 quicklog 优先(更新鲜)。 */
156
+ function sourceRank(entry) {
157
+ return entry && entry.source === 'flow' ? 1 : 0
158
+ }
159
+
160
+ /**
161
+ * 任务上下文 × quicklog 条目匹配(双源条目统一打分,排序 source 键优先)。
162
+ * @param {string} specBase - .sillyspec 根
163
+ * @param {string} taskContext - 任务描述串(与 matchKnowledge 的 query 同源)
164
+ * @param {{ limit?: number }} [opts] - 注入上限(默认 3)
165
+ * @returns {{ hits: Array<{ qlId: string, date: string, title: string, files: string[] }>, report: string }}
166
+ * 无 quicklog / 无命中 / 任何异常 → { hits: [], report: '' }(fail-open,永不抛)
167
+ */
168
+ export function matchQuicklogContext(specBase, taskContext, { limit = 3 } = {}) {
169
+ try {
170
+ if (!specBase || !taskContext) return { hits: [], report: '' }
171
+ const entries = parseQuicklogEntries(specBase)
172
+ if (entries.length === 0) return { hits: [], report: '' }
173
+ const tokens = contextTokens(taskContext)
174
+ if (tokens.length === 0) return { hits: [], report: '' }
175
+
176
+ const scored = []
177
+ for (const e of entries) {
178
+ const hay = [e.title, e.solution, e.files.join(' ')].join(' ').toLowerCase()
179
+ const basenames = new Set(e.files.map((f) => f.split('/').pop().toLowerCase()))
180
+ let score = 0
181
+ let tokenHits = 0
182
+ let fileHit = 0
183
+ for (const t of tokens) {
184
+ let hit = false
185
+ if (/[^\x00-\x7f]/.test(t)) {
186
+ hit = hay.includes(t)
187
+ } else {
188
+ hit = new RegExp(`(^|[^a-z0-9])${escapeRegex(t)}([^a-z0-9]|$)`).test(hay)
189
+ if (!hit && t.length >= 4) {
190
+ for (const b of basenames) {
191
+ if (b.includes(t)) {
192
+ hit = true
193
+ fileHit++
194
+ score += FILE_HIT_WEIGHT
195
+ break
196
+ }
197
+ }
198
+ }
199
+ }
200
+ if (hit) {
201
+ score += 1
202
+ tokenHits++
203
+ }
204
+ }
205
+ if (fileHit >= 1 || tokenHits >= MIN_TOKEN_HITS) {
206
+ scored.push({ entry: e, score, fileHit })
207
+ }
208
+ }
209
+ scored.sort((a, b) =>
210
+ (sourceRank(a.entry) - sourceRank(b.entry)) ||
211
+ (b.fileHit - a.fileHit) ||
212
+ (b.score - a.score) ||
213
+ String(b.entry.date).localeCompare(String(a.entry.date))
214
+ )
215
+ const top = scored.slice(0, Math.max(1, limit)).map((s) => ({
216
+ qlId: s.entry.qlId,
217
+ date: String(s.entry.date).slice(0, 10),
218
+ title: s.entry.title,
219
+ files: s.entry.files,
220
+ }))
221
+ return { hits: top, report: renderQuicklogSection(top) }
222
+ } catch {
223
+ return { hits: [], report: '' }
224
+ }
225
+ }
226
+
227
+ /**
228
+ * 注入段渲染(prompt.js 与 execute.js 孪生共用——仅段头层级不同)。
229
+ * 一行一条:ql-ID · 标题(截 60)· 日期 · 至多 2 个触碰文件。零正文注入——
230
+ * quicklog 条目正文可能很长,注入面只做「知道有这回事 + 回源指针」。
231
+ * @param {Array<{qlId,date,title,files}>} hits
232
+ * @param {{ h3?: boolean }} [opts] - execute.js Wave prompt 内用 h3(### )对齐知识段层级
233
+ */
234
+ export function renderQuicklogSection(hits, { h3 = false } = {}) {
235
+ if (!Array.isArray(hits) || hits.length === 0) return ''
236
+ const lines = [
237
+ `${h3 ? '### ' : ''}🕘 近期 quick 修补(quicklog 机械匹配,top-${hits.length}——历史记录非权威断言,细节按 ql-ID 回源 .sillyspec/quicklog/ 或读代码)`,
238
+ ]
239
+ for (const h of hits) {
240
+ const files = (h.files || []).slice(0, 2).join(', ')
241
+ const title = String(h.title || '').slice(0, 60)
242
+ const date = String(h.date || '').slice(0, 10)
243
+ lines.push(` - ${h.qlId} · ${title} · ${date}${files ? ` · ${files}` : ''}`)
244
+ }
245
+ return lines.join('\n')
246
+ }