sillyspec 3.28.4 → 3.28.6

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 (52) hide show
  1. package/AGENTS.md +1 -0
  2. package/package.json +1 -1
  3. package/results-review-tier.md +18 -0
  4. package/sa-rename-probe/src/new.js +4 -0
  5. package/src/agent-session-log.js +4 -3
  6. package/src/archive-delta.js +2 -2
  7. package/src/change-list.js +11 -1
  8. package/src/commit-suggest.js +52 -1
  9. package/src/config-cat.js +6 -1
  10. package/src/config-schema.js +13 -0
  11. package/src/decision-distill.js +53 -7
  12. package/src/decisions-io.js +96 -0
  13. package/src/design-facts.js +3 -0
  14. package/src/dispatch/probe.js +31 -8
  15. package/src/docs-check.js +77 -8
  16. package/src/friction-tally.js +221 -0
  17. package/src/index.js +430 -18
  18. package/src/mcp-server.js +144 -0
  19. package/src/module-impact.js +8 -1
  20. package/src/modules.js +46 -34
  21. package/src/progress/change-registry.js +20 -0
  22. package/src/progress.js +16 -1
  23. package/src/quicklog.js +60 -6
  24. package/src/review-dispatch.js +377 -0
  25. package/src/run/archive-distill.js +48 -0
  26. package/src/run/command.js +35 -5
  27. package/src/run/complete-handlers.js +136 -3
  28. package/src/run/complete.js +170 -2
  29. package/src/run/gate-snapshot.js +159 -0
  30. package/src/run/gates.js +166 -69
  31. package/src/run/prompt.js +54 -9
  32. package/src/run/quick-audit.js +78 -16
  33. package/src/run/shared.js +138 -7
  34. package/src/run/stage.js +31 -2
  35. package/src/run/verify-quality-scan.js +383 -0
  36. package/src/scope-audit.js +941 -0
  37. package/src/sillyhub-mcp/client.js +58 -3
  38. package/src/stage-review-checklist.js +44 -0
  39. package/src/stage-review.js +122 -4
  40. package/src/stages/archive.js +57 -164
  41. package/src/stages/brainstorm.js +8 -12
  42. package/src/stages/execute.js +13 -10
  43. package/src/stages/plan.js +3 -10
  44. package/src/stages/verify.js +14 -33
  45. package/src/sync.js +51 -10
  46. package/src/task-review.js +108 -16
  47. package/src/taskcard.js +46 -3
  48. package/src/validate-artifacts.js +163 -0
  49. package/src/verify-postcheck.js +90 -10
  50. package/src/verify-probes.js +134 -12
  51. package/src/worktree-apply.js +119 -14
  52. 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
+ }
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { execSync, spawnSync } from 'child_process'
26
+ import { createHash } from 'crypto'
26
27
  import { IR_STRICT_SINCE } from './constants.js'
27
28
  import { gitQuiet } from './git-helper.js'
28
29
  import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
@@ -1058,8 +1059,33 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1058
1059
  !p.startsWith('.sillyspec/changes/') &&
1059
1060
  !p.startsWith('.sillyspec/.runtime/') &&
1060
1061
  !p.startsWith('.sillyspec/quicklog/'))
