sillyspec 3.28.12 → 3.29.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 (47) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +222 -223
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
  4. package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
  5. package/package.json +1 -1
  6. package/src/blast-surface.js +168 -0
  7. package/src/ceremony-tier.js +359 -0
  8. package/src/change-risk-profile.js +75 -202
  9. package/src/config-schema.js +21 -0
  10. package/src/decision-distill.js +32 -25
  11. package/src/decisions-io.js +32 -0
  12. package/src/design-facts.js +1 -0
  13. package/src/doctor-diagnostics.js +148 -1
  14. package/src/fr-index.js +359 -0
  15. package/src/index.js +92 -5
  16. package/src/knowledge-stats.js +124 -2
  17. package/src/modules.js +28 -0
  18. package/src/prefill.js +491 -0
  19. package/src/probe7-anchor-check.js +8 -6
  20. package/src/quick-gate-profile.js +8 -4
  21. package/src/quick-session-owner.js +63 -0
  22. package/src/review-dispatch.js +473 -1
  23. package/src/review-material-pack.js +265 -0
  24. package/src/review-tier.js +185 -41
  25. package/src/run/archive-distill.js +35 -0
  26. package/src/run/command.js +158 -87
  27. package/src/run/complete-handlers.js +82 -1
  28. package/src/run/complete.js +37 -1
  29. package/src/run/gates.js +301 -19
  30. package/src/run/prompt.js +454 -21
  31. package/src/run/shared.js +36 -0
  32. package/src/run/verify-quality-scan.js +14 -6
  33. package/src/scope-audit.js +8 -1
  34. package/src/span-risk-surface.js +127 -0
  35. package/src/stage-contract-spec.js +2 -2
  36. package/src/stage-contract.js +172 -98
  37. package/src/stage-review.js +26 -2
  38. package/src/stages/brainstorm.js +34 -16
  39. package/src/stages/execute.js +10 -2
  40. package/src/stages/plan.js +29 -12
  41. package/src/stages/verify.js +7 -7
  42. package/src/taskcard.js +3 -0
  43. package/src/verify-postcheck.js +375 -20
  44. package/src/verify-probes.js +964 -30
  45. package/src/worktree.js +1 -1
  46. package/templates/prompts/taskcard-rules.md +1 -0
  47. package/templates/prompts/verify-probes.md +1 -1
