sillyspec 3.29.5 → 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 (110) 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-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
@@ -0,0 +1,236 @@
1
+ /**
2
+ * flow-parity.js — 轻量变更资产对齐三件(2026-09-25-thin-parity-assets,能力/资产对照表终核收口)。
3
+ *
4
+ * ① reconcileModuleDocs:模块文档同步对账——厚道 module-impact 死信门的轻量变更等价物(advisory):
5
+ * 交付文件命中模块图 → 点名模块与文档路径;模块代码变了而文档未动 → 强提示。模块文档是
6
+ * 后续变更 module 命中/门禁收窄/知识注入的原料(verify -68% 那笔账的来源),失供是复利折旧。
7
+ * (2026-09-27-thin-module-scope-persist:同一计算增加结构化返回面 modules/uncoveredDirs/
8
+ * moduleMaps,随 change-patch.json 落盘供平台展示轻量变更影响模块范围。)
9
+ * ② renderVerifyReceipt:verify-result 机器回执——人类可读收口结论(实测面/评审/绑定/冻结 sha),
10
+ * 厚道有轻量变更缺的审计资产;机器合成勿手改。
11
+ * ③ harvestSlot4Decision:design 槽4(风险与死路)实质作答收割合成 decisions.md——轻量变更决策
12
+ * 产出为零的补口(死路与风险取舍正是 decisions.md 该记的内容;已有文件不覆盖)。
13
+ */
14
+ import { existsSync, readFileSync, writeFileSync, readdirSync } from 'node:fs'
15
+ import { join, relative } from 'node:path'
16
+ import { execFileSync } from 'node:child_process'
17
+ import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
18
+ import { collectModuleMaps, normalizeMapPath } from './module-resolve.js'
19
+ import { writeAtomicSync } from './fs-atomic.js'
20
+
21
+ /**
22
+ * 模块文档对账(advisory)+ 结构化落盘面(2026-09-27-thin-module-scope-persist)。
23
+ * @param cwd 仓库根(子项目前缀判定 + specBase→仓根相对换算)
24
+ * @param ownFiles 交付文件(posix;.sillyspec/ 治理面内部滤除——模块命中只对账代码交付面,
25
+ * 否则本变更目录工件会污染未登记清单)
26
+ * @param committedRaw 含 .sillyspec 的原始提交面(仓根相对——模块文档是否随变更更新以此判定)
27
+ * @returns {{lines: string[], hits: number,
28
+ * modules: Array<{id: string, files: number, doc: string|null, docTouched: boolean, docMissing: boolean}>,
29
+ * uncoveredDirs: Array<{dir: string, files: number}>, moduleMaps: string[]}}
30
+ * lines 为空=无命中零输出;结构化三键恒在场(无图/零命中=空数组),随 change-patch.json 落盘。
31
+ * 口径对齐 module-resolve.collectModuleMaps(2026-09-27-thin-module-scope-persist 修复旧实现
32
+ * 四缺陷:js-yaml 顶层迭代不进 modules: 包层致命中恒 0、字母序取首个项目图致多项目仓读错图、
33
+ * 子项目 paths 前缀缺失、docTouched 拿 specBase 相对路径比仓根相对 committedRaw 恒 false)。
34
+ */
35
+ export function reconcileModuleDocs({ cwd, specBase, ownFiles, committedRaw }) {
36
+ let maps = []
37
+ try { maps = collectModuleMaps({ cwd: cwd || specBase, specBase }) } catch { maps = [] }
38
+ if (maps.length === 0) return { lines: [], hits: 0, modules: [], uncoveredDirs: [], moduleMaps: [] }
39
+ const deliverables = [...new Set((ownFiles || []).map((f) => String(f).replace(/\\/g, '/')))]
40
+ .filter((f) => !f.startsWith('.sillyspec/'))
41
+ const committed = new Set((committedRaw || []).map((f) => String(f).replace(/\\/g, '/')))
42
+ // specBase 相对 → 仓根相对(docTouched 比对基准:committedRaw 是 git diff 仓根相对输出)
43
+ const specRel = (() => {
44
+ if (!cwd) return ''
45
+ try {
46
+ const r = relative(cwd, specBase).replace(/\\/g, '/')
47
+ if (!r || r === '.') return ''
48
+ return r.replace(/\/+$/, '') + '/'
49
+ } catch { return '' }
50
+ })()
51
+ const lines = []
52
+ const modules = []
53
+ let hits = 0
54
+ for (const map of maps) {
55
+ for (const [modId, entry] of map.entries) {
56
+ const effs = (entry.paths || [])
57
+ .map((pp) => (map.prefix + normalizeMapPath(pp)).replace(/\/+$/, ''))
58
+ .filter(Boolean)
59
+ if (effs.length === 0) continue
60
+ const hitFiles = deliverables.filter((f) => effs.some((e) => f === e || f.startsWith(e + '/')))
61
+ if (hitFiles.length === 0) continue
62
+ hits++
63
+ const docRel = entry.doc ? `docs/${map.project}/${String(entry.doc).replace(/^\/+/, '')}` : null
64
+ const docAbs = docRel ? join(specBase, docRel) : null
65
+ const docTouched = docRel ? committed.has(specRel + docRel) : false
66
+ const docMissing = docAbs ? !existsSync(docAbs) : false
67
+ modules.push({ id: modId, files: hitFiles.length, doc: docRel, docTouched, docMissing })
68
+ if (docTouched) lines.push(` ✓ ${modId}(${hitFiles.length} 文件)——文档 ${docRel} 已同步`)
69
+ else lines.push(` ⚠️ ${modId}(${hitFiles.length} 文件)——文档${docAbs && existsSync(docAbs) ? ` ${docRel} ` : '(缺失)'}未随变更更新:若行为/接口有变请先补文档(模块文档是后续变更门禁收窄与知识注入的原料)`)
70
+ }
71
+ }
72
+ // 未登记模块目录检测(2026-09-25-thin-fr-quality,R16 实证:observation 新模块不在图 → FR
73
+ // 落伪域 auto-frontend):交付目录不在任何模块 paths 下时点名提示登记——首个变更把家建好,
74
+ // 后续变更的模块命中/门禁收窄/知识注入才能吃到
75
+ const allPaths = []
76
+ for (const map of maps) {
77
+ for (const entry of map.entries.values()) {
78
+ for (const pp of entry.paths || []) allPaths.push((map.prefix + normalizeMapPath(pp)).replace(/\/+$/, ''))
79
+ }
80
+ }
81
+ const isCovered = (f) => allPaths.some((pp) => pp && (f === pp || f.startsWith(pp + '/')))
82
+ const uncoveredMap = new Map()
83
+ for (const f of deliverables) {
84
+ if (isCovered(f)) continue
85
+ const dir = String(f).split('/').slice(0, -1).join('/')
86
+ if (dir) uncoveredMap.set(dir, (uncoveredMap.get(dir) || 0) + 1)
87
+ }
88
+ const uncoveredDirs = [...uncoveredMap.entries()].sort((a, b) => b[1] - a[1]).map(([dir, files]) => ({ dir, files }))
89
+ const topDirs = uncoveredDirs.slice(0, 3)
90
+ const out = [...lines]
91
+ if (hits > 0) out.unshift(`📎 模块文档对账(advisory):交付面命中 ${hits} 个模块——`)
92
+ if (topDirs.length > 0) {
93
+ out.push(`🗺️ 未登记模块图的交付目录(${topDirs.map((e) => `${e.dir}(${e.files} 文件)`).join('、')}${uncoveredDirs.length > 3 ? ' 等' : ''})——本变更的 FR 将落伪域 auto-*`)
94
+ out.push(` 建议收口前在模块图(docs/<project>/modules/_module-map.yaml)登记模块条目(模块 id + paths 指向目录 + doc),后续变更的模块命中/门禁收窄/知识注入才能吃到`)
95
+ }
96
+ return { lines: out, hits, modules, uncoveredDirs, moduleMaps: maps.map((m) => `docs/${m.project}/modules/_module-map.yaml`) }
97
+ }
98
+
99
+ /**
100
+ * verify-result 机器回执(归档前合成,随变更目录留档)。
101
+ */
102
+ export function renderVerifyReceipt({ change, baseline, head, gateSummary, review, traceCount, patchMeta, generatedAt }) {
103
+ const sha = patchMeta && patchMeta.patchSha256 ? patchMeta.patchSha256.slice(0, 12) : null
104
+ const reviewLine = review
105
+ ? review.verdict === 'exempt'
106
+ ? '豁免(低风险证据齐全' + (review.sampled ? '' : '') + ')'
107
+ : `${review.verdict}(reviewer 见 review.json${Number.isFinite(review.findingsP1) ? `,P1 ${review.findingsP1}` : ''})`
108
+ : '—'
109
+ const headNote = head && baseline && head === baseline
110
+ ? '(等于基线——交付代码尚未提交,冻结面以 change.patch 实际内容为准)'
111
+ : ''
112
+ return [
113
+ `---`,
114
+ `author: flow-machine-draft`,
115
+ `created_at: ${generatedAt}`,
116
+ `---`,
117
+ `# 验证回执(flow)— ${change}`,
118
+ ``,
119
+ `- **结论**:PASS(flow done 2/2 协议调用收口)`,
120
+ `- **基线..收口时 HEAD**:${baseline ? baseline.slice(0, 10) : '?'}..${head ? head.slice(0, 10) : '?'}${headNote}`,
121
+ `- **实测面**:${gateSummary || '—'}`,
122
+ `- **独立评审**:${reviewLine}`,
123
+ `- **测试绑定**:${traceCount > 0 ? `${traceCount} 行(test-trace.json,已随发号提升)` : '0(无锚行)'}`,
124
+ `- **交付冻结**:${sha ? `change.patch(sha256 ${sha}…)` : '(无冻结件)'}`,
125
+ `- **生成**:${generatedAt}(机器合成,勿手改;明细见 flow-telemetry.jsonl / change-patch.json)`,
126
+ ``,
127
+ ].join('\n')
128
+ }
129
+
130
+ /**
131
+ * design 槽4(风险与死路)实质作答收割 → decisions.md(已有不覆盖)。
132
+ * @returns {{harvested: boolean, reason?: string}}
133
+ */
134
+ export function harvestSlot4Decision({ changeDir, change }) {
135
+ const decPath = join(changeDir, 'decisions.md')
136
+ if (existsSync(decPath)) return { harvested: false, reason: 'decisions.md 已在场(不覆盖)' }
137
+ let dText
138
+ try { dText = readFileSync(join(changeDir, 'design.md'), 'utf8') } catch { return { harvested: false, reason: '无 design.md' } }
139
+ const lines = dText.replace(/\r\n/g, '\n').split('\n')
140
+ let inSlot = false
141
+ const buf = []
142
+ for (const line of lines) {
143
+ if (/^<!--\s*AGENT:槽4/.test(line)) { inSlot = true; continue }
144
+ if (inSlot && (/^<!--/.test(line) || /^#{1,6}\s/.test(line))) break
145
+ if (inSlot) buf.push(line)
146
+ }
147
+ const answer = buf.join('\n').trim()
148
+ if (!answer || /^不适用/.test(answer)) return { harvested: false, reason: '槽4 空/不适用' }
149
+ const text = [
150
+ '---',
151
+ `author: flow-machine-draft`,
152
+ `created_at: ${new Date().toISOString()}`,
153
+ '---',
154
+ `# 决策记录(Decisions)— ${change}`,
155
+ '',
156
+ `## D-001@v1: 风险与死路(design 槽4 收割)`,
157
+ `- 类型:process`,
158
+ `- 状态:confirmed`,
159
+ // 类型/状态字段(2026-09-26-slot4-distill-fix):蒸馏链入选要 status∈{confirmed|accepted|rejected}
160
+ // 且 type∈七类白名单,字段解析只认 `- 字段:值` 列表行形态——此前收割条目零字段致永不入选,
161
+ // 「随蒸馏链进 knowledge」断链(thin-agent-tasks 教训留档归档而 knowledge 零落地的实证)。
162
+ // 槽4 是定案的流程/方法论取舍,process+confirmed 语义成立。
163
+ `- 答案:${answer.replace(/\s*\n+\s*/g, ' ')}`,
164
+ // 正文用「答案」标签(同修):distill 落盘「理由:」行取 entry.answer——此前「- 决策:」非白名单
165
+ // 字段,正文不随条目进 knowledge(骨架落盘内容丢失)。一行展平(评审 P1 清偿):字段解析是
166
+ // 单行契约,多行槽4 作答经 \n 续行会静默丢后续行——收割端压平为单行(空格分隔)保全文。
167
+ '',
168
+ ].join('\n')
169
+ writeAtomicSync(decPath, text)
170
+ return { harvested: true }
171
+ }
172
+
173
+ export default { reconcileModuleDocs, renderVerifyReceipt, harvestSlot4Decision, collectFreezeFiles, backfillGateSummary }
174
+
175
+ /**
176
+ * patch 冻结面的提交面过滤(2026-09-27-tool-debt-cleanup 抽出为纯函数供单测锁定):
177
+ * 非 .sillyspec/ 全留 + 本变更目录(治理工件)+ .sillyspec/docs/ 交付文档(dogfood 模块卡
178
+ * 是交付物——2026-09-27-gate-docs-cleanup 评审 P2 实证旧口径把已提交模块卡漏出审计 patch)。
179
+ * 他侧 .sillyspec/changes/**(quicklog/knowledge WIP)仍滤除。反斜杠归一。
180
+ */
181
+ export function filterCommittedFace(committedRaw, ownPrefix) {
182
+ return committedRaw
183
+ .map((f) => String(f).replace(/\\/g, '/'))
184
+ .filter((p) => !p.startsWith('.sillyspec/') || p.startsWith(ownPrefix) || p.startsWith('.sillyspec/docs/'))
185
+ }
186
+
187
+ /**
188
+ * patch 冻结面收集(2026-09-25-thin-r16-patches 修复①,R16 P2:agent 提交晚于 done →
189
+ * 冻结件只有治理件)。committed 提交面为主;exclusive(会话专属 worktree,调用方按
190
+ * gate-snapshot 同款判定注入)时未提交 dirty 交付面一并入冻结——独占树内 dirty 全归属本变更;
191
+ * 共享主仓无法归属 → 不入面只警告(他侧声明的 dirty 连警告都免)。
192
+ */
193
+ export function collectFreezeFiles({ cwd, specBase, change, committed, exclusive }) {
194
+ const deliverable = (f) => !String(f).replace(/\\/g, '/').startsWith('.sillyspec/')
195
+ let dirty = []
196
+ try {
197
+ const out = execFileSync('git', ['status', '--porcelain'], { cwd, encoding: 'utf8', timeout: 30000, windowsHide: true })
198
+ for (const line of String(out).split('\n')) {
199
+ if (!line || line.length < 4) continue
200
+ const p = line.slice(3).trim().replace(/^"|"$/g, '')
201
+ const arrow = p.indexOf(' -> ')
202
+ const path = (arrow !== -1 ? p.slice(arrow + 4) : p).replace(/\\/g, '/')
203
+ if (deliverable(path) && !dirty.includes(path)) dirty.push(path)
204
+ }
205
+ } catch { /* git 失败零 dirty 面 */ }
206
+ // 归属切分单点:exclusive(worktree/--freeze-dirty)与共享主仓共用——他侧已声明 dirty 永不入冻
207
+ // (评审 P2 修复:--freeze-dirty 承诺「非他侧声明」,全量冻入会造成跨变更审计双计;worktree
208
+ // 下 own 恒为全量,切分无伤)
209
+ let ownDirty = dirty
210
+ try {
211
+ ownDirty = splitOwnVsForeignDiffFiles(cwd, change, dirty, { specBase }).own
212
+ } catch { /* 归属切分失败:exclusive 保留全量(声明即边界),共享主仓退全量警告 */ }
213
+ if (exclusive) return { files: [...new Set([...committed, ...ownDirty])], dirtyAdded: ownDirty, dirtyWarned: [] }
214
+ return { files: [...committed], dirtyAdded: [], dirtyWarned: ownDirty }
215
+ }
216
+
217
+ /**
218
+ * 实测面回填(修复③:ledger 子步断点续跑 skip 后回执不失忆)——从 verify-runs 最近
219
+ * test-result.json(带 change 键)重建摘要;无匹配返回 null。
220
+ */
221
+ export function backfillGateSummary(runtimeRoot, change) {
222
+ try {
223
+ const runsDir = join(runtimeRoot, 'verify-runs')
224
+ if (!existsSync(runsDir)) return null
225
+ for (const d of [...readdirSync(runsDir)].sort().reverse()) {
226
+ const p = join(runsDir, d, 'test-result.json')
227
+ if (!existsSync(p)) continue
228
+ let r
229
+ try { r = JSON.parse(readFileSync(p, 'utf8')) } catch { continue }
230
+ if (r && r.change === change) {
231
+ return `test: ${r.status ?? '?'}${r.command ? ` ← ${r.command}` : ''}${typeof r.duration_ms === 'number' ? `(${(r.duration_ms / 1000).toFixed(1)}s)` : ''} 结果:${p}`
232
+ }
233
+ }
234
+ } catch { /* 回填 best-effort */ }
235
+ return null
236
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * flow-review.js — 轻量变更独立评审(2026-09-25-thin-review-slice)。
3
+ *
4
+ * 定档哲学:危险证据累积制,体积/文件数出局(撞实验 F-02 实证——几行 ref 清零的小改是 P1,
5
+ * 30 文件的改名无风险)。信号五路,任一命中即需评审;缺省要评审、豁免要多证并举:
6
+ * ① 高危交付语义词(一票升级,任何信号压不住——唯⑤声明通道 --no-review 显式豁免除外;
7
+ * 实现即此优先序:--no-review 先判,fr-governance-sweep 头注释清偿)——at-least-once/
8
+ * exactly-once/不丢失/
9
+ * 不重复/串台类承诺是 P1 高发区(R15-F-01 即承诺-实现落差);
10
+ * ② 盲维四问实质作答——设计记录「边界与并发」槽有非「不适用」开头的作答=该维风险面存在
11
+ * (作答即信号:没有那个维度就不会写出实质内容;adopted 无槽设计退化为机制词全文扫描);
12
+ * ③ 交付 diff 原语——冻结 patch 里的并发/冲突/游标/取消类原语(物证,不依赖自述);
13
+ * ④ 决策密度——edit_ratio 超阈(amend 改写比例=真实取舍密度的机械代理);
14
+ * ⑤ 声明通道——--review / --no-review 一票(上下文最全的人直接表态)。
15
+ *
16
+ * 豁免 ≠ 免责:全信号不命中而豁免的变更按 1/4 定额抽查采样(确定性哈希分桶),采样命中照评
17
+ * 并进遥测——误豁免率可测量可校准(先例:review-dispatch shadow 影子期 N=10 转正判据)。
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+
22
+ /** 高危交付语义承诺词(一票升级)。2026-09-25-platform-feedback-batch2 D 收敛:移除「幂等」
23
+ * (实现手段非交付语义,由 diff 原语面覆盖);保留的七个词均为面向用户的交付语义承诺。 */
24
+ const PROMISE_RE = /at[- ]least[- ]once|exactly[- ]once|不丢失|不重复|不丢不重|不重不漏|串台/i
25
+
26
+ /** adopted(头脑风暴预段)design 无槽时的机制词全文扫描(窄表防误报)。 */
27
+ const MECHANISM_RE = /乱序|并发写|竞态|死锁|事务隔离|AbortSignal|多实例串|游标推进/
28
+
29
+ /** 交付 diff 危险原语(物证面)。 */
30
+ const PRIMITIVE_RE = /asyncio\.(?:Lock|Event|Queue|gather)|threading\.|with_for_update|IntegrityError|ON CONFLICT|AbortSignal|AbortController|signal\.abort|\.rollback\(|since=|\bcursor\b/i
31
+
32
+ /** 豁免抽查采样:确定性哈希 1/4 定额(SALT 防碰巧连坐,调表需同步核对既有夹具零碰撞)。 */
33
+ const SAMPLE_SALT = 7
34
+ export function sampleBucket(name) {
35
+ let h = 0
36
+ for (const ch of String(name || '')) h = (h * 31 + ch.codePointAt(0)) >>> 0
37
+ return (((h ^ SAMPLE_SALT) >>> 0) % 4) === 0
38
+ }
39
+
40
+ /** 读 AGENT 槽作答(标记行之后到下一标记/标题之前的非空行拼接)。 */
41
+ function readSlotAnswer(text, markerRe) {
42
+ const lines = String(text || '').replace(/\r\n/g, '\n').split('\n')
43
+ let inSlot = false
44
+ const buf = []
45
+ for (const line of lines) {
46
+ if (markerRe.test(line)) { inSlot = true; continue }
47
+ if (inSlot && (/^<!--/.test(line) || /^#{1,6}\s/.test(line))) break
48
+ if (inSlot) buf.push(line)
49
+ }
50
+ return buf.join('\n').trim()
51
+ }
52
+
53
+ /** 剥 MACHINE-DRAFT 机器段(design.md 的问题模板自带「串台」等承诺词字样——模板自污染面,
54
+ * 扫描只看 agent 作答面;proposal/requirements 的机器段承载用户原话不剥)。 */
55
+ function stripMachineSections(text) {
56
+ return String(text || '')
57
+ .replace(/<!--\s*MACHINE-DRAFT:[\w.-]+:[0-9a-f]{64}:begin[^\n]*-->[\s\S]*?<!--\s*MACHINE-DRAFT:[\w.-]+:end\s*-->/g, '')
58
+ .replace(/<!--\s*MACHINE-DRAFT:[\w.-]+:[^\n]*:begin[^\n]*-->[\s\S]*?<!--\s*MACHINE-DRAFT:[\w.-]+:end\s*-->/g, '')
59
+ }
60
+
61
+ /**
62
+ * 危险证据定档。@returns {{required: boolean, reasons: string[], exemptEvidence: string[], sampled: boolean}}
63
+ */
64
+ export function classifyReviewNeed({ changeDir, input = '', patchText = null, editRatio = null, reviewForce = null, change = '' }) {
65
+ if (reviewForce === true) return { required: true, reasons: ['显式 --review 声明(一票)'], exemptEvidence: [], sampled: false }
66
+ if (reviewForce === false) return { required: false, reasons: [], exemptEvidence: ['显式 --no-review 声明(一票)'], sampled: false }
67
+ const reasons = []
68
+ const exemptEvidence = []
69
+
70
+ // ① 承诺词:input + 治理工件全文(design 只扫作答面——机器问题模板含「串台」字样属模板自污染)
71
+ const texts = [String(input || '')]
72
+ for (const f of ['proposal.md', 'requirements.md', 'design.md']) {
73
+ try {
74
+ const t = readFileSync(join(changeDir, f), 'utf8').replace(/\r\n/g, '\n')
75
+ texts.push(f === 'design.md' ? stripMachineSections(t) : t)
76
+ } catch { /* 缺件由 redraft/槽位门兜底 */ }
77
+ }
78
+ const all = texts.join('\n')
79
+ const hit = all.match(PROMISE_RE)
80
+ if (hit) reasons.push(`高危交付语义承诺词命中「${hit[0]}」(一票升级)`)
81
+ else exemptEvidence.push('无高危承诺词')
82
+
83
+ // ② 盲维四问作答:实质作答=风险面(adopted 无槽退化机制词扫描)
84
+ try {
85
+ const dText = readFileSync(join(changeDir, 'design.md'), 'utf8').replace(/\r\n/g, '\n')
86
+ if (/<!--\s*AGENT:槽3/.test(dText)) {
87
+ const answer = readSlotAnswer(dText, /^<!--\s*AGENT:槽3/)
88
+ if (!answer) exemptEvidence.push('盲维四问未作答(将由槽位门拦收)')
89
+ else if (/^不适用/.test(answer)) exemptEvidence.push('盲维四问自述不适用')
90
+ else reasons.push('盲维四问有实质作答(存在时序/并发/切换/作用域风险面)')
91
+ } else if (MECHANISM_RE.test(dText)) {
92
+ reasons.push('头脑风暴设计含并发/时序类机制词(adopted 扫描)')
93
+ } else {
94
+ exemptEvidence.push('adopted 设计无机制词命中')
95
+ }
96
+ } catch { /* 无 design → 槽位门/redraft 面兜底 */ }
97
+
98
+ // ③ 交付 diff 原语
99
+ // 分径(fr-governance-sweep 评审 P3 清偿):null=patch 采集失败(flow.js patch 子步 fail-soft
100
+ // 的 catch 传 null)≠ 空=真无交付 diff——前者不能作为「纯治理面变更」豁免证据(有真实交付但
101
+ // patch 恰好失败的变更会凭误标证据整体豁免评审——防线虚焊),改判需评审。
102
+ if (patchText != null && String(patchText).trim()) {
103
+ if (PRIMITIVE_RE.test(patchText)) reasons.push('交付 diff 含并发/游标/取消类原语')
104
+ else exemptEvidence.push('diff 无危险原语')
105
+ } else if (patchText != null) {
106
+ exemptEvidence.push('无交付 diff(纯治理面变更)')
107
+ } else {
108
+ reasons.push('交付 diff 不可得(patch 留档失败)——豁免证据不成立,需评审')
109
+ }
110
+
111
+ // ④ 决策密度
112
+ if (typeof editRatio === 'number' && editRatio > 0.5) reasons.push(`决策密度高(edit_ratio=${editRatio})`)
113
+ else exemptEvidence.push('决策密度低(无 amend 或未超阈)')
114
+
115
+ if (reasons.length > 0) return { required: true, reasons, exemptEvidence, sampled: false }
116
+ if (sampleBucket(change)) return { required: true, reasons: ['豁免抽查采样(1/4 定额)'], exemptEvidence, sampled: true }
117
+ return { required: false, reasons, exemptEvidence, sampled: false }
118
+ }
119
+
120
+ /**
121
+ * 评审员任务书(flow done 首次命中需评审时打印——agent 起干净上下文子代理执行)。
122
+ * 材料有界(change.patch 为冻结件;治理工件已含字节帽意识);请求预算硬帽防失控勘察(R14 教训)。
123
+ */
124
+ export function renderReviewerTaskbook({ change, changeDir }) {
125
+ return [
126
+ `⚖️ 独立评审任务书 — ${change}`,
127
+ `══════════════════════════════════════`,
128
+ `【角色】你是独立评审员(干净上下文,未参与实现)——拿承诺对代码,不信自述,零误报标准。`,
129
+ ``,
130
+ `【材料(按需 Read,请求预算硬帽 12 次——超帽即止,包不足以作答写 cannot_verify)】`,
131
+ ` - ${join(changeDir, 'requirements.md')}(FR 承诺 + 每条 FR 的测试绑定作答)`,
132
+ ` - ${join(changeDir, 'design.md')}(设计承诺:做法/接口契约/盲维四问作答/风险)`,
133
+ ` - ${join(changeDir, 'change.patch')}(交付 diff 冻结件——代码事实的唯一来源)`,
134
+ ``,
135
+ `【检查单(逐条作答,P1=承诺被违反或有数据正确性风险)】`,
136
+ ` 1. FR↔实现↔测试三方一致:每条 FR 的实现兑现承诺?绑定作答声称的测试真存在且断言该行为?`,
137
+ ` 2. 盲维四问真实性:design 作答说「不适用」的维度,diff 代码真的没有该风险面吗?(重点:乱序到达/并发写/切换清理/多工作区作用域)`,
138
+ ` 3. 披露边界显式裁决(必答,不许默认放行——R16 实证:已披露的取舍最易被放行):design/requirements 里每条**声明的设计边界或取舍**`,
139
+ ` (如「晚到事件不再重发,是交付语义的显式边界」),逐条写明「可接受/不可接受」+一句理由——`,
140
+ ` 可接受 → dimensionNotes 对应维度写「边界已裁决:可接受」;不可接受 → 按严重度进 findings(P1=数据正确性风险)。`,
141
+ ` 没有任何声明边界时写「无声明边界」。未裁决=未审。`,
142
+ ` 4. 作答中的防护声明(如有):声称的防护机制在代码里真实存在且生效?`,
143
+ ` 5. 生成物同步(如涉及):类型/契约文件与实现对齐?`,
144
+ ``,
145
+ `【纪律】只读——禁止修改任何文件、禁止 git 操作;唯一产物是 review.json。`,
146
+ ``,
147
+ `【产物 schema(写到 ${join(changeDir, 'review.json')},UTF-8 JSON)】`,
148
+ `{`,
149
+ ` "schemaVersion": 1, "change": "${change}", "reviewer": "subagent",`,
150
+ ` "verdict": "PASS" | "FAIL",`,
151
+ ` "findings": [ { "severity": "P1|P2|P3", "title": "…", "evidence": "代码锚点+机理一句话", "location": "file:line" } ],`,
152
+ ` "dimensionNotes": { "乱序": "ok|finding|n/a", "并发": "ok|finding|n/a", "切换": "ok|finding|n/a", "作用域": "ok|finding|n/a" },`,
153
+ ` "reviewedAt": "<ISO 时间>"`,
154
+ `}`,
155
+ ``,
156
+ `完成后由主会话重跑:sillyspec flow done --change ${change}(断点续,已完成子步幂等跳过)`,
157
+ ].join('\n')
158
+ }
159
+
160
+ /**
161
+ * review.json 校验(三态:ok / schema 错误清单)。FAIL 或含 P1 由调用方拦截。
162
+ */
163
+ export function validateReviewJson(path) {
164
+ let raw
165
+ try { raw = readFileSync(path, 'utf8') } catch { return { ok: false, errors: [`review.json 不可读:${path}`], review: null } }
166
+ let r
167
+ try { r = JSON.parse(raw) } catch (e) { return { ok: false, errors: [`review.json 不是合法 JSON:${e.message}`], review: null } }
168
+ const errors = []
169
+ if (r.schemaVersion !== 1) errors.push('schemaVersion 必须为 1')
170
+ if (!['PASS', 'FAIL'].includes(r.verdict)) errors.push('verdict 必须为 PASS|FAIL')
171
+ if (!Array.isArray(r.findings)) errors.push('findings 必须为数组')
172
+ else {
173
+ r.findings.forEach((f, i) => {
174
+ if (!f || typeof f !== 'object') { errors.push(`findings[${i}] 非对象`); return }
175
+ if (!/^P[123]$/i.test(String(f.severity || ''))) errors.push(`findings[${i}].severity 必须 P1|P2|P3`)
176
+ if (!f.title) errors.push(`findings[${i}].title 缺失`)
177
+ })
178
+ }
179
+ if (!r.reviewer || typeof r.reviewer !== 'string') errors.push('reviewer 缺失')
180
+ if (errors.length > 0) return { ok: false, errors, review: null }
181
+ return { ok: true, errors: [], review: r }
182
+ }
183
+
184
+ export default { classifyReviewNeed, renderReviewerTaskbook, validateReviewJson, sampleBucket }