1061
- if (wtFiles.length > 0) {
1062
- mainFiles = [...new Set([...(mainFiles || []), ...wtFiles])]
1062
+ // 已提交口径补齐(2026-09-10 用户反馈②「先提交则 diff 空」时序两难的 diff 半边):
1063
+ // worktree 内已 commit 的改动对主仓 base..HEAD 与 status --porcelain 双双不可见——
1064
+ // 用 merge-base(主仓 HEAD, worktree HEAD)(= worktree 创建锚点)到 worktree HEAD 的
1065
+ // commit diff 补入,未提交 ∪ 已提交两形态全覆盖。wtGitDir===cwd(in-place 退化)时
1066
+ // 已提交改动已在 mainFiles,跳过防双并。fail-open:git 失败退回 status-only 现状。
1067
+ let committedFiles = []
1068
+ if (wtGitDir !== cwd) {
1069
+ try {
1070
+ const mainHead = gitQuiet(cwd, ['rev-parse', 'HEAD'], { timeout: 15000 })
1071
+ const wtHead = gitQuiet(wtGitDir, ['rev-parse', 'HEAD'], { timeout: 15000 })
1072
+ const mb = (mainHead && wtHead)
1073
+ ? gitQuiet(wtGitDir, ['merge-base', wtHead, mainHead], { timeout: 15000 })
1074
+ : null
1075
+ if (mb) {
1076
+ const diffOut = gitQuiet(wtGitDir, ['diff', '--name-only', mb, wtHead], { timeout: 30000 })
1077
+ committedFiles = String(diffOut || '').split('\n')
1078
+ .map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim())
1079
+ .filter(p => p && p !== '.sillyspec' &&
1080
+ !p.startsWith('.sillyspec/changes/') &&
1081
+ !p.startsWith('.sillyspec/.runtime/') &&
1082
+ !p.startsWith('.sillyspec/quicklog/'))
1083
+ }
1084
+ } catch { /* 已提交补齐失败退回 status-only(fail-open,不拖垮未提交并入) */ }
1085
+ }
1086
+ const merged = [...new Set([...(mainFiles || []), ...wtFiles, ...committedFiles])]
1087
+ if (wtFiles.length > 0 || committedFiles.length > 0) {
1088
+ mainFiles = merged
1063
1089
  }
1064
1090
  }
1065
1091
  } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open) */ }
@@ -1929,6 +1955,40 @@ export function printVerifyDeletionCheck(result) {
1929
1955
  console.warn(' 提示:确认删除是否预期。预期删除请在 design.md 清单用「删除」操作显式声明;误删请恢复。')
1930
1956
  }
1931
1957
 
1958
+ /**
1959
+ * verify-result.md 内容回退检测(2026-09-10 用户反馈③:报告在 verify 中途被平台同步覆盖
1960
+ * 回旧版一次,只能靠人眼发现后重写)。覆盖者在本仓之外(daemon/平台侧按服务端版本回写),
1961
+ * CLI 侧能做的是**检测可见**:每次 CLI 读到该文件时记高水位指纹(content hash + mtime),
1962
+ * 下次读到「内容变了且 mtime 回退」= 旧版复活的确定性指纹(服务端回写会顺带恢复服务端
1963
+ * mtime,时间倒流唯此一路)→ ⚠️ 告警。纯 advisory 不阻断;高水位只前进不后退(回退态
1964
+ * 不覆盖指纹,agent 重写后新指纹正常接管)。fail-open:任何异常视为无回退。
1965
+ * @param {string} specBase spec 根(hwm 文件落 .runtime/)
1966
+ * @param {string} changeName 变更名
1967
+ * @param {string} absPath verify-result.md 绝对路径
1968
+ * @returns {{ regressed: boolean, prevMtime: number|null }}
1969
+ */
1970
+ export function trackVerifyResultRegression(specBase, changeName, absPath) {
1971
+ try {
1972
+ if (!absPath || !existsSync(absPath)) return { regressed: false, prevMtime: null }
1973
+ const st = statSync(absPath)
1974
+ const hash = createHash('sha256').update(readFileSync(absPath)).digest('hex')
1975
+ const hwmPath = join(specBase, '.runtime', `verify-result-hwm-${changeName}.json`)
1976
+ let prev = null
1977
+ try { prev = JSON.parse(readFileSync(hwmPath, 'utf8')) } catch { prev = null }
1978
+ const regressed = !!(prev && typeof prev.hash === 'string' &&
1979
+ prev.hash !== hash && typeof prev.mtimeMs === 'number' &&
1980
+ st.mtimeMs < prev.mtimeMs - 1000)
1981
+ // 高水位只前进:当前 mtime 更新(正常编辑/重写)才覆盖指纹;回退态保留旧指纹供重写后再比对
1982
+ if (!prev || st.mtimeMs >= (prev.mtimeMs || 0)) {
1983
+ try {
1984
+ mkdirSync(join(specBase, '.runtime'), { recursive: true })
1985
+ writeFileSync(hwmPath, JSON.stringify({ hash, mtimeMs: st.mtimeMs, ts: Date.now() }) + '\n')
1986
+ } catch { /* hwm 落盘失败 → 下次当首次(fail-open) */ }
1987
+ }
1988
+ return { regressed, prevMtime: prev && prev.mtimeMs ? prev.mtimeMs : null }
1989
+ } catch { return { regressed: false, prevMtime: null } }
1990
+ }
1991
+
1932
1992
  /**
1933
1993
  * required-evidence 对账(advisory,不阻断 verify 完成)。
1934
1994
  *
@@ -2224,14 +2284,20 @@ function parsePorcelainFilePaths(raw) {
2224
2284
  * 统一收尾:filterDeliverableFiles 过滤基建产物(.sillyspec/changes|.runtime|quicklog、
2225
2285
  * meta.json——流程产物不算 scope creep)+ 去重排序(输出确定性)。
2226
2286
  *
2227
- * @returns {{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, degradedReason: string|null }}
2287
+ * baseAnchor:与 files 同源的 diff 基点锚(scope-audit 行数采集复用,本模块自身不消费)——
2288
+ * 形态 A 读同一份 worktree meta.json(优先级 baselineCommit>actualBaseHash>baseHash,与
2289
+ * resolveMainChangedFiles 同口径,三锚全缺 null);形态 B 取 merge-base hash(分支不存在/
2290
+ * merge-base 不可得 null);降级返回恒 null。
2291
+ *
2292
+ * @returns={{ ok: boolean, form: 'worktree'|'post-apply', files: string[], sources: string[], foreignExcluded: number, degradedReason: string|null, baseAnchor: string|null }}
2228
2293
  */
