sillyspec 3.28.8 → 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 (61) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  4. package/AGENTS.md +3 -2
  5. package/README.md +35 -11
  6. package/SKILL.md +24 -10
  7. 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
  8. package/package.json +1 -1
  9. package/src/change-risk-profile.js +20 -0
  10. package/src/config-schema.js +32 -0
  11. package/src/db.js +11 -3
  12. package/src/decision-distill.js +15 -3
  13. package/src/design-facts.js +33 -3
  14. package/src/docs-gate.js +105 -6
  15. package/src/doctor-diagnostics.js +224 -2
  16. package/src/friction-ledger.js +171 -0
  17. package/src/hooks/worktree-guard.js +13 -1
  18. package/src/index.js +188 -17
  19. package/src/knowledge-classify.js +363 -0
  20. package/src/knowledge-hits.js +74 -0
  21. package/src/knowledge-stats.js +180 -0
  22. package/src/progress/change-registry.js +183 -8
  23. package/src/progress/shared.js +3 -2
  24. package/src/progress.js +100 -8
  25. package/src/quick-gate-profile.js +251 -0
  26. package/src/quicklog.js +155 -14
  27. package/src/review-dispatch.js +2 -1
  28. package/src/run/command.js +107 -12
  29. package/src/run/complete-handlers.js +511 -21
  30. package/src/run/complete.js +54 -16
  31. package/src/run/concurrent-detect.js +98 -0
  32. package/src/run/gates.js +50 -9
  33. package/src/run/prompt.js +134 -5
  34. package/src/run/quick-audit.js +111 -13
  35. package/src/run/shared.js +76 -10
  36. package/src/run/stage.js +6 -0
  37. package/src/scan-diff.js +61 -30
  38. package/src/scan-postcheck.js +74 -0
  39. package/src/scan-refresh.js +477 -0
  40. package/src/scan-staleness.js +25 -13
  41. package/src/scope-audit.js +365 -35
  42. package/src/stage-contract.js +185 -2
  43. package/src/stage-review.js +23 -7
  44. package/src/stages/brainstorm.js +6 -0
  45. package/src/stages/execute.js +85 -3
  46. package/src/stages/knowledge.js +52 -3
  47. package/src/stages/quick.js +1 -1
  48. package/src/stages/verify.js +1 -0
  49. package/src/sync-noise.js +114 -6
  50. package/src/sync.js +135 -7
  51. package/src/task-review.js +292 -21
  52. package/src/verify-postcheck.js +20 -0
  53. package/src/verify-probes.js +340 -4
  54. package/src/worktree-apply.js +538 -44
  55. package/src/worktree-deps.js +100 -2
  56. package/src/worktree.js +117 -5
  57. package/templates/agents-instruction.md +2 -2
  58. package/templates/prompts/testcase-design.md +2 -1
  59. package/templates/prompts/verify-probes.md +4 -1
  60. package/.patch16-tmp.mjs +0 -169
  61. package/sa-rename-probe/src/new.js +0 -4
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
12
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'
@@ -17,6 +18,7 @@ import { resolveVerifyChangedFiles } from './verify-postcheck.js'
17
18
  import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
18
19
  import { WorktreeManager } from './worktree.js'
19
20
  import { resolveRuntimeRoot } from './run/shared.js'
21
+ import { DB } from './db.js'
20
22
 
21
23
  // ── review.json schema version ──
22
24
  //
@@ -334,6 +336,26 @@ export function readReview(reviewPath) {
334
336
  return { ok: true, review: parsed, errors: [] }
335
337
  }
