sillyspec 3.28.7 → 3.28.9

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 (79) hide show
  1. package/.claude/skills/sillyspec-quick/SKILL.md +2 -0
  2. package/.husky/pre-push +3 -1
  3. package/AGENTS.md +3 -2
  4. package/README.md +35 -11
  5. package/SKILL.md +24 -10
  6. package/SillySpec-/350/203/275/345/212/233/344/272/256/347/202/271/345/205/250/346/231/257-2026-09-14.pptx +0 -0
  7. package/package.json +1 -1
  8. package/src/agent-session-log.js +446 -35
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-cat.js +6 -3
  11. package/src/config-schema.js +32 -0
  12. package/src/constants.js +3 -0
  13. package/src/db.js +23 -6
  14. package/src/decision-distill.js +15 -3
  15. package/src/design-facts.js +33 -3
  16. package/src/docs-check.js +137 -12
  17. package/src/docs-gate.js +108 -6
  18. package/src/doctor-diagnostics.js +243 -9
  19. package/src/friction-ledger.js +171 -0
  20. package/src/friction-tally.js +7 -2
  21. package/src/git-helper.js +6 -4
  22. package/src/hooks/worktree-guard.js +72 -30
  23. package/src/index.js +356 -81
  24. package/src/knowledge-classify.js +363 -0
  25. package/src/knowledge-hits.js +74 -0
  26. package/src/knowledge-stats.js +180 -0
  27. package/src/local-detect.js +30 -15
  28. package/src/local-register.js +13 -4
  29. package/src/mcp-server.js +38 -8
  30. package/src/progress/change-registry.js +224 -43
  31. package/src/progress/consistency-doctor.js +11 -4
  32. package/src/progress/shared.js +3 -2
  33. package/src/progress/stage-machine.js +3 -1
  34. package/src/progress/step-store.js +30 -17
  35. package/src/progress.js +123 -8
  36. package/src/quick-gate-profile.js +251 -0
  37. package/src/quicklog.js +199 -32
  38. package/src/review-dispatch.js +2 -1
  39. package/src/run/command.js +124 -15
  40. package/src/run/complete-handlers.js +514 -24
  41. package/src/run/complete.js +82 -25
  42. package/src/run/concurrent-detect.js +99 -1
  43. package/src/run/gate-snapshot.js +115 -5
  44. package/src/run/gates.js +85 -14
  45. package/src/run/prompt.js +143 -6
  46. package/src/run/quick-audit.js +136 -15
  47. package/src/run/shared.js +174 -20
  48. package/src/run/stage.js +6 -0
  49. package/src/run/verify-quality-scan.js +14 -3
  50. package/src/scan-diff.js +61 -30
  51. package/src/scan-postcheck.js +74 -0
  52. package/src/scan-refresh.js +477 -0
  53. package/src/scan-staleness.js +25 -13
  54. package/src/scope-audit.js +365 -35
  55. package/src/spec-sync.js +19 -14
  56. package/src/stage-contract-spec.js +4 -3
  57. package/src/stage-contract.js +185 -2
  58. package/src/stage-review.js +29 -12
  59. package/src/stages/brainstorm.js +7 -1
  60. package/src/stages/execute.js +91 -4
  61. package/src/stages/knowledge.js +52 -3
  62. package/src/stages/plan-postcheck.js +57 -12
  63. package/src/stages/quick.js +1 -1
  64. package/src/stages/verify.js +1 -0
  65. package/src/sync-noise.js +114 -6
  66. package/src/sync.js +201 -11
  67. package/src/task-review.js +333 -27
  68. package/src/verify-facts-schema.js +10 -3
  69. package/src/verify-postcheck.js +41 -6
  70. package/src/verify-probes.js +348 -7
  71. package/src/worktree-apply.js +594 -62
  72. package/src/worktree-cross.js +12 -18
  73. package/src/worktree-deps.js +100 -2
  74. package/src/worktree.js +255 -30
  75. package/src/wt-commit.js +128 -0
  76. package/templates/agents-instruction.md +2 -2
  77. package/templates/prompts/testcase-design.md +2 -1
  78. package/templates/prompts/verify-probes.md +4 -1
  79. package/sa-rename-probe/src/new.js +0 -4
@@ -9,13 +9,16 @@
9
9
  */
10
10
 
11
11
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
12
- import { join, resolve } from 'path'
12
+ import { join, resolve, basename, dirname } from 'path'
13
+ import { execFileSync } from 'child_process'
13
14
  import { git, unquoteGitPath } from './git-helper.js'
14
15
  import { pathMatches } from './change-list.js'
15
16
  import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
16
17
  import { resolveVerifyChangedFiles } from './verify-postcheck.js'
18
+ import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
17
19
  import { WorktreeManager } from './worktree.js'
18
20
  import { resolveRuntimeRoot } from './run/shared.js'
21
+ import { DB } from './db.js'
19
22
 
20
23
  // ── review.json schema version ──
21
24
  //
@@ -30,6 +33,16 @@ import { resolveRuntimeRoot } from './run/shared.js'
30
33
  // 跨仓 task 写 review.json 可用 v2 带 repo 字段,单仓/旧 change 仍用 v1。后续可拆分 stage-review 专用
31
34
  // 常量,本期遵循 allowed_paths 约束不扩散到 stage-review.js。
32
35
  export const REVIEW_SCHEMA_VERSION = 1
36
+
37
+ /**
38
+ * review.json 写入溯源戳(坑 review-json-mystery-overwrite,2026-09-12 驾驭第十七批①,
39
+ * 用户实证 task-04 review 被覆写成空壳、gate 报零改动伪造重写恢复——四个 writer 均有
40
+ * 覆盖守卫,无堆栈钉不死覆写者):每次写入附 writtenBy(写入通道 + PID)+ writtenAt,
41
+ * emptyDiff 伪造 error 直显两字段——下次复现自曝身份,事后可归因。
42
+ */
43
+ function reviewProvenanceStamp(channel) {
44
+ return { writtenBy: `${channel}#pid${process.pid}`, writtenAt: new Date().toISOString() }
45
+ }
33
46
  const REVIEW_SCHEMA_VERSIONS_ACCEPTED = [1, 2]