2229
- function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
2294
+ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName }) {
2230
2295
  const metaPath = join(specBase, '.runtime', 'worktrees', changeName, 'meta.json')
2231
2296
  const form = existsSync(metaPath) ? 'worktree' : 'post-apply'
2232
2297
  const sources = []
2233
2298
  const union = new Set()
2234
2299
  let foreignExcluded = 0
2300
+ let baseAnchor = null
2235
2301
 
2236
2302
  if (form === 'worktree') {
2237
2303
  // —— 形态 A:worktree 存活,整链复用(锚点优先级 baselineCommit>actualBaseHash>baseHash、
@@ -2239,22 +2305,36 @@ function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName })
2239
2305
  const files = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase })
2240
2306
  if (files === null) {
2241
2307
  return { ok: false, form, files: [], sources, foreignExcluded,
2242
- degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)' }
2308
+ degradedReason: 'worktree 锚点 diff 与主仓 fallback 均失败(git 不可用 / 非仓库)', baseAnchor: null }
2243
2309
  }
2310
+ // baseAnchor:读同一份 worktree meta.json 锚点(baselineCommit>actualBaseHash>baseHash,
2311
+ // resolveMainChangedFiles 同口径;解析失败/三锚全缺 → null)
2312
+ try {
2313
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
2314
+ baseAnchor = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash || null
2315
+ } catch {}
2244
2316
  sources.push('worktree:diff-base..HEAD', 'worktree:status-porcelain(uncommitted)')
2245
2317
  for (const f of files) union.add(normalizeReconcilePath(f))