336
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
+
337
359
  /**
338
360
  * 校验 plan.md 里所有「已勾选 [x]」task 的 review.json 是否 schema 完整(坑 review-json-field-gap)。
339
361
  *
@@ -1103,6 +1125,130 @@ export function resolveExecuteRunForChange({ runtimeRoot, changeName, taskIds =
1103
1125
  }
1104
1126
  }
1105
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
+
1106
1252
  /**
1107
1253
  * worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
1108
1254
  * 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
@@ -1170,21 +1316,30 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1170
1316
  }
1171
1317
  }
1172
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}`)
1173
1330
  // base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
1174
- let diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { specBase })
1331
+ let diffFiles = attribution.diffFiles
1175
1332
  // ⚠️ 并入 worktree 未提交改动(坑 draft-attribution-uncommitted-worktree,2026-08-21 实证):
1176
1333
  // 子代理默认不 commit(execute 复盘 a 同源事实),真实改动全在 worktree working-tree——只看
1177
1334
  // base..HEAD commit diff 时归属恒空,9/9 草稿全成「无归属」靠主代理手写升级。与
1178
1335
  // verifyReviewGitEvidence 的 working-tree 并入口径同源:status --porcelain 文件(排除
1179
1336
  // .sillyspec/ 运行时产物)并入归属集,按 allowed_paths 正常路径归属;review.head 仍为 HEAD
1180
1337
  // commit——evidence 校验对「commit diff 空 + working-tree 有改动」明示不判伪造,语义一致。
1181
- try {
1182
- const wm = new WorktreeManager({ cwd })
1183
- const meta = wm.getMeta(changeName)
1184
- if (meta) {
1185
- const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1186
- ? meta.worktreePath
1187
- : 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
1188
1343
  const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1189
1344
  const wtFiles = parsePorcelainFiles(wtStatus)
1190
1345
  .map(p => String(p).replace(/\\/g, '/'))
@@ -1211,12 +1366,19 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1211
1366
  diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles, ...committedFiles])]
1212
1367
  console.log(`[sillyspec] 草稿归属并入 worktree 改动 ${wtFiles.length + committedFiles.length} 个(未提交 ${wtFiles.length} + 已提交 ${committedFiles.length},按 allowed_paths 归属)`)
1213
1368
  }
1214
- }
1215
- } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
1369
+ } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
1370
+ }
1216
1371
  // 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
1217
1372
  // 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
1373
+ // 终态空源(D-004):worktree-cleaned(DB 判 worktree 但分支 ref 已删)空集的 reason 显式
1374
+ // 注记「不可归因」,与普通「无 diff」区分——绝不回退主仓窗口补救。
1218
1375
  if (!ctx && (!diffFiles || diffFiles.length === 0)) {
1219
- 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
+ }
1220
1382
  }
1221
1383
 
1222
1384
  // base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
@@ -1248,6 +1410,15 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1248
1410
  // 有 ctx:主仓 HEAD 失败不阻断跨仓 task;head 留 null,主仓 task 后续按空 changedFiles 跳过
1249
1411
  }
1250
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
+ }
1251
1422
 
1252
1423
  const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1253
1424
  let generated = 0
@@ -1652,6 +1823,89 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
1652
1823
  * @param {object} [opts.platformOpts]
1653
1824
  * @returns {Promise<{ok: boolean, reviewPath?: string, executeRunId?: string, errors: string[], warnings: string[]}>}
1654
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
+
1655
1909
  export async function writeTaskReview({
1656
1910
  changeName, cwd, taskId, specVerdict, qualityVerdict,
1657
1911
  reviewerNotes = '', requiredEvidence = [],
@@ -1758,22 +2012,30 @@ export async function writeTaskReview({
1758
2012
  changedFiles = changedFilesOverride.map(p => String(p).replace(/\\/g, '/'))
1759
2013
  warnings.push(`changedFiles 采用显式覆盖(${changedFiles.length} 个)——evidence 校验仍会与 git diff 相交比对`)
1760
2014
  } else {
1761
- let diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { specBase })
1762
- // 并入 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
1763
2019
  try {
1764
- const wm = new WorktreeManager({ cwd })
1765
- const meta = wm.getMeta(changeName)
1766
- if (meta) {
1767
- const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1768
- ? meta.worktreePath
1769
- : 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
1770
2032
  const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1771
2033
  const wtFiles = parsePorcelainFiles(wtStatus)
1772
2034
  .map(p => String(p).replace(/\\/g, '/'))
1773
2035
  .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1774
2036
  if (wtFiles.length > 0) diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
1775
- }
1776
- } catch { /* 退回 commit diff 口径 */ }
2037
+ } catch { /* 退回 commit diff 口径 */ }
2038
+ }
1777
2039
  const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1778
2040
  changedFiles = allowedPaths.length > 0
1779
2041
  ? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
@@ -1791,6 +2053,15 @@ export async function writeTaskReview({
1791
2053
  }
1792
2054
  }
1793
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
+
1794
2065
  // ── 组装 + schema 自检 + 落盘 ──
1795
2066
  const review = {
1796
2067
  schemaVersion: REVIEW_SCHEMA_VERSION,
@@ -1163,6 +1163,26 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1163
1163
  ? meta.worktreePath
1164
1164
  : cwd
1165
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
+ }
1166
1186
  if (files !== null) return files
1167
1187
  // worktree diff 异常 → 落主仓兜底(保持与原 gitChangedFiles 相同的 null 语义)
1168
1188
  }