34
47
  // repo 缺省值(review.repo / task 卡 repo 缺省时统一按 'main' 处理)。design §7.2 / §7.4。
35
48
  const DEFAULT_REPO_KEY = 'main'
@@ -323,6 +336,26 @@ export function readReview(reviewPath) {
323
336
  return { ok: true, review: parsed, errors: [] }
324
337
  }
325
338
 
339
+ /**
340
+ * 判定 review 是否经显式 CLI 命令式写入(`sillyspec review write` → writeTaskReview)。
341
+ * (ql-20260915-001 修复②,坑 review-write-draft-marker-stuck:execute 期间反复
342
+ * 「草稿 review changedFiles 为空,跳过自动勾选」,主仓文档面任务自动勾选断链。)
343
+ *
344
+ * 判据:reviewProvenanceStamp 落的 writtenBy 以 'writeTaskReview' 开头(含 ':force' 变体)。
345
+ * 显式写入带 agent 供给的 verdict(非草稿兜底通道),即使 reviewerNotes 字面残留
346
+ * 'auto-generated draft'(agent 升级草稿时 --notes 引用/复述草稿文案、或 adopt 通道合并
347
+ * 保留旧 notes 的变体)也一律按非草稿处理——verdict 判定不再受草稿零 diff 守卫限制。
348
+ *
349
+ * 无 writtenBy 的存量 review(provenance 机制引入前手写)按非显式处理,走原 notes
350
+ * 启发式判定(向后兼容,行为不回退)。
351
+ *
352
+ * @param {object|null} review readReview 返回的 review 对象
353
+ * @returns {boolean}
354
+ */
355
+ export function isExplicitReviewWrite(review) {
356
+ return typeof review?.writtenBy === 'string' && review.writtenBy.startsWith('writeTaskReview')
357
+ }
358
+
326
359
  /**
327
360
  * 校验 plan.md 里所有「已勾选 [x]」task 的 review.json 是否 schema 完整(坑 review-json-field-gap)。
328
361
  *
@@ -493,7 +526,11 @@ export function validateTaskReviews(opts) {
493
526
  }
494
527
  }
495
528
  if (reviewGitDir) {
496
- const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache)
529
+ const evidence = verifyReviewGitEvidence(review, reviewGitDir, evidenceCache, {
530
+ mainGitDir: gitDir,
531
+ changeName: changeDir ? basename(changeDir) : null,
532
+ specBase: changeDir ? dirname(dirname(changeDir)) : null,
533
+ })
497
534
  for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
498
535
  if (!evidence.ok) {
499
536
  for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
@@ -515,7 +552,8 @@ export function validateTaskReviews(opts) {
515
552
  const scopeNote = Array.isArray(review.diffPaths) && review.diffPaths.length > 0
516
553
  ? `(diffPaths 路径限定切片为空——统一 commit 模式下 diffPaths 应为本 task 的 allowed_paths;task 确未实现请把 verdict 回 fail)`
517
554
  : ''
518
- errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造${scopeNote}`)
555
+ const prov = (review.writtenBy || review.writtenAt) ? `[写入溯源:${review.writtenBy || '?'} @ ${review.writtenAt || '?'}]` : '[无写入溯源—— older writer 产物]'
556
+ errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造${scopeNote}${prov}`)
519
557
  continue
520
558
  }
521
559
  }
@@ -622,7 +660,7 @@ function parsePorcelainFiles(statusOut) {
622
660
  * 一次校验批:working-tree status 是快照语义,跨批复用会读到陈旧状态。
623
661
  * @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
624
662
  */
625
- export function verifyReviewGitEvidence(review, gitDir, cache = null) {
663
+ export function verifyReviewGitEvidence(review, gitDir, cache = null, opts = {}) {
626
664
  const errors = []
627
665
  const warnings = []
628
666
  const c = cache || (cache = {})
@@ -667,7 +705,7 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
667
705
  // 可执行指引(坑 review-head-real-commit-late,2026-08-22 实证:head 必须真实 commit 的
668
706
  // 要求撞门才知道——报错直接给操作序列:worktree 改动先 commit,取 HEAD 作 head)
669
707
  errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造。` +
670
- `head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先 git add -A && git commit -m "<task>",` +
708
+ `head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先 sillyspec wt-commit --change <变更名> -m "<task-NN>" -- <本 task 精确路径>(串行锁 + pathspec,防卷入并行兄弟 WIP;退路 git add -- <精确路径> && git commit,禁 add -A),` +
671
709
  `再取 git rev-parse HEAD 作 head(base 用 task 卡锡点或基线 commit);不要填分支名/伪 hash/working-tree 描述`)
672
710
  }
673
711
  }
@@ -703,7 +741,22 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
703
741
  c.wtStatus.set(gitDir, wtStatus)
704
742
  }
705
743
  if (wtStatus && wtStatus.trim().length > 0) {
706
- const wtFiles = parsePorcelainFiles(wtStatus)
744
+ let wtFiles = parsePorcelainFiles(wtStatus)
745
+ // 归属过滤(2026-09-12 审查批 A-①,坑 task-review-foreign-wip-blindspot):主仓 in-place
746
+ // 模式下 wtStatus 含**全部**并行会话的未提交文件——他者 WIP 稀释 diffFiles 使 emptyDiff
747
+ // (零改动→伪造 ERROR)在共享仓永不触发、交叉比对更易混过。对齐 verify-postcheck
748
+ // resolveMainChangedFiles 的 splitOwnVsForeignDiffFiles 剔除口径(他者声明文件归他者)。
749
+ // 仅主仓 gitDir 过滤(跨仓/worktree 无主 specBase 声明源,行为不变);无 opts 零回归。
750
+ if (wtFiles.length > 0 && opts.mainGitDir && opts.changeName
751
+ && resolve(gitDir) === resolve(opts.mainGitDir)) {
752
+ try {
753
+ const { own, foreign } = splitOwnVsForeignDiffFiles(gitDir, opts.changeName, wtFiles, { specBase: opts.specBase })
754
+ if (foreign.length > 0) {
755
+ wtFiles = own
756
+ warnings.push(`working-tree 并入已排除 ${foreign.length} 个并行会话声明的文件(${foreign.slice(0, 3).map(x => x.file).join(', ')}${foreign.length > 3 ? ' 等' : ''})`)
757
+ }
758
+ } catch { /* 归属过滤失败退回全量并入(fail-open 与旧口径一致) */ }
759
+ }
707
760
  if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
708
761
  if (commitDiffFiles.length === 0) {
709
762
  warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),交叉对账并入 working-tree 未提交改动 ${wtFiles.length} 个文件 —— 按有效改动处理,不判零改动伪造`)
@@ -1072,6 +1125,130 @@ export function resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds =
1072
1125
  }