2246
2318
  } else {
2247
2319
  // —— 形态 B:post-apply 自取三源 ——
2248
2320
  let diffOk = false
2249
2321
  let statusOk = false
2250
- // B1 merge-base 锚定 diff(分支/merge-base 不可得时静默省略该源,不算 git 失败)
2322
+ // B1 merge-base 锚定 diff(分支/merge-base 不可得时静默省略该源,不算 git 失败)。
2323
+ // 分支删除后回退审计 tag(quick-df1fed77):apply+cleanup 删分支时 worktree.js:1065 打
2324
+ // sillyspec-audit/<branch> tag 锚定 tip(gc 安全)——tag ref 可继续算 merge-base,baseAnchor
2325
+ // 与 B1 diff 源在归档/已提交形态下恢复完整(否则恒 null,行数只剩 HEAD 未提交窗口口径)。
2251
2326
  const branch = 'sillyspec/' + changeName
2252
2327
  const branchHash = gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', branch + '^{commit}'], { timeout: 30 * 1000 })
2253
- if (typeof branchHash === 'string' && branchHash.trim()) {
2328
+ const auditTag = 'sillyspec-audit/' + branch
2329
+ const diffRef = (typeof branchHash === 'string' && branchHash.trim())
2330
+ ? branch
2331
+ : (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 }) ? auditTag : null)
2332
+ if (diffRef) {
2254
2333
  // 形态 B 定义下 meta 已删,baseBranch 无从读 → 缺省 'main'(run/prompt.js:764 同缺省);
2255
2334
  // 主分支叫 master 等仓库 merge-base 失败 → 与分支不存在同处置(省略该源)
2256
- const mergeBase = gitQuiet(cwd, ['merge-base', 'main', branch], { timeout: 30 * 1000 })
2335
+ const mergeBase = gitQuiet(cwd, ['merge-base', 'main', diffRef], { timeout: 30 * 1000 })
2257
2336
  if (typeof mergeBase === 'string' && mergeBase.trim()) {
2337
+ baseAnchor = mergeBase.trim()
2258
2338
  const files = runGitDiffNameOnly(cwd, mergeBase.trim())
2259
2339
  if (files !== null) {
2260
2340
  diffOk = true
@@ -2289,12 +2369,12 @@ function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, changeName })
2289
2369
  }
2290
2370
  if (!diffOk && !statusOk) {
2291
2371
  return { ok: false, form, files: [], sources, foreignExcluded,
2292
- degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)' }
2372
+ degradedReason: 'merge-base 锚定 diff 未得且主仓 status 失败(git 不可用 / 非仓库 / 无锚点分支)', baseAnchor: null }
2293
2373
  }
2294
2374
  }
2295
2375
 
2296
2376
  const files = [...new Set(filterDeliverableFiles([...union]).filter(Boolean))].sort()
2297
- return { ok: true, form, files, sources, foreignExcluded, degradedReason: null }
2377
+ return { ok: true, form, files, sources, foreignExcluded, degradedReason: null, baseAnchor }
2298
2378
  }
2299
2379
 