@@ -0,0 +1,265 @@
1
+ /**
2
+ * review-material-pack.js — 评审材料包(2026-09-19-review-material-pack / D-001~D-002)
3
+ *
4
+ * 结构病根(2026-09-19 成本复盘实证:评审 ~200x 信息放大):每次独立评审从零重建仓库认知,
5
+ * 根因是阶段 prompt 的必读清单(旧 brainstorm.js :417/:424 的两条全读指令——本变更 task-02
6
+ * 已删)优先级压过增量回灌块。本模块是材料包的组装单点:
7
+ * 四阶段包形态(D-002):
8
+ * grill-first = designDigest + fileList + crossPoints[≤5] + snippets[](点名源码片段)
9
+ * plan-review = hardConstraintDelta[](plan 相对 design 硬约束的差量)
10
+ * execute-qa = diffSummary + designHotZone + checklist
11
+ * re-review = priorFindings + fixDiff(经 {PRIOR_REVIEW_FACTS} 复审基线段渲染,非本模块槽)
12
+ * 槽位分权(design Wave 1.1):三阶段走 {REVIEW_MATERIALS};再审走 {PRIOR_REVIEW_FACTS}
13
+ * (stage-review.js renderPriorRoundFindingsMd 排他语化——task-02)且模板不含本槽。
14
+ *
15
+ * 基准面语义(D-004,由 task-02 写进 prompt):包是必答基准面非禁读清单——包外可定向查证
16
+ * 但须列明、禁全量扫读;评审者自检「包不足→cannot_verify+列缺件」。
17
+ *
18
+ * 纯函数为主;extractDiffSummary 的 base 解序复用 resolveVerifyChangedFiles(verify-postcheck.js:1113
19
+ * 既有锚点优先级 actualBaseHash/baselineCommit>baseHash——禁独立解 base,防重蹈 baseline
20
+ * 同步文件误入坑,fact-face-checkpoint-pollution 同族)。
21
+ *
22
+ * CLI 半边装配(2026-09-19-review-material-cli-wiring,Gap 1 收口):assembleStageReviewMaterials
23
+ * 是 prompt.js tier 注入链的组包入口——机械抽素材半边(designDigest/fileList/硬约束/diff/热区/
24
+ * checklist),主代理点名半边(五交叉点/plan 差量)留位不预填(decisions.md D-002)。
25
+ */
26
+ import { readFileSync } from 'fs'
27
+ import { join } from 'path'
28
+ import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
29
+
30
+ /** 材料包渲染上限(防病态体量:包本身不能成为新的全量扫读面) */
31
+ const PACK_MAX_CHARS = 12000
32
+ const SNIPPET_MAX_CHARS = 2400
33
+
34
+ /** 截断工具:超限加省略号注记 */
35
+ function clamp(text, max, label) {
36
+ const s = String(text || '')
37
+ if (s.length <= max) return s
38
+ return s.slice(0, max) + `\n…(${label} 超 ${max} 字符截断——需全文时定向查证并列明)`
39
+ }
40
+
41
+ /**
42
+ * design 热区抽取(execute.js:979-1023 designHotzone 先例泛化):按节名抽取指定 section 的
43
+ * 正文(## 标题起、下一 ## 止)。sections 缺省取评审最常用的两节(非目标/兼容策略)。
44
+ * @returns {string} 「### <节名>\n<body」拼接;无命中返回空串。
45
+ */
46
+ export function extractDesignHotZone(designContent, sections = ['非目标', '兼容策略']) {
47
+ const text = String(designContent || '')
48
+ if (!text) return ''
49
+ const parts = []
50
+ for (const name of sections) {
51
+ const re = new RegExp(`^#{2,3}\\s*[^\\n]*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^\\n]*$`, 'm')
52
+ const heading = text.match(re)
53
+ if (!heading) continue
54
+ const start = heading.index + heading[0].length
55
+ const rest = text.slice(start)
56
+ const next = rest.match(/^#{2,3}\s/m)
57
+ const body = (next ? rest.slice(0, next.index) : rest).trim()
58
+ if (body) parts.push(`### ${name}\n${body}`)
59
+ }
60
+ return parts.join('\n\n')
61
+ }
62
+
63
+ /**
64
+ * diff 摘要抽取(base 解序对齐既有基建):文件名单委托 resolveVerifyChangedFiles(锚点优先级
65
+ * actualBaseHash/baselineCommit>baseHash 由该函数内部解析),±行数经 git diff --stat 叠加。
66
+ * namesOnly 版不跑 --stat(评审只要名单时省一次 git)。
67
+ * @returns {{ files: string[], stat: string }} stat 为 --stat 尾段文本(best-effort,失败空串)。
68
+ */
69
+ export async function extractDiffSummary({ cwd, changeName, specBase, withStat = true, statText = null }) {
70
+ const { resolveVerifyChangedFiles } = await import('./verify-postcheck.js')
71
+ const files = resolveVerifyChangedFiles(cwd, changeName, null, { specBase }) || []
72
+ // QA Gap 3 修正:stat 窗口对齐文件名单(按名单限定路径跑工作树口径 --stat,伪装 HEAD~1 三元已除);
73
+ // 多提交窗口的精确 stat 由主代理组包时显式传入 statText 覆盖(buildReviewMaterialPack 的
74
+ // diffSummary.stat 消费同一字符串)——base 解序仍单点在 resolveVerifyChangedFiles 内部。
75
+ let stat = statText != null ? String(statText) : ''
76
+ if (statText == null && withStat && files.length > 0) {
77
+ try {
78
+ const { safeGit } = await import('./git-helper.js')
79
+ // safeGit 返回 { value, error } 非裸串(git-helper.js:95)——取 .value;此前 String(整个对象)
80
+ // 渲染成 [object Object](2026-09-19-review-material-cli-wiring dogfood 注入接线时咬出:
81
+ // 零生产调用期潜伏,接线后成活面)。
82
+ const out = safeGit(cwd, ['diff', '--stat', 'HEAD', '--', ...files], { trim: false })
83
+ stat = String((out && out.value) || '')
84
+ } catch { /* stat best-effort:失败只降级为名单 */ }
85
+ }
86
+ return { files, stat: clamp(stat, 2000, 'diff --stat') }
87
+ }
88
+
89
+ /**
90
+ * 源码片段抽取(点名文件×行窗):crossPoints 的 snippets 组装器。
91
+ * @param {Array<{file: string, from?: number, to?: number}>} anchors
92
+ * @returns {string} 「`file:from-to`\n```…```」拼接(每片段独立 clamp)。
93
+ */
94
+ export function extractSnippets(repoRoot, anchors = []) {
95
+ const parts = []
96
+ for (const a of anchors.slice(0, 8)) {
97
+ if (!a || !a.file) continue
98
+ try {
99
+ const lines = readFileSync(join(repoRoot, a.file), 'utf8').split('\n')
100
+ const from = Math.max(1, Number(a.from) || 1)
101
+ const to = Math.min(lines.length, Number(a.to) || from + 29)
102
+ const body = lines.slice(from - 1, to).map((l, i) => `${from + i}\t${l}`).join('\n')
103
+ parts.push('`' + a.file + ':' + from + '-' + to + '`\n' + clamp(body, SNIPPET_MAX_CHARS, a.file))
104
+ } catch { /* 不可读文件跳过(评审者自检会列缺件) */ }
105
+ }
106
+ return parts.join('\n\n')
107
+ }
108
+
109
+ /**
110
+ * 四阶段材料包组装(纯渲染,不落盘)。
111
+ * @param {'grill-first'|'plan-review'|'execute-qa'|'re-review'} stage
112
+ * @param {object} inputs 按 stage 取用:
113
+ * grill-first: { designDigest, fileList[], crossPoints[{title,anchors[{file,from,to}]}], repoRoot }
114
+ * plan-review: { hardConstraints[{id,text}], planDelta[{id,status,note}] }
115
+ * execute-qa: { diffSummary{files,stat}, designContent, checklist[] }
116
+ * re-review: { priorFindingsMd, fixDiff } (提示:re-review 经 {PRIOR_REVIEW_FACTS} 渲染,
117
+ * 本函数产出仅供该渲染体拼装——不进 {REVIEW_MATERIALS} 槽,槽位分权铁律)
118
+ * @returns {string} 注入文本(含基准面语义头)
119
+ */
120
+ export function buildReviewMaterialPack(stage, inputs = {}) {
121
+ const header = '## 评审材料包(基准面——checklist 逐条只对本包作答;包外定向查证合法但须列明,禁全量扫读;包不足以作答时 cannot_verify+列缺件)\n'
122
+ let body = ''
123
+ if (stage === 'grill-first') {
124
+ const pts = (inputs.crossPoints || []).slice(0, 5).map((p, i) => `${i + 1}. ${p.title}${p.anchors && p.anchors.length ? '\n' + extractSnippets(inputs.repoRoot || '.', p.anchors) : ''}`)
125
+ body = [
126
+ '### design 要点', clamp(inputs.designDigest || '', 4000, 'designDigest'),
127
+ '### 文件清单', (inputs.fileList || []).map(f => '- ' + f).join('\n') || '(无)',
128
+ '### 五个交叉点(主代理点名)', pts.join('\n') || '(无——主代理未点名,评审者按 cannot_verify 列缺件)',
129
+ ].join('\n\n')
130
+ } else if (stage === 'plan-review') {
131
+ const hc = (inputs.hardConstraints || []).map(h => `- [${h.id}] ${h.text}`).join('\n')
132
+ const delta = (inputs.planDelta || []).map(d => `- [${d.id}] ${d.status}${d.note ? '——' + d.note : ''}`).join('\n')
133
+ body = ['### design 硬约束', hc || '(无)', '### plan 差量(逐约束判定:一致/偏离/未覆盖)', delta || '(无——主代理未提供差量,评审者按 cannot_verify 列缺件)'].join('\n\n')
134
+ } else if (stage === 'execute-qa') {
135
+ body = [
136
+ '### diff 摘要', (inputs.diffSummary && inputs.diffSummary.files || []).map(f => '- ' + f).join('\n') || '(无)',
137
+ inputs.diffSummary && inputs.diffSummary.stat ? '```diff\n' + inputs.diffSummary.stat + '\n```' : '',
138
+ '### design 热区(CLI 抽取)', extractDesignHotZone(inputs.designContent || '') || '(无命中节)',
139
+ '### 验收清单', (inputs.checklist || []).map(c => '- [ ] ' + c).join('\n') || '(无)',
140
+ ].filter(Boolean).join('\n\n')
141
+ } else if (stage === 're-review') {
142
+ body = ['### 上一轮 findings', clamp(inputs.priorFindingsMd || '', 4000, 'priorFindings'), '### 对应修复 diff', clamp(inputs.fixDiff || '', 4000, 'fixDiff')].join('\n\n')
143
+ } else {
144
+ return ''
145
+ }
146
+ return clamp(header + '\n' + body, PACK_MAX_CHARS, '材料包')
147
+ }
148
+
149
+ // ══ CLI 半边素材装配(2026-09-19-review-material-cli-wiring,Gap 1 收口)══════════
150
+ // prompt.js tier 注入链的组包入口:机械抽取各阶段素材后调 buildReviewMaterialPack。
151
+ // 主代理点名半边(grill 五交叉点 / plan 差量判定)恒不预填——留位缺件提示由模板补位
152
+ // 指引接手(decisions.md D-002)。re-review 不在本函数面(两槽互斥铁律,走
153
+ // {PRIOR_REVIEW_FACTS})。整体 best-effort:异常/素材全缺 → ''(与占位符缺失同态)。
154
+
155
+ /** design.md 章节行号索引(execute.js:1011 先例):`L<行> ## <节名>` 全列。 */
156
+ function sectionIndexLines(designContent) {
157
+ const out = []
158
+ String(designContent || '').split('\n').forEach((l, i) => {
159
+ const m = l.match(/^##\s+(.+)$/)
160
+ if (m) out.push(`L${i + 1} ## ${m[1].trim()}`)
161
+ })
162
+ return out
163
+ }
164
+
165
+ /** 抓 section 裸正文(## 标题起、下一 ## 止)——extractDesignHotZone 的无拼装版。 */
166
+ function sectionBody(text, name) {
167
+ const re = new RegExp(`^#{2,3}\\s*[^\\n]*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^\\n]*$`, 'm')
168
+ const heading = text.match(re)
169
+ if (!heading) return ''
170
+ const start = heading.index + heading[0].length
171
+ const rest = text.slice(start)
172
+ const next = rest.match(/^#{2,3}\s/m)
173
+ return (next ? rest.slice(0, next.index) : rest).trim()
174
+ }
175
+
176
+ /** design.md「文件变更清单」表路径列机械解析(第二列剥反引号与 NEW:/MOD: 前缀——操作列已含
177
+ * 新增/修改语义,前缀不重复进清单;表头/分隔行跳过;cap 40)。 */
178
+ function extractDesignFileList(designContent) {
179
+ const body = sectionBody(designContent, '文件变更清单')
180
+ const out = []
181
+ for (const line of body.split('\n')) {
182
+ const t = line.trim()
183
+ if (!t.startsWith('|')) continue
184
+ const cols = t.split('|').map(c => c.trim())
185
+ if (cols.length < 3) continue
186
+ const path = (cols[2] || '').replace(/^`+|`+$/g, '').replace(/^(?:NEW|MOD):/, '')
187
+ if (!path || path === '文件路径' || /^-+$/.test(path)) continue
188
+ out.push(path)
189
+ }
190
+ return out.slice(0, 40)
191
+ }
192
+
193
+ /** design.md「全局硬约束」节编号/圆点行 → [{id:'HC-n', text}](cap 10)。 */
194
+ function extractHardConstraints(designContent) {
195
+ const body = sectionBody(designContent, '全局硬约束')
196
+ const out = []
197
+ for (const line of body.split('\n')) {
198
+ const m = line.match(/^\s*(?:\d+[.、)]|[-*])\s+(.+)$/)
199
+ if (!m) continue
200
+ const text = m[1].trim()
201
+ if (text) out.push({ id: `HC-${out.length + 1}`, text })
202
+ if (out.length >= 10) break
203
+ }
204
+ return out
205
+ }
206
+
207
+ /** decisions.md accepted 且 P0/P1 的条目兜底(design 缺「全局硬约束」节时)——cap 10。 */
208
+ function extractDecisionConstraints(decisionsContent) {
209
+ const out = []
210
+ for (const b of String(decisionsContent || '').split(/^##\s+/m).slice(1)) {
211
+ const hm = (b.split('\n')[0] || '').match(/^(D-\d+@\w+):\s*(.+)$/)
212
+ if (!hm) continue
213
+ const status = (b.match(/^-\s*status:\s*(\w+)/m) || [])[1]
214
+ const prio = (b.match(/^-\s*priority:\s*(P[012])/m) || [])[1]
215
+ if (status !== 'accepted' || (prio !== 'P0' && prio !== 'P1')) continue
216
+ out.push({ id: hm[1], text: `${hm[2].trim()}(${prio} 决策)` })
217
+ if (out.length >= 10) break
218
+ }
219
+ return out
220
+ }
221
+
222
+ /**
223
+ * CLI 半边素材装配(详见文件首注释块)。cwd 仅作 grill 阶段 extractSnippets 的 repoRoot
224
+ * 传递(交叉点留位时不触达);diff 解序单点在 extractDiffSummary→resolveVerifyChangedFiles。
225
+ * @param {'grill-first'|'plan-review'|'execute-qa'} stage
226
+ * @returns {Promise<string>} 注入文本;素材全缺/未知 stage/参数缺失 → ''。
227
+ */
228
+ export async function assembleStageReviewMaterials({ stage, cwd, changeName, specBase }) {
229
+ try {
230
+ if (!stage || !changeName || !specBase) return ''
231
+ const changeDir = join(specBase, 'changes', changeName)
232
+ let designContent = ''
233
+ try { designContent = readFileSync(join(changeDir, 'design.md'), 'utf8') } catch { /* design 缺失→按阶段判定素材是否全缺 */ }
234
+ if (stage === 'grill-first') {
235
+ if (!designContent) return ''
236
+ const idx = sectionIndexLines(designContent)
237
+ const digestBody = extractDesignHotZone(designContent, ['背景', '设计目标'])
238
+ const designDigest = [
239
+ idx.length ? '章节行号索引(需要其余章节时按行号定向读并列明,禁全量扫读):\n' + idx.join('\n') : '',
240
+ digestBody,
241
+ ].filter(Boolean).join('\n\n') || '(design 无背景/设计目标/章节可抽取——按章节索引定向读 design.md 对应节并列明)'
242
+ return buildReviewMaterialPack('grill-first', {
243
+ designDigest,
244
+ fileList: extractDesignFileList(designContent),
245
+ crossPoints: [],
246
+ repoRoot: cwd,
247
+ })
248
+ }
249
+ if (stage === 'plan-review') {
250
+ let hardConstraints = extractHardConstraints(designContent)
251
+ if (hardConstraints.length === 0) {
252
+ try { hardConstraints = extractDecisionConstraints(readFileSync(join(changeDir, 'decisions.md'), 'utf8')) } catch { /* decisions 缺失→素材全缺 */ }
253
+ }
254
+ if (hardConstraints.length === 0) return ''
255
+ return buildReviewMaterialPack('plan-review', { hardConstraints, planDelta: [] })
256
+ }
257
+ if (stage === 'execute-qa') {
258
+ const diffSummary = await extractDiffSummary({ cwd, changeName, specBase })
259
+ if (!designContent && (!diffSummary || (diffSummary.files || []).length === 0)) return ''
260
+ return buildReviewMaterialPack('execute-qa', { diffSummary, designContent, checklist: REVIEW_CHECKLISTS.execute })
261
+ }
262
+ return ''
263
+ } catch { /* 装配 best-effort:失败零注入(与占位符缺失同态) */ }
264
+ return ''
265
+ }
@@ -7,71 +7,215 @@
7
7
  * scanProfile(决定 maxAgentCalls / 是否禁子代理)只在 scan 阶段生效(run.js:810
8
8
  * 门控 stageName==='scan');change-risk-profile 的 P0/P1/P2 只管 apply 阻塞与
9
9
  * verify 证据。两者都不约束 brainstorm/plan/execute/propose 的审查方式。
10
- * 故新选「plan_level / 变更文件数」作为审查分级维度。
11
10
  *
12
- * 分级结果:
13
- * - tier=self → 当前 agent 自审,CLI 放行(不要求 review.json)
14
- * - tier=independent → 强制独立审查子代理 + review.json,CLI 硬校验
11
+ * 2026-09-18-ceremony-risk-pricing task-02(FR-02 / D-002):分级判定权从「plan_level
12
+ * 三分支+文件数≤3 启发式」移交 ceremony-tier.js 三轴定价引擎——classifyReviewTier
13
+ * 组装 blast/span/friction 输入后委托 computeCeremonyTier 取档,评审 tier 由档位映射:
14
+ * S0/S1 → self(S1 reason 附 CLI 清单核验提示文案)
15
+ * S2 → independent×1(单轮独立审查子代理)
16
+ * S3 → independent(多轮语义由 prompt 侧渲染)
15
17
  *
16
- * CLI 侧(run.js done gate)与 prompt 注入侧用同一个函数,保证两边判定一致。
17
- * 判定权归 agent 的 plan_level 自主判断(CLI 只做确定性映射,见 classifyReviewTier 注释);
18
- * 无 plan_level 的阶段才退文件数启发式。
18
+ * 输入组装(调用方零适配,brownfield 兼容):
19
+ * - riskDetection:调用侧透传 detectChangeRisk 判级输出(升档接线属 task-03 / prompt
20
+ * 档位化属 task-05);缺失时 plan_level 代理映射(none→doc-only / light→
21
+ * unit-sufficient / full→contract-required,经引擎档位等价复刻旧三分支行为);都缺 →
22
+ * 引擎 brownfield 保守缺省 S2(不静默降级)。
23
+ * - declaredFiles:design.md 变更清单解析(span 轴;fileCount 返回字段同源)。
24
+ * - frictionCounts:调用侧 ledger 口径透传,缺省空账(friction 轴不参与)。
25
+ *
26
+ * 旧「design 文件数≤SELF_REVIEW_FILE_THRESHOLD 判 self」降级为断路器:仅在 blast 为
27
+ * brownfield 保守缺省 S2 且 span/friction 均未起爆时复刻旧启发式的 ≤ 阈值 arm 保现状
28
+ * 兼容(≤3 文件小变更不因缺 risk 输入被升仪,ceremonyTier 仍如实报 S2 供审计);真实
29
+ * 信号在场(riskDetection 输入 / span 起爆 / friction 起爆)时引擎档强制,文件数不再
30
+ * 独自决定 independent。
31
+ *
32
+ * 返回双字段过渡(R-05):{ tier, ceremonyTier, reason, fileCount }——tier/reason/fileCount
33
+ * 现字段语义保留(gates.js:1031 与 prompt.js:1158 消费 tier.reason),ceremonyTier 为
34
+ * 引擎档(S0~S3)。回退路径:委托收敛在 classifyReviewTier 单点,摘除即回旧文件数规则。
19
35
  */
20
36
 
21
37
  import { parseFileChangeList } from './change-list.js'
38
+ import { computeCeremonyTier, CEREMONY_TIERS } from './ceremony-tier.js'
39
+ import { resolveChangeRisk, extractExplicitRiskLevel } from './change-risk-profile.js'
40
+ import { readFileSync, existsSync } from 'fs'
41
+ import { join, dirname, basename } from 'path'
42
+ import { loadBlastDeclarationsAllProjects } from './blast-surface.js'
43
+ import { loadSpanRiskPatternsAllProjects } from './span-risk-surface.js'
22
44
 
23
45
  /**
24
- * 审查分级阈值:变更文件数 ≤ 此值 → tier=self。
25
- * 依据:≤3 文件的变更通常是单点修复/文案调整,独立审查子代理的仪式成本 > 收益。
46
+ * 审查分级阈值:变更文件数 ≤ 此值 → 旧启发式断路器命中(brownfield 兼容倾向 self)。
47
+ * 2026-09-18 task-02 起降级为 brownfield 断路器(见 classifyReviewTier 注释),
48
+ * 不再独自决定 independent——评审档由 computeCeremonyTier 定。
26
49
  */
27
50
  export const SELF_REVIEW_FILE_THRESHOLD = 3
28
51
 
52
+ /** plan_level → blast 代理映射(无 riskDetection 输入时的 brownfield 兼容:经引擎等价复刻旧三分支) */
53
+ const PLAN_LEVEL_BLAST_PROXY = {
54
+ none: 'doc-only',
55
+ light: 'unit-sufficient',
56
+ full: 'contract-required',
57
+ }
58
+
59
+ /** plan_level 归一:非 none/light/full 一律视同无锚点(与旧实现 fall-through 语义一致) */
60
+ function normalizePlanLevel(planLevel) {
61
+ return planLevel === 'none' || planLevel === 'light' || planLevel === 'full' ? planLevel : null
62
+ }
63
+
29
64
  /**
30
- * 审查分级
31
- *
32
- * 判定权归 agent 的 plan_level 判定(2026-08-14 tier-plan-level 改造):plan_level 是 agent
33
- * 在 plan step1 按 prompt 分级规则做的自主判断,tier 直接映射它——CLI 不再用文件数启发式
34
- * 推翻 agent 判定(此前 light + 4 文件会被文件数强制 independent,两套标准打架且不透明,
35
- * 实证:agent 判 light 自审通过,完成时 CLI 按 7 文件强制 independent review.json)。
65
+ * 审查分级(评审档由 computeCeremonyTier 三轴定价决定)
36
66
  *
37
- * 判定顺序(短路):
38
- * 1. plan_level === 'none' → self(极小变更)
39
- * 2. plan_level === 'light' → self(agent 自主判定轻量,自审)
40
- * 3. plan_level === 'full' → independent(full 语义即大变更需独立审查,CLI 确定性强制)
41
- * 4. 无 plan_level(brainstorm 等阶段 plan.md 未生成)→ 退文件数启发式:
42
- * design.md 变更文件数 ≤ SELF_REVIEW_FILE_THRESHOLD → self,否则 independent
67
+ * 判定顺序:
68
+ * 1. 组装 blast 输入(ql-20260918-007 双轨修复):riskDetection(调用侧透传)> design/plan
69
+ * 实判(designPath 可读即真跑 detectChangeRisk + extractExplicitRiskLevel 并入)> plan_level
70
+ * 代理映射(无 designPath 兜底)> 引擎 brownfield 保守缺省 S2;档位文件(阶段门已结算价,
71
+ * 含摩擦升档)在场且更高时兜底并入;declaredFiles 取 design.md 变更清单;frictionCounts 缺省空账
72
+ * 2. 委托 computeCeremonyTier 取档 → ceremonyTier(S0~S3)
73
+ * 3. 档位映射评审 tier:S0/S1 → self(S1 附 CLI 清单核验提示);S2 → independent×1;
74
+ * S3 → independent(多轮语义由 prompt 侧渲染)
75
+ * 4. 旧文件数断路器(降级兼容):blast 为 brownfield 纯缺省 S2 且 span/friction 均未
76
+ * 起爆时,fileCount ≤ SELF_REVIEW_FILE_THRESHOLD → self(复刻旧启发式 ≤ 阈值 arm,
77
+ * 行为近似现状不静默降级;真实信号在场时本断路器不适用,引擎档强制)
43
78
  *
44
- * fail-safe:无 plan_level 且 designPath 传了但解析不到文件清单(fileCount=0)按字面 0
45
- * 处理(真无文件变更 = 极小);designPath 完全没传(fileCount=null,无法证明规模)→
46
- * independent(宁严勿松)。
79
+ * fail-safe:designPath 完全没传(fileCount=null,无法证明规模)→ 不走断路器 →
80
+ * brownfield S2 → independent(宁严勿松);riskDetection.level 未知 → 引擎保守缺省 S2。
47
81
  *
48
82
  * @param {object} [opts]
49
83
  * @param {string} [opts.planLevel] - plan_level 分类结果(none/light/full)。brainstorm/propose 在 plan 之前,可能拿不到。
50
- * @param {string} [opts.designPath] - design.md 绝对路径,用于算变更文件数
51
- * @returns {{ tier: 'self'|'independent', reason: string, fileCount: number|null }}
84
+ * 2026-09-18 task-02 起为 brownfield 代理信号:仅无 riskDetection 输入时参与 blast 映射。
85
+ * @param {string} [opts.designPath] - design.md 绝对路径,用于算变更文件数(span 轴 declaredFiles 同源)
86
+ * @param {{level: string}} [opts.riskDetection] - detectChangeRisk 判级输出(task-01 契约,只消费 level);
87
+ * 在场时优先于 plan_level 代理(plan_level 降级为编排标签,不再定价)
88
+ * @param {object} [opts.frictionCounts] - friction-ledger 累计账口径 { gate_rollback, review_rejected }
89
+ * (携 verify_run_failed 第三键透传容忍);缺省空账
90
+ * @returns {{ tier: 'self'|'independent', ceremonyTier: 'S0'|'S1'|'S2'|'S3', reason: string, fileCount: number|null }}
52
91
  */
53
- export function classifyReviewTier({ planLevel, designPath } = {}) {
54
- if (planLevel === 'none') {
55
- return { tier: 'self', reason: 'plan_level=none(极小变更,agent 自主判定自审)', fileCount: null }
92
+ export function classifyReviewTier({ planLevel, designPath, riskDetection, frictionCounts } = {}) {
93
+ const normalizedPlanLevel = normalizePlanLevel(planLevel)
94
+ const hasRiskDetection = riskDetection != null && typeof riskDetection === 'object'
95
+
96
+ // design.md 变更清单:fileCount 返回字段(现语义)+ span 轴 declaredFiles 输入(同源一次解析)
97
+ let fileCount = null
98
+ let declaredFiles = []
99
+ if (designPath) {
100
+ declaredFiles = [...parseFileChangeList(designPath)]
101
+ fileCount = declaredFiles.length
56
102
  }
57
- if (planLevel === 'light') {
58
- return { tier: 'self', reason: 'plan_level=light(agent 自主判定轻量变更,自审)', fileCount: null }
103
+
104
+ // blast 输入组装(ql-20260918-007 双轨修复 + 2026-09-19-ceremony-pricing-five-cuts task-03 声明面切换):
105
+ // riskDetection > design/plan 实判(resolveChangeRisk 声明面:declaredFiles × blast 声明 + explicit)>
106
+ // plan_level 代理 > undefined。实判优先治「full 代理把 S1 推成 S2」的双轨(回放实验实证:
107
+ // 档位文件 S1 vs 审查面 S2)——designPath 可读即真跑声明面判级,plan_level 代理降为无
108
+ // designPath 的 brownfield 兜底(plan_level 仅编排,D-002)。
109
+ let blastInput = null
110
+ let blastSource = null
111
+ let explicitInput = null
112
+ // span 轴声明面(2026-09-19-span-risk-pattern-migration task-03):与 blast 实判同 caliber——
113
+ // designPath 可读时按其推导 specBase 装载 AllProjects 并集(loadBlastDeclarationsAllProjects
114
+ // 同款用法);riskDetection 透传(调用方自带判级)或推导失败 → 空表(span 模式维关闭,D-003)
115
+ let spanRiskPatterns = []
116
+ if (hasRiskDetection) {
117
+ blastInput = riskDetection
118
+ blastSource = `riskDetection.level=${riskDetection.level}`
119
+ } else if (designPath) {
120
+ try {
121
+ const designContent = readFileSync(designPath, 'utf8')
122
+ explicitInput = extractExplicitRiskLevel(designContent)
123
+ // specBase 推导:designPath 形如 <specBase>/changes/<change>/design.md(worktree/主仓同形)
124
+ // → 三层 dirname(QA 实证:两层只得 <specBase>/changes、声明面装载恒空表)。推导失败
125
+ // (非常规路径)→ 空声明面(S1 起步不拦审查分级)。
126
+ const specBase = dirname(dirname(dirname(designPath)))
127
+ const blastDeclarations = loadBlastDeclarationsAllProjects({ specBase })
128
+ spanRiskPatterns = loadSpanRiskPatternsAllProjects({ specBase })
129
+ const risk = resolveChangeRisk({ files: declaredFiles, blastDeclarations, explicitRiskLevel: explicitInput })
130
+ blastInput = { level: risk.level }
131
+ blastSource = `声明面判级 tier=${risk.tier}/level=${risk.level}(design 文件清单 × blast 声明,D-008;explicit=${risk.explicit})`
132
+ } catch {
133
+ // design 读失败 → 降回代理链(不因 IO 异常拦审查分级)
134
+ blastInput = null
135
+ }
59
136
  }
60
- if (planLevel === 'full') {
61
- return { tier: 'independent', reason: 'plan_level=full(agent 自主判定大变更,独立审查防确认偏差)', fileCount: null }
137
+ if (blastInput === null) {
138
+ if (normalizedPlanLevel) {
139
+ blastInput = { level: PLAN_LEVEL_BLAST_PROXY[normalizedPlanLevel] }
140
+ blastSource = `plan_level=${normalizedPlanLevel} 代理映射 ${PLAN_LEVEL_BLAST_PROXY[normalizedPlanLevel]}`
141
+ } else {
142
+ blastInput = undefined
143
+ blastSource = '无 risk 输入,brownfield 保守缺省'
144
+ }
62
145
  }
63
146
 
64
- let fileCount = null
147
+ // 档位文件兜底(canon 真相源,含摩擦升档):designPath 推导 <specBase>/.runtime/ceremony-tier-<change>.json,
148
+ // 在场且高于实判档时并入(只升不降语义——档位文件是阶段门已结算的权威价,实判只补内容升级面)
149
+ let tierFileTier = null
65
150
  if (designPath) {
66
- fileCount = parseFileChangeList(designPath).size
151
+ try {
152
+ const changeDir = dirname(designPath)
153
+ const tierPath = join(dirname(dirname(changeDir)), '.runtime', `ceremony-tier-${basename(changeDir)}.json`)
154
+ if (existsSync(tierPath)) {
155
+ const tf = JSON.parse(readFileSync(tierPath, 'utf8'))
156
+ if (tf && CEREMONY_TIERS.includes(tf.tier)) tierFileTier = tf.tier
157
+ }
158
+ } catch { /* 坏档当无档(实判仍兜底) */ }
67
159
  }
68
160
 
69
- if (fileCount !== null && fileCount <= SELF_REVIEW_FILE_THRESHOLD) {
70
- return { tier: 'self', reason: `无 plan_level,变更文件 ${fileCount} ≤ ${SELF_REVIEW_FILE_THRESHOLD}(启发式)`, fileCount }
161
+ // 委托三轴定价引擎取档(ceremony-tier.js 单点真相源)
162
+ const priced = computeCeremonyTier({ riskDetection: blastInput, explicitRiskLevel: explicitInput, declaredFiles, spanRiskPatterns, frictionCounts })
163
+ let ceremonyTier = priced.tier
164
+ if (tierFileTier && CEREMONY_TIERS.indexOf(tierFileTier) > CEREMONY_TIERS.indexOf(ceremonyTier)) {
165
+ ceremonyTier = tierFileTier
71
166
  }
167
+ const tierFileNote = tierFileTier ? `;档位文件 ${tierFileTier} 兜底${tierFileTier === ceremonyTier ? '并入' : '在场(未高于实判,不生效)'}` : ''
168
+
169
+ // brownfield 纯缺省判定:档位完全来自 blast 保守缺省(无 risk 输入、无 plan_level 代理、
170
+ // 无档位文件、span/friction 均未起爆)——旧文件数断路器的唯一适用面
171
+ const brownfieldPureDefault =
172
+ blastInput === undefined && tierFileTier === null && priced.components.span === 'S0' && priced.components.friction === 'S0'
72
173
 
73
- const reason = fileCount === null
74
- ? '无法解析 design.md 变更文件清单,按正常规模处理(fail-safe)'
75
- : `无 plan_level,变更文件 ${fileCount} > ${SELF_REVIEW_FILE_THRESHOLD}(启发式)`
76
- return { tier: 'independent', reason, fileCount }
174
+ // blast 来源文案已在组装段生成(blastSource,含实判/代理/缺省三态);此处仅追加档位文件注记
175
+
176
+ // ── 旧文件数断路器(降级兼容,R-05 过渡期形态)──
177
+ if (brownfieldPureDefault && fileCount !== null && fileCount <= SELF_REVIEW_FILE_THRESHOLD) {
178
+ return {
179
+ tier: 'self',
180
+ ceremonyTier,
181
+ reason: `无 plan_level,变更文件 ${fileCount} ≤ ${SELF_REVIEW_FILE_THRESHOLD}(启发式断路器保兼容);ceremony 档 ${ceremonyTier}(blast:${blastSource},span/friction 未起爆不升仪)${tierFileNote}`,
182
+ fileCount,
183
+ }
184
+ }
185
+
186
+ // ── S0/S1 → self(S1 附 CLI 清单核验提示文案,D-005 轻仪菜单)──
187
+ if (ceremonyTier === 'S0' || ceremonyTier === 'S1') {
188
+ const base = normalizedPlanLevel === 'none'
189
+ ? 'plan_level=none(极小变更,agent 自主判定自审)'
190
+ : normalizedPlanLevel === 'light'
191
+ ? 'plan_level=light(agent 自主判定轻量变更,自审)'
192
+ : `ceremony 档 ${ceremonyTier} 判 self`
193
+ const s1Hint = ceremonyTier === 'S1'
194
+ ? ';S1 仪式=CLI 清单核验(按 design 变更清单逐项自检核对,无需独立审查子代理)'
195
+ : ''
196
+ return {
197
+ tier: 'self',
198
+ ceremonyTier,
199
+ reason: `${base};ceremony 档 ${ceremonyTier}(blast:${blastSource})${s1Hint}${tierFileNote}`,
200
+ fileCount,
201
+ }
202
+ }
203
+
204
+ // ── S2/S3 → independent(S2=单轮;S3 多轮语义由 prompt 侧渲染)──
205
+ const base = normalizedPlanLevel === 'full'
206
+ ? 'plan_level=full(agent 自主判定大变更,独立审查防确认偏差)'
207
+ : brownfieldPureDefault
208
+ ? (fileCount === null
209
+ ? '无法解析 design.md 变更文件清单,按正常规模处理(fail-safe)'
210
+ : `无 plan_level,变更文件 ${fileCount} > ${SELF_REVIEW_FILE_THRESHOLD}(启发式)`)
211
+ : `ceremony 档 ${ceremonyTier} 判 independent`
212
+ const independentForm = ceremonyTier === 'S2'
213
+ ? 'independent×1(单轮独立审查子代理)'
214
+ : 'independent(多轮语义由 prompt 侧渲染)'
215
+ return {
216
+ tier: 'independent',
217
+ ceremonyTier,
218
+ reason: `${base};ceremony 档 ${ceremonyTier}(blast:${blastSource})→ ${independentForm}${tierFileNote}`,
219
+ fileCount,
220
+ }
77
221
  }
@@ -45,4 +45,39 @@ export async function executeArchiveDistill({ cwd, specBase, changeName }) {
45
45
  } catch (e) {
46
46
  console.warn(`\n⚠️ 决策提炼执行异常,降级跳过(best-effort,不阻断归档——同旧步降级语义): ${e && e.message ? e.message : e}`)
47
47
  }
48
+
49
+ // ── FR 索引提炼(2026-09-18-fr-index-l1 L1:稳定 id 发号+承接翻链;同 best-effort 降级语义)──
50
+ try {
51
+ const { indexRequirements } = await import('../fr-index.js')
52
+ const fr = indexRequirements({ changeDir, knowledgeRoot, headHash })
53
+ const frWritten = Array.isArray(fr && fr.written) ? fr.written : []
54
+ if (fr && fr.skipped) {
55
+ console.log(`\nℹ️ FR 索引零输出:${fr.skipped}`)
56
+ } else if (frWritten.length > 0) {
57
+ console.log(`\n📌 FR 索引(CLI 机械执行,幂等):${frWritten.length} 条已发号入库——`)
58
+ for (const w of frWritten) console.log(` - ${w.id} → ${w.file}`)
59
+ }
60
+ for (const w of (fr && fr.warnings) || []) {
61
+ console.warn(` ⚠️ ${w}`)
62
+ }
63
+ // 遥测(L3 证据发生器指标,D-006/D-008):取代链跟随事件
64
+ const { appendKnowledgeHit } = await import('../knowledge-hits.js')
65
+ const runtimeRoot = join(specBase, '.runtime')
66
+ for (const s of (fr && fr.superseded) || []) {
67
+ appendKnowledgeHit(runtimeRoot, { type: 'fr-supersede', change: changeName, from: s.from, to: s.to })
68
+ }
69
+ // 删除缺口探针(D-008 护栏③):观察信号,不算 L3 门禁——L1 无删除声明义务,只采趋势数据
70
+ for (const u of (fr && fr.unreferenced) || []) {
71
+ appendKnowledgeHit(runtimeRoot, { type: 'fr-unreferenced', change: changeName, domain: u.domain, count: u.count })
72
+ console.log(` ℹ️ [观察信号·不算 L3 门禁] 触达域 ${u.domain} 有 ${u.count} 条 active FR 未被本次承接引用(fr-unreferenced 遥测,L3 裁决趋势数据)`)
73
+ }
74
+ } catch (e) {
75
+ console.warn(`\n⚠️ FR 索引执行异常,降级跳过(best-effort,不阻断归档): ${e && e.message ? e.message : e}`)
76
+ // 死信留痕(ql-20260918-013):降级只 warn 会蒸发(probe8 实证——D14 只能事后推断缺号)。
77
+ // change 目录留标记,doctor/人工可见;手工重放 indexRequirements 补号后可删。
78
+ try {
79
+ const { writeFileSync: wf } = await import('node:fs')
80
+ wf(join(changeDir, 'fr-index-skipped.md'), `# FR 索引降级死信\n\n- 时间:${new Date().toISOString()}\n- 原因:${e && e.message ? e.message : e}\n- 处置:node 重放 indexRequirements({changeDir, knowledgeRoot, headHash}) 补号后删本标记\n`)
81
+ } catch { /* 死信写入失败不再降级(已在降级路径) */ }
82
+ }
48
83
  }