1073
1126
  }
1074
1127
 
1128
+ // ── 归因模式分流(2026-09-14-change-ownership-guards task-03 / D-004@v1 / FR-03)──
1129
+ // 草稿/changedFiles 归因的取数源路由:判定源=changes.isolation_mode 列(DB 读,meta 缺失也可判,
1130
+ // 与 change-registry.readChangeIsolation 同列;此处直连 db.js 读——task-review 是叶子模块,
1131
+ // 静态 import progress.js 会成环 progress→consistency-doctor→task-review)。进程内按
1132
+ // dbPath+change 缓存(CLI 短进程生命周期内 isolation_mode 不变,避免重复 open/init)。
1133
+ const _isolationModeCache = new Map()
1134
+
1135
+ /**
1136
+ * 读 changes.isolation_mode(只读;无库/无行/读失败 → null)。DB 路径候选序:
1137
+ * platformOpts.specRoot > specDriftAnchor > specBase > cwd/.sillyspec(首个存在 sillyspec.db 的根)。
1138
+ * 模块内函数(非 export——check-syntax 22e-b 死码门禁:无跨文件消费不导出)。
1139
+ * @param {{ cwd: string, specBase: string, platformOpts?: object, changeName: string }} opts
1140
+ * @returns {string|null} isolation_mode('worktree'|'native-worktree'|'in-place-fallback')或 null
1141
+ */
1142
+ function readChangeIsolationMode({ cwd, specBase, platformOpts = {}, changeName }) {
1143
+ if (!changeName) return null
1144
+ const roots = []
1145
+ const addRoot = (r) => { if (r && !roots.includes(r)) roots.push(r) }
1146
+ addRoot(platformOpts && platformOpts.specRoot)
1147
+ addRoot(platformOpts && platformOpts.specDriftAnchor)
1148
+ addRoot(specBase)
1149
+ addRoot(join(cwd, '.sillyspec'))
1150
+ for (const root of roots) {
1151
+ const dbPath = join(root, '.runtime', 'sillyspec.db')
1152
+ if (!existsSync(dbPath)) continue
1153
+ const key = dbPath + '::' + changeName
1154
+ if (_isolationModeCache.has(key)) return _isolationModeCache.get(key)
1155
+ let mode = null
1156
+ try {
1157
+ const db = new DB(dbPath)
1158
+ db.init()
1159
+ const row = db.getDb().prepare('SELECT isolation_mode FROM changes WHERE name = ?').get(changeName)
1160
+ mode = (row && typeof row.isolation_mode === 'string' && row.isolation_mode) || null
1161
+ } catch { mode = null }
1162
+ _isolationModeCache.set(key, mode)
1163
+ return mode
1164
+ }
1165
+ return null
1166
+ }
1167
+
1168
+ /**
1169
+ * 归因取数源路由(单一入口,草稿生成 generateTaskReviewDrafts 与 mechanics 代算 writeTaskReview
1170
+ * 两处共用)。判定优先级(D-004):
1171
+ * 1. DB isolation_mode ∈ {worktree, native-worktree} → worktree 归因:worktree 目录活则原口径
1172
+ * (resolveVerifyChangedFiles meta 路径 + porcelain 并入,零回归);meta 缺失/in-place meta/
1173
+ * 目录已删 → 分支 ref 口径(merge-base(主仓 HEAD, 分支 tip)..tip 的 commit diff + worktree
1174
+ * 目录在则 porcelain 并入,与 verify reconcile 同口径)。分支 ref 也已删(cleanup 后态)→
1175
+ * fail-closed 空集 +「不可归因(worktree 已清理)」注记,绝不回退主仓窗口(复审残留①)。
1176
+ * 2. DB in-place-fallback 但 meta 判 worktree 且目录活 → 仍取 worktree(fail-safe:不因 DB 行
1177
+ * 陈旧切向主仓共享脏窗口——归因源只会更隔离不会更共享),注记不一致。
1178
+ * 3. 其余(DB in-place / NULL)→ 主仓窗口原行为零回归;NULL 且 meta 缺失加存量路由注记
1179
+ * (存量态不追求完美只保安全,D-004 残留②/R-04)。
1180
+ * meta 在时交叉校验不一致以 DB 为准(方向 1 的 worktree 覆盖);返回 notes 逐条说明路由依据。
1181
+ *
1182
+ * @param {{ cwd: string, changeName: string, specBase: string, platformOpts?: object }} opts
1183
+ * @returns {{ mode: 'worktree'|'worktree-branch'|'worktree-cleaned'|'main-window', diffFiles: string[]|null, notes: string[], meta: object|null, mergeGitDir: string|null, branch?: string, branchBase?: string, branchHead?: string }}
1184
+ */
1185
+ function resolveAttributionDiffFiles({ cwd, changeName, specBase, platformOpts = {} }) {
1186
+ const notes = []
1187
+ const wm = new WorktreeManager({ cwd })
1188
+ let meta = null
1189
+ try { meta = wm.getMeta(changeName) } catch { meta = null }
1190
+ const iso = readChangeIsolationMode({ cwd, specBase, platformOpts, changeName })
1191
+ const isoWorktree = iso === 'worktree' || iso === 'native-worktree'
1192
+
1193
+ if (isoWorktree) {
1194
+ const wtGitDir = (meta && meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1195
+ ? meta.worktreePath
1196
+ : null
1197
+ if (wtGitDir) {
1198
+ if (meta.mode !== iso) notes.push(`meta.mode=${meta.mode} 与 DB isolation_mode=${iso} 不一致——以 DB 为准(同属 worktree 族,归因源不变)`)
1199
+ return { mode: 'worktree', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes, meta, mergeGitDir: wtGitDir }
1200
+ }
1201
+ // meta 缺失 / in-place meta / worktree 目录已删:分支 ref 判定(不落主仓窗口)
1202
+ const branch = (meta && meta.branch) || `sillyspec/${changeName}`
1203
+ let branchTip = null
1204
+ try { branchTip = runGit(cwd, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]) } catch { branchTip = null }
1205
+ if (!branchTip) {
1206
+ notes.push(`不可归因(worktree 已清理)——DB 判 worktree(isolation_mode=${iso})但分支 ref ${branch} 不存在(cleanup 后态),fail-closed 空集,不回退主仓窗口`)
1207
+ return { mode: 'worktree-cleaned', diffFiles: [], notes, meta, mergeGitDir: null }
1208
+ }
1209
+ let branchBase = null
1210
+ let files = []
1211
+ try {
1212
+ const mainHead = runGit(cwd, ['rev-parse', 'HEAD'])
1213
+ const mb = mainHead ? runGit(cwd, ['merge-base', branchTip, mainHead]) : null
1214
+ branchBase = mb || mainHead || null
1215
+ if (branchBase) {
1216
+ files = String(runGit(cwd, ['diff', '--name-only', branchBase, branchTip]) || '')
1217
+ .split('\n').map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim())
1218
+ .filter(p => p && p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1219
+ }
1220
+ } catch { /* 已提交口径 git 失败 → 空集 fail-closed(不回退主仓窗口) */ }
1221
+ // 未提交并入:约定路径 worktree 目录仍在(meta 失联孤儿)→ porcelain(与 verify reconcile 同口径)
1222
+ const wtDirGuess = join(wm.worktreeBase, changeName)
1223
+ if (existsSync(wtDirGuess)) {
1224
+ try {
1225
+ const st = runGit(wtDirGuess, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1226
+ const wtFiles = parsePorcelainFiles(st)
1227
+ .map(p => String(p).replace(/\\/g, '/'))
1228
+ .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1229
+ files = [...new Set([...files, ...wtFiles])]
1230
+ } catch { /* porcelain 失败退回 commit diff 口径 */ }
1231
+ }
1232
+ notes.push(`meta 缺失/失效,DB isolation_mode=${iso} 判 worktree——归因源=worktree 分支 ${branch} diff(base..HEAD+porcelain,与 verify reconcile 同口径),不落主仓共享脏窗口`)
1233
+ return { mode: 'worktree-branch', diffFiles: files, notes, meta, mergeGitDir: null, branch, branchBase, branchHead: branchTip }
1234
+ }
1235
+
1236
+ if (iso === 'in-place-fallback' && meta && meta.mode !== 'in-place-fallback'
1237
+ && meta.worktreePath && existsSync(meta.worktreePath)) {
1238
+ notes.push(`DB isolation_mode=in-place-fallback 但 meta.mode=${meta.mode}——worktree 分支仍是隔离归因源(fail-safe:不因 DB 行陈旧切向主仓共享窗口),按 meta 取 worktree 归因`)
1239
+ return { mode: 'worktree', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes, meta, mergeGitDir: meta.worktreePath }
1240
+ }
1241
+
1242
+ // in-place(DB 或 meta)与 NULL 存量:主仓窗口(原行为零回归)
1243
+ if (!meta && iso === null) {
1244
+ notes.push('isolation_mode 未登记(存量变更/无 DB 行)且 worktree meta 缺失——按主仓窗口归因(存量兼容路由,D-004 残留②:不追求完美只保安全)')
1245
+ }
1246
+ const mergeGitDir = meta
1247
+ ? ((meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) ? meta.worktreePath : cwd)
1248
+ : null
1249
+ return { mode: 'main-window', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes, meta, mergeGitDir }
1250
+ }
1251
+
1075
1252
  /**
1076
1253
  * worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
1077
1254
  * 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
@@ -1139,21 +1316,30 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1139
1316
  }
1140
1317
  }
1141
1318
 
1319
+ // ── 归因模式分流(task-03 / D-004@v1):判定源=changes.isolation_mode(DB 读)──
1320
+ // worktree 模式(含 meta 缺失回退态)归因唯一源=worktree 分支 diff(绝不落主仓共享脏窗口,
1321
+ // troubleshooting §65 事件②根因);in-place 维持主仓窗口;NULL 存量按 meta 在场路由。
1322
+ // 分流自身异常 → 退回原 resolveVerifyChangedFiles 口径(fail-open,不比修复前差)。
1323
+ let attribution
1324
+ try {
1325
+ attribution = resolveAttributionDiffFiles({ cwd, changeName, specBase, platformOpts })
1326
+ } catch (e) {
1327
+ attribution = { mode: 'main-window', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes: [`归因分流解析异常(${e && e.message ? e.message : e})——退回原口径`], meta: null, mergeGitDir: null }
1328
+ }
1329
+ for (const note of attribution.notes) console.log(`[sillyspec] 归因注记:${note}`)
1142
1330
  // base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
1143
- let diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { specBase })
1331
+ let diffFiles = attribution.diffFiles
1144
1332
  // ⚠️ 并入 worktree 未提交改动(坑 draft-attribution-uncommitted-worktree,2026-08-21 实证):
1145
1333
  // 子代理默认不 commit(execute 复盘 a 同源事实),真实改动全在 worktree working-tree——只看
1146
1334
  // base..HEAD commit diff 时归属恒空,9/9 草稿全成「无归属」靠主代理手写升级。与
1147
1335
  // verifyReviewGitEvidence 的 working-tree 并入口径同源:status --porcelain 文件(排除
1148
1336
  // .sillyspec/ 运行时产物)并入归属集,按 allowed_paths 正常路径归属;review.head 仍为 HEAD
1149
1337
  // commit——evidence 校验对「commit diff 空 + working-tree 有改动」明示不判伪造,语义一致。
1150
- try {
1151
- const wm = new WorktreeManager({ cwd })
1152
- const meta = wm.getMeta(changeName)
1153
- if (meta) {
1154
- const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1155
- ? meta.worktreePath
1156
- : cwd
1338
+ // D-004 后仅在归因源指向活跃 worktree 目录 / in-place meta(mergeGitDir)时执行——
1339
+ // worktree-branch/cleaned 归因的未提交并入已在 resolveAttributionDiffFiles 内按同口径完成。
1340
+ if (attribution.mergeGitDir) {
1341
+ try {
1342
+ const wtGitDir = attribution.mergeGitDir
1157
1343
  const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1158
1344
  const wtFiles = parsePorcelainFiles(wtStatus)
1159
1345
  .map(p => String(p).replace(/\\/g, '/'))
@@ -1180,12 +1366,19 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1180
1366
  diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles, ...committedFiles])]
1181
1367
  console.log(`[sillyspec] 草稿归属并入 worktree 改动 ${wtFiles.length + committedFiles.length} 个(未提交 ${wtFiles.length} + 已提交 ${committedFiles.length},按 allowed_paths 归属)`)
1182
1368
  }
1183
- }
1184
- } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
1369
+ } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
1370
+ }
1185
1371
  // 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
1186
1372
  // 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
1373
+ // 终态空源(D-004):worktree-cleaned(DB 判 worktree 但分支 ref 已删)空集的 reason 显式
1374
+ // 注记「不可归因」,与普通「无 diff」区分——绝不回退主仓窗口补救。
1187
1375
  if (!ctx && (!diffFiles || diffFiles.length === 0)) {
1188
- return { generated: 0, skipped: 0, unattributed: [], executeRunId, reason: 'base..head 无代码 diff(改动未 commit?)' }
1376
+ return {
1377
+ generated: 0, skipped: 0, unattributed: [], executeRunId,
1378
+ reason: attribution.mode === 'worktree-cleaned'
1379
+ ? '不可归因(worktree 已清理)——DB 判 worktree 但分支 ref 已删,fail-closed 空集(不回退主仓窗口)'
1380
+ : 'base..head 无代码 diff(改动未 commit?)',
1381
+ }
1189
1382
  }
1190
1383
 
1191
1384
  // base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
@@ -1217,6 +1410,15 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1217
1410
  // 有 ctx:主仓 HEAD 失败不阻断跨仓 task;head 留 null,主仓 task 后续按空 changedFiles 跳过
1218
1411
  }
1219
1412
  }
1413
+ // worktree-branch 归因的 base/head 锚定(task-03 / D-004):meta 缺失但分支 ref 活着时用
1414
+ // merge-base(主仓 HEAD, 分支 tip)=创建锚点..分支 tip 锚定——DB 判 worktree 的回退态仍能
1415
+ // 生成带真实 git 锚点的草稿(否则上方「无 meta.baseHash」早退白费分支归因成果)。分支 ref
1416
+ // 在主仓共享 .git 内,reviewGitDir=cwd 可解析,evidence 校验的 base..head diff 与归因同源。
1417
+ if (!base && attribution.branchBase && attribution.branchHead) {
1418
+ base = attribution.branchBase
1419
+ head = attribution.branchHead
1420
+ reviewGitDir = cwd
1421
+ }
1220
1422
 
1221
1423
  const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1222
1424
  let generated = 0
@@ -1321,6 +1523,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1321
1523
  ';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
1322
1524
  }
1323
1525
  if (draftRepo) draft.repo = draftRepo
1526
+ Object.assign(draft, reviewProvenanceStamp('generateTaskReviewDrafts:no-attribution'))
1324
1527
  // fail-open(对齐函数头契约 + execute-run-dir-fail-loud 测试锁定的「写失败返回统计不抛」):
1325
1528
  // 无归属草稿写入失败(如 tasks/ 被普通文件占位 ENOTDIR)降级 skipped,不崩整个草稿生成
1326
1529
  try {
@@ -1352,6 +1555,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1352
1555
  reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
1353
1556
  }
1354
1557
  if (draftRepo) draft.repo = draftRepo
1558
+ Object.assign(draft, reviewProvenanceStamp('generateTaskReviewDrafts'))
1355
1559
  mkdirSync(reviewDir, { recursive: true })
1356
1560
  writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
1357
1561
  generated++
@@ -1568,6 +1772,7 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
1568
1772
 
1569
1773
  try {
1570
1774
  if (mechanicsChanged || verdictTouched || evidenceFilled) {
1775
+ Object.assign(merged, reviewProvenanceStamp('adoptTaskReviewMechanics'))
1571
1776
  writeFileSync(reviewPath, JSON.stringify(merged, null, 2) + '\n')
1572
1777
  adopted++
1573
1778
  } else {
@@ -1618,6 +1823,89 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
1618
1823
  * @param {object} [opts.platformOpts]
1619
1824
  * @returns {Promise<{ok: boolean, reviewPath?: string, executeRunId?: string, errors: string[], warnings: string[]}>}
1620
1825
  */
1826
+ // ── ql-20260915-003 修复③(坑 interrupted-residue-detect,2026-09-15 用户实证:中断的
1827
+ // execute 子代理在工作区留下半成品 import 语法坏文件,无自动检测靠人工 diff 发现)──
1828
+ //
1829
+ // 对 changedFiles 中的 .js/.mjs/.cjs 逐个 `node --check`(子进程数组形式不经 shell,单文件
1830
+ // 5s 超时;总数 >60 抽样前 60 并提示——大变更全量查的耗时不可接受)。
1831
+ //
1832
+ // 判定分层(Node v24 实测:.js 的 file-check 受最近 package.json type 影响——无 type 时
1833
+ // ESM 半成品可能静默通过、合法 ESM 又会报 import 语句误错):
1834
+ // - .mjs/.cjs:file-check 权威(对应 module/commonjs 全量解析)
1835
+ // - .js:file-check **与** stdin `--input-type=module` check 双跑,两者都报 SyntaxError
1836
+ // 才判残留(合法 ESM / 合法 CJS(含 sloppy-mode)必有一路通过,零假阳;真断档半成品
1837
+ // 两路都挂)。stdin check 不可用/无 SyntaxError 字样 → fail-open 跳过该文件。
1838
+ //
1839
+ // 语法失败 → 返回「疑似中断残留」warning(advisory:只提醒,不改 verdict / 勾选语义 /
1840
+ // 落盘行为——quick --done 的 test/lint 实测硬门才是卡点,这里补「中断半成品」的可见性)。
1841
+ // 文件在候选根下不存在(diff 里的删除态文件等)直接跳过;超时(e.killed)无法定论不报警。
1842
+ // 仅 review write 时点执行一次,非热路径。
1843
+ const SYNTAX_CHECK_EXT_RE = /\.(?:js|mjs|cjs)$/
1844
+ const SYNTAX_CHECK_MAX_FILES = 60
1845
+ // 严格 .js(.mjs/.cjs 也 endsWith('.js'),须排除——否则 sloppy-mode .cjs 会误入 stdin ESM
1846
+ // 复核路径产生假阳)
1847
+ const isPlainJs = (f) => f.endsWith('.js') && !f.endsWith('.mjs') && !f.endsWith('.cjs')
1848
+ function _syntaxErrLine(e) {
1849
+ const raw = String((e.stderr && e.stderr.toString()) || e.message || '')
1850
+ const line = raw.split('\n').find(l => l.includes('SyntaxError'))
1851
+ return line ? line.trim() : null
1852
+ }
1853
+ function detectInterruptedSyntaxResidue(changedFiles, candidateRoots) {
1854
+ const warnings = []
1855
+ const jsFiles = (Array.isArray(changedFiles) ? changedFiles : [])
1856
+ .filter(f => typeof f === 'string' && SYNTAX_CHECK_EXT_RE.test(f))
1857
+ if (jsFiles.length === 0) return warnings
1858
+ const overCap = jsFiles.length > SYNTAX_CHECK_MAX_FILES
1859
+ for (const f of overCap ? jsFiles.slice(0, SYNTAX_CHECK_MAX_FILES) : jsFiles) {
1860
+ let abs = null
1861
+ for (const root of candidateRoots) {
1862
+ if (!root) continue
1863
+ const p = join(root, f)
1864
+ if (existsSync(p)) { abs = p; break }
1865
+ }
1866
+ if (!abs) continue
1867
+ try {
1868
+ execFileSync(process.execPath, ['--check', abs], { timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
1869
+ if (isPlainJs(f)) {
1870
+ // 无 type:module 上下文的 .js:file-check 通过不代表语法完整(Node v24 实测假阴),
1871
+ // 再跑 stdin ESM 全量解析兜底——两路都挂才判残留
1872
+ let src = ''
1873
+ try { src = readFileSync(abs, 'utf8') } catch { continue }
1874
+ try {
1875
+ execFileSync(process.execPath, ['--input-type=module', '--check'], { input: src, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
1876
+ } catch (e2) {
1877
+ if (e2 && e2.killed) continue
1878
+ const line = _syntaxErrLine(e2)
1879
+ if (!line) continue // 非 SyntaxError(如版本不支持 stdin check)→ fail-open
1880
+ warnings.push(`⚠️ 疑似中断残留:${f} 语法错误(${line})——子代理异常退出可能留下半成品,请 diff 该文件确认还原或补完`)
1881
+ }
1882
+ }
1883
+ } catch (e) {
1884
+ if (e && e.killed) continue // 单文件超时:无法定论,不产假阳 warning
1885
+ if (!isPlainJs(f)) {
1886
+ const line = _syntaxErrLine(e)
1887
+ if (line) warnings.push(`⚠️ 疑似中断残留:${f} 语法错误(${line})——子代理异常退出可能留下半成品,请 diff 该文件确认还原或补完`)
1888
+ continue
1889
+ }
1890
+ // .js file-check 挂可能是模块口径误错(合法 ESM 在无 type 上下文报 import 语句)——
1891
+ // stdin ESM 解析仲裁:也挂才算真残留
1892
+ let src = ''
1893
+ try { src = readFileSync(abs, 'utf8') } catch { continue }
1894
+ try {
1895
+ execFileSync(process.execPath, ['--input-type=module', '--check'], { input: src, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
1896
+ } catch (e2) {
1897
+ if (e2 && e2.killed) continue
1898
+ const line = _syntaxErrLine(e2) || _syntaxErrLine(e)
1899
+ if (line) warnings.push(`⚠️ 疑似中断残留:${f} 语法错误(${line})——子代理异常退出可能留下半成品,请 diff 该文件确认还原或补完`)
1900
+ }
1901
+ }
1902
+ }
1903
+ if (overCap) {
1904
+ warnings.push(`⚠️ 中断残留检测抽样:changedFiles 含 ${jsFiles.length} 个 .js 系文件,仅语法检查前 ${SYNTAX_CHECK_MAX_FILES} 个(interrupted-residue-detect)`)
1905
+ }
1906
+ return warnings
1907
+ }
1908
+
1621
1909
  export async function writeTaskReview({
1622
1910
  changeName, cwd, taskId, specVerdict, qualityVerdict,
1623
1911
  reviewerNotes = '', requiredEvidence = [],
@@ -1724,22 +2012,30 @@ export async function writeTaskReview({
1724
2012
  changedFiles = changedFilesOverride.map(p => String(p).replace(/\\/g, '/'))
1725
2013
  warnings.push(`changedFiles 采用显式覆盖(${changedFiles.length} 个)——evidence 校验仍会与 git diff 相交比对`)
1726
2014
  } else {
1727
- let diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { specBase })
1728
- // 并入 worktree 未提交改动(与草稿生成同源口径:子代理默认不 commit)
2015
+ // ── 归因模式分流(task-03 / D-004@v1):与草稿生成同源路由(判定源=changes.isolation_mode)──
2016
+ // worktree 模式(含 meta 缺失回退态)取 worktree 分支 diff,绝不落主仓共享脏窗口;
2017
+ // in-place/NULL 存量维持主仓窗口原口径。分流异常退回原 resolveVerifyChangedFiles(fail-open)。
2018
+ let attribution
1729
2019
  try {
1730
- const wm = new WorktreeManager({ cwd })
1731
- const meta = wm.getMeta(changeName)
1732
- if (meta) {
1733
- const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1734
- ? meta.worktreePath
1735
- : cwd
2020
+ attribution = resolveAttributionDiffFiles({ cwd, changeName, specBase, platformOpts })
2021
+ } catch (e) {
2022
+ attribution = { mode: 'main-window', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes: [`归因分流解析异常(${e && e.message ? e.message : e})——退回原口径`], meta: null, mergeGitDir: null }
2023
+ }
2024
+ for (const note of attribution.notes) warnings.push(`归因注记:${note}`)
2025
+ let diffFiles = attribution.diffFiles
2026
+ // 并入 worktree 未提交改动(与草稿生成同源口径:子代理默认不 commit)。D-004 后仅在
2027
+ // 归因源指向活跃 worktree 目录 / in-place meta(mergeGitDir)时执行——worktree-branch/
2028
+ // cleaned 归因的未提交并入已在 resolveAttributionDiffFiles 内按同口径完成。
2029
+ if (attribution.mergeGitDir) {
2030
+ try {
2031
+ const wtGitDir = attribution.mergeGitDir
1736
2032
  const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1737
2033
  const wtFiles = parsePorcelainFiles(wtStatus)
1738
2034
  .map(p => String(p).replace(/\\/g, '/'))
1739
2035
  .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1740
2036
  if (wtFiles.length > 0) diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
1741
- }
1742
- } catch { /* 退回 commit diff 口径 */ }
2037
+ } catch { /* 退回 commit diff 口径 */ }
2038
+ }
1743
2039
  const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1744
2040
  changedFiles = allowedPaths.length > 0
1745
2041
  ? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
@@ -1757,6 +2053,15 @@ export async function writeTaskReview({
1757
2053
  }
1758
2054
  }
1759
2055
 
2056
+ // ── ql-20260915-003 修复③(坑 interrupted-residue-detect):changedFiles 定稿后的
2057
+ // 语法检查(advisory warning,见 detectInterruptedSyntaxResidue 注释)。候选根依次试
2058
+ // reviewGitDir(meta 活跃时的 worktree 根)、cwd、worktree-branch 归因的孤儿目录兜底。
2059
+ for (const w of detectInterruptedSyntaxResidue(changedFiles, [
2060
+ reviewGitDir,
2061
+ cwd,
2062
+ join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName),
2063
+ ])) warnings.push(w)
2064
+
1760
2065
  // ── 组装 + schema 自检 + 落盘 ──
1761
2066
  const review = {
1762
2067
  schemaVersion: REVIEW_SCHEMA_VERSION,
@@ -1770,6 +2075,7 @@ export async function writeTaskReview({
1770
2075
  ...(requiredEvidence.filter(Boolean).length > 0 ? { requiredEvidence: requiredEvidence.filter(Boolean) } : {}),
1771
2076
  reviewerNotes: reviewerNotes || `written by sillyspec review write (${base.slice(0, 8)}..${head.slice(0, 8)})`,
1772
2077
  }
2078
+ Object.assign(review, reviewProvenanceStamp('writeTaskReview' + (force ? ':force' : '')))
1773
2079
  const schemaResult = validateReviewSchema(review)
1774
2080
  if (!schemaResult.ok) {
1775
2081
  return { ok: false, errors: schemaResult.errors, warnings }
@@ -42,8 +42,15 @@ export function classifyVerifiedFile(path) {
42
42
  /** 证据账 task 行:`- task-NN: <status> | verifiedFiles: a, b(豁免:…)`——占位 <待填:*> 不命中(fail-closed) */
43
43
  const EVIDENCE_LINE_RE = /^- (task-\d+):\s*(satisfied|missing|partial)\b([^\n]*)$/
44
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|]+)/
45
+ /**
46
+ * 回执行:`- claim: … | command: … | exit: 0 | log: path`——四字段行首锚定,占位不命中。
47
+ * 双宽度容收(坑 receipt-fullwidth-parse,2026-09-12 驾驭第十四批③,用户上一变更实证、
48
+ * 本次手动避开后要求修根:①分隔符同时认半角 | 与全角 |(中文输入法手写回执高频形态,
49
+ * 全角形态旧正则整行不命中 → 回执槽 0 条 → integration-critical 误报无绿回执);
50
+ * ②log 字段 rest-of-line(旧 [^\s|+] 遇含空格/全角括号路径截断——`日志(第 1 次).log`
51
+ * 被截成 `日志(第` → 「日志不存在」假红);log 是末字段,行内若有 |/| 尾注取首段剥注。
52
+ */
53
+ const RECEIPT_LINE_RE = /^- claim:\s*([^||\n]+)[||]\s*command:\s*([^||\n]+)[||]\s*exit:\s*(\d+)\s*[||]\s*log:\s*(.+)$/
47
54
 
48
55
  /**
49
56
  * 解析 verify-result.md 的两个受控槽段(D-001@v2 slot-backfill 录入界面)。
@@ -101,7 +108,7 @@ export function parseEvidenceSlots(mdText) {
101
108
  claim: m[1].trim(),
102
109
  command: m[2].trim(),
103
110
  exitCode: Number(m[3]),
104
- logPath: m[4].trim().replace(/^`|`$/g, ''),
111
+ logPath: m[4].split(/[||]/)[0].trim().replace(/^`|`$/g, ''),
105
112
  })
106
113
  }
107
114
  }
@@ -88,7 +88,11 @@ export function extractTestCommand(yamlText) {
88
88
  if (quoted && quoted[1]) {
89
89
  return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
90
90
  }
91
- const bare = yamlText.match(/^\s*test:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
91
+ // bare 值不得排除引号字符(坑 verify-bare-quote-miss,2026-09-12 审查批 B-③):
92
+ // `test: cd "C:\proj" && npm test` 是合法 YAML 裸标量,旧 char class [^\n#"'] 对含引号值
93
+ // 整体不匹配 → 判「未配置」→ 实测硬门静默 skipped(fail-open)。全引号形态已被上方
94
+ // quoted 分支先行消费,此处的引号只可能是嵌在值中部的。同款修正在 extractLintCommand。
95
+ const bare = yamlText.match(/^\s*test:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
92
96
  if (bare && bare[1]) {
93
97
  const cmd = bare[1].trim()
94
98
  return cmd.toLowerCase() === 'unavailable' ? null : cmd
@@ -109,7 +113,7 @@ function extractLintCommand(yamlText) {
109
113
  if (quoted && quoted[1]) {
110
114
  return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
111
115
  }
112
- const bare = yamlText.match(/^\s*lint:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
116
+ const bare = yamlText.match(/^\s*lint:[ \t]*([^\n#]+?)[ \t]*(?:#.*)?$/m)
113
117
  if (bare && bare[1]) {
114
118
  const cmd = bare[1].trim()
115
119
  return cmd.toLowerCase() === 'unavailable' ? null : cmd
@@ -154,7 +158,7 @@ function recordVerifyLintTally({ specBase, result }) {
154
158
  * advisory 起步:失败只打印不阻断(test 门已 fail-closed,lint 门观察期后再升级)。
155
159
  * 信任边界与 runVerifyTestCheck 一致:命令只来源于主仓 .sillyspec/local.yaml。
156
160
  */
157
- export function runVerifyLintCheck({ cwd, specBase }) {
161
+ export function runVerifyLintCheck({ cwd, specBase, timeoutMs } = {}) {
158
162
  const localYamlPath = join(specBase, 'local.yaml')
159
163
  const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
160
164
  const command = extractLintCommand(yamlText)
@@ -172,7 +176,9 @@ export function runVerifyLintCheck({ cwd, specBase }) {
172
176
  }
173
177
  }
174
178
 
175
- const LINT_TIMEOUT_MS = Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
179
+ // timeoutMs(2026-09-12 驾驭第十七批 dogfood):门禁快照内 node_modules 经 junction I/O 较慢,
180
+ // 3min 默认预算实测被超时杀(180.1s 假败)——快照路径显式传 5min;优先级 显式参 > env > 默认
181
+ const LINT_TIMEOUT_MS = timeoutMs || Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
176
182
  const startedAt = Date.now()
177
183
  let exitCode = 0
178
184
  let output = ''
@@ -577,7 +583,12 @@ export function extractModules(yamlText) {
577
583
  */
578
584
  function parseFlowValue(flowText, key) {
579
585
  // 键可能带引号也可能不带:path: "x" 或 "path": "x"
580
- const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]+?))\s*(?=[,}]|$)`)
586
+ // bare 值的逗号终止是「下一键开始的逗号」而非任意逗号(坑 flow-value-comma-truncate,
587
+ // 2026-09-12 审查批 C-③):`test: pytest -k a,b` 旧 [^,}]+? 在第一个逗号截断 → 实测跑
588
+ // 残损命令 `pytest -k a`(假红/碰巧假绿)。bare 分支与终止 lookahead 双侧同口径:只把
589
+ // 「后随键形态的逗号」当分隔符——消费侧负向前瞻排除它,终止侧正向前瞻要求它(或 } / 行尾)。
590
+ const sepComma = String.raw`,\s*"?[A-Za-z0-9_.\-]+"?\s*:`
591
+ const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]|,(?!${sepComma.slice(1)}))+?)\s*(?=\}|${sepComma}|$)`)
581
592
  const m = flowText.match(re)
582
593
  if (!m) return null
583
594
  let v = m[1].trim()
@@ -666,7 +677,11 @@ export function extractKnownFailures(yamlText) {
666
677
  // CRLF 归一(坑 verify-modules-crlf-blanket-fallback):块式正则的 `.+`/`\n?` 在 CRLF 行
667
678
  // 间失配,只捕获到第一条豁免项就停——归一后整块捕获恢复
668
679
  const yaml = normalizeLineEndings(yamlText)
669
- const inline = yaml.match(/^known_failures:\s*\[([^\]]*)\]\s*(?:#.*)?$/m)
680
+ // inline 捕获组用贪婪 .*(坑 known-failures-inline-nested-bracket,2026-09-12 审查批 C-④):
681
+ // 旧 [^\]]* 在值内首个 ] 停——豁免项是 glob/pytest id 可含 [] 字符类(tests/\[x\]::case),
682
+ // 旧版整表静默清空 → 预存失败全落 remaining 假红且难归因。贪婪 .* + 后随 \] 行尾锚,
683
+ // 吃到最后一个 ](行尾注释里含 ] 的极端形态会被并入值,远优于静默清空)。
684
+ const inline = yaml.match(/^known_failures:\s*\[(.*)\]\s*(?:#.*)?$/m)
670
685
  if (inline) {
671
686
  return inline[1].split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean)
672
687
  }
@@ -1148,6 +1163,26 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1148
1163
  ? meta.worktreePath
1149
1164
  : cwd
1150
1165
  const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
1166
+ // 坑 verify-evidence-account-diff-misses-committed-changes(2026-09-13 实证):
1167
+ // wt-commit 流程把本变更提交落在**主仓 HEAD**,worktree 分支不前移——仅查 worktree
1168
+ // 侧 diffBase..HEAD 时已提交文件全部 miss(7 task diffHit=false 连环假红,verify 两连
1169
+ // 阻断只能走人工豁免)。并集**主仓同区间** diff(对齐 wt-commit 产出形态,坑文档建议的
1170
+ // 「change 基线 commit..HEAD」口径);主仓区间含并行会话提交 → 过他者声明过滤
1171
+ //(splitOwnVsForeignDiffFiles,与下方 fallback 同款)防 diffHit 假阳。主仓查询失败
1172
+ // 不损原 worktree 结果。
1173
+ if (files !== null && gitDir !== cwd) {
1174
+ try {
1175
+ const mainFiles = runGitDiffNameOnly(cwd, `${diffBase}..HEAD`)
1176
+ if (Array.isArray(mainFiles)) {
1177
+ const merged = [...new Set([...files, ...mainFiles])]
1178
+ const { own, foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, merged, { specBase: sb })
1179
+ if (foreign.length > 0) {
1180
+ console.warn(`⚠️ verify 对账(主仓区间并集)已排除 ${foreign.length} 个并行会话声明的文件:${foreign.slice(0, 5).map(x => `${x.file}←${x.owners[0]}`).join(', ')}${foreign.length > 5 ? ' 等' : ''}`)
1181
+ }
1182
+ return own
1183
+ }
1184
+ } catch { /* 主仓区间查询失败 → 保持 worktree 结果 */ }
1185
+ }
1151
1186
  if (files !== null) return files
1152
1187
  // worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
1153
1188
  }