2300
2380
  /**
@@ -24,6 +24,7 @@ import {
24
24
  FACTS_SCHEMA_VERSION, EVIDENCE_SLOT_HEADING, RECEIPT_SLOT_HEADING, parseEvidenceSlots,
25
25
  } from './verify-facts-schema.js'
26
26
  import { parseFileChangeListDetailed } from './change-list.js'
27
+ import { parseDecisions } from './decision-distill.js'
27
28
  import { parseAllowedPaths } from './stages/plan-postcheck.js'
28
29
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
29
30
  import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
@@ -152,16 +153,18 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
152
153
  const moduleDirs = [...new Set(allowed.map(p => dirname(p.split('\\').join('/'))).filter(d => d && d !== '.'))]
153
154
  const testFiles = []
154
155
  for (const d of moduleDirs) {
155
- // 同探针 1 的 worktree 回退(坑 probe1-worktree-path-blind):新模块目录 apply 前只在
156
- // worktree——主仓目录缺失时到 worktree 找 co-located 测试(路径相对 worktree 根呈现)
157
- let searchRoot = join(cwd, d)
158
- let relBase = cwd
159
- if (!existsSync(searchRoot) && wtRoot && existsSync(join(wtRoot, d))) {
160
- searchRoot = join(wtRoot, d)
161
- relBase = wtRoot
162
- }
163
- for (const f of findTestFiles(searchRoot, relBase)) {
164
- if (!testFiles.includes(f)) testFiles.push(f)
156
+ // 双根并集扫描(坑 probe3-worktree-test-false-negative,2026-09-10 驾驭小结第五批②,
157
+ // 用户实证 5 条假 warning):apply 前新测试文件只在 worktree(untracked),而模块目录在
158
+ // 主仓**已存在**——旧「主仓目录缺失才回退 worktree」条件不触发,探针 3 报「未找到测试
159
+ // 文件」假阴。改为无条件双根并集(主仓 ∪ worktree,各自存在才扫,路径相对各自根呈现,
160
+ // 按 rel 去重主仓优先——与探针 5 的三根并集同族)。主仓 in-place(wtRoot=null)零变化。
161
+ const roots = []
162
+ if (existsSync(join(cwd, d))) roots.push({ root: join(cwd, d), base: cwd })
163
+ if (wtRoot && wtRoot !== cwd && existsSync(join(wtRoot, d))) roots.push({ root: join(wtRoot, d), base: wtRoot })
164
+ for (const s of roots) {
165
+ for (const f of findTestFiles(s.root, s.base)) {
166
+ if (!testFiles.includes(f)) testFiles.push(f)
167
+ }
165
168
  }
166
169
  }
167
170
  probe3.tasks.push({
@@ -355,6 +358,22 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
355
358
  }
356
359
  }
357
360
 
361
+ /**
362
+ * 平台模式产物路径回显注记(2026-09-10 驾驭小结②,用户实锤「显示混乱」):
363
+ * verify-probes --init 在平台模式(.sillyspec-platform.json pointer 存在)下 spec 根解析为
364
+ * hub 镜像目录——回显的物理路径(骨架生成/已存在/facts 刷新)都在镜像根下,而产物经
365
+ * daemon spec-sync 落主仓 .sillyspec/changes/<change>/。agent/人工核对以主仓路径为准,
366
+ * 回显行尾追加本注记消歧义(本地模式返回空串,回显零变化)。
367
+ * @param {string|null} platformBase 平台镜像根(null/undefined = 非平台模式)
368
+ * @param {string} changeName 变更名
369
+ * @param {string} file 产物文件名(verify-result.md / verify-facts.json)
370
+ * @returns {string} 注记文本(非平台模式为 '')
371
+ */
372
+ export function formatPlatformPathNote(platformBase, changeName, file) {
373
+ if (!platformBase) return ''
374
+ return `(平台模式:物理写盘在 hub 镜像根 ${platformBase};主仓同步位置 .sillyspec/changes/${changeName}/${file},核对以主仓路径为准)`
375
+ }
376
+
358
377
  /**
359
378
  * verify-probes --init 落盘 verify-facts.json(CLI 全权写)。v2(2026-09-08-ir-verify-facts)改
360
379
  * 分段合并:re-init 刷新机器段(probes/generatedAt),保留既有 slot-backfill/实测固化段
@@ -363,9 +382,10 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
363
382
  * @param {string} changeDir 变更目录(spec 根下 changes/<name>)
364
383
  * @param {object} result runVerifyProbes 返回值
365
384
  * @param {string} changeName 变更名(统一命令行呈现)
385
+ * @param {{ platformNote?: string }} [opts] platformNote 回显行尾追加(平台模式路径消歧,纯回显层不进落盘)
366
386
  * @returns {{ facts: object, path: string }}
367
387
  */
368
- export function writeVerifyFacts(changeDir, result, changeName) {
388
+ export function writeVerifyFacts(changeDir, result, changeName, opts = {}) {
369
389
  const fresh = buildVerifyFacts(result, { changeName })
370
390
  const factsPath = join(changeDir, 'verify-facts.json')
371
391
  let facts = fresh
@@ -383,7 +403,7 @@ export function writeVerifyFacts(changeDir, result, changeName) {
383
403
  }
384
404
  } catch { /* 首次落盘/v1/损坏 → 全新快照(v1 无固化段,直接升 v2) */ }
385
405
  writeFileSync(factsPath, JSON.stringify(facts, null, 2) + '\n')
386
- console.log(`📝 已刷新 verify-facts.json 机器底稿: ${factsPath}(v2 分段合并,固化段保留;CLI 全权写,勿手改)`)
406
+ console.log(`📝 已刷新 verify-facts.json 机器底稿: ${factsPath}(v2 分段合并,固化段保留;CLI 全权写,勿手改)${opts.platformNote || ''}`)
387
407
  return { facts, path: factsPath }
388
408
  }
