sillyspec 3.28.4 → 3.28.5

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 (49) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/src/agent-session-log.js +4 -3
  5. package/src/archive-delta.js +2 -2
  6. package/src/change-list.js +11 -1
  7. package/src/commit-suggest.js +52 -1
  8. package/src/config-cat.js +6 -1
  9. package/src/decision-distill.js +45 -5
  10. package/src/decisions-io.js +96 -0
  11. package/src/design-facts.js +3 -0
  12. package/src/dispatch/probe.js +31 -8
  13. package/src/docs-check.js +68 -3
  14. package/src/index.js +426 -17
  15. package/src/mcp-server.js +144 -0
  16. package/src/module-impact.js +8 -1
  17. package/src/modules.js +46 -34
  18. package/src/progress/change-registry.js +18 -0
  19. package/src/progress.js +13 -1
  20. package/src/quicklog.js +60 -6
  21. package/src/review-dispatch.js +377 -0
  22. package/src/run/archive-distill.js +48 -0
  23. package/src/run/command.js +19 -5
  24. package/src/run/complete-handlers.js +63 -3
  25. package/src/run/complete.js +139 -2
  26. package/src/run/gate-snapshot.js +88 -0
  27. package/src/run/gates.js +115 -55
  28. package/src/run/prompt.js +54 -9
  29. package/src/run/quick-audit.js +38 -13
  30. package/src/run/shared.js +109 -6
  31. package/src/run/stage.js +17 -2
  32. package/src/run/verify-quality-scan.js +336 -0
  33. package/src/scope-audit.js +735 -0
  34. package/src/sillyhub-mcp/client.js +58 -3
  35. package/src/stage-review-checklist.js +44 -0
  36. package/src/stage-review.js +122 -4
  37. package/src/stages/archive.js +57 -164
  38. package/src/stages/brainstorm.js +5 -9
  39. package/src/stages/execute.js +13 -10
  40. package/src/stages/plan.js +3 -10
  41. package/src/stages/verify.js +14 -33
  42. package/src/sync.js +51 -10
  43. package/src/task-review.js +108 -16
  44. package/src/taskcard.js +46 -3
  45. package/src/validate-artifacts.js +163 -0
  46. package/src/verify-postcheck.js +90 -10
  47. package/src/verify-probes.js +134 -12
  48. package/src/worktree-apply.js +119 -14
  49. package/templates/agents-instruction.md +1 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * run/verify-quality-scan.js(P0-1,docs/sillyspec/noai-ir-roadmap.md §3)。
