sillyspec 3.33.2 → 3.33.4

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 (53) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +4 -0
  2. package/AGENTS.md +1 -1
  3. package/package.json +1 -1
  4. package/src/config-known-failures.js +258 -0
  5. package/src/decision-distill.js +26 -5
  6. package/src/decision-redomain.js +5 -4
  7. package/src/design-facts.js +1 -1
  8. package/src/docs-check.js +8 -4
  9. package/src/doctor-diagnostics.js +89 -6
  10. package/src/flow-review-pack.js +35 -3
  11. package/src/flow-review.js +31 -8
  12. package/src/flow.js +6 -2
  13. package/src/fr-index.js +7 -3
  14. package/src/git-helper.js +82 -0
  15. package/src/handoff.js +4 -0
  16. package/src/hooks/worktree-guard.js +12 -3
  17. package/src/hot-cache.js +60 -0
  18. package/src/index.js +99 -7
  19. package/src/knowledge-graph.js +82 -2
  20. package/src/knowledge-match.js +18 -2
  21. package/src/knowledge-quicklog.js +17 -2
  22. package/src/knowledge-vector.js +5 -3
  23. package/src/machine-interface.js +34 -13
  24. package/src/progress/change-registry.js +15 -0
  25. package/src/progress/shared.js +4 -0
  26. package/src/progress/stage-machine.js +15 -2
  27. package/src/progress.js +5 -0
  28. package/src/redomain-unmapped.js +204 -0
  29. package/src/redomain.js +5 -3
  30. package/src/review-dispatch.js +109 -47
  31. package/src/run/bg-sync.js +4 -0
  32. package/src/run/code-face-key.js +4 -1
  33. package/src/run/command.js +20 -5
  34. package/src/run/complete.js +185 -14
  35. package/src/run/gates.js +31 -2
  36. package/src/run/prompt.js +9 -1
  37. package/src/run/shared.js +18 -1
  38. package/src/run/stage.js +22 -19
  39. package/src/run/verify-quality-scan.js +51 -0
  40. package/src/runtime-paths.js +3 -0
  41. package/src/sillyhub-mcp/client.js +11 -6
  42. package/src/spawn-settle.js +47 -0
  43. package/src/spec-sync.js +89 -3
  44. package/src/stages/brainstorm.js +1 -0
  45. package/src/stages/knowledge.js +25 -3
  46. package/src/timing.js +55 -0
  47. package/src/ui-visual.js +3 -0
  48. package/src/verify-postcheck.js +73 -4
  49. package/src/watcher.js +4 -0
  50. package/src/worktree-apply.js +67 -4
  51. package/src/worktree.js +80 -11
  52. package/src/yaml-scalar-unquote.js +43 -0
  53. package/templates/agents-instruction.md +1 -1
@@ -3,6 +3,10 @@ name: sillyspec:flow
3
3
  description: 轻量变更(默认快道)——需求明确的小/中改动。适合用户说"直接改、快速修、顺手调整、改个文案、修个小 bug、更新一个文件、需求很明确、开干吧、把这个改了"。协议链:flow start → 补 spec 面(FR 绑定+design 四节)→ 批准(flow approve / 用户显式 --autopilot)→ 干活(改代码写测试)→ flow done(实测门+测试绑定+patch 留档)。顺序纪律:先答 spec 面、批准后才动码(读代码探究不算动码)。
4
4
  ---
5
5
 
6
+ ## 交互规范
7
+
8
+ **当需要用户从多个选项中做出选择时,必须使用 Claude Code 内置的 AskUserQuestion 工具,将选项以参数传入。** 不要用编号列表让用户手动输入数字。
9
+
6
10
  ## 何时使用
7
11
 
8
12
  - 需求已含决策:改什么、成功标准说得清(能列 1-3 条可验证标准)
package/AGENTS.md CHANGED
@@ -33,7 +33,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文
33
33
  1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
34
34
  2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md(如有))或现有代码依据,无依据不改。
35
35
  3. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档(轻量道即:FR 绑定+design 四节+用户批准先于动码,读代码探究不算动码)。测试按层适用:纯逻辑先写测试,行为契约断言用户可见行为,视觉样式与纯配置/文档不写测试(记「不适用:理由」,验收靠规范与人工)。