389
409
 
@@ -483,6 +503,108 @@ export function backfillMissingEvidenceSlots(mdPath, requiredEvidenceItems = [])
483
503
  return { added }
484
504
  }
485
505
 
506
+ /**
507
+ * 结论草稿槽行预填(P0-1 前置三,noai-ir-roadmap §3):机械事实全绿时把骨架「结论枚举:」
508
+ * 待填槽替换为 PASS 草稿——显式「草稿待确认」内联标注(非默认值语义),agent 复核后可改写;
509
+ * gate 对槽行的独立复核逻辑不经本函数(判定链不动,预填只省打字成本)。纯文本变换三态:
510
+ * 槽行未填(仍含「待填」)→ 改写;已填(任何枚举值)→ 原样返回;无槽行(legacy 存量)→
511
+ * 原样返回。改写后槽行满足 extractVerifyConclusionSlot 行首锚定枚举解析(返回 PASS)。
512
+ */
513
+ export function applyConclusionDraftToText(text, draft = 'PASS') {
514
+ const normalized = String(text).replace(/\r\n/g, '\n')
515
+ const lines = normalized.split('\n')
516
+ for (let i = 0; i < lines.length; i++) {
517
+ if (/^结论枚举:/.test(lines[i]) && lines[i].includes('待填')) {
518
+ lines[i] = `结论枚举:${draft}(🤖 CLI 草稿待确认——机械事实全绿自动预填:noAI 实测通过 + 探针 1/3/5/6 干净 + 风险门非 integration/deployment;复核后不同意即改写本行枚举值,gate 独立复核不受预填影响)`
519
+ return lines.join('\n')
520
+ }
521
+ }
522
+ return text
523
+ }
524
+
525
+ /**
526
+ * 决策追踪矩阵机械半边(P0-1,noai-ir-roadmap §3):D→FR→task 链自结构化字段构建——
527
+ * decisions.md 条目(parseDecisions 双格式解析,与 distill 同源单一实现)× tasks/task-NN.md
528
+ * frontmatter 的 decision_ids / requirement_ids(taskcard 9 硬校验字段,CLI 骨架写入——比
529
+ * tasks.md 行内注解可靠)。Evidence / 状态两列是人工判断置 <待填>;D 无 task 回指 →
530
+ * ⚠️ 未闭环显式列出(这正是矩阵要暴露的风险,不静默)。
531
+ * @returns {{ rows: string[], decisionCount: number, taskCount: number }|null}
532
+ * null = 无 decisions.md / 解析 0 条(矩阵段留 TODO 不注入)
533
+ */
534
+ export function buildDecisionChainMatrix(changeDir) {
535
+ let decisions
536
+ try {
537
+ decisions = parseDecisions(changeDir)
538
+ } catch {
539
+ return null
540
+ }
541
+ if (!decisions || decisions.missing || (decisions.entries || []).length === 0) return null
542
+ // task 卡 frontmatter 结构化字段反查(列表 [a, b] 形态;frontmatter 由 plan-postcheck 硬校验)
543
+ const taskRefs = []
544
+ const tasksDir = join(changeDir, 'tasks')
545
+ try {
546
+ if (existsSync(tasksDir)) {
547
+ for (const f of readdirSync(tasksDir).filter((f) => /^task-\d+\.md$/.test(f)).sort()) {
548
+ const raw = readFileSync(join(tasksDir, f), 'utf8')
549
+ const id = (raw.match(/^id:\s*(\S+)/m) || [])[1] || f.replace(/\.md$/, '')
550
+ const decLine = (raw.match(/^decision_ids:\s*\[([^\]]*)\]/m) || [])[1] || ''
551
+ const reqLine = (raw.match(/^requirement_ids:\s*\[([^\]]*)\]/m) || [])[1] || ''
552
+ taskRefs.push({
553
+ task: id,
554
+ decisions: new Set(decLine.split(',').map((s) => s.trim()).filter(Boolean)),
555
+ reqs: new Set(reqLine.split(',').map((s) => s.trim()).filter(Boolean)),
556
+ })
557
+ }
558
+ }
559
+ } catch { /* tasks 目录不可读 → 矩阵 task 列全标未闭环(信息仍在) */ }
560
+ const rows = []
561
+ for (const d of decisions.entries) {
562
+ // task 卡 decision_ids 可能写 D-001(无 @vN)——id 与 number 双匹配同源
563
+ const hitTasks = taskRefs.filter((t) => t.decisions.has(d.id) || t.decisions.has(d.number))
564
+ const frs = [...new Set(hitTasks.flatMap((t) => [...t.reqs]))].sort()
565
+ const taskCell = hitTasks.length > 0 ? hitTasks.map((t) => t.task).join('、') : '⚠️ 未闭环(无 task 回指)'
566
+ const frCell = frs.length > 0 ? frs.join('、') : '⚠️ 未映射'
567
+ rows.push(`| ${d.id} | ${frCell} | ${taskCell} | <待填:证据回指> | <待填> |`)
568
+ }
569
+ return { rows, decisionCount: decisions.entries.length, taskCount: taskRefs.length }
570
+ }
571
+
572
+ /**
573
+ * 矩阵机械半边注入 verify-result.md(--init 接线):只在「决策追踪矩阵」段内仍是骨架
574
+ * TODO 行(含 D-xxx 形态)且段内无既有表格时替换注入——幂等(agent 已写/前次注入零改动),
575
+ * 不触碰正文其余部分。落盘失败 fail-soft 返回 null。
576
+ * @returns {{ decisions: number, tasks: number }|null} null = 无 decisions/无可替换 TODO/已注入
577
+ */
578
+ export function injectDecisionChainDraft(mdPath, changeDir) {
579
+ let text
580
+ try { text = readFileSync(mdPath, 'utf8') } catch { return null }
581
+ const headingIdx = text.indexOf('## 决策追踪矩阵')
582
+ if (headingIdx === -1) return null
583
+ const nextHeading = text.indexOf('\n## ', headingIdx + 1)
584
+ const sectionEnd = nextHeading === -1 ? text.length : nextHeading
585
+ const section = text.slice(headingIdx, sectionEnd)
586
+ if (/^\|/m.test(section)) return null // 已有表格 → 尊重既有内容
587
+ const todoRe = /<!--TODO:[^\n]*D-xxx[^\n]*-->/
588
+ if (!todoRe.test(section)) return null // 无骨架 TODO(手写正文)→ 不动
589
+ const matrix = buildDecisionChainMatrix(changeDir)
590
+ if (!matrix || matrix.rows.length === 0) return null
591
+ const block = [
592
+ '<!-- 机械半边预填(CLI,P0-1):D→FR→task 链自 decisions.md × tasks/*.md frontmatter 结构化字段构建;',
593
+ ' Evidence / 状态两列是人工判断——逐格复核,未闭环行必须在报告标注风险 -->',
594
+ '| 决策 ID | FR | Task | Evidence | 状态 |',
595
+ '|---|---|---|---|---|',
596
+ ...matrix.rows,
597
+ ].join('\n')
598
+ const next = text.slice(0, headingIdx) + section.replace(todoRe, block) + text.slice(sectionEnd)
599
+ try {
600
+ writeFileSync(mdPath, next)
601
+ } catch (e) {
602
+ console.warn(`⚠️ 决策追踪矩阵预填落盘失败(fail-soft): ${e && e.message ? e.message : e}`)
603
+ return null
604
+ }
605
+ return { decisions: matrix.decisionCount, tasks: matrix.taskCount }
606
+ }
607
+
486
608
  /**
487
609
  * 生成 verify-result.md 骨架(七章节;探针结果机械预填,语义章节 <!--TODO--> 占位)。
488
610
  * 结论走「结论枚举:」固定槽行(刀③,2026-09-08)——占位符不含枚举词(<待填:三选一>),