3
+ *
4
+ * verify「运行测试和质量扫描」步 noAI 化的四件实现:
5
+ * 1. executeVerifyQualityScan——noAI 动作(stage.js/complete.js 的 _cliAction
6
+ * 'verifyRunQualityScan' 分支):本步提前实测 commands.test/lint(与 --done 对账同源
7
+ * runVerifyTestCheck/runVerifyLintCheck),结果 + 代码指纹落盘;全绿自动盖 completed,
8
+ * 失败打印输出 + 归因提示后 throw(步骤不推进,agent 修复后 run verify 复入本步重测)。
9
+ * 2. 指纹复用(前置一·防重复跑):computeQualityScanFingerprint = HEAD + 非文档脏集 +
10
+ * local.yaml commands/test_strategy 配置。--done 对账指纹匹配即复用实测结果免重跑
11
+ * (长套件 2~10 分钟不再跑两遍);代码一变立即失配 → 照旧亲测。verify 护栏禁改源码,
12
+ * 本步实测 → --done 窗口内合法产出只有规范文档(.sillyspec/、docs/、*.md 已排除出
13
+ * 指纹),复用窗口内无假阴性面。信任边界:复用记录在 .runtime(agent 可写空间),
14
+ * 伪造需主动协同造假,超出「偷懒幻觉谎报」威胁模型(CLI 亲测要拦的正是后者),
15
+ * 伪造面与 verify-result.md 本身同级,且 pre-push 另有网。
16
+ * 3. 归因透传(前置二):renderVerifyTestAttribution——并行 WIP 归因鉴别①②自 gates.js
17
+ * inline 块迁出为单一实现(输出逐字保持),noAI 失败路径与 --done 失败路径共用。
18
+ * CLI 实测失败 ≠ 本变更的错:他者在途文件 / 旧基线生成产物两类归因缺失时,agent 会
19
+ * 替并行会话背锅(2026-08-28 51 文件误导重跑事故教训)。
20
+ * 4. evaluateConclusionDraft(前置三·结论草稿不进判定链):机械事实全绿(实测通过 + lint
21
+ * 非 failed + 探针 1/3/5/6 干净 + 风险门非 integration/deployment-critical)才给 PASS
22
+ * 草稿;gate 对结论槽的独立复核逻辑不动——草稿只省打字成本,槽行仍由 agent 复核可改写。
23
+ */
24
+ import { createHash } from 'node:crypto'
25
+ import { execSync } from 'node:child_process'
26
+ import { existsSync, readFileSync, mkdirSync } from 'node:fs'
27
+ import { join, dirname } from 'node:path'
28
+ import { writeAtomicSync } from '../fs-atomic.js'
29
+ import { gitQuiet } from '../git-helper.js'
30
+ import { resolveRuntimeRoot } from './shared.js'
31
+ import { detectChangeRisk } from '../change-risk-profile.js'
32
+
33
+ const RECORD_SCHEMA_VERSION = 1
34
+
35
+ export function qualityScanRecordPath(specBase, changeName) {
36
+ if (!changeName) return null
37
+ return join(specBase, '.runtime', `verify-quality-scan-${changeName}.json`)
38
+ }
39
+
40
+ /**
41
+ * 指纹非代码面排除:verify 阶段步骤 6 实测 → --done 窗口内的合法产出只有规范文档——
42
+ * 排除后这些写入不再触发失配重跑;src 一动(护栏外行为)立即失配照旧亲测。
43
+ */
44
+ function isNonCodePath(p) {
45
+ return p.startsWith('.sillyspec/') || p.startsWith('docs/') || p.endsWith('.md')
46
+ }
47
+
48
+ function porcelainCodeLines(porcelain) {
49
+ return String(porcelain || '')
50
+ .split('\n')
51
+ .filter(Boolean)
52
+ .map((l) => (l.slice(3) || '').replace(/^"|"$/g, '').replace(/\\/g, '/'))
53
+ .filter((p) => p && !isNonCodePath(p))
54
+ .sort()
55
+ }
56
+
57
+ /**
58
+ * 代码指纹:sha256(local.yaml commands 段 + test_strategy 行 + git HEAD + 非文档脏集)。
59
+ * git 不可用 → null(fail-closed:store 落 null / load 视为无记录,复用窗口关闭)。
60
+ * 配置面口径与 prompt.js {LOCAL_COMMANDS} 注入的块扫描同款(含 unavailable 行剔除)。
61
+ */
62
+ export function computeQualityScanFingerprint({ cwd, specBase }) {
63
+ const localPath = join(specBase, 'local.yaml')
64
+ let configText = ''
65
+ try {
66
+ if (existsSync(localPath)) {
67
+ const lines = readFileSync(localPath, 'utf8').replace(/\r\n?/g, '\n').split('\n')
68
+ const start = lines.findIndex((l) => /^commands:\s*(#.*)?$/.test(l))
69
+ if (start !== -1) {
70
+ const body = []
71
+ for (let i = start + 1; i < lines.length && !/^\S/.test(lines[i]); i++) body.push(lines[i])
72
+ configText = body.filter((l) => !/^\s*[\w.-]+:\s*['"]?unavailable['"]?\s*(#.*)?$/i.test(l)).join('\n')
73
+ }
74
+ const ts = lines.find((l) => /^test_strategy:/.test(l))
75
+ if (ts) configText += '\n' + ts.trim()
76
+ }
77
+ } catch { /* 读不到按空配置面处理 */ }
78
+ const head = gitQuiet(cwd, ['rev-parse', 'HEAD'])
79
+ const porcelain = gitQuiet(cwd, ['status', '--porcelain'])
80
+ if (head === null || porcelain === null) return null
81
+ const codeLines = porcelainCodeLines(porcelain)
82
+ return createHash('sha256')
83
+ .update([configText, head.trim(), codeLines.join('|')].join('\n---\n'))
84
+ .digest('hex')
85
+ }
86
+
87
+ /**
88
+ * 实测结果 + 指纹落盘(noAI 动作调用;--done 复用读回)。fail-soft:落盘失败只 warn,
89
+ * --done 将照旧亲测(复用是优化不是正确性依赖)。
90
+ */
91
+ export function storeQualityScan({ specBase, cwd, changeName, testResult, lintResult }) {
92
+ const path = qualityScanRecordPath(specBase, changeName)
93
+ if (!path) return null
94
+ const fingerprint = computeQualityScanFingerprint({ cwd, specBase })
95
+ try {
96
+ mkdirSync(dirname(path), { recursive: true })
97
+ writeAtomicSync(path, JSON.stringify({
98
+ schemaVersion: RECORD_SCHEMA_VERSION,
99
+ source: 'cli-noai',
100
+ change: changeName,
101
+ fingerprint,
102
+ ranAt: new Date().toISOString(),
103
+ testResult,
104
+ lintResult: lintResult || null,
105
+ }, null, 2) + '\n')
106
+ return { path, fingerprint }
107
+ } catch (e) {
108
+ console.warn(`⚠️ 质量扫描复用记录落盘失败(fail-soft,--done 将照旧亲测): ${e && e.message ? e.message : e}`)
109
+ return null
110
+ }
111
+ }
112
+
113
+ /**
114
+ * 读回复用记录:schema/source 校验 + 指纹当前匹配 + testResult 有效(passed/skipped;
115
+ * failed 不复用——失败态指纹下步骤本就未完成,防御性兜底)。任一不满足 → null。
116
+ */
117
+ export function loadReusableQualityScan({ specBase, cwd, changeName }) {
118
+ const path = qualityScanRecordPath(specBase, changeName)
119
+ if (!path || !existsSync(path)) return null
120
+ try {
121
+ const rec = JSON.parse(readFileSync(path, 'utf8'))
122
+ if (!rec || rec.schemaVersion !== RECORD_SCHEMA_VERSION || rec.source !== 'cli-noai') return null
123
+ if (!rec.testResult || !rec.testResult.status) return null
124
+ if (rec.testResult.status === 'failed') return null
125
+ const fp = computeQualityScanFingerprint({ cwd, specBase })
126
+ if (!fp || fp !== rec.fingerprint) return null
127
+ return { testResult: rec.testResult, lintResult: rec.lintResult || null, ranAt: rec.ranAt || null, fingerprint: fp }
128
+ } catch {
129
+ return null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 并行 WIP 归因鉴别(前置二):迁自 gates.js verify 实测失败归因 inline 块,输出逐字保持。
135
+ * 判据①他者声明文件命中 dirty 集;判据②本变更 apply 的文件与主仓近期提交重叠且旧内容
136
+ * 仍在工作区(生成产物旧基线)。全降级不抛——归因提示失败不影响阻断语义。
137
+ */
138
+ export async function renderVerifyTestAttribution({ cwd, changeName, specBase, runtimeRoot }) {
139
+ try {
140
+ const { collectForeignDeclaredFiles } = await import('../foreign-declared.js')
141
+ const foreignMap = collectForeignDeclaredFiles(cwd, changeName, { specBase, runtimeRoot })
142
+ // dirty 集提前算好:归因提示①(他者在途)与②(生成产物旧基线)共用
143
+ const dirty = (gitQuiet(cwd, ['diff', '--name-only', 'HEAD']) || '')
144
+ .split('\n').filter(Boolean).map((f) => f.replace(/\\/g, '/'))
145
+ const dirtySet = new Set(dirty)
146
+ if (foreignMap.size > 0) {
147
+ const hitForeign = dirty.filter((f) => foreignMap.has(f))
148
+ if (hitForeign.length > 0) {
149
+ console.error(` ℹ️ 归因提示:主仓检出 ${hitForeign.length} 个并行会话声明的在途文件(${hitForeign.slice(0, 5).join(', ')}${hitForeign.length > 5 ? ' 等' : ''})——实测失败可能混入他者 WIP 而非本变更问题。待其提交/收尾后复验,仍失败才是本变更的。`)
150
+ }
151
+ }
152
+ // 第二判据(坑 derived-artifact-stale-baseline / verify-attr2-foreign-wip-misdirection):
153
+ // 完整语义见 gates.js filterStaleBaselineOverlap 注释——两道过滤(必须 dirty + 剔除他者声明集)。
154
+ try {
155
+ const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
156
+ if (changeName && existsSync(pathspecFile)) {
157
+ const ownFiles = new Set(readFileSync(pathspecFile, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean).map((f) => f.replace(/\\/g, '/')))
158
+ if (ownFiles.size > 0) {
159
+ const recent = (gitQuiet(cwd, ['log', '-n', '10', '--name-only', '--pretty=format:']) || '')
160
+ .split('\n').map((l) => l.trim().replace(/\\/g, '/')).filter(Boolean)
161
+ const overlap = [...new Set(recent)].filter((f) => ownFiles.has(f))
162
+ if (overlap.length > 0) {
163
+ const { filterStaleBaselineOverlap } = await import('./gates.js')
164
+ const actionable = filterStaleBaselineOverlap(overlap, dirtySet, foreignMap)
165
+ if (actionable.length > 0) {
166
+ console.error(` ℹ️ 归因提示②:本变更有 ${actionable.length} 个文件与主仓最近 10 条提交重叠且旧内容仍在工作区(${actionable.slice(0, 5).join(', ')}${actionable.length > 5 ? ' 等' : ''})——若为生成产物(api-types 等),本变更可能在旧基线生成并覆盖了已合入内容;先在新基线重跑生成命令(如 gen:types)再复验。(重叠共 ${overlap.length} 个,其余未在工作区改动或属他者会话在途文件,已归提示①/无需处理)`)
167
+ }
168
+ }
169
+ }
170
+ }
171
+ } catch { /* 判据②失败不影响主流程 */ }
172
+ } catch { /* 归因提示失败不影响阻断语义 */ }
173
+ }
174
+
175
+ /**
176
+ * coverage 存在性事实(P2,noai-ir-roadmap §5):local.yaml 显式配置 commands.coverage
177
+ * 才采集(不自动解析任意 runner stdout——格式多样性是长期维护税);跑该命令后读 lcov
178
+ * 产物(coverage_artifact 可配,缺省 coverage/lcov.info)取「有覆盖记录的文件集」,
179
+ * 与变更 diff 文件求交集。**语义边界(评审一裁决):交集只证明存在相关覆盖记录,
180
+ * 不证明行为覆盖**——渲染必须带「存在性」标注,防弱事实被当强结论用(新幻觉源)。
181
+ * 全程 fail-soft:未配置/命令失败且产物缺失/产物非 lcov 形态 → skipped + reason。
182
+ * 返回纯 JSON 可序列化(落进质量扫描记录)。
183
+ */
184
+ export async function runCoverageExistenceCheck({ cwd, specBase, changeName }) {
185
+ const skipped = (reason) => ({ status: 'skipped', reason, coveredChanged: [], uncoveredChanged: [] })
186
+ const localPath = join(specBase, 'local.yaml')
187
+ let yamlText = null
188
+ try { yamlText = readFileSync(localPath, 'utf8') } catch { return skipped(`local.yaml 不可读(${localPath})`) }
189
+ const covCmd = (yamlText.match(/^\s*coverage:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m) || [])[1]
190
+ if (!covCmd || covCmd.trim().toLowerCase() === 'unavailable') {
191
+ return skipped('未配置 commands.coverage(coverage 存在性事实不采集——配置后自动启用)')
192
+ }
193
+ const artifactRel = (yamlText.match(/^\s*coverage_artifact:\s*["']?([^"'\n#]+?)["']?\s*(?:#.*)?$/m) || [])[1] || 'coverage/lcov.info'
194
+ let runNote = ''
195
+ try {
196
+ execSync(covCmd.trim(), { cwd, encoding: 'utf8', timeout: 10 * 60 * 1000, maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] })
197
+ runNote = '命令退出码 0'
198
+ } catch (e) {
199
+ runNote = `命令退出非 0(${e.status ?? '?'})——产物若已生成仍按产物判定`
200
+ }
201
+ let lcov = ''
202
+ try {
203
+ lcov = readFileSync(join(cwd, artifactRel), 'utf8')
204
+ } catch {
205
+ return skipped(`coverage 产物缺失(${artifactRel};${runNote})`)
206
+ }
207
+ const covered = [...lcov.matchAll(/^SF:(.+)$/gm)].map((m) => m[1].trim().replace(/\\/g, '/')).filter(Boolean)
208
+ if (covered.length === 0) return skipped(`产物无 SF: 记录(${artifactRel} 非 lcov 形态?)`)
209
+ let changed = null
210
+ try {
211
+ const { resolveVerifyChangedFiles } = await import('../verify-postcheck.js')
212
+ changed = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
213
+ } catch { /* 变更面不可得 → 空对账 */ }
214
+ const { coveredChanged, uncoveredChanged } = intersectCoverageExistence(covered, Array.isArray(changed) ? changed : [])
215
+ return { status: 'computed', artifact: artifactRel, runNote, coveredSetSize: covered.length, coveredChanged, uncoveredChanged }}
216
+
217
+ /** 纯函数:lcov SF 集 × 变更文件集交集(双向后缀匹配容忍绝对/仓相对前缀差) */
218
+ export function intersectCoverageExistence(coveredPaths, changedFiles) {
219
+ const covered = (Array.isArray(coveredPaths) ? coveredPaths : []).map((c) => String(c).split('\\').join('/'))
220
+ const coveredChanged = []
221
+ const uncoveredChanged = []
222
+ for (const f of (Array.isArray(changedFiles) ? changedFiles : [])) {
223
+ const n = String(f).split('\\').join('/')
224
+ const hit = covered.some((c) => c === n || c.endsWith('/' + n) || n.endsWith('/' + c))
225
+ ;(hit ? coveredChanged : uncoveredChanged).push(n)
226
+ }
227
+ return { coveredChanged, uncoveredChanged }
228
+ }
229
+
230
+ /** 存在性事实渲染(语义边界内建:只说「有覆盖记录」,不说「已覆盖」) */
231
+ export function renderCoverageExistenceReport(cov) {
232
+ if (!cov || cov.status === 'skipped') {
233
+ return `ℹ️ coverage 存在性事实:${cov ? cov.reason : '未采集'}`
234
+ }
235
+ const total = cov.coveredChanged.length + cov.uncoveredChanged.length
236
+ if (total === 0) return 'ℹ️ coverage 存在性事实:无变更文件可对账(diff 为空)'
237
+ const lines = [
238
+ `📊 测试覆盖**存在性**事实(非行为覆盖断言——只证明「有覆盖记录」,不证明「覆盖了变更行为」):${cov.coveredChanged.length}/${total} 个变更文件在 coverage 产物中有记录(${cov.artifact};${cov.runNote})`,
239
+ ]
240
+ if (cov.uncoveredChanged.length > 0) {
241
+ lines.push(` ⚠️ 无覆盖记录的变更文件(存在性缺口——行为是否覆盖仍需你判断):${cov.uncoveredChanged.slice(0, 10).join('、')}${cov.uncoveredChanged.length > 10 ? ' 等' : ''}`)
242
+ }
243
+ return lines.join('\n')
244
+ }
245
+
246
+ /**
247
+ * noAI 动作本体:提前实测 test+lint → 指纹落盘 → 全绿返回(stage.js 盖 completed 自动前进);
248
+ * 失败(test failed / lint 硬门档 failed)打印归因提示后 throw——步骤不推进,agent 修复后
249
+ * `run verify` 复入本步重新实测(指纹随代码修复失配,--done 不会复用旧失败结果)。
250
+ */
251
+ export async function executeVerifyQualityScan({ cwd, specBase, changeName, platformOpts }) {
252
+ const { runVerifyTestCheck, printVerifyTestCheck, runVerifyLintCheck, printVerifyLintCheck, shouldBlockVerifyLint } = await import('../verify-postcheck.js')
253
+ console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.test(同步,长套件 2~10 分钟无输出属正常)——结果按代码指纹落盘,最终 --done 对账直接复用免重跑。`)
254
+ const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
255
+ printVerifyTestCheck(testCheck)
256
+ const lintCheck = runVerifyLintCheck({ cwd, specBase })
257
+ if (lintCheck.status !== 'skipped') {
258
+ console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.lint…`)
259
+ }
260
+ printVerifyLintCheck(lintCheck)
261
+ // P2(noai-ir-roadmap §5):coverage 存在性事实——commands.coverage 显式配置才采集,
262
+ // 只证「有覆盖记录」不证「覆盖了行为」(渲染自带语义边界),fail-soft 不阻断。
263
+ let coverageCheck = null
264
+ try {
265
+ coverageCheck = await runCoverageExistenceCheck({ cwd, specBase, changeName })
266
+ console.log(renderCoverageExistenceReport(coverageCheck))
267
+ } catch (e) {
268
+ console.warn(`⚠️ coverage 存在性事实采集异常(fail-soft): ${e && e.message ? e.message : e}`)
269
+ }
270
+ storeQualityScan({ specBase, cwd, changeName, testResult: testCheck, lintResult: lintCheck, coverageCheck })
271
+ const testFailed = testCheck.status === 'failed'
272
+ const lintBlocked = shouldBlockVerifyLint(lintCheck)
273
+ if (testFailed || lintBlocked) {
274
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
275
+ await renderVerifyTestAttribution({ cwd, changeName, specBase, runtimeRoot })
276
+ const which = testFailed ? '测试' : 'lint'
277
+ throw new Error(`noAI 质量扫描实测${which}失败——本步骤未完成(进度不推进)。输出与归因提示见上方;修复后重跑 sillyspec run verify${changeName ? ` --change ${changeName}` : ''} 复入本步重新实测。`)
278
+ }
279
+ console.log(`\n✅ noAI 质量扫描全绿——本步骤自动完成;--done 对账将复用本次实测(代码变更即失配重测)。机械事实持续全绿时,verify-probes --init 会预填 PASS 结论草稿(草稿待确认,复核后可改写)。`)
280
+ }
281
+
282
+ /**
283
+ * 结论草稿评估(前置三):机械事实全绿才返回 draft='PASS';任一不绿返回 { draft: null, reasons }
284
+ * (reasons 逐条列出未预填依据,console 呈现给 agent——预填缺席也要可解释,不静默)。
285
+ * 判定面:①指纹匹配的 noAI 实测(skip ≠ 实测,不预填)②lint 非 failed(未配置=中性绿)
286
+ * ③探针 1 零命中 ④探针 3 有任务且全 hasTest/located ⑤探针 5 零缺口 ⑥探针 6 零删除
287
+ * (有删除一律留人工判定,保守)⑦风险等级非 integration/deployment-critical(那两级
288
+ * 需集成回执 + Runtime Evidence 人工链,草稿无从预填)。gate 判定链不经本函数。
289
+ */
290
+ export function evaluateConclusionDraft({ cwd, specBase, changeName, changeDir, probesResult }) {
291
+ const reasons = []
292
+ const reuse = loadReusableQualityScan({ specBase, cwd, changeName })
293
+ if (!reuse) {
294
+ return { draft: null, reasons: ['无指纹匹配的 noAI 实测记录(未跑质量扫描步 / 代码已变 / git 不可用)'] }
295
+ }
296
+ const t = reuse.testResult
297
+ if (t.status === 'skipped') {
298
+ reasons.push(`测试按策略跳过(${t.reason ? String(t.reason).slice(0, 80) : 'test_strategy'})——结论缺客观测试核验,不预填`)
299
+ } else if (t.status !== 'passed') {
300
+ reasons.push(`测试实测状态 ${t.status},不预填`)
301
+ }
302
+ const lint = reuse.lintResult
303
+ if (lint && lint.status === 'failed') {
304
+ reasons.push(`lint 实测失败(${lint.reason || '退出码非 0'}),不预填`)
305
+ }
306
+ const probes = probesResult || {}
307
+ const p1 = (probes.probe1 && probes.probe1.matches) || []
308
+ if (p1.length > 0) reasons.push(`探针 1 命中 ${p1.length} 处未实现标记`)
309
+ const p3tasks = (probes.probe3 && probes.probe3.tasks) || []
310
+ if (p3tasks.length === 0) {
311
+ reasons.push('探针 3 未解析到任务(无测试覆盖账),不预填')
312
+ } else {
313
+ const noTest = p3tasks.filter((x) => !x.hasTest).map((x) => x.task)
314
+ if (noTest.length > 0) reasons.push(`探针 3 测试缺失:${noTest.join('、')}`)
315
+ const unlocated = p3tasks.filter((x) => !x.located).map((x) => x.task)
316
+ if (unlocated.length > 0) reasons.push(`探针 3 模块目录未定位:${unlocated.join('、')}`)
317
+ }
318
+ const p5 = (probes.probe5 && probes.probe5.missingBackend) || []
319
+ if (p5.length > 0) reasons.push(`探针 5 API 契约缺口 ${p5.length} 项`)
320
+ const p6 = (probes.probe6 && probes.probe6.deletions) || []
321
+ if (p6.length > 0) reasons.push(`探针 6 删除对账 ${p6.length} 项(待人工判定)`)
322
+ try {
323
+ const designPath = join(changeDir || '', 'design.md')
324
+ const planPath = join(changeDir || '', 'plan.md')
325
+ const designContent = existsSync(designPath) ? readFileSync(designPath, 'utf8') : ''
326
+ const planContent = existsSync(planPath) ? readFileSync(planPath, 'utf8') : ''
327
+ const risk = detectChangeRisk({ designContent, planContent, changedFiles: [] })
328
+ if (risk && (risk.level === 'integration-critical' || risk.level === 'deployment-critical')) {
329
+ reasons.push(`风险等级 ${risk.level}——需集成证据链(回执槽 + Runtime Evidence),不预填`)
330
+ }
331
+ } catch (e) {
332
+ reasons.push(`风险判级异常(${e && e.message ? e.message : e})——保守不预填`)
333
+ }
334
+ if (reasons.length > 0) return { draft: null, reasons }
335
+ return { draft: 'PASS', reasons }
336
+ }