36
- 4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`。以落盘文件与测试结果为准,不信口头"已完成"。
36
+ 4. **实证核验再收口**:触及 `src`/`test` 的改动,CLI 会在收口时亲自实测(实测失败阻断收口;纯 doc/配置自动跳过)。测试面按变更动态推断(本变更测试 ∪ FR 关联回归 ∪ import 依赖,runner 自项目结构推断——无需配置);lint 走 `.sillyspec/local.yaml` 的 `commands.lint`(未配置/标 `unavailable` → 收口跳过并留痕理由,归 pre-push/CI 门——pre-push 已跑同款 lint 的仓可标 unavailable 免重复)。以落盘文件与测试结果为准,不信口头"已完成"。自验口径=变更测试面(新增测试+受影响既有面)即收口证据(lint 已配置时一并实测;标 `unavailable` 的仓归 git 钩子/CI 门)——全量套件归 pre-push 门,勿每变更重复手跑(共享仓全量红先做归属判别,非本变更引入如实留痕不阻断);跑批输出落 `.sillyspec/.runtime/logs/` 日志回看尾部,勿整段吞掉。
37
37
  5. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
38
38
  6. **git hook 拦截提交时禁止跳过**(如 `.husky/pre-push`),修复问题后再提交。
39
39
  7. **代码必须兼容 Windows / Linux / macOS**(路径 / 换行 / 并发都要顾)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.33.2",
3
+ "version": "3.33.4",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -0,0 +1,258 @@
1
+ /**
2
+ * config-known-failures —— local.yaml known_failures 键的行级增删查(2026-10-21-p0-p1-hardening FR-07)
3
+ *
4
+ * 背景(dsh z2 实证):agent 用 PS5.1 `Set-Content -Encoding UTF8` 手改 50KB local.yaml 的
5
+ * known_failures 行 → BOM/mojibake 不可逆损坏(510 个 `?`),恢复等用户人工贴备份 2h13m。
6
+ * known_failures 无 CLI 管理面 = 手改场景必然存在。本模块给 `sillyspec config known-failures
7
+ * list|add|remove` 提供纯逻辑核心:
8
+ * - 行级定向手术(块式/流式两种既有形态都认),保留文件其余内容与注释——禁止 js-yaml 整体
9
+ * 重序列化(丢注释,用户文档性受损);
10
+ * - 改前 YAML 全文可解析性门(jsYaml.load 抛错 → 拒改,坏文件上叠加手术只会更坏);
11
+ * - 写入 writeAtomicSync(tmp+rename 原子替换)+ UTF-8 无 BOM 新写。
12
+ * 读法口径对齐 docs-check.js known_failures 键提取(块式 + 流式)——不 import(该模块拖重链)。
13
+ */
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import jsYaml from 'js-yaml'
16
+ import { writeAtomicSync } from './fs-atomic.js'
17
+ import { unquoteYamlScalar, unescapeYamlDoubleQuoted } from './yaml-scalar-unquote.js'
18
+
19
+ /** 块式头行:known_failures: 顶格、后接注释或空(docs-check.js:982 同口径) */
20
+ const BLOCK_HEADER_RE = /^known_failures:\s*(?:#.*)?$/
21
+ /** 流式行:known_failures: [a, b](docs-check.js:971 同口径) */
22
+ const INLINE_HEADER_RE = /^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/
23
+ /** 块式条目行:缩进 + 列表杠 + 键(支持裸/双引/单引形态,尾注释剥除) */
24
+ const ITEM_RE = /^(\s+)-\s*(?:"([^"]*)"|'([^']*)'|([^#]*?))\s*(?:#.*)?$/
25
+
26
+ /** 键的裸写安全性:字母数字/下划线/连字符/点/中文——其余(逗号方括号引号#等)写回时加双引号 */
27
+ const BARE_KEY_SAFE_RE = /^[\w.\-\u4e00-\u9fff]+$/
28
+
29
+ /**
30
+ * 纯函数:从 local.yaml 文本提取 known_failures 键清单(块式 + 流式两形态)。
31
+ * 无 known_failures 段 → 空数组;块头无条目(含 `known_failures: []` 流式空)→ 空数组。
32
+ * @param {string} text
33
+ * @returns {string[]}
34
+ */
35
+ export function parseKnownFailureKeys(text) {
36
+ const src = String(text || '')
37
+ const lines = src.split(/\r?\n/)
38
+ // 流式优先(同键只可能一种形态在场;流式正则更具体)。条目规范化走 yaml-scalar-unquote
39
+ // 单点(2026-10-21-known-failures-unescape):双引号条目反转义(renderKey 写侧按规范转义,
40
+ // 读侧不反转义会把 \\. 留成字面双反斜杠——锚定豁免模式静默失配)。
41
+ for (const l of lines) {
42
+ const m = l.match(INLINE_HEADER_RE)
43
+ if (m) {
44
+ return (m[1] || '')
45
+ .split(',')
46
+ .map((s) => s.trim())
47
+ .filter(Boolean)
48
+ .map((s) => unquoteYamlScalar(s))
49
+ }
50
+ }
51
+ const idx = lines.findIndex((l) => BLOCK_HEADER_RE.test(l))
52
+ if (idx === -1) return []
53
+ const keys = []
54
+ for (let i = idx + 1; i < lines.length; i++) {
55
+ const l = lines[i]
56
+ if (/^\S/.test(l)) break // 下一个顶层键——段结束
57
+ const m = l.match(ITEM_RE)
58
+ if (m) {
59
+ // 双引组内文反转义 / 单引组 ''→' / 兜底组(裸值或正则引号组吃不掉的形态,如内嵌
60
+ // '' 的单引号条目——ITEM_RE 单引号组 [^']* 在第二个引号停)统一过 unquoteYamlScalar
61
+ const k = m[2] !== undefined
62
+ ? unescapeYamlDoubleQuoted(m[2])
63
+ : m[3] !== undefined
64
+ ? m[3].replace(/''/g, "'")
65
+ : unquoteYamlScalar(m[4] || '')
66
+ if (k) keys.push(k)
67
+ }
68
+ }
69
+ return keys
70
+ }
71
+
72
+ function renderKey(k) {
73
+ return BARE_KEY_SAFE_RE.test(k) ? k : `"${String(k).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
74
+ }
75
+
76
+ /**
77
+ * 纯函数:行级手术增删 known_failures 键。
78
+ * - 增:去重后追加(块式→段尾插条目行;流式→行内数组追加;无段→文件尾整段追加);
79
+ * - 删:移除命中条目(块式删行;流式重拼数组);删空后流式写 `[]`、块式头改写 `known_failures: []`;
80
+ * - 改前全文 jsYaml 可解析门:抛错 → { ok:false }(坏文件上拒绝手术);
81
+ * - 文件其余行(含注释/空行/换行风格)逐字节保留——只动 known_failures 相关行。
82
+ * @param {string} text 原 local.yaml 全文
83
+ * @param {{ add?: string[], remove?: string[] }} edit
84
+ * @returns {{ ok: true, next: string, keys: string[] } | { ok: false, error: string }}
85
+ */
86
+ export function applyKnownFailureEdit(text, { add = [], remove = [] } = {}) {
87
+ const src = String(text || '')
88
+ // 坏 YAML 拒改门(FR-07:mojibake/残缺文件上手术只会更坏——先修文件再管理键)
89
+ try {
90
+ const doc = jsYaml.load(src)
91
+ if (doc !== null && typeof doc !== 'object') {
92
+ return { ok: false, error: 'local.yaml 顶层不是映射(YAML 合法但形态异常)——拒改,请人工核对' }
93
+ }
94
+ } catch (e) {
95
+ return { ok: false, error: `local.yaml YAML 解析失败(${String(e && e.message ? e.message : e).split('\n')[0]})——拒改。先从备份/git 历史恢复或重建文件,再用本命令管理 known_failures` }
96
+ }
97
+ const nl = src.includes('\r\n') ? '\r\n' : '\n'
98
+ const lines = src.split(/\r?\n/)
99
+ // trailing newline 语义:原文以换行结尾 → 重组时补回
100
+ const endedWithNl = /\n$/.test(src) || /\r\n$/.test(src)
101
+
102
+ const addSet = [...add].filter(Boolean)
103
+ const removeSet = new Set([...remove].filter(Boolean))
104
+
105
+ // 流式形态
106
+ const inlineIdx = lines.findIndex((l) => INLINE_HEADER_RE.test(l))
107
+ if (inlineIdx !== -1) {
108
+ const cur = parseKnownFailureKeys(src)
109
+ const curSet = new Set(cur)
110
+ const removeHit = cur.filter((k) => removeSet.has(k))
111
+ let next = cur.filter((k) => !removeSet.has(k))
112
+ for (const k of addSet) {
113
+ if (!next.includes(k) && !curSet.has(k)) next.push(k)
114
+ else if (curSet.has(k) && !removeSet.has(k) && !next.includes(k)) next.push(k)
115
+ }
116
+ const inlineMatch = lines[inlineIdx].match(INLINE_HEADER_RE)
117
+ const trailingComment = (inlineMatch[0].match(/#.*$/)?.[0]) || ''
118
+ lines[inlineIdx] = next.length > 0
119
+ ? `known_failures: [${next.map(renderKey).join(', ')}]${trailingComment ? ` ${trailingComment}` : ''}`
120
+ : `known_failures: []${trailingComment ? ` ${trailingComment}` : ''}`
121
+ const out = lines.join(nl) + (endedWithNl ? nl : '')
122
+ return { ok: true, next: out, keys: next, removedKeys: removeHit }
123
+ }
124
+
125
+ // 块式形态
126
+ const blockIdx = lines.findIndex((l) => BLOCK_HEADER_RE.test(l))
127
+ if (blockIdx !== -1) {
128
+ const cur = parseKnownFailureKeys(src)
129
+ const removeHit = cur.filter((k) => removeSet.has(k))
130
+ const keep = cur.filter((k) => !removeSet.has(k))
131
+ const curSet = new Set(cur)
132
+ for (const k of addSet) if (!keep.includes(k) && !curSet.has(k)) keep.push(k)
133
+ // 段边界:块头行之后连续的缩进行(条目/空行/注释行都属于段——条目行重写,其余保留)
134
+ let end = blockIdx + 1
135
+ while (end < lines.length && !/^\S/.test(lines[end])) end++
136
+ // 重组段:保留段内注释/空行的相对位置有过度工程之嫌——known_failures 段语义就是键清单,
137
+ // 直写键条目行(段内注释罕见,注释保真交给其余 99% 的文件内容)
138
+ const section = keep.map((k) => ` - ${renderKey(k)}`)
139
+ lines.splice(blockIdx + 1, end - (blockIdx + 1), ...section)
140
+ if (keep.length === 0) {
141
+ // 删空:块头改流式空(两种 reader 都认 `[]`;裸块头= null 语义靠 reader 兜底,不赌)
142
+ lines[blockIdx] = 'known_failures: []'
143
+ }
144
+ const out = lines.join(nl) + (endedWithNl ? nl : '')
145
+ return { ok: true, next: out, keys: keep, removedCount: removeHit.length, removedKeys: removeHit }
146
+ }
147
+
148
+ // 无段:文件尾整段追加(add 空且 remove 命不中 → 无事可做,原样返回)
149
+ if (addSet.length === 0) return { ok: true, next: src, keys: [] }
150
+ const keys = addSet.filter((k, i) => addSet.indexOf(k) === i)
151
+ const header = src.length === 0 ? '' : (endedWithNl ? '' : nl)
152
+ const out = `${src}${header}known_failures:${nl}${keys.map((k) => ` - ${renderKey(k)}`).join(nl)}${nl}`
153
+ return { ok: true, next: out, keys }
154
+ }
155
+
156
+ /**
157
+ * 读文件列键(CLI list / doctor 联动用)。
158
+ * @param {string} yamlPath
159
+ * @returns {{ ok: boolean, keys?: string[], error?: string, missing?: boolean }}
160
+ */
161
+ export function listKnownFailures(yamlPath) {
162
+ if (!existsSync(yamlPath)) return { ok: false, missing: true, error: `local.yaml 不存在: ${yamlPath}` }
163
+ let text
164
+ try {
165
+ text = readFileSync(yamlPath, 'utf8')
166
+ } catch (e) {
167
+ return { ok: false, error: `读取失败: ${e && e.message ? e.message : e}` }
168
+ }
169
+ try {
170
+ jsYaml.load(text)
171
+ } catch (e) {
172
+ return { ok: false, error: `local.yaml YAML 解析失败(${String(e && e.message ? e.message : e).split('\n')[0]})——键清单不可信,先修复文件(doctor local_yaml_integrity 维度有指引)` }
173
+ }
174
+ return { ok: true, keys: parseKnownFailureKeys(text) }
175
+ }
176
+
177
+ /**
178
+ * 文件级增删(读 → applyKnownFailureEdit → 原子写)。
179
+ * @param {string} yamlPath
180
+ * @param {{ add?: string[], remove?: string[] }} edit
181
+ * @returns {{ ok: boolean, keys?: string[], error?: string }}
182
+ */
183
+ export function mutateKnownFailuresFile(yamlPath, edit) {
184
+ if (!existsSync(yamlPath)) return { ok: false, error: `local.yaml 不存在: ${yamlPath}——先 sillyspec local detect / platform connect 生成,再管理 known_failures` }
185
+ let text
186
+ try {
187
+ text = readFileSync(yamlPath, 'utf8')
188
+ } catch (e) {
189
+ return { ok: false, error: `读取失败: ${e && e.message ? e.message : e}` }
190
+ }
191
+ const r = applyKnownFailureEdit(text, edit)
192
+ if (!r.ok) return r
193
+ if (r.next !== text) writeAtomicSync(yamlPath, r.next)
194
+ return { ok: true, keys: r.keys, removedKeys: r.removedKeys || [] }
195
+ }
196
+
197
+ /**
198
+ * CLI 入口薄壳(index.js config known-failures 接线消费):打印 + 退出码。
199
+ * @param {{ yamlPath: string, exists: boolean, verb: string, keys: string[], json: boolean }} p
200
+ * @returns {number} process.exitCode
201
+ */
202
+ export function runKnownFailuresCommand({ yamlPath, exists, verb, keys = [], json = false }) {
203
+ const want = verb || 'list'
204
+ if (!['list', 'add', 'remove'].includes(want)) {
205
+ console.error(`❌ 未知动作: config known-failures ${want}`)
206
+ console.error('用法: sillyspec config known-failures list | add <key…> | remove <key…>')
207
+ return 2
208
+ }
209
+ if (want !== 'list' && keys.filter(Boolean).length === 0) {
210
+ console.error(`❌ config known-failures ${want} 需要至少一个键参数(空格分隔多个)`)
211
+ return 2
212
+ }
213
+ if (want === 'list') {
214
+ if (!exists) {
215
+ if (json) console.log(JSON.stringify({ cmd: 'config known-failures list', exists: false }))
216
+ else console.error(`📭 未找到 local.yaml(${yamlPath})——无 known_failures 可列`)
217
+ return 1
218
+ }
219
+ const r = listKnownFailures(yamlPath)
220
+ if (!r.ok) {
221
+ if (json) console.log(JSON.stringify({ cmd: 'config known-failures list', ok: false, error: r.error }))
222
+ else console.error(`❌ ${r.error}`)
223
+ return 1
224
+ }
225
+ if (json) {
226
+ console.log(JSON.stringify({ cmd: 'config known-failures list', ok: true, count: r.keys.length, keys: r.keys }))
227
+ } else if (r.keys.length === 0) {
228
+ console.log('📭 known_failures 为空(无豁免键)')
229
+ } else {
230
+ console.log(`📋 known_failures(${r.keys.length} 个):`)
231
+ for (const k of r.keys) console.log(` - ${k}`)
232
+ }
233
+ return 0
234
+ }
235
+ if (!exists) {
236
+ console.error(`📭 未找到 local.yaml(${yamlPath})——先 sillyspec local detect / platform connect 生成`)
237
+ return 1
238
+ }
239
+ const r = mutateKnownFailuresFile(yamlPath, want === 'add' ? { add: keys } : { remove: keys })
240
+ if (!r.ok) {
241
+ console.error(`❌ 拒改:${r.error}`)
242
+ return 1
243
+ }
244
+ const uniq = [...new Set(keys.filter(Boolean))]
245
+ // remove 诚实文案(2026-10-21-known-failures-unescape FR-02):仅未命中键注记静默忽略——
246
+ // 旧文案恒打「已移除…(未命中键静默忽略)」,全命中时也自称可能有未命中,误导排查。
247
+ const removedSet = new Set(r.removedKeys || [])
248
+ const missed = want === 'remove' ? uniq.filter((k) => !removedSet.has(k)) : []
249
+ if (json) console.log(JSON.stringify({ cmd: `config known-failures ${want}`, ok: true, changed: want === 'remove' ? [...removedSet] : uniq, missed, keys: r.keys }))
250
+ else {
251
+ const note = want === 'remove'
252
+ ? (missed.length > 0 ? `(未命中键静默忽略:${missed.join('、')})` : '')
253
+ : ''
254
+ console.log(`✅ known_failures 已${want === 'add' ? '添加' : '移除'}:${(want === 'remove' ? [...removedSet] : uniq).join('、')}${note}——现共 ${r.keys.length} 个`)
255
+ console.log(` 校验:sillyspec config known-failures list`)
256
+ }
257
+ return 0
258
+ }
@@ -479,15 +479,36 @@ function applyEntryToSections(sections, entry, headHash, sectionNumber, originId
479
479
  // INDEX.md decisions 路由行幂等维护(FR-05:写入责任在本模块;只动 ## Decisions 段内指向 decisions/ 的行)
480
480
  // ---------------------------------------------------------------------------
481
481
 
482
+ /** 路由行共享泛词(FR-02,2026-10-21-knowledge-tier-denoise):decisions 面 decision|决策 /
483
+ * FR 面 FR|需求|承接 曾是全体域共享关键词——查询含泛词即命中全部路由行,注入席位退化为
484
+ * INDEX 行序抽签(本仓 Decisions 段 30 行全带泛词实证)。模板侧已删;存量行由
485
+ * syncIndexRoutingLines(stripTokens) 幂等剥离。 */
486
+ export const DECISIONS_ROUTE_STRIP_TOKENS = ['decision', '决策']
487
+ export const FR_ROUTE_STRIP_TOKENS = ['FR', '需求', '承接']
488
+
489
+ /** 路由行关键词表剥泛词:`- kw1|kw2 → [..](..)` 关键词段剔除 tokens(精确等值,trim 后判),
490
+ * 域名与其余关键词保留;关键词表剥空则整行原样保留(不造坏行)。幂等:已剥行再剥零漂移。 */
491
+ function stripRouteTokens(line, tokens) {
492
+ if (!Array.isArray(tokens) || tokens.length === 0) return line
493
+ const m = String(line).match(/^(\s*-\s+)([^→]+?)(\s*→[\s\S]*)$/)
494
+ if (!m) return line
495
+ const kept = m[2].split('|').map((k) => k.trim()).filter(Boolean).filter((k) => !tokens.includes(k))
496
+ if (kept.length === 0) return line
497
+ return `${m[1]}${kept.join('|')}${m[3]}`
498
+ }
499
+
482
500
  /**
483
501
  * 域文件实况 ↔ INDEX 段路由行对账:缺行补、失效行删、重复行去重;不动其他类别行。
484
502
  * 2026-09-18-fr-index-l1 参数化:section/subdir/makeLine/matchTarget 经参(decisions 缺省不变),
485
503
  * fr 侧传 'FR 需求索引' 段与 fr/ 子目录——两类路由行各守各段互不干扰。
504
+ * stripTokens(FR-02):对保留行与新行关键词表幂等剥离共享泛词(缺省 [] 行为不变)。
486
505
  */
487
506
  export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
488
507
  const sectionName = opts.section || 'Decisions'
489
508
  const subdir = opts.subdir || 'decisions'
490
- const makeLine = opts.makeLine || ((d) => `- ${d}|decision|决策 → [${subdir}/${d}.md](${subdir}/${d}.md)`)
509
+ const stripTokens = Array.isArray(opts.stripTokens) ? opts.stripTokens : []
510
+ // 路由键=域名(FR-02 泛词退役后的唯一共享形态);makeLine 传参者自定(fr 面 auto- 伪域附裸段关键词)
511
+ const makeLine = opts.makeLine || ((d) => `- ${d} → [${subdir}/${d}.md](${subdir}/${d}.md)`)
491
512
  const esc = subdir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
492
513
  const targetRe = opts.matchTarget || new RegExp(`^-\\s+.*→\\s*\\[[^\\]]*\\]\\(${esc}/([^)#]+?)(?:\\.md)?(?:#[^)]*)?\\)\\s*$`)
493
514
  const domainDir = join(knowledgeRoot, subdir)
@@ -528,15 +549,15 @@ export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
528
549
  for (const l of body) {
529
550
  const m = l.match(targetRe)
530
551
  if (!m) { kept.push(l); continue } // 非路由行(注释/空行)原样保留
531
- // 目标域文件仍存在的路由行原样保留(关键词可能被人工扩充);失效行(文件已不存在)与重复行删
532
- if (domainSet.has(m[1]) && !seen.has(m[1])) { seen.add(m[1]); kept.push(l) }
552
+ // 目标域文件仍存在的路由行保留(关键词可能被人工扩充——剥共享泛词,其余原样);失效行(文件已不存在)与重复行删
553
+ if (domainSet.has(m[1]) && !seen.has(m[1])) { seen.add(m[1]); kept.push(stripRouteTokens(l, stripTokens)) }
533
554
  }
534
555
  // 补行插在段尾空行之前(段尾空行是与下一 `## ` 的分隔,新行追加其后会出现行间空洞)
535
556
  const tail = []
536
557
  while (kept.length > 0 && kept[kept.length - 1].trim() === '') tail.unshift(kept.pop())
537
558
  for (const d of desired) {
538
559
  const m = d.match(targetRe)
539
- if (m && !seen.has(m[1])) kept.push(d) // 缺行补
560
+ if (m && !seen.has(m[1])) kept.push(stripRouteTokens(d, stripTokens)) // 缺行补(新模板本无泛词,剥是对旧式 makeLine 的防御)
540
561
  }
541
562
  kept.push(...tail)
542
563
  const sepNeeded = secEnd < lines.length && kept.length > 0 && kept[kept.length - 1].trim() !== ''
@@ -710,7 +731,7 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
710
731
  }
711
732
 
712
733
  // INDEX 路由行对账(仅在实际有条目写入时;零输出场景不动 INDEX)
713
- if (written.length > 0) syncIndexRoutingLines(knowledgeRoot)
734
+ if (written.length > 0) syncIndexRoutingLines(knowledgeRoot, { stripTokens: DECISIONS_ROUTE_STRIP_TOKENS })
714
735
 
715
736
  return { written, skipped: null, needsWait }
716
737
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { existsSync, readFileSync, unlinkSync } from 'fs'
20
20
  import { join } from 'path'
21
- import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines } from './decision-distill.js'
21
+ import { splitKnowledgeSections, joinKnowledgeFile, syncIndexRoutingLines, DECISIONS_ROUTE_STRIP_TOKENS } from './decision-distill.js'
22
22
  import { writeAtomicSync } from './fs-atomic.js'
23
23
 
24
24
  const DECISION_HEADER_RE = /^## (D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+)@v(\d+)\s*(.*)$/
@@ -33,8 +33,9 @@ function newDomainPreamble(domain) {
33
33
  ]
34
34
  }
