sillyspec 3.32.1 → 3.32.3

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.
@@ -27,8 +27,8 @@ import { createHash } from 'crypto'
27
27
  import { IR_STRICT_SINCE } from './constants.js'
28
28
  import { gitQuiet } from './git-helper.js'
29
29
  import { resolveRuntimeRoot } from './run/shared.js'
30
- import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs'
31
- import { join, resolve } from 'path'
30
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, appendFileSync } from 'fs'
31
+ import { join, resolve, isAbsolute } from 'path'
32
32
  import { readChangeTrace, testAnchorFile } from './test-bindings.js'
33
33
  import { collectFrLinkedTests } from './fr-index.js'
34
34
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
@@ -47,7 +47,8 @@ import { discoverModuleIndex } from './decision-distill.js'
47
47
  import { writeAtomicSync } from './fs-atomic.js'
48
48
  // target_files 声明侧解析(Wave 1 已落地):依赖链已核实无环——plan-postcheck 不反向依赖本模块,
49
49
  // 且本模块已经 worktree-apply.js:21 间接依赖 plan-postcheck,此处改直连不引入新环(task-04)
50
- import { parseTargetFiles, parseRepo } from './stages/plan-postcheck.js'
50
+ import { parseTargetFiles, parseRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
51
+ import { crossWorktreePath } from './worktree-cross.js'
51
52
  // 探针重跑(P3b task-02 一致性抽查):依赖方向已核实无环——verify-probes 只依赖
52
53
  // fs/path/git-helper/change-list/plan-postcheck/contract-matrix/foreign-declared/run-shared,
53
54
  // 不反向依赖本模块,此处直连不引入循环。smoke 机器段一致性(2026-09-17-api-coverage-smoke
@@ -1878,7 +1879,27 @@ export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}
1878
1879
  let anyAvailable = mainFiles !== null
1879
1880
  for (const entry of crossEntries) {
1880
1881
  // 跨仓仓 gitDir = 跨仓仓根(MultiRepoContext._buildCrossRepoEntry 已 fail-closed 保证可达)
1881
- const files = runGitDiffNameOnly(entry.gitDir, 'HEAD~1..HEAD')
1882
+ // FR-07(2026-10-09-verify-reuse-friction):跨仓 worktree 在场(verify 常在 apply 前)时
1883
+ // 交付面在 worktree——meta.baseHash..工作树 窗口(多笔提交+未提交全覆盖);HEAD~1..HEAD
1884
+ // 只看主副本最近一笔,对多笔交付是假信号。meta 缺失回退现行窗口。
1885
+ let files = null
1886
+ try {
1887
+ const wtPath = crossWorktreePath(opts.specBase || join(cwd, '.sillyspec'), changeName, entry.repoKey)
1888
+ const metaPath = join(wtPath, 'meta.json')
1889
+ if (existsSync(metaPath)) {
1890
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
1891
+ const base = meta && meta.baseHash
1892
+ if (base && /^[0-9a-f]{7,40}$/i.test(base)) {
1893
+ const d1 = gitQuiet(wtPath, ['diff', '--name-only', base])
1894
+ const d2 = gitQuiet(wtPath, ['ls-files', '--others', '--exclude-standard'])
1895
+ const mergedWt = [...new Set(String(d1 || '').split(String.fromCharCode(10)).concat(String(d2 || '').split(String.fromCharCode(10))))].map(x => x.trim()).filter(x => x && x !== 'meta.json' && !x.startsWith('.sillyspec/'))
1896
+ // 与 cross-repo-reconcile B' 档同语义:双查皆失败(d1/d2 双 null)→ 不成窗,
1897
+ // files 保持 null 回退 HEAD~1..HEAD(低报不如口径如实降级)
1898
+ if (d1 !== null || d2 !== null) files = mergedWt
1899
+ }
1900
+ }
1901
+ } catch { /* 回退 HEAD~1..HEAD */ }
1902
+ if (files === null) files = runGitDiffNameOnly(entry.gitDir, 'HEAD~1..HEAD')
1882
1903
  if (files !== null) {
1883
1904
  anyAvailable = true
1884
1905
  for (const f of files) {
@@ -1991,7 +2012,7 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1991
2012
  * resultPath: string|null,
1992
2013
  * }}
1993
2014
  */
1994
- export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null, restrictFiles = null, faceOverride = null }) {
2015
+ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null, restrictFiles = null, faceOverride = null, observability = null }) {
1995
2016
  // restrictFiles(坑 quick-gate-并行全流程变更脏文件误伤,2026-09-21 实证):本会话声明
1996
2017
  // 文件集(quick gate 传 allowedFiles)——提供时 module 子集的变更文件面收窄到
1997
2018
  // 「实际变更 ∩ restrictFiles」,窗口内未声明的并行全流程变更 WIP 文件不再进模块选择
@@ -2186,7 +2207,7 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
2186
2207
  }
2187
2208
  } else {
2188
2209
  console.log(`ℹ️ 动态测试子集(缺省):本变更测试 ∪ FR 关联回归 ∪ import 依赖 = ${scopeFiles.length} 个(并集去重后的总数;分量 deps ${depsAutoFiles.length}${frCount ? ` + FR 绑定 ${frCount}` : ''},重叠只计一次);runner 自项目结构推断。显式 test_strategy: full 恢复全量`)
2189
- mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles, frPre })
2210
+ mainResult = runModuleSubset({ cwd, specBase, changeName, hits: [], knownFailures, changedFiles: lastChangedFiles, frPre, observability })
2190
2211
  }
2191
2212
  // ledger 记账(无论增/全):head=当前 HEAD(git 失败 null → 下轮自动回全子集);失败批文件
2192
2213
  // 落账供下轮增量;passed 后 failedFiles 清空 → 下轮自然回全子集基线
@@ -2924,21 +2945,54 @@ export function resolveVerifyAnchorSet({ specBase, changeName }) {
2924
2945
  return [...ids]
2925
2946
  }
2926
2947
 
2927
- /** 残差解析:active·非 superseded·锚命中的 trace 行 → { anchors, rows, files, dangling } */
2948
+ /**
2949
+ * trace 行 repo 归属解析根(2026-10-09-verify-reuse-friction FR-06 / D-005@v1):
2950
+ * 行 repo 字段(additive,缺省=主仓)经 local.yaml repos 注册表换根——跨仓 task 卡抄入的
2951
+ * 仓根相对 tests 路径不再按主仓解析恒悬空(2026-10-09 tombstone 取证:3 文件悬空 fail-fast
2952
+ * 连烧 4 轮实测)。注册表缺失/键未注册 → 该行回退主仓根(现状行为),dangling 消息带
2953
+ * repo: 前缀使错配可见。读法同 verify-probes 探针 7 先例(join(specBase,'local.yaml') +
2954
+ * parseRepoRegistry,相对路径按主仓 cwd resolve)。
2955
+ */
2956
+ function traceRepoRoots({ specBase, cwd }) {
2957
+ try {
2958
+ const yamlPath = join(specBase, 'local.yaml')
2959
+ if (!existsSync(yamlPath)) return new Map()
2960
+ const reg = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
2961
+ const m = new Map()
2962
+ for (const [k, v] of reg) m.set(k, isAbsolute(v) ? v : resolve(cwd, v))
2963
+ return m
2964
+ } catch { return new Map() }
2965
+ }
2966
+
2967
+ /** 残差解析:active·非 superseded·锚命中的 trace 行 → { anchors, rows, files, dangling, perFile } */
2928
2968
  export function resolveTraceResidual({ specBase, changeName, cwd }) {
2929
2969
  const anchors = resolveVerifyAnchorSet({ specBase, changeName })
2930
- if (anchors.length === 0) return { anchors, rows: [], files: [], dangling: [] }
2970
+ if (anchors.length === 0) return { anchors, rows: [], files: [], dangling: [], perFile: [] }
2931
2971
  const rows = readChangeTrace(join(specBase, 'changes', changeName))
2932
2972
  .filter(r => anchors.includes(r.anchor) && r.state === 'active' && r.status !== 'superseded')
2933
- // tests 条目可携带用例锚(2026-09-26-binding-anchor-fidelity)——文件面/悬空判定走剥锚
2934
- const files = [...new Set(rows.flatMap(r => (r.tests || []).map(testAnchorFile)))].sort()
2935
- const dangling = files.filter(f => !existsSync(resolve(cwd, f)))
2936
- return { anchors, rows, files, dangling }
2973
+ // tests 条目可携带用例锚(2026-09-26-binding-anchor-fidelity)——文件面/悬空判定走剥锚。
2974
+ // FR-06(2026-10-09):文件按行 repo 归属配对(perFile),悬空判定/残差执行按行根解析。
2975
+ const repoRoots = traceRepoRoots({ specBase, cwd })
2976
+ const seen = new Set()
2977
+ const perFile = []
2978
+ for (const r of rows) {
2979
+ const repoKey = r.repo && r.repo !== 'main' ? r.repo : null
2980
+ for (const t of (r.tests || [])) {
2981
+ const f = testAnchorFile(t)
2982
+ const key = (repoKey ? repoKey + '' : '') + f
2983
+ if (!seen.has(key)) { seen.add(key); perFile.push({ repo: repoKey, file: f }) }
2984
+ }
2985
+ }
2986
+ const files = [...new Set(perFile.map(x => x.file))].sort()
2987
+ const dangling = perFile
2988
+ .filter(x => !existsSync(resolve((x.repo && repoRoots.get(x.repo)) || cwd, x.file)))
2989
+ .map(x => x.repo ? `${x.repo}:${x.file}` : x.file)
2990
+ return { anchors, rows, files, dangling, perFile }
2937
2991
  }
2938
2992
 
2939
2993
  /** 残差执行段:按 buildDepsBatches 同口径组卷(扩展名分语言/pytest 前缀自模块命令推断),
2940
2994
  * 30/块分卷破 deps 面的帽(残差是绑定面钦定必须跑的,不适用发现面防膨胀帽)。 */
2941
- function runTraceResidual({ cwd, files, hits, knownFailures = [] }) {
2995
+ function runTraceResidual({ cwd, files, groups = null, hits, knownFailures = [] }) {
2942
2996
  // 嵌套 test-runner 防污染:孙代 node --test 会误连外层 reporter(NODE_TEST_* 环境变量,
2943
2997
  // 单测实测:嵌套下挂测被误判 passed)——spawn 前剥除、跑完还原(同步窗口安全;真实门禁
2944
2998
  // 进程无此变量,零影响)。
@@ -2946,12 +3000,17 @@ function runTraceResidual({ cwd, files, hits, knownFailures = [] }) {
2946
3000
  const strippedKeys = []
2947
3001
  for (const k of Object.keys(process.env)) { if (k.startsWith('NODE_TEST')) { strippedKeys.push(k); delete process.env[k] } }
2948
3002
  try {
2949
- return runTraceResidualInner({ cwd, files, hits, knownFailures })
3003
+ // FR-06(2026-10-09-verify-reuse-friction):残差按行 repo 分组执行——跨仓组在其仓根
3004
+ // 跑(组卷 cwd=组根),主仓组现状不变;groups 缺席回退旧 files 单主仓组(零迁移)。
3005
+ const execGroups = Array.isArray(groups) && groups.length > 0
3006
+ ? groups
3007
+ : [{ repo: null, root: cwd, files }]
3008
+ return execGroups.flatMap(g => runTraceResidualInner({ cwd: g.root, repoLabel: g.repo, files: g.files, hits, knownFailures }))
2950
3009
  } finally {
2951
3010
  for (const k of strippedKeys) process.env[k] = savedEnv[k]
2952
3011
  }
2953
3012
  }
2954
- function runTraceResidualInner({ cwd, files, hits, knownFailures }) {
3013
+ function runTraceResidualInner({ cwd, repoLabel = null, files, hits, knownFailures }) {
2955
3014
  const norm = (p) => String(p).replace(/\\/g, '/')
2956
3015
  const segments = []
2957
3016
  for (let i = 0; i < files.length; i += 30) {
@@ -2998,6 +3057,19 @@ export function applyTraceResidual({ mainResult, action, cwd, specBase, changeNa
2998
3057
  const provable = (action === 'deps-auto-subset' || action === 'dynamic-subset')
2999
3058
  ? new Set((depsAutoFiles || []).map(f => String(f).replace(/\\/g, '/'))) : new Set()
3000
3059
  const residualFiles = tr.files.filter(f => !provable.has(f))
3060
+ // FR-06(2026-10-09-verify-reuse-friction):残差执行面按行 repo 分组(provable 差集按
3061
+ // 文件名判,跨仓文件同样成立);组根=注册表仓根,未注册回退主仓(与悬空判定同口径)。
3062
+ const _repoRoots = traceRepoRoots({ specBase, cwd })
3063
+ const _gmap = new Map()
3064
+ for (const x of (tr.perFile || [])) {
3065
+ if (provable.has(String(x.file).replace(/\\/g, '/'))) continue
3066
+ const _rk = x.repo || null
3067
+ const _root = (_rk && _repoRoots.get(_rk)) || cwd
3068
+ const _gk = _rk + '' + _root
3069
+ if (!_gmap.has(_gk)) _gmap.set(_gk, { repo: _rk, root: _root, files: [] })
3070
+ _gmap.get(_gk).files.push(x.file)
3071
+ }
3072
+ const residualGroups = [..._gmap.values()].map(g => ({ ...g, files: [...new Set(g.files)] }))
3001
3073
  try {
3002
3074
  writeTraceDisclosure({
3003
3075
  specBase, changeName,
@@ -3015,7 +3087,7 @@ export function applyTraceResidual({ mainResult, action, cwd, specBase, changeNa
3015
3087
  if (residualFiles.length === 0) return mainResult
3016
3088
  let segs
3017
3089
  try {
3018
- segs = runTraceResidual({ cwd, files: residualFiles, hits, knownFailures })
3090
+ segs = runTraceResidual({ cwd, files: residualFiles, groups: residualGroups, hits, knownFailures })
3019
3091
  } catch (e) {
3020
3092
  return { ...mainResult, status: 'failed', reason: `trace 残差段执行异常(fail-closed):${e && e.message ? e.message : e}` }
3021
3093
  }
@@ -3042,7 +3114,7 @@ export function applyTraceResidual({ mainResult, action, cwd, specBase, changeNa
3042
3114
  * export 供直测(buildDepsBatches 同款先例——fixture 需真跑测试文件,不宜经
3043
3115
  * runVerifyTestCheck 全链只为断言组卷接线)。
3044
3116
  */
3045
- export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [], frPre = null }) {
3117
+ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [], changedFiles = [], frPre = null, observability = null }) {
3046
3118
  const subsetStartedAt = Date.now()
3047
3119
  const perModule = hits.map(h => runOneModule(h.name, h.test, cwd, knownFailures))
3048
3120
  // 依赖测试伪模块(module-test-face-rot,2026-09-16 本会话实证):硬编码模块测试清单不含
@@ -3158,6 +3230,9 @@ export function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures
3158
3230
  changeName,
3159
3231
  result,
3160
3232
  extra: {
3233
+ // 复用观测(2026-10-09-verify-reuse-friction FR-04):真跑轮携带指纹与判定原因——
3234
+ // 取证直读 test-result.json,不再依赖 stdout 考古。additive:调用方未传则零字段。
3235
+ ...(observability || {}),
3161
3236
  modules: perModule.map(r => ({
3162
3237
  name: r.name,
3163
3238
  command: r.command,
@@ -3218,6 +3293,26 @@ export function persistLintResult({ specBase, changeName, testResultPath, lint }
3218
3293
  }
3219
3294
  }
3220
3295
 
3296
+ /**
3297
+ * 复用判定追加日志(2026-10-09-verify-reuse-friction FR-04):复用命中轮不产生
3298
+ * test-result.json(无真实执行——那是它的语义),判定落
3299
+ * .runtime/verify-runs/reuse-decisions-<change>.jsonl 逐行追加(ts + layer + hit +
3300
+ * reason + fingerprint 摘要),与真跑轮 test-result.json 的 fingerprint/reuse_decision
3301
+ * 互为对照——取证直读文件,不再依赖 stdout。best-effort:追加失败不阻断任何路径。
3302
+ */
3303
+ export function appendReuseDecision({ specBase, changeName, decision }) {
3304
+ try {
3305
+ if (!specBase || !changeName || !decision || typeof decision !== 'object') return false
3306
+ const dir = join(specBase, '.runtime', 'verify-runs')
3307
+ mkdirSync(dir, { recursive: true })
3308
+ appendFileSync(join(dir, `reuse-decisions-${changeName}.jsonl`), JSON.stringify({
3309
+ ts: new Date().toISOString(),
3310
+ ...decision,
3311
+ }) + '\n')
3312
+ return true
3313
+ } catch { /* 观测失败不阻断 */ return false }
3314
+ }
3315
+
3221
3316
  /**
3222
3317
  * 结果落盘到 .runtime/verify-runs/<ts>/test-result.json(供追溯与 SillyHub 消费)。
3223
3318
  * 多模块时 extra.modules 描述各模块明细。export 供 test 验证台账落盘形状。
@@ -3994,7 +4089,7 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
3994
4089
  try {
3995
4090
  // 锚定变更分支/审计 tag(非 blanket 20 提交——base 提交文件不得误救)
3996
4091
  const rescueRef = (typeof branchHash === 'string' && branchHash.trim()) ? branch : (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', auditTag + '^{commit}'], { timeout: 30 * 1000 }) ? auditTag : null)
3997
- if (!rescueRef) throw new Error('no-rescue-ref')
4092
+ if (rescueRef) {
3998
4093
  // -n 形态计数(2026-10-04-log-window-arity:裸 '20' 在 git ≥2.4x 是 ambiguous argument
3999
4094
  // fatal——fail-open 静默吞后救赎 note 路径从未生效,plan-target-files D7 在纯 HEAD 稳定红)
4000
4095
  const recent = gitQuiet(cwd, ['log', '-n', '20', '--name-only', '--format=%h', rescueRef], { timeout: 30 * 1000, trim: false })
@@ -4005,6 +4100,34 @@ export function resolveReconcileActualFiles({ cwd, specBase, runtimeRoot, change
4005
4100
  sources.push(`main:log-20-commit-window(declared-rescue-only:${rescueRef})`)
4006
4101
  }
4007
4102
  }
4103
+ } else {
4104
+ // ③ 提交信息锚定窗口(2026-10-09-verify-papercuts ③):主代理直改形态(无 worktree/
4105
+ // 分支/审计 tag——交付直接 commit 到主干、消息含变更名)此前救援窗口恒空 → ②类全量
4106
+ // 假红,只能手工喂 apply-pathspec 才能过(当日收口实证撞 3 轮)。锚定规则:近 30 提交中
4107
+ // 消息含变更名的提交,其 --name-only 触及面进窗口——declared-rescue-only 语义不变
4108
+ // (不进 union、只救「声明而 missing」),他变更提交的文件不满足消息锚定天然不入窗。
4109
+ try {
4110
+ const msgWindow = gitQuiet(cwd, ['log', '-n', '30', '--name-only', '--format=%h %s'], { timeout: 30 * 1000, trim: false })
4111
+ // 边界锚定(审查 P3 修正):子串 includes 会把同日前缀变更名(A=…-x 是 B=…-x2 的
4112
+ // 前缀)误纳——改为变更名两侧不得紧贴 [\w-](消息里的括号/空格/行首尾都是自然边界)。
4113
+ const msgAnchorRe = new RegExp('(?<![\\w-])' + String(changeName).replace(/[\\.*+?^${}()|[\\]]/g, '\\$&') + '(?![\\w-])')
4114
+ if (typeof msgWindow === 'string' && msgAnchorRe.test(msgWindow)) {
4115
+ const files = []
4116
+ let inOwnCommit = false
4117
+ for (const l of msgWindow.split(String.fromCharCode(10))) {
4118
+ const t = l.trim()
4119
+ if (!t) { continue }
4120
+ const header = t.match(/^[0-9a-f]{7,40} /)
4121
+ if (header) { inOwnCommit = msgAnchorRe.test(t); continue }
4122
+ if (inOwnCommit && !/^[0-9a-f]{7,40}$/.test(t)) files.push(t)
4123
+ }
4124
+ if (files.length > 0) {
4125
+ commitWindowFiles = files
4126
+ sources.push('main:log-msg-window(declared-rescue-only:HEAD)')
4127
+ }
4128
+ }
4129
+ } catch { /* 消息窗口失败静默省略(不干扰主链) */ }
4130
+ }
4008
4131
  } catch { /* 窗口源失败静默省略 */ }
4009
4132
  // B3 apply-pathspec 兜底(存在则并入,读取失败静默忽略——前两源不受影响)
4010
4133
  const pathspecFile = join(runtimeRoot, `apply-pathspec-${changeName}.txt`)
@@ -2180,6 +2180,9 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
2180
2180
  task: fmId || f.replace(/\.md$/, ''),
2181
2181
  acceptance: parsed.acceptance,
2182
2182
  fmError: parsed.status === 'invalid-yaml' ? parsed.error : null,
2183
+ // FR-06(2026-10-09-verify-reuse-friction):行 repo 归属写侧透传(跨仓卡 tests
2184
+ // 仓根相对,读侧按行根解析)
2185
+ repo: parseRepo(raw) || null,
2183
2186
  raw,
2184
2187
  })
2185
2188
  }
@@ -2280,6 +2283,9 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
2280
2283
  discovery: 'machine',
2281
2284
  confirmed_by: null,
2282
2285
  source_change: changeName,
2286
+ // FR-06(2026-10-09-verify-reuse-friction):跨仓卡的 tests 是仓根相对——
2287
+ // 行携 repo 归属供读侧按行根解析(缺省不落键=主仓,D-005@v1)。
2288
+ ...(t.repo && t.repo !== 'main' ? { repo: t.repo } : {}),
2283
2289
  })
2284
2290
  })
2285
2291
  }
@@ -3854,3 +3860,138 @@ export function refreshProbeSections(existingText, freshReportText) {
3854
3860
  }
3855
3861
  return { text: out.join('\n'), replaced, kept, appended, carriedRows }
3856
3862
  }
3863
+
3864
+ // ── --force 保人工面(2026-10-09-verify-papercuts ②)──
3865
+ // postmortem(2026-10-09-verify-reuse-friction 收口实证):--force 全骨架重置虽有自动备份,
3866
+ // 但结论槽/移交项表/探针矩阵已填判定/决策矩阵已填格仍全部被 wipe——重填整套人工面是当日
3867
+ // 最大单点浪费(备份在 .runtime 里,找回-回填的来回远比携载贵)。本组合器把 --force 语义
3868
+ // 从「全量重置 + 备份找回」升级为「机器段全量重刷 + 人工面按段携载」:
3869
+ // - 纯人工段(结论/移交项/证据账/集成回执/任务完成度/设计一致性/独立复核/风险等级):
3870
+ // 旧段已填(无 <待填/<TODO 占位)→ 原样保留;未填 → 用新骨架(占位换新占位,无损失);
3871
+ // - 矩阵段(决策追踪矩阵/接口验证覆盖矩阵):表格行按首列键携载(mergeProbeSection 同
3872
+ // 款语义——已填 Evidence/判定行不丢,新增行从新骨架进场);
3873
+ // - 探针容器段(## 探针结果):逐探针号 merge(已填表格行携载,机器结论行刷新);
3874
+ // - 其余机器段:新骨架;旧文独有的自定义段:追加保留(不销毁用户内容)。
3875
+ // 安全方向与 refreshProbeSections 一致:宁可少刷新不误删;stale 由 gate 的
3876
+ // checkProbeConsistency 独立把关。备份照旧保留(兜底通道不撤)。
3877
+
3878
+ /** 顶层段切分(^## 标题行起,至下一 ^## 或 EOF;# 级更细的 #### 归属其容器段) */
3879
+ function splitTopSections(text) {
3880
+ const lines = String(text ?? '').split('\n')
3881
+ const sections = []
3882
+ let cur = null
3883
+ for (let i = 0; i < lines.length; i++) {
3884
+ if (/^## /.test(lines[i])) {
3885
+ if (cur) cur.endLine = i
3886
+ cur = { headingLine: lines[i], startLine: i, endLine: lines.length }
3887
+ sections.push(cur)
3888
+ }
3889
+ }
3890
+ return { lines, sections }
3891
+ }
3892
+
3893
+ /** 段身份键:剥 [层:…] 尾注与(…)括注——同族段跨渲染措辞漂移仍可对上 */
3894
+ function topSectionKey(headingLine) {
3895
+ return headingLine
3896
+ .replace(/^##\s*/, '')
3897
+ .replace(/\s*\[.*\]\s*$/, '')
3898
+ .replace(/(.*$/, '')
3899
+ .trim()
3900
+ }
3901
+
3902
+ const HUMAN_SECTION_KEYS = ['结论', '移交项', '证据账', '集成验证回执', '任务完成度', '设计一致性', '独立复核', '变更风险等级', '技术债务', 'Runtime Evidence', '代码审查']
3903
+ const MATRIX_SECTION_KEYS = ['决策追踪矩阵', '接口验证覆盖矩阵']
3904
+ const PROBE_CONTAINER_KEY = '探针结果'
3905
+
3906
+ /** 探针容器段合并:新骨架容器为基,逐探针号对旧容器做已填行携载;旧独有探针号追加 */
3907
+ function mergeProbeContainer(oldContainerLines, freshContainerLines) {
3908
+ const oldParsed = splitProbeSectionRanges(oldContainerLines.join('\n'))
3909
+ const freshParsed = splitProbeSectionRanges(freshContainerLines.join('\n'))
3910
+ const out = [...freshParsed.lines]
3911
+ let carriedRows = 0
3912
+ const oldByNum = new Map(oldParsed.sections.map(s => [s.num, s]))
3913
+ for (let idx = freshParsed.sections.length - 1; idx >= 0; idx--) {
3914
+ const fs = freshParsed.sections[idx]
3915
+ const os = oldByNum.get(fs.num)
3916
+ if (!os) continue
3917
+ const merged = mergeProbeSection(
3918
+ oldParsed.lines.slice(os.startLine, os.endLine),
3919
+ freshParsed.lines.slice(fs.startLine, fs.endLine),
3920
+ )
3921
+ // 倒序替换(审查 P2 修正,2026-10-09-verify-papercuts):mergeProbeSection 对含 stale
3922
+ // 键的段会在段尾追加(行数 > freshSeg),正序 splice 会使后续探针段 startLine 错位——
3923
+ // 从后往前替换则前面行号不受影响,任一段行数膨胀都安全。
3924
+ carriedRows += merged.carried
3925
+ // 用 merged 文本替换 fresh 容器中该探针段(逐段行数已知,从后往前替换保行号稳定)
3926
+ const freshSeg = freshParsed.lines.slice(fs.startLine, fs.endLine)
3927
+ out.splice(fs.startLine, freshSeg.length, ...merged.text.split('\n'))
3928
+ }
3929
+ const freshNums = new Set(freshParsed.sections.map(s => s.num))
3930
+ for (const os of oldParsed.sections) {
3931
+ if (!freshNums.has(os.num)) {
3932
+ out.push('', ...oldParsed.lines.slice(os.startLine, os.endLine))
3933
+ }
3934
+ }
3935
+ return { text: out.join('\n'), carriedRows }
3936
+ }
3937
+
3938
+ /**
3939
+ * --force 应用骨架:机器段全量重刷 + 人工面携载(纯函数,导出供回归直测)。
3940
+ * @param {string} existingText 现文(含已填人工面)
3941
+ * @param {string} skeletonText generateVerifyResultSkeleton 新骨架
3942
+ * @returns {{ text: string, carriedSections: string[], carriedRows: number }}
3943
+ */
3944
+ export function applySkeletonPreservingHuman(existingText, skeletonText) {
3945
+ const old = splitTopSections(existingText)
3946
+ const fresh = splitTopSections(skeletonText)
3947
+ const oldByKey = new Map()
3948
+ for (const s of old.sections) {
3949
+ const k = topSectionKey(s.headingLine)
3950
+ if (!oldByKey.has(k)) oldByKey.set(k, s) // 同键段取首个(重复标题形态保首段)
3951
+ }
3952
+ const carriedSections = []
3953
+ let carriedRows = 0
3954
+ const out = []
3955
+ let cursor = 0
3956
+ for (const fs of fresh.sections) {
3957
+ // 骨架段前的行原样带过
3958
+ for (; cursor < fs.startLine; cursor++) out.push(fresh.lines[cursor])
3959
+ const k = topSectionKey(fs.headingLine)
3960
+ const os = oldByKey.get(k)
3961
+ const freshSeg = fresh.lines.slice(fs.startLine, fs.endLine)
3962
+ if (!os) {
3963
+ out.push(...freshSeg)
3964
+ } else if (HUMAN_SECTION_KEYS.includes(k)) {
3965
+ const oldSeg = old.lines.slice(os.startLine, os.endLine)
3966
+ if (/<待填|<!--TODO/.test(oldSeg.join('\n'))) {
3967
+ out.push(...freshSeg) // 未填 → 换新骨架占位
3968
+ } else {
3969
+ out.push(...oldSeg) // 已填 → 原样保留
3970
+ carriedSections.push(k)
3971
+ }
3972
+ } else if (MATRIX_SECTION_KEYS.includes(k)) {
3973
+ const merged = mergeProbeSection(old.lines.slice(os.startLine, os.endLine), freshSeg)
3974
+ carriedRows += merged.carried
3975
+ out.push(...merged.text.split('\n'))
3976
+ if (merged.carried > 0) carriedSections.push(k)
3977
+ } else if (k === PROBE_CONTAINER_KEY) {
3978
+ const merged = mergeProbeContainer(old.lines.slice(os.startLine, os.endLine), freshSeg)
3979
+ carriedRows += merged.carriedRows
3980
+ out.push(...merged.text.split('\n'))
3981
+ if (merged.carriedRows > 0) carriedSections.push(k)
3982
+ } else {
3983
+ out.push(...freshSeg) // 机器段(探针概览/接口声明等)→ 新骨架
3984
+ }
3985
+ cursor = fs.endLine
3986
+ oldByKey.delete(k)
3987
+ }
3988
+ for (; cursor < fresh.lines.length; cursor++) out.push(fresh.lines[cursor])
3989
+ // 旧文独有段(用户自定义节)追加保留
3990
+ if (oldByKey.size > 0) {
3991
+ out.push('')
3992
+ for (const [, s] of oldByKey) {
3993
+ out.push(...old.lines.slice(s.startLine, s.endLine))
3994
+ }
3995
+ }
3996
+ return { text: out.join('\n'), carriedSections, carriedRows }
3997
+ }
package/src/watcher.js CHANGED
@@ -339,8 +339,12 @@ export function inferEvents(prev, next) {
339
339
  const p = prev.files[key];
340
340
  const n = next.files[key];
341
341
  if (!p && n) {
342
- events.push(mk('file', n.stage, `${key} 出现`));
343
- if (n.checked > 0) events.push(mk('task-done', n.stage, `checked 0→${n.checked}`));
342
+ events.push(mk('file', n.stage, `${key} 出现`))
343
+ // 勾选节奏域=任务队列文件(tasks.md / tasks/*.md,stage='tasks')。其余阶段文件首现的
344
+ // 勾选计数不是 tick 信号:design.md 自审清单天然预勾(整体写盘的已完成断言)——发了就是
345
+ // 幻影单拍跳(2026-10-08-batch-tick-false-positive 实证:design 首现 0→8 被节奏门误拒
346
+ // 六次独立 tick);tasks.md 首现带已勾仍发(保住「整卡预勾创建」的真实捕捉面)。
347
+ if (n.checked > 0 && n.stage === 'tasks') events.push(mk('task-done', n.stage, `checked 0→${n.checked}`))
344
348
  } else if (p && n && p.hash !== n.hash) {
345
349
  events.push(mk('file-update', n.stage, `${key} 内容变更`));
346
350
  }
package/src/wt-commit.js CHANGED
@@ -17,7 +17,7 @@
17
17
  *
18
18
  * 成功返回 HEAD hash(review.json 的 head 锚点直接可用);无变更 → ok=true + skipped。
19
19
  */
20
- import { join, resolve } from 'node:path'
20
+ import { join, resolve, relative, sep, basename, dirname } from 'node:path'
21
21
  import { existsSync, readFileSync } from 'node:fs'
22
22
  import { withFileLock } from './quicklog.js'
23
23
  import { safeGit } from './git-helper.js'
@@ -55,6 +55,48 @@ async function gitWithLockRetry(cwd, args, label, { retries = 6, waitMs = 1000 }
55
55
  * @returns {Promise<{ok:boolean,skipped:boolean,head:string,shortHead:string,worktreePath:string,changeName:string,files:string[]}>}
56
56
  * ok=false 时抛错(调用方打错误 + exit 1),skipped=无变更可提交(HEAD 不动,不算失败)。
57
57
  */
58
+ /**
59
+ * 从 cwd 推断变更名(wt-commit 免 --change 通道;FR-08 / D-006@v1)。
60
+ * 跨仓 worktree 名 <change>--<repoKey>——仅当后缀命中 repos 注册表(worktrees 上两级
61
+ * local.yaml)且剥出候选是已知变更目录才剥(不猜切分);全段命中主仓已知变更不剥(防
62
+ * 变更真名恰以 --repoKey 结尾);均不中 → 原样透传(现状行为,调用方自然报错引导
63
+ * 显式 --change)。导出供三态回归直测(index.js dispatch 内联形态不可测——独立审查指出的
64
+ * 验证面缺口)。
65
+ */
66
+ export async function inferChangeFromWorktreeCwd(dir) {
67
+ // 深路径归一(FR-08):cwd 可能在 worktree 内多层深处——specBase 推导须从 worktree 根起算。
68
+ // 找 'worktrees' 目录段在原路径中的位置,根 = 该段后第一段截尾;specBaseGuess = 根上溯
69
+ // 三级(worktrees → .runtime → .sillyspec 的父,即主仓 specBase)。
70
+ const s = String(dir)
71
+ const norm = s.split('\\').join('/')
72
+ const wtIdx = norm.lastIndexOf('/worktrees/')
73
+ let seg = null
74
+ let specBaseGuess = null
75
+ if (wtIdx !== -1) {
76
+ const rest = norm.slice(wtIdx + '/worktrees/'.length)
77
+ const rootRel = rest.split('/')[0]
78
+ seg = rootRel || null
79
+ const rootNorm = norm.slice(0, wtIdx + '/worktrees/'.length + rootRel.length)
80
+ specBaseGuess = rootNorm.split('/').slice(0, -3).join('/')
81
+ }
82
+ if (seg && seg.includes('--')) {
83
+ try {
84
+ if (specBaseGuess) {
85
+ const knownWhole = existsSync(join(specBaseGuess, 'changes', seg))
86
+ const dash = seg.lastIndexOf('--')
87
+ const cand = seg.slice(0, dash)
88
+ const suffix = seg.slice(dash + 2)
89
+ if (!knownWhole && cand && /^[A-Za-z0-9_.-]+$/.test(suffix)) {
90
+ const { parseRepoRegistry } = await import('./stages/plan-postcheck.js')
91
+ const reg = parseRepoRegistry(readFileSync(join(specBaseGuess, 'local.yaml'), 'utf8'))
92
+ if (reg.has(suffix) && existsSync(join(specBaseGuess, 'changes', cand))) seg = cand
93
+ }
94
+ }
95
+ } catch { /* 注册表不可读 → 原样透传(fail-safe) */ }
96
+ }
97
+ return seg
98
+ }
99
+
58
100
  export async function runWtCommit({ changeName, message, pathspecs = [], pathspecFile = null, cwd = process.cwd() }) {
59
101
  if (!changeName) throw new Error('缺少 --change <变更名>(worktree 名;在 worktree 内运行时可省略,CLI 从 cwd 推断)')
60
102
  if (!message || !message.trim()) throw new Error('缺少 -m/--message <提交信息>(建议 "<task-NN 摘要>")')
@@ -77,10 +119,47 @@ export async function runWtCommit({ changeName, message, pathspecs = [], pathspe
77
119
  // 隔离模式(worktree 目录存在)→ 提交目标 = worktree 本体;in-place-fallback(meta 声明无
78
120
  // worktree 直改主仓)→ 主仓根(与 MultiRepoContext 主仓 entry 的 worktreePath 兜底口径一致)。
79
121
  // 顺序先看目录再看 meta:worktree 目录存在即隔离态,meta 缺失/损坏不改变目标(fail-safe)。
122
+ // FR-08(2026-10-09-verify-reuse-friction / D-006@v1):cwd 已在本变更的 worktree 内 →
123
+ // 提交目标=该 worktree 本体。跨仓 worktree 名 <change>--<repoKey> 不在 getWorktreePath
124
+ // 的主仓命名域(原路径必落「worktree 不存在」错);主仓 worktree 场景与原值同目录零漂移。
125
+ // 归属校验(不猜):worktree 根段 === changeName,或 === changeName--<注册repoKey>(repos
126
+ // 注册表读自 worktreeBase 上两级的 local.yaml;注册表不可读 → 只认精确名,fail-safe)。
80
127
  let target = null
81
- if (existsSync(worktreePath)) {
128
+ {
129
+ // 基座发现(目录事实):优先 wm.worktreeBase(主仓);cwd 不在其下时向上找名为 worktrees
130
+ // 的目录作基座(跨仓 worktree 是跨仓仓的 git worktree,WorktreeManager 从其 cwd 算不出
131
+ // 主仓基座——跨仓 worktree 的父目录本身就是 worktrees 基座)。
132
+ let base = resolve(wm.worktreeBase)
133
+ {
134
+ let d = resolve(cwd)
135
+ for (;;) {
136
+ if (dirname(d) === base) break
137
+ const parent = dirname(d)
138
+ if (basename(parent) === 'worktrees') { base = parent; break }
139
+ if (parent === d) break
140
+ d = parent
141
+ }
142
+ }
143
+ const cwdAbs = resolve(cwd)
144
+ if (cwdAbs.startsWith(base + sep)) {
145
+ const wtRoot = relative(base, cwdAbs).split(sep)[0]
146
+ let own = wtRoot === changeName
147
+ if (!own && wtRoot.startsWith(changeName + '--')) {
148
+ const suffix = wtRoot.slice(changeName.length + 2)
149
+ if (suffix && /^[A-Za-z0-9_.-]+$/.test(suffix)) {
150
+ try {
151
+ const { parseRepoRegistry } = await import('./stages/plan-postcheck.js')
152
+ const reg = parseRepoRegistry(readFileSync(join(base, '..', '..', 'local.yaml'), 'utf8'))
153
+ own = reg.has(suffix)
154
+ } catch { own = false }
155
+ }
156
+ }
157
+ if (own && existsSync(join(base, wtRoot))) target = join(base, wtRoot)
158
+ }
159
+ }
160
+ if (!target && existsSync(worktreePath)) {
82
161
  target = worktreePath
83
- } else {
162
+ } else if (!target) {
84
163
  const meta = wm.getMeta(changeName)
85
164
  if (meta && meta.mode === 'in-place-fallback') {
86
165
  target = resolve(wm.worktreeBase, '..', '..', '..') // <main>/.sillyspec/.runtime/worktrees → <main>