sillyspec 3.29.5 → 3.30.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 (110) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  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 +18 -116
  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-brainstorm.md +1 -1
  10. package/assets/command-cards/run-quick.md +8 -25
  11. package/package.json +2 -1
  12. package/packages/dashboard/server/executor.js +2 -1
  13. package/packages/dashboard/server/parser.js +7 -7
  14. package/r7-test-env/README-R7-TEST.md +93 -0
  15. package/r7-test-env/demo-project/lib/calc.js +6 -0
  16. package/r7-test-env/demo-project/package.json +1 -0
  17. package/r7-test-env/demo-project/test.js +10 -0
  18. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  19. package/r7-test-env/sillyspec-r7.cmd +3 -0
  20. package/round4/account.mjs +18 -5
  21. package/round4/b1-analyze-task08.mjs +40 -0
  22. package/round4/b1-b1-target-validation.md +32 -0
  23. package/round4/b3a-prompt-audit-result.md +66 -0
  24. package/round4/b3a-prompt-audit.mjs +89 -0
  25. package/round4/optimization-plan.md +165 -0
  26. package/round4/r4-final-report.html +347 -0
  27. package/round4/r5-scenario-matrix.md +46 -0
  28. package/src/change-list.js +9 -0
  29. package/src/classify-change.js +6 -0
  30. package/src/command-cards.js +2 -1
  31. package/src/commit-guard.js +2 -0
  32. package/src/config-schema.js +64 -12
  33. package/src/db.js +35 -4
  34. package/src/decision-distill.js +9 -3
  35. package/src/docs-check.js +1 -1
  36. package/src/doctor-diagnostics.js +44 -2
  37. package/src/flow-draft.js +748 -0
  38. package/src/flow-parity.js +236 -0
  39. package/src/flow-review.js +184 -0
  40. package/src/flow.js +1492 -0
  41. package/src/foreign-declared.js +17 -1
  42. package/src/fr-index.js +396 -22
  43. package/src/fs-atomic.js +5 -1
  44. package/src/git-helper.js +2 -2
  45. package/src/greenfield-bootstrap.js +98 -0
  46. package/src/handoff.js +120 -7
  47. package/src/hunk-attribution.js +191 -0
  48. package/src/index.js +570 -16
  49. package/src/knowledge-classify.js +47 -1
  50. package/src/knowledge-digest.js +162 -0
  51. package/src/knowledge-quicklog.js +246 -0
  52. package/src/machine-draft.js +128 -0
  53. package/src/machine-interface.js +107 -5
  54. package/src/mcp-server.js +1 -0
  55. package/src/module-resolve.js +3 -1
  56. package/src/preview-progress.js +160 -0
  57. package/src/progress/change-registry.js +11 -3
  58. package/src/progress/shared.js +3 -2
  59. package/src/progress.js +56 -13
  60. package/src/quicklog.js +44 -6
  61. package/src/redomain.js +147 -0
  62. package/src/review-material-pack.js +204 -1
  63. package/src/run/archive-distill.js +4 -2
  64. package/src/run/bg-sync.js +67 -0
  65. package/src/run/command.js +123 -10
  66. package/src/run/complete-handlers.js +327 -72
  67. package/src/run/complete.js +350 -71
  68. package/src/run/gate-snapshot-ledger.js +213 -0
  69. package/src/run/gate-snapshot.js +250 -34
  70. package/src/run/gates.js +370 -233
  71. package/src/run/green-cache.js +123 -0
  72. package/src/run/junction-rm.js +70 -0
  73. package/src/run/next.js +14 -0
  74. package/src/run/prompt.js +185 -55
  75. package/src/run/quick-audit.js +185 -12
  76. package/src/run/shared.js +120 -3
  77. package/src/run/stage.js +182 -216
  78. package/src/run/test-ledger.js +264 -0
  79. package/src/run/verify-quality-scan.js +84 -7
  80. package/src/scope-audit.js +6 -1
  81. package/src/sentinel-assertions.js +114 -0
  82. package/src/setup.js +3 -3
  83. package/src/spec-sync.js +41 -1
  84. package/src/stage-contract.js +26 -8
  85. package/src/stage-review-checklist.js +2 -0
  86. package/src/stage-review.js +7 -2
  87. package/src/stage-templates.js +157 -0
  88. package/src/stages/archive.js +2 -0
  89. package/src/stages/brainstorm.js +24 -106
  90. package/src/stages/execute.js +221 -84
  91. package/src/stages/knowledge.js +74 -1
  92. package/src/stages/plan-postcheck.js +352 -1
  93. package/src/stages/plan.js +23 -1
  94. package/src/stages/quick.js +10 -2
  95. package/src/stages/verify.js +4 -2
  96. package/src/sync.js +81 -23
  97. package/src/task-done.js +181 -0
  98. package/src/task-review.js +8 -55
  99. package/src/test-bindings.js +549 -0
  100. package/src/timeline.js +218 -0
  101. package/src/ui-visual.js +175 -0
  102. package/src/verify-draft.js +214 -0
  103. package/src/verify-postcheck.js +860 -158
  104. package/src/verify-probes.js +59 -3
  105. package/src/watcher.js +1031 -0
  106. package/src/worktree-apply.js +44 -4
  107. package/src/worktree-deps.js +22 -8
  108. package/src/worktree-policy.js +42 -0
  109. package/src/worktree.js +102 -15
  110. package/templates/agents-instruction.md +37 -18
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * machine-draft.js — MACHINE-DRAFT 指纹三件套泛化(R7 切片三 task-04 / D-004 / FR-08)。
3
+ *
4
+ * 从 verify-draft.js(verify 填槽制 v2 件二)抽出通用原语,供 flow 四件机器稿(flow-draft)
5
+ * 复用;verify-draft 改为消费方(导出签名与行为逐字不动,零回归硬门)。
6
+ *
7
+ * 三件套语义(护栏#2:守卫必须是验收侧的机制,永远不是生成侧的劝说):
8
+ * - wrapSection:机器预填段以 MACHINE-DRAFT begin/end 注释对包裹,begin 内嵌段内容
9
+ * sha256(CRLF 归一);amendCmd 按消费方参数化(verify-result 指向 verify-probes
10
+ * --amend-draft,flow 稿指向 flow amend-draft——勿把 agent 引向错误命令);
11
+ * - verifyMarkers:三态拒收判定——标记缺失(被删/整份重写)/内容哈希失配(改写)/
12
+ * 手工重锚未审计(标记哈希与台账不一致);
13
+ * - reanchorText:按当前内容重锚哈希的纯文本变换(amend 留痕通道的机械半边;
14
+ * 台账审计与落盘归消费方)。
15
+ *
16
+ * 本模块纯函数(无 fs)——md 文本与台账对象由消费方读写;段 key 命名空间归消费方
17
+ * (verify-result 单文件段键 / flow 按文件名+段键组合)。
18
+ */
19
+ import { createHash } from 'node:crypto'
20
+
21
+ export const MARK_BEGIN_RE = /^<!--\s*MACHINE-DRAFT:([\w.-]+):([0-9a-f]{64}):begin.*-->\s*$/
22
+ const MARK_END_RE = /^<!--\s*MACHINE-DRAFT:([\w.-]+):end.*-->\s*$/
23
+
24
+ /**
25
+ * 任务勾选态归一(2026-09-25-thin-done-gate-calibration 坑1):勾选是 flow start 横幅教的
26
+ * 预期写面(收口哨兵的证据面),不该参与「被改写」判定——哈希前把 `- [x] task-NN` 归一回
27
+ * `- [ ]`(draft 产出恒 [ ],存量台账两侧归一后同哈希,兼容;真实改写任务文本/增删行仍失配)。
28
+ * 口径只认 task-NN 行,普通清单勾选行不归一(不扩大书写面)。
29
+ */
30
+ const TASK_CHECK_STATE_RE = /^([-*] \[)[ xX]\](?= task-\d)/gm
31
+ function normalizeTaskCheckboxState(s) {
32
+ return String(s).replace(TASK_CHECK_STATE_RE, '$1 ]')
33
+ }
34
+
35
+ /** 段内容哈希(CRLF 归一——跨平台写盘行尾漂移不炸指纹;task-NN 勾选态归一——见上)。 */
36
+ export function bodyHash(s) {
37
+ return createHash('sha256').update(normalizeTaskCheckboxState(String(s).replace(/\r\n/g, '\n'))).digest('hex')
38
+ }
39
+
40
+ /**
41
+ * 包裹机器段(begin 标记 + body + end 标记 + 尾空行)。标记格式与既有 MARK_*_RE 逐字兼容。
42
+ * @returns {string} 四行块(含尾空行,消费方按原位替换)
43
+ */
44
+ export function wrapSection({ key, body, amendCmd, guardNote = '整段改写会被 verify --done 拒收' }) {
45
+ return [
46
+ `<!-- MACHINE-DRAFT:${key}:${bodyHash(body)}:begin 机器预填段——${guardNote};确要修改:${amendCmd} 留痕重锚 -->`,
47
+ body,
48
+ `<!-- MACHINE-DRAFT:${key}:end -->`,
49
+ '',
50
+ ].join('\n')
51
+ }
52
+
53
+ /**
54
+ * 解析 md 中全部标记段(CRLF 归一)。
55
+ * @returns {{key:string, markerHash:string, contentLines:string[]}[]}
56
+ */
57
+ export function parseMarkerBlocks(text) {
58
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n')
59
+ const blocks = []
60
+ let current = null
61
+ for (const line of lines) {
62
+ const b = line.match(MARK_BEGIN_RE)
63
+ if (b) { current = { key: b[1], markerHash: b[2], contentLines: [] }; blocks.push(current); continue }
64
+ const e = line.match(MARK_END_RE)
65
+ if (e) { current = null; continue }
66
+ if (current) current.contentLines.push(line)
67
+ }
68
+ return blocks
69
+ }
70
+
71
+ /**
72
+ * 三态拒收判定(--done 验收侧承重件的纯函数半边)。
73
+ * @param {object} p
74
+ * @param {string} p.text md 全文
75
+ * @param {Object<string,{hash:string}>} p.sections 台账段哈希(sidecar.sections 形态)
76
+ * @param {string} p.amendCmd 修复指引命令文案(消费方参数化)
77
+ * @returns {string[]} violations(空=干净;文案含修复指引)
78
+ */
79
+ export function verifyMarkers({ text, sections, amendCmd }) {
80
+ const blocks = parseMarkerBlocks(text)
81
+ const byKey = new Map(blocks.map((b) => [b.key, b]))
82
+ const violations = []
83
+ const fix = (key) => `还原机器段原文,或确要修改时跑 ${amendCmd} 留痕重锚(段键 ${key})`
84
+ for (const [key, rec] of Object.entries(sections || {})) {
85
+ const b = byKey.get(key)
86
+ if (!b) {
87
+ violations.push(`机器段「${key}」的 MACHINE-DRAFT 标记缺失(被删除或整份重写)——${fix(key)}`)
88
+ continue
89
+ }
90
+ const actual = bodyHash(b.contentLines.join('\n'))
91
+ if (actual !== rec.hash) {
92
+ violations.push(`机器段「${key}」内容与指纹失配(被改写)——${fix(key)}`)
93
+ } else if (b.markerHash !== rec.hash) {
94
+ // 内容未动但标记哈希与台账不一致 = 标记被手工重锚而未经 amend 审计
95
+ violations.push(`机器段「${key}」标记指纹与 sidecar 台账不一致(未经 --amend-draft 的手工重锚)——${fix(key)}`)
96
+ }
97
+ }
98
+ return violations
99
+ }
100
+
101
+ /**
102
+ * 按当前内容重锚全部标记段(纯文本变换;不落盘不记台账——消费方持 mdPath/台账)。
103
+ * @returns {{text:string, keys:string[], contentByKey:Object<string,string>}}
104
+ */
105
+ export function reanchorText({ text, amendCmd, guardNote = '整段改写会被 verify --done 拒收' }) {
106
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n')
107
+ const contentLinesByKey = new Map()
108
+ let current = null
109
+ for (const line of lines) {
110
+ const b = line.match(MARK_BEGIN_RE)
111
+ if (b) { current = b[1]; contentLinesByKey.set(current, []); continue }
112
+ const e = line.match(MARK_END_RE)
113
+ if (e) { current = null; continue }
114
+ if (current) contentLinesByKey.get(current).push(line)
115
+ }
116
+ const keys = [...contentLinesByKey.keys()]
117
+ const next = lines.map((line) => {
118
+ const b = line.match(MARK_BEGIN_RE)
119
+ if (!b) return line
120
+ const content = (contentLinesByKey.get(b[1]) || []).join('\n')
121
+ return `<!-- MACHINE-DRAFT:${b[1]}:${bodyHash(content)}:begin 机器预填段——${guardNote};确要修改:${amendCmd} 留痕重锚 -->`
122
+ }).join('\n')
123
+ const contentByKey = {}
124
+ for (const k of keys) contentByKey[k] = (contentLinesByKey.get(k) || []).join('\n')
125
+ return { text: next, keys, contentByKey }
126
+ }
127
+
128
+ export default { wrapSection, parseMarkerBlocks, verifyMarkers, reanchorText, bodyHash, MARK_BEGIN_RE }