35
35
 
36
- /** 段 → {id, number, version, title, change}(id=number@version 即迁域不换号的身份) */
37
- function sectionMeta(s) {
36
+ /** 段 → {id, number, version, title, change}(id=number@version 即迁域不换号的身份)。
37
+ * 导出供 redomain-unmapped 批量清偿驱动复用(FR-04,2026-10-21-knowledge-tier-denoise)。 */
38
+ export function sectionMeta(s) {
38
39
  const m = DECISION_HEADER_RE.exec(s.lines[0] || '')
39
40
  if (!m) return null
40
41
  const id = `${m[1]}@v${m[2]}`
@@ -135,7 +136,7 @@ export function redomainDecisionEntries({ knowledgeRoot, from, to, anchors = nul
135
136
  // INDEX 路由行:decisions 面默认参数(幂等;缺席行补上)
136
137
  const indexPath = join(knowledgeRoot, 'INDEX.md')
137
138
  const before = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
138
- try { syncIndexRoutingLines(knowledgeRoot) } catch { /* INDEX 缺席/同步失败不拦迁移 */ }
139
+ try { syncIndexRoutingLines(knowledgeRoot, { stripTokens: DECISIONS_ROUTE_STRIP_TOKENS }) } catch { /* INDEX 缺席/同步失败不拦迁移 */ }
139
140
  const after = existsSync(indexPath) ? readFileSync(indexPath, 'utf8') : ''
140
141
 
141
142
  return {
@@ -387,7 +387,7 @@ export function generateDesignSkeleton({ changeName, decisionsText, author, now
387
387
  lines.push('- [ ] frontmatter 字段齐全(author/created_at/scale)')
388
388
  lines.push('- [ ] 引用所有当前版本 D-xxx@vN')
389
389
  lines.push('- [ ] 涉及生命周期关键词时含「生命周期契约表」或紧邻豁免短语')
390
- lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选,输出须带素材来源声明:token 源/组件来源/入口位置)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记;本仓形态未在 local.yaml ui_prototype 登记的,收口前补登记)')
390
+ lines.push('- [ ] UI 原型分级核对(涉前端文件时变更目录应有 prototype-*.html——新生成(定稿=与实现同方言的真码产物,手绘仅限粗选,输出须带素材来源声明:token 源/组件来源/入口位置;产物必须可溯源到源码(管线编译/真实渲染捕获/登记复用三通道),手写 HTML 不得交付,对比稿供用户决策即定稿)或登记复用(复用须按 prototype-<名称>.html 命名,execute 才注入)或跳过原因记入风险登记;本仓形态未在 local.yaml ui_prototype 登记的,收口前补登记)')
391
391
  lines.push('- [ ] 不确定的问题标注「⚠️ 自审存疑」')
392
392
  lines.push('')
393
393
 
package/src/docs-check.js CHANGED
@@ -18,6 +18,7 @@ import { join, resolve as pathResolve, relative as pathRelative, sep as pathSep
18
18
  import { execFileSync } from 'node:child_process'
19
19
  import jsYaml from 'js-yaml'
20
20
  import { parseModuleMapSimple } from './modules.js'
21
+ import { unquoteYamlScalar } from './yaml-scalar-unquote.js'
21
22
 
22
23
  // ── HEAD 模式内容解析层(坑 docs-gate-shared-worktree-parallel-block,2026-09-08 实证)──
23
24
  // pre-push gate 校验的语义对象是「被推送的提交树」,而磁盘工作区混有并行会话的未提交
@@ -967,10 +968,12 @@ export function extractKnownFailureKeys(yamlText) {
967
968
  if (!yamlText) return []
968
969
  const yaml = String(yamlText).replace(/\r\n?/g, '\n')
969
970
  // 贪婪 .* 同 verify-postcheck.extractKnownFailures(坑 known-failures-inline-nested-bracket:
970
- // 值内 ] 使 [^\]]* 截断、整表静默清空;两处复刻必须同口径互指)
971
+ // 值内 ] 使 [^\]]* 截断、整表静默清空;两处复刻必须同口径互指)。条目规范化三读侧统一走
972
+ // yaml-scalar-unquote(2026-10-21-known-failures-unescape:双引号反转义——写侧按 YAML 规范
973
+ // 转义的 \\. 读侧不还原会把豁免键留成字面双反斜杠)
971
974
  const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
972
975
  if (inline) {
973
- return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
976
+ return inline[1].split(',').map(s => unquoteYamlScalar(s)).filter(Boolean)
974
977
  }
975
978
  // 坑 verify-known-failures-comment-line-truncation → verify-known-failures-block-fragile-chain
976
979
  // (与 verify-postcheck.extractKnownFailures 口径互指对齐,逐字同步):块式捕获曾要求
@@ -995,10 +998,11 @@ export function extractKnownFailureKeys(yamlText) {
995
998
  .map(s => {
996
999
  const item = s.trim()
997
1000
  const full = item.match(/^(['"])(.*)\1$/)
998
- if (full) return full[2]
1001
+ // 引号值经 unquoteYamlScalar 反转义(与流式/verify-postcheck 三读侧同口径);裸值照旧剥行尾注释
1002
+ if (full) return unquoteYamlScalar(item)
999
1003
  const stripped = item.replace(/[ \t]+#.*$/, '').trim()
1000
1004
  const q2 = stripped.match(/^(['"])(.*)\1$/)
1001
- return q2 ? q2[2] : stripped
1005
+ return q2 ? unquoteYamlScalar(stripped) : stripped
1002
1006
  })
1003
1007
  .filter(Boolean)
1004
1008
  }
@@ -25,6 +25,8 @@
25
25
  import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
26
26
  import { runtimeTempPath } from './runtime-paths.js'
27
27
  import { safeGit } from './git-helper.js';
28
+ import { batchCatFileExists } from './git-helper.js';
29
+ import { batchStageBlobSha256 } from './git-helper.js';
28
30
  import { createHash } from 'crypto';
29
31
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
30
32
  import { join, relative, resolve, dirname } from 'path';
@@ -630,6 +632,59 @@ function localYamlHasPlatformSection(cwd) {
630
632
  }
631
633
  }
632
634
 
635
+ // ── local.yaml 完整性维度(2026-10-21-p0-p1-hardening FR-07 / dsh z2 实证)────────────
636
+ // agent 手改 local.yaml(PS5.1 Set-Content -Encoding UTF8)把 50KB 文件写成 GBK mojibake
637
+ // (不可逆,510 个 ?)——commands/known_failures/门禁全部 reader 对坏文件 fail-open/降级,
638
+ // doctor 却零提示(agent 原话推理实证「Doctor doesn't complain about local.yaml」),最终等
639
+ // 用户人工贴备份 2h13m。本维度把「文件存在但不可解析 / 含 BOM」显性化(只读,绝不自动修)。
640
+ export function detectLocalYamlIntegrity(cwd) {
641
+ const dim = {
642
+ name: 'local_yaml_integrity',
643
+ label: 'local.yaml 完整性',
644
+ safe_actions: [],
645
+ findings: [],
646
+ };
647
+ const p = join(cwd, '.sillyspec', 'local.yaml');
648
+ if (!existsSync(p)) return dim; // 无文件是合法状态(本地模式),不报
649
+ let text;
650
+ try {
651
+ text = readFileSync(p, 'utf8');
652
+ } catch (e) {
653
+ dim.findings.push(
654
+ `local_yaml_unreadable: local.yaml 读取失败(${String(e && e.message ? e.message : e).split('\n')[0]})——` +
655
+ `权限/占用问题;修复后用 sillyspec config known-failures list 校验键面`
656
+ );
657
+ return dim;
658
+ }
659
+ if (text.charCodeAt(0) === 0xfeff) {
660
+ dim.findings.push(
661
+ `local_yaml_bom: local.yaml 含 UTF-8 BOM——PS5.1 写文件常见(-Encoding UTF8 恒写 BOM);` +
662
+ `YAML 解析器多可容忍,但定向行扫描 reader 与 PowerShell 工具链易致盲。` +
663
+ `修复:node -e "const fs=require('fs');const p=process.argv[1];fs.writeFileSync(p,fs.readFileSync(p,'utf8').replace(/^\\uFEFF/,''))" ${p}`
664
+ );
665
+ }
666
+ try {
667
+ jsYaml.load(text);
668
+ } catch (e) {
669
+ dim.findings.push(
670
+ `local_yaml_unparsable: local.yaml YAML 解析失败(${String(e && e.message ? e.message : e).split('\n')[0]})——` +
671
+ `commands/known_failures/门禁等全部 reader 对坏文件 fail-open 或降级,后续 gate 行为不可信` +
672
+ `(dsh z2 实证:手改炸文件后 doctor 失明,人工恢复耗时 2h13m)。` +
673
+ `修复:从备份/git 历史恢复或按 sillyspec config schema 重建;known_failures 键改用 ` +
674
+ `sillyspec config known-failures add/remove 管理(坏文件上该命令拒改并给指引)`
675
+ );
676
+ dim.safe_actions.push({
677
+ dimension: 'local_yaml_integrity',
678
+ action: 'restore_local_yaml',
679
+ target: p,
680
+ risk: 'confirm_required',
681
+ rationale: '坏配置是所有门的输运层——文件不可解析时任何 reader 行为都不可信,必须先恢复再谈其他',
682
+ next_step: `从备份/git 历史恢复或重建 ${p},然后 sillyspec config known-failures list 校验`,
683
+ });
684
+ }
685
+ return dim;
686
+ }
687
+
633
688
  function detectRepoNativeChain(cwd, pointer) {
634
689
  const dim = {
635
690
  name: 'repo_native_chain',
@@ -1023,6 +1078,17 @@ function detectApplyManifestDrift(cwd, specDir) {
1023
1078
  const scanned = manifests.slice(0, 5)
1024
1079
  const findings = []
1025
1080
  let checked = 0
1081
+ // ── staged 态批量预取(2026-10-21-protocol-tax task-07 / FR-07)──
1082
+ // 旧实现逐文件 `git show :<path>`(最近 5 份 manifest × 全部文件逐个 spawn,Windows 税
1083
+ // 可观);单次 `git cat-file --batch` 按输入序回流等价替换。miss=null=未暂存,与旧
1084
+ // gitQuiet null 同语义;整批失败空表全 miss(不洗白:暂存面查不到就当未暂存处理)。
1085
+ const stagedEntries = []
1086
+ for (const m of scanned) {
1087
+ for (const ent of m.files) {
1088
+ if (ent && typeof ent.path === 'string' && typeof ent.sha256 === 'string') stagedEntries.push(ent)
1089
+ }
1090
+ }
1091
+ const stagedMap = batchStageBlobSha256(cwd, stagedEntries.map((e) => e.path))
1026
1092
  for (const m of scanned) {
1027
1093
  for (const ent of m.files) {
1028
1094
  if (!ent || typeof ent.path !== 'string' || typeof ent.sha256 !== 'string') continue // 坏条目跳过
@@ -1036,10 +1102,8 @@ function detectApplyManifestDrift(cwd, specDir) {
1036
1102
  .update(Buffer.from(raw.toString('latin1').replace(/\r\n/g, '\n'), 'latin1'))
1037
1103
  .digest('hex')
1038
1104
  } catch { /* 盘上无 → 丢失分支候选 */ }
1039
- // staged 态:git show :<path> buffer 直接 sha256(staged blob 即 LF 规范态,与写侧同基)
1040
- let stagedHash = null
1041
- const blob = gitQuiet(cwd, ['show', `:${ent.path}`], { encoding: 'buffer', timeout: 15000 })
1042
- if (Buffer.isBuffer(blob)) stagedHash = createHash('sha256').update(blob).digest('hex')
1105
+ // staged 态:查批量预取表(task-07;staged blob 即 LF 规范态,与写侧同基)
1106
+ const stagedHash = stagedMap.get(ent.path) ?? null
1043
1107
  const tag = `${m.change}×${ent.path}`
1044
1108
  if (worktreeHash === null && stagedHash === null) {
1045
1109
  findings.push(`${tag} 丢失(盘上与暂存区皆无;期望 ${short(ent.sha256)})`)
@@ -1233,6 +1297,22 @@ export function detectArchiveIntegrity(cwd, authoritySpecDir) {
1233
1297
  };
1234
1298
  }
1235
1299
  const offenders = [];
1300
+ // ── 锚点批量预检(2026-10-21-protocol-tax task-07 / FR-07)──
1301
+ // 旧实现主循环内逐 SHA 起 `git cat-file -e`(消费仓 681 归档 × ≤2 SHA × Windows 单 spawn
1302
+ // ~130ms ≈ 25s+,doctor 实测 41.8s 的 70% 在此)。改为循环前收集全量 40-hex SHA、单次
1303
+ // `git cat-file --batch-check` 判存,主循环 anchorOk 查表。等价性:missing/探针失败 =
1304
+ // 旧 safeGit error → 均按不可解析=悬空同向 fail;change-patch.json 缺失/不可解析 →
1305
+ // 两版同样不计锚点欠账(主循环 catch 语义原样)。
1306
+ const anchorShas = new Set();
1307
+ for (const name of names) {
1308
+ try {
1309
+ const cpMetaPre = JSON.parse(readFileSync(join(archiveDir, name, 'change-patch.json'), 'utf8'));
1310
+ for (const sha of [cpMetaPre?.head, cpMetaPre?.baseline]) {
1311
+ if (sha && /^[0-9a-f]{40}$/i.test(String(sha))) anchorShas.add(String(sha).toLowerCase());
1312
+ }
1313
+ } catch { /* change-patch.json 缺失/不可解析:主循环既有语义不变(不计锚点欠账) */ }
1314
+ }
1315
+ const anchorExists = batchCatFileExists(cwd, [...anchorShas]);
1236
1316
  // FR 索引面(D14 第四检查数据源,循环外一次扫描复用;fr/ 不存在=仓未启用,全跳不误报)
1237
1317
  const frKnowledgeRoot = join(authoritySpecDir, 'knowledge');
1238
1318
  const frEnabled = existsSync(join(frKnowledgeRoot, 'fr'));
@@ -1323,7 +1403,7 @@ export function detectArchiveIntegrity(cwd, authoritySpecDir) {
1323
1403
  const cpMeta = JSON.parse(readFileSync(cpJsonPath, 'utf8'));
1324
1404
  const anchorOk = (sha) => {
1325
1405
  if (!sha || !/^[0-9a-f]{40}$/i.test(String(sha))) return !sha; // 空锚不算悬空(无 git 基线形态)
1326
- return !safeGit(cwd, ['cat-file', '-e', `${sha}^{commit}`]).error;
1406
+ return anchorExists.get(String(sha).toLowerCase()) === true; // 查批量预检表(task-07/FR-07)
1327
1407
  };
1328
1408
  const headDangling = !anchorOk(cpMeta.head);
1329
1409
  const baseDangling = !anchorOk(cpMeta.baseline);
@@ -1606,7 +1686,10 @@ export async function runDoctorDiagnostics({ cwd }) {
1606
1686
  } catch { gateLeakRuntimeRoot = null }
1607
1687
  const gateSnapshotLeak = detectGateSnapshotLeak({ runtimeRoot: gateLeakRuntimeRoot })
1608
1688
  const knowledgeGraphIntegrity = detectKnowledgeGraphIntegrity(cwd, authoritySpecDir || join(cwd, '.sillyspec'))
1609
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, crossWorktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity];
1689
+ // FR-07(2026-10-21-p0-p1-hardening / dsh z2 实证):local.yaml 完整性——此前 doctor 对
1690
+ // mojibake 坏文件完全失明(agent 手改炸文件后 doctor 报一堆别项零提示,等用户人工恢复 2h13m)
1691
+ const localYamlIntegrity = detectLocalYamlIntegrity(cwd);
1692
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, crossWorktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity, localYamlIntegrity];
1610
1693
 
1611
1694
  return {
1612
1695
  dimensions,
@@ -11,7 +11,7 @@
11
11
  * ③ 摘要从冻结件本身算——change.patch 文本直解,不走活 git(review-material-pack 的
12
12
  * extractDiffSummary 走活 git 解 base 那条路不能搬:防「包说的」与评审员手里冻结面漂移)。
13
13
  */
14
- import { readFileSync } from 'node:fs'
14
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
15
15
  import { join } from 'node:path'
16
16
 
17
17
  /** 材料包整体体积帽(对齐 review-material-pack.js PACK_MAX_CHARS 先例——包不能成为新的全量面)。 */
@@ -140,16 +140,47 @@ export function buildFreezeAttribution({ declaredFiles, freezeFiles, foreign })
140
140
  }
141
141
  }
142
142
 
143
+ /**
144
+ * ledger 测试回执扫描(2026-10-21-review-rerun-scope):verify-runs 各 runId 目录的
145
+ * test-result.json 按 change 匹配取目录名时间序最新一条(CLI 收口亲测产物,符合护栏③
146
+ * 机械抽取——非 agent 自述)。fail-soft:目录缺席/坏 JSON/无匹配 → null(调用方零注入)。
147
+ */
148
+ export function findLatestTestReceipt(verifyRunsDir, changeName) {
149
+ try {
150
+ if (!verifyRunsDir || !changeName || !existsSync(verifyRunsDir)) return null
151
+ const runIds = readdirSync(verifyRunsDir).filter((d) => /^\d{14,}$/.test(d)).sort()
152
+ for (let i = runIds.length - 1; i >= 0; i--) {
153
+ const p = join(verifyRunsDir, runIds[i], 'test-result.json')
154
+ try {
155
+ const j = JSON.parse(readFileSync(p, 'utf8'))
156
+ // kind:'lint' 独立回执(verify-postcheck 同名同目录落盘)不是测试面——跳过防「测试回执」标签错挂 lint 结果
157
+ if (j && j.change === changeName && j.kind !== 'lint' && (j.status || j.exit_code !== undefined)) return j
158
+ } catch { /* 坏 JSON 跳过更旧轮 */ }
159
+ }
160
+ } catch { /* 扫描失败零注入 */ }
161
+ return null
162
+ }
163
+
164
+ /** 回执行渲染(机械——ran_at/status/exit/耗时/命令面截断)。 */
165
+ function testReceiptLines(r) {
166
+ const cmd = String(r.command || '').replace(/\s+/g, ' ').slice(0, 120)
167
+ const dur = Number.isFinite(r.duration_ms) ? `${Math.round(r.duration_ms / 1000)}s` : '?'
168
+ return [
169
+ ` ledger 测试回执(CLI 收口亲测,${r.ran_at || '时间未知'}):${r.status || 'unknown'} exit=${r.exit_code ?? '?'} ${dur} | 面:${cmd}${String(r.command || '').length > 120 ? '…' : ''}`,
170
+ ` (回执为 CLI 亲测——评审按复跑口径抽验变更面即可,勿整体重跑)`,
171
+ ]
172
+ }
173
+
143
174
  /**
144
175
  * 装配材料包注入行(唯一组包入口):FR 表、patch 摘要、归属切分三子源各自可得各自产出、
145
176
  * 全缺返回 null(调用方零注入)。体积帽行级截断(不切半行),截断注记置于基准面语义行之前——
146
177
  * 语义行恒为末行;标题+正文+注记+语义行总长恒 ≤ PACK_MAX_CHARS(截断预留注记预算)。
147
178
  * requirements.md 读失败(缺件)→ FR 子段零产出;attribution 缺席/非法 → 归属子段零产出
148
179
  * (fail-soft 不抛穿,其余子段不受影响)。
149
- * @param {{ changeDir: string, patchText: string|null, attribution?: object|null }} args
180
+ * @param {{ changeDir: string, patchText: string|null, attribution?: object|null, testReceipt?: object|null }} args
150
181
  * @returns {{ lines: string[], truncated: boolean } | null}
151
182
  */
152
- export function assembleReviewPack({ changeDir, patchText, attribution = null }) {
183
+ export function assembleReviewPack({ changeDir, patchText, attribution = null, testReceipt = null }) {
153
184
  let frRows = []
154
185
  try {
155
186
  frRows = parseFrBindingRows(readFileSync(join(changeDir, 'requirements.md'), 'utf8'))
@@ -167,6 +198,7 @@ export function assembleReviewPack({ changeDir, patchText, attribution = null })
167
198
  }
168
199
  const attr = normalizeAttribution(attribution)
169
200
  if (attr) body.push(...attributionLines(attr))
201
+ if (testReceipt && typeof testReceipt === 'object') body.push(...testReceiptLines(testReceipt))
170
202
  const note = ` …(材料包超 ${PACK_MAX_CHARS} 字符截断——需全文时定向查证并列明)`
171
203
  let budget = PACK_MAX_CHARS - PACK_TITLE.length - PACK_BASELINE.length - note.length
172
204
  const lines = [PACK_TITLE]