sillyspec 3.28.3 → 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 (77) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +25 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/results-review-tier.md +18 -0
  15. package/src/agent-session-log.js +4 -3
  16. package/src/archive-delta.js +82 -0
  17. package/src/change-delete.js +2 -1
  18. package/src/change-list.js +18 -3
  19. package/src/change-risk-profile.js +77 -6
  20. package/src/commit-suggest.js +52 -1
  21. package/src/config-cat.js +17 -1
  22. package/src/constants.js +3 -1
  23. package/src/decision-distill.js +45 -5
  24. package/src/decisions-io.js +96 -0
  25. package/src/design-facts.js +3 -0
  26. package/src/dispatch/probe.js +31 -8
  27. package/src/docs-check.js +102 -3
  28. package/src/doctor-diagnostics.js +441 -5
  29. package/src/foreign-declared.js +53 -2
  30. package/src/index.js +606 -22
  31. package/src/mcp-server.js +144 -0
  32. package/src/module-impact.js +84 -12
  33. package/src/modules.js +46 -34
  34. package/src/plan-adopt-waves.js +13 -5
  35. package/src/progress/change-registry.js +34 -0
  36. package/src/progress.js +75 -2
  37. package/src/quick-recommend.js +6 -1
  38. package/src/quicklog.js +70 -8
  39. package/src/review-dispatch.js +377 -0
  40. package/src/run/archive-distill.js +48 -0
  41. package/src/run/command.js +48 -13
  42. package/src/run/complete-handlers.js +215 -9
  43. package/src/run/complete.js +184 -2
  44. package/src/run/gate-snapshot.js +88 -0
  45. package/src/run/gates.js +206 -58
  46. package/src/run/prompt.js +150 -14
  47. package/src/run/quick-audit.js +38 -13
  48. package/src/run/shared.js +183 -12
  49. package/src/run/stage.js +52 -2
  50. package/src/run/verify-quality-scan.js +336 -0
  51. package/src/runtime-hygiene.js +84 -0
  52. package/src/scope-audit.js +735 -0
  53. package/src/sillyhub-mcp/client.js +58 -3
  54. package/src/spec-sync.js +63 -8
  55. package/src/stage-contract.js +83 -3
  56. package/src/stage-review-checklist.js +44 -0
  57. package/src/stage-review.js +125 -4
  58. package/src/stages/archive.js +60 -167
  59. package/src/stages/brainstorm.js +7 -17
  60. package/src/stages/doctor.js +41 -515
  61. package/src/stages/execute.js +13 -10
  62. package/src/stages/plan-postcheck.js +191 -7
  63. package/src/stages/plan.js +7 -35
  64. package/src/stages/quick.js +11 -11
  65. package/src/stages/verify.js +24 -39
  66. package/src/sync-noise.js +158 -0
  67. package/src/sync.js +93 -27
  68. package/src/task-review.js +133 -17
  69. package/src/taskcard.js +46 -3
  70. package/src/validate-artifacts.js +163 -0
  71. package/src/verify-facts-schema.js +141 -0
  72. package/src/verify-postcheck.js +347 -29
  73. package/src/verify-probes.js +275 -24
  74. package/src/workflow.js +6 -0
  75. package/src/worktree-apply.js +167 -24
  76. package/src/worktree.js +5 -1
  77. package/templates/agents-instruction.md +1 -0
package/src/taskcard.js CHANGED
@@ -74,6 +74,7 @@ export function buildTaskcardSkeleton({ taskId, title, titleZh, author, now, dep
74
74
  title: yamlScalar(title),
75
75
  title_zh: yamlScalar(titleZh),
76
76
  author: yamlScalar(author),
77
+ generated_by: 'sillyspec-taskcard', // P2-e provenance 戳(CLI 骨架出品标记)
77
78
  created_at: now,
78
79
  priority: 'P0',
79
80
  depends_on: (Array.isArray(dependsOn) && dependsOn.length > 0)
@@ -93,6 +94,7 @@ id: ${fm.id}
93
94
  title: ${fm.title}
94
95
  title_zh: ${fm.title_zh}
95
96
  author: ${fm.author}
97
+ generated_by: ${fm.generated_by}
96
98
  created_at: ${fm.created_at}
97
99
  priority: ${fm.priority}
98
100
  depends_on: ${fm.depends_on}
@@ -232,6 +234,18 @@ export function cmdTaskcard(changeName, opts = {}) {
232
234
  // 本地墙钟(坑 taskcard-created-at-utc:toISOString 落 UTC,本地 09:39 写 01:39,子代理手工改两次)
233
235
  const now = nowWallClock()
234
236
 
237
+ // ── depends_on 的 plan.md Wave 反填(坑 taskcard-depends-wave-backfill,2026-09-10 驾驭
238
+ // 小结第五批③,三批子代理均发现骨架 depends_on 为空):注册表行内注解
239
+ // `(depends_on: task-01,02)` 是第一来源,但 agent 写 tasks.md 常漏注解 → 骨架全空 →
240
+ // plan-adopt-waves 拓扑退化全进 Wave 1。plan.md Wave 段(`## Wave N` + `- task-NN` 纯 ID
241
+ // 引用行)是 plan 阶段的分组真相——Wave N 任务 depends_on = Wave N-1 全部任务(波间串行
242
+ // 波内并行的既定语义,传递闭包等价)。仅注解缺失时兜底,注解在场优先(更细粒度)。
243
+ const waveDepsByTask = parsePlanWaveDeps(existsSync(planPath) ? planPath : null)
244
+ function depsFor(id, reg) {
245
+ if (reg && Array.isArray(reg.dependsOn) && reg.dependsOn.length > 0) return reg.dependsOn
246
+ return waveDepsByTask.get(id) || []
247
+ }
248
+
235
249
  const tasksDir = join(changeDir, 'tasks')
236
250
  mkdirSync(tasksDir, { recursive: true })
237
251
 
@@ -248,12 +262,41 @@ export function cmdTaskcard(changeName, opts = {}) {
248
262
  }
249
263
  writeFileSync(filePath, buildTaskcardSkeleton({
250
264
  taskId: id, title: finalTitle, titleZh: finalTitleZh, author, now,
251
- // depends_on 从 tasks.md 行内注解反填(坑 taskcard-design-field-conflicts):注册表
252
- // 注解与 plan/design 的依赖声明同源,骨架直接带上省主代理逐卡转录
253
- dependsOn: reg ? reg.dependsOn : [],
265
+ // depends_on 双来源:① tasks.md 行内注解反填(坑 taskcard-design-field-conflicts)
266
+ // ②注解缺失时按 plan.md Wave 分组兜底(坑 taskcard-depends-wave-backfill,见上)
267
+ dependsOn: depsFor(id, reg),
254
268
  sets,
255
269
  }), 'utf8')
256
270
  created.push(filePath)
257
271
  }
258
272
  return { created, skipped, tasksDir }
259
273
  }
274
+
275
+ /**
276
+ * plan.md Wave 段解析 → 每任务的跨波依赖(Wave N 任务 → Wave N-1 全部任务 ID)。
277
+ * Wave 段形态(plan 铁律):`## Wave N` 标题 + `- task-NN` 纯 ID 引用行。Wave 1 / 无 Wave /
278
+ * 解析异常 → 空 Map(调用方回退注解来源,fail-soft)。
279
+ * @param {string|null} planPath plan.md 绝对路径(null → 空 Map)
280
+ * @returns {Map<string, string[]>} taskId → 前一波任务 ID 列表
281
+ */
282
+ export function parsePlanWaveDeps(planPath) {
283
+ const out = new Map()
284
+ if (!planPath) return out
285
+ let content
286
+ try { content = readFileSync(planPath, 'utf8').replace(/\r\n/g, '\n') } catch { return out }
287
+ const waves = [] // 每元素:task id 数组(按 Wave 标题出现顺序)
288
+ let cur = null
289
+ for (const line of content.split('\n')) {
290
+ const w = line.match(/^##\s+Wave\s+(\d+)\s*$/i)
291
+ if (w) { cur = []; waves.push(cur); continue }
292
+ if (cur === null) continue
293
+ const t = line.match(/^[-*]\s+(task-\d{1,3})\s*$/i)
294
+ if (t) cur.push(`task-${t[1].replace(/^task-/i, '').padStart(2, '0')}`)
295
+ }
296
+ for (let w = 1; w < waves.length; w++) {
297
+ const prev = waves[w - 1]
298
+ if (!prev || prev.length === 0) continue
299
+ for (const id of waves[w]) out.set(id, [...prev])
300
+ }
301
+ return out
302
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * validate-artifacts.js(P2-d,noai-ir-roadmap §5):产物 schema 校验总命令的实现体。
3
+ *
4
+ * 背景:verify-facts 是唯一有真 validator 的 artifact(validateFactsV2),review.json/
5
+ * verify-required-evidence/endpoints/module-map 各自散落或无独立校验入口。本模块把既有
6
+ * 校验器聚合为一把伞(`sillyspec validate --change <c>`):逐产物 pass/fail/skip 三态 +
7
+ * exit 1 on fail——「新解析器写之前先过 schema」有了统一落点。
8
+ *
9
+ * 复用面(不重复造校验器):verify-facts validateFactsV2 / task review validateReviewSchema /
10
+ * stage review validateStageReviewSchema / module-map parseModuleMapSimple。轻形状检查仅覆盖
11
+ * 无既有 validator 的 required-evidence 与 endpoints(容忍缺省,出场才算)。
12
+ */
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+
16
+ function checkJson(path, fn) {
17
+ if (!existsSync(path)) return { status: 'skip', reason: '不存在(未生成,不算失败)' }
18
+ let data
19
+ try {
20
+ data = JSON.parse(readFileSync(path, 'utf8'))
21
+ } catch (e) {
22
+ return { path, status: 'fail', issues: [`JSON 解析失败: ${e.message}`] }
23
+ }
24
+ try {
25
+ const issues = fn(data) || []
26
+ return { path, status: issues.length > 0 ? 'fail' : 'pass', issues }
27
+ } catch (e) {
28
+ return { path, status: 'fail', issues: [e.message] }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 校验一个变更的全部结构化产物。
34
+ * @returns {{ checks: Array<{ artifact: string, path?: string, status: 'pass'|'fail'|'skip', issues?: string[], reason?: string }>, ok: boolean }}
35
+ */
36
+ export async function validateChangeArtifacts({ cwd, specBase, changeName, runtimeRoot }) {
37
+ const checks = []
38
+ const changeDir = join(specBase, 'changes', changeName)
39
+ const rt = runtimeRoot || join(specBase, '.runtime')
40
+
41
+ // ① verify-facts.json(既有真 validator)
42
+ {
43
+ const { validateFactsV2 } = await import('./verify-facts-schema.js')
44
+ const r = checkJson(join(changeDir, 'verify-facts.json'), (d) => {
45
+ const v = validateFactsV2(d)
46
+ return v && !v.ok ? (v.errors || ['validateFactsV2 不通过']) : []
47
+ })
48
+ checks.push({ artifact: 'verify-facts.json', ...r })
49
+ }
50
+
51
+ // ② verify-required-evidence.json(轻形状:execute Task Review Gate 的 cannot_verify 账)
52
+ {
53
+ const r = checkJson(join(changeDir, 'verify-required-evidence.json'), (d) => {
54
+ const issues = []
55
+ if (!d || typeof d !== 'object' || !Array.isArray(d.items)) issues.push('顶层缺 items 数组')
56
+ else {
57
+ for (const it of d.items) {
58
+ if (!it || typeof it.task !== 'string') issues.push(`items[].task 非字符串: ${JSON.stringify(it).slice(0, 60)}`)
59
+ if (!Array.isArray(it.evidence)) issues.push(`${it && it.task}: evidence 非数组`)
60
+ }
61
+ }
62
+ return issues
63
+ })
64
+ checks.push({ artifact: 'verify-required-evidence.json', ...r })
65
+ }
66
+
67
+ // ③ task review.json(最新 execute run 的逐 task 产物——既有 validateReviewSchema)
68
+ {
69
+ const markerPath = join(rt, `current-execute-run-id-${changeName}`)
70
+ const runId = existsSync(markerPath) ? readFileSync(markerPath, 'utf8').trim() : null
71
+ if (!runId) {
72
+ checks.push({ artifact: 'task reviews', status: 'skip', reason: '无 execute run marker(未经 execute 或已清理)' })
73
+ } else {
74
+ const tasksDir = join(rt, 'execute-runs', runId, 'tasks')
75
+ const files = existsSync(tasksDir) ? readdirSync(tasksDir).filter((d) => existsSync(join(tasksDir, d, 'review.json'))).map((d) => join(tasksDir, d, 'review.json')) : []
76
+ if (files.length === 0) {
77
+ checks.push({ artifact: 'task reviews', status: 'skip', reason: `run ${runId} 无 review.json` })
78
+ } else {
79
+ const { validateReviewSchema } = await import('./task-review.js')
80
+ const issues = []
81
+ for (const f of files) {
82
+ try {
83
+ const v = validateReviewSchema(JSON.parse(readFileSync(f, 'utf8')))
84
+ if (v && !v.ok) issues.push(`${f}: ${(v.errors || ['schema 不通过']).join(';').slice(0, 120)}`)
85
+ } catch (e) {
86
+ issues.push(`${f}: ${e.message}`)
87
+ }
88
+ }
89
+ checks.push({ artifact: `task reviews(${files.length} 个,run ${runId})`, status: issues.length > 0 ? 'fail' : 'pass', issues })
90
+ }
91
+ }
92
+ }
93
+
94
+ // ④ stage reviews(brainstorm/plan/execute 的阶段评审产物——既有 validateStageReviewSchema)
95
+ {
96
+ const dir = join(rt, 'stage-reviews')
97
+ const files = existsSync(dir) ? readdirSync(dir).filter((d) => existsSync(join(dir, d, 'review.json'))).map((d) => join(dir, d, 'review.json')) : []
98
+ if (files.length === 0) {
99
+ checks.push({ artifact: 'stage reviews', status: 'skip', reason: '无 stage-reviews 产物' })
100
+ } else {
101
+ const { validateStageReviewSchema } = await import('./stage-review.js')
102
+ const issues = []
103
+ for (const f of files) {
104
+ try {
105
+ const v = validateStageReviewSchema(JSON.parse(readFileSync(f, 'utf8')))
106
+ if (v && !v.ok) issues.push(`${f}: ${(v.errors || ['schema 不通过']).join(';').slice(0, 120)}`)
107
+ } catch (e) {
108
+ issues.push(`${f}: ${e.message}`)
109
+ }
110
+ }
111
+ checks.push({ artifact: `stage reviews(${files.length} 份)`, status: issues.length > 0 ? 'fail' : 'pass', issues })
112
+ }
113
+ }
114
+
115
+ // ⑤ endpoints 契约产物(contract-artifacts/<change>/<task>/endpoints.json——轻形状)
116
+ {
117
+ const dir = join(rt, 'contract-artifacts', changeName)
118
+ const files = existsSync(dir) ? readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).flatMap((d) => existsSync(join(dir, d.name, 'endpoints.json')) ? [join(dir, d.name, 'endpoints.json')] : []) : []
119
+ if (files.length === 0) {
120
+ checks.push({ artifact: 'endpoints', status: 'skip', reason: '无 contract-artifacts 产物' })
121
+ } else {
122
+ const issues = []
123
+ for (const f of files) {
124
+ const r = checkJson(f, (d) => (!d || typeof d !== 'object' || (!Array.isArray(d.endpoints) && !Array.isArray(d)) ? ['缺 endpoints 数组'] : []))
125
+ if (r.status === 'fail') issues.push(`${f}: ${(r.issues || []).join(';')}`)
126
+ }
127
+ checks.push({ artifact: `endpoints(${files.length} 份)`, status: issues.length > 0 ? 'fail' : 'pass', issues })
128
+ }
129
+ }
130
+
131
+ // ⑥ module-map(全局一份/多份——schema_version 2 + canonical 解析可读)
132
+ {
133
+ const { parseModuleMapSimple } = await import('./modules.js')
134
+ const docsRoot = join(specBase, 'docs')
135
+ const maps = existsSync(docsRoot) ? readdirSync(docsRoot, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync(join(docsRoot, d.name, 'modules', '_module-map.yaml'))).map((d) => join(docsRoot, d.name, 'modules', '_module-map.yaml')) : []
136
+ if (maps.length === 0) {
137
+ checks.push({ artifact: 'module-map', status: 'skip', reason: '无 _module-map.yaml(未 scan)' })
138
+ } else {
139
+ const issues = []
140
+ for (const m of maps) {
141
+ const text = readFileSync(m, 'utf8')
142
+ const sv = text.match(/^schema_version:\s*(\d+)/m)
143
+ if (!sv) issues.push(`${m}: 缺 schema_version 声明`)
144
+ else if (sv[1] !== '2') issues.push(`${m}: schema_version=${sv[1]}(期望 2)`)
145
+ if (!parseModuleMapSimple(text) || Object.keys(parseModuleMapSimple(text)).length === 0) issues.push(`${m}: canonical 解析 0 模块`)
146
+ }
147
+ checks.push({ artifact: `module-map(${maps.length} 份)`, status: issues.length > 0 ? 'fail' : 'pass', issues })
148
+ }
149
+ }
150
+
151
+ return { checks, ok: checks.every((c) => c.status !== 'fail') }
152
+ }
153
+
154
+ export function renderValidateReport(result) {
155
+ const lines = ['🧪 产物 schema 校验(validate 总命令)']
156
+ for (const c of result.checks) {
157
+ const mark = c.status === 'pass' ? '✅' : c.status === 'skip' ? '⏭️ ' : '❌'
158
+ lines.push(` ${mark} ${c.artifact}${c.status === 'skip' ? ' — ' + (c.reason || '跳过') : ''}`)
159
+ for (const i of (c.issues || []).slice(0, 5)) lines.push(` - ${i}`)
160
+ }
161
+ lines.push(result.ok ? '\n✅ 全部在场产物校验通过(skip = 未生成不算失败)' : `\n❌ ${result.checks.filter((c) => c.status === 'fail').length} 类产物校验失败`)
162
+ return lines.join('\n')
163
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * verify-facts-schema.js — verify-facts.json v2 schema 单点(2026-09-08-ir-verify-facts task-01 / D-005@v2)。
3
+ *
4
+ * facts v2 的字段/枚举/豁免形态/路径分类/槽段解析集中于此,builder(verify-probes.buildVerifyFacts)、
5
+ * 对账引擎(verify-postcheck.runVerifyRequiredEvidenceCheck)、集成证据(change-risk-profile 经
6
+ * stage-contract)、骨架渲染(generateVerifyResultSkeleton)四方 import 同源,禁复制。
7
+ *
8
+ * 双层写入模型(D-001@v2):机器数据(probes/tests/factsConsistency)CLI 直写;判断层
9
+ * (结论枚举/证据状态/回执声明)经 verify-result.md 受控槽段录入、--done 时 CLI 解析固化
10
+ * (slot-backfill),单向渲染约束对象是机器数据。
11
+ */
12
+ export const FACTS_SCHEMA_VERSION = 2
13
+
14
+ /** 证据状态枚举单源(「## 证据账」槽行 + facts.requiredEvidence[].status 共用) */
15
+ export const EVIDENCE_STATUS = ['satisfied', 'missing', 'partial']
16
+
17
+ /** 豁免后缀形态:missing + (豁免:<理由>)——机器可解析,无括注的 missing 不算豁免(模块私有:parseEvidenceSlots 内部消费,外部读解析结果的 exempt 字段) */
18
+ const EXEMPTION_RE = /(豁免:[^)]+)/
19
+
20
+ /** 证据账槽段标题(与 generateVerifyResultSkeleton 渲染逐字一致——避开既有「## Runtime Evidence」人工判断章) */
21
+ export const EVIDENCE_SLOT_HEADING = '## 证据账(cannot_verify 任务)'
22
+
23
+ /** 集成验证回执槽段标题 */
24
+ export const RECEIPT_SLOT_HEADING = '## 集成验证回执'
25
+
26
+ /**
27
+ * verifiedFiles 路径分类:决定核验口径(design Phase 2)。
28
+ * artifact 类(.runtime/ 日志 / 文档——resolveVerifyChangedFiles 天然不覆盖)核 存在×mtime,
29
+ * diff 交集豁免;其余(code)核 存在×mtime×diff 交集。
30
+ * @param {string} path 仓根相对路径(posix)
31
+ * @returns {'code'|'artifact'}
32
+ */
33
+ export function classifyVerifiedFile(path) {
34
+ const p = String(path || '').replace(/\\/g, '/')
35
+ if (p.startsWith('.runtime/') || p.startsWith('.sillyspec/.runtime/')) return 'artifact'
36
+ if (/\.(log|txt|out)$/i.test(p)) return 'artifact'
37
+ if (p.startsWith('.sillyspec/docs/') || p.startsWith('docs/')) return 'artifact'
38
+ if (p.endsWith('.md') || p.endsWith('.mdx')) return 'artifact'
39
+ return 'code'
40
+ }
41
+
42
+ /** 证据账 task 行:`- task-NN: <status> | verifiedFiles: a, b(豁免:…)`——占位 <待填:*> 不命中(fail-closed) */
43
+ const EVIDENCE_LINE_RE = /^- (task-\d+):\s*(satisfied|missing|partial)\b([^\n]*)$/
44
+
45
+ /** 回执行:`- claim: … | command: … | exit: 0 | log: path`——四字段行首锚定,占位不命中 */
46
+ const RECEIPT_LINE_RE = /^- claim:\s*([^|]+)\|\s*command:\s*([^|]+)\|\s*exit:\s*(\d+)\s*\|\s*log:\s*([^\s|]+)/
47
+
48
+ /**
49
+ * 解析 verify-result.md 的两个受控槽段(D-001@v2 slot-backfill 录入界面)。
50
+ * 占位形态(<待填:三选一> / <待填:0 或非 0>)不含枚举词,行首锚定 + 枚举词双保险 fail-closed。
51
+ * @param {string} mdText verify-result.md 全文(LF 归一由调用方 readFileSync 层负责或此处容错)
52
+ * @returns {{ requiredEvidence: Array<{task,status,exempt:boolean,exemptionReason:string|null,verifiedFiles:string[]}>, runtimeEvidence: Array<{claim,command,exitCode,logPath}>, hasEvidenceSlot: boolean, hasReceiptSlot: boolean }}
53
+ */
54
+ export function parseEvidenceSlots(mdText) {
55
+ const text = String(mdText || '').replace(/\r\n/g, '\n')
56
+ const lines = text.split('\n')
57
+
58
+ // 槽段定位:标题行到下一个同级/更高级标题或文末
59
+ const sectionOf = (heading) => {
60
+ const start = lines.findIndex(l => l.startsWith(heading))
61
+ if (start === -1) return null
62
+ const body = []
63
+ for (let i = start + 1; i < lines.length; i++) {
64
+ if (/^#{1,2}\s/.test(lines[i])) break
65
+ body.push(lines[i])
66
+ }
67
+ return body
68
+ }
69
+
70
+ const evidenceBody = sectionOf(EVIDENCE_SLOT_HEADING)
71
+ const receiptBody = sectionOf(RECEIPT_SLOT_HEADING)
72
+
73
+ const requiredEvidence = []
74
+ if (evidenceBody) {
75
+ for (const line of evidenceBody) {
76
+ const m = line.match(EVIDENCE_LINE_RE)
77
+ if (!m) continue
78
+ const [, task, status, rest] = m
79
+ const exemptM = rest.match(EXEMPTION_RE)
80
+ const vfMatch = rest.match(/verifiedFiles:\s*([^\n(]*)/)
81
+ const verifiedFiles = (vfMatch ? vfMatch[1] : '')
82
+ .split(',')
83
+ .map(s => s.trim().replace(/^`|`$/g, ''))
84
+ .filter(s => s.length > 0 && !s.startsWith('<'))
85
+ requiredEvidence.push({
86
+ task,
87
+ status,
88
+ exempt: status === 'missing' && !!exemptM,
89
+ exemptionReason: exemptM ? exemptM[0] : null,
90
+ verifiedFiles,
91
+ })
92
+ }
93
+ }
94
+
95
+ const runtimeEvidence = []
96
+ if (receiptBody) {
97
+ for (const line of receiptBody) {
98
+ const m = line.match(RECEIPT_LINE_RE)
99
+ if (!m) continue
100
+ runtimeEvidence.push({
101
+ claim: m[1].trim(),
102
+ command: m[2].trim(),
103
+ exitCode: Number(m[3]),
104
+ logPath: m[4].trim().replace(/^`|`$/g, ''),
105
+ })
106
+ }
107
+ }
108
+
109
+ return {
110
+ requiredEvidence,
111
+ runtimeEvidence,
112
+ hasEvidenceSlot: evidenceBody !== null,
113
+ hasReceiptSlot: receiptBody !== null,
114
+ }
115
+ }
116
+
117
+ /**
118
+ * facts v2 结构校验(写入前/消费侧防线)。
119
+ * @param {object} facts
120
+ * @returns {{ ok: boolean, errors: string[] }}
121
+ */
122
+ export function validateFactsV2(facts) {
123
+ const errors = []
124
+ if (!facts || typeof facts !== 'object') return { ok: false, errors: ['facts 非对象'] }
125
+ if (facts.schemaVersion !== FACTS_SCHEMA_VERSION) {
126
+ errors.push(`schemaVersion 应为 ${FACTS_SCHEMA_VERSION},实际 ${facts.schemaVersion}`)
127
+ }
128
+ if (!Array.isArray(facts.probes ? Object.keys(facts.probes) : null)) {
129
+ errors.push('probes 段缺失或非对象')
130
+ }
131
+ if (facts.conclusion != null && !['PASS', 'PASS WITH NOTES', 'FAIL'].includes(facts.conclusion)) {
132
+ errors.push(`conclusion 非法枚举值:${facts.conclusion}`)
133
+ }
134
+ for (const item of facts.requiredEvidence || []) {
135
+ if (!EVIDENCE_STATUS.includes(item.status)) errors.push(`requiredEvidence[${item.task}] status 非法:${item.status}`)
136
+ if (item.status === 'satisfied' && (!Array.isArray(item.verifiedFiles) || item.verifiedFiles.length === 0)) {
137
+ errors.push(`requiredEvidence[${item.task}] satisfied 但 verifiedFiles 为空`)
138
+ }
139
+ }
140
+ return { ok: errors.length === 0, errors }
141
+ }