sillyspec 3.28.8 → 3.28.10

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 (69) hide show
  1. package/.claude/full.json +1 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +222 -224
  3. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  4. package/.claude/skills/sillyspec-quick/SKILL.md +118 -117
  5. package/AGENTS.md +3 -2
  6. package/README.md +35 -11
  7. package/SKILL.md +24 -10
  8. 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
  9. package/package.json +1 -1
  10. package/src/change-list.js +73 -20
  11. package/src/change-risk-profile.js +20 -0
  12. package/src/config-schema.js +62 -0
  13. package/src/contract-matrix.js +84 -5
  14. package/src/cross-repo-reconcile.js +134 -0
  15. package/src/db.js +11 -3
  16. package/src/decision-distill.js +15 -3
  17. package/src/design-facts.js +89 -20
  18. package/src/docs-gate.js +105 -6
  19. package/src/doctor-diagnostics.js +224 -2
  20. package/src/friction-ledger.js +171 -0
  21. package/src/hooks/worktree-guard.js +13 -1
  22. package/src/index.js +206 -18
  23. package/src/knowledge-classify.js +382 -0
  24. package/src/knowledge-hits.js +74 -0
  25. package/src/knowledge-stats.js +180 -0
  26. package/src/probe7-anchor-check.js +69 -0
  27. package/src/progress/change-registry.js +183 -8
  28. package/src/progress/shared.js +3 -2
  29. package/src/progress.js +100 -8
  30. package/src/quick-gate-profile.js +251 -0
  31. package/src/quicklog.js +164 -18
  32. package/src/review-dispatch.js +2 -1
  33. package/src/run/command.js +131 -13
  34. package/src/run/complete-handlers.js +511 -21
  35. package/src/run/complete.js +123 -23
  36. package/src/run/concurrent-detect.js +98 -0
  37. package/src/run/gate-snapshot.js +110 -2
  38. package/src/run/gates.js +141 -16
  39. package/src/run/prompt.js +221 -11
  40. package/src/run/quick-audit.js +111 -13
  41. package/src/run/shared.js +147 -11
  42. package/src/run/stage.js +6 -0
  43. package/src/scan-diff.js +61 -30
  44. package/src/scan-postcheck.js +74 -0
  45. package/src/scan-refresh.js +477 -0
  46. package/src/scan-staleness.js +25 -13
  47. package/src/scope-audit.js +456 -56
  48. package/src/stage-contract.js +185 -2
  49. package/src/stage-review.js +23 -7
  50. package/src/stages/brainstorm.js +6 -0
  51. package/src/stages/cmd-existence.js +9 -3
  52. package/src/stages/execute.js +85 -3
  53. package/src/stages/knowledge.js +76 -6
  54. package/src/stages/plan-postcheck.js +32 -5
  55. package/src/stages/quick.js +1 -1
  56. package/src/stages/verify.js +1 -0
  57. package/src/sync-noise.js +170 -6
  58. package/src/sync.js +141 -9
  59. package/src/task-review.js +366 -46
  60. package/src/verify-postcheck.js +212 -55
  61. package/src/verify-probes.js +727 -16
  62. package/src/worktree-apply.js +685 -48
  63. package/src/worktree-deps.js +124 -3
  64. package/src/worktree.js +391 -15
  65. package/templates/agents-instruction.md +2 -2
  66. package/templates/prompts/testcase-design.md +2 -1
  67. package/templates/prompts/verify-probes.md +4 -1
  68. package/.patch16-tmp.mjs +0 -169
  69. 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,194 @@ 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
+
1252
+ /**
1253
+ * worktree/in-place 改动文件集:porcelain 未提交 ∪ committed merge-base 补齐——口径单一化。
1254
+ * 口径出处:D-004@v1(worktree 归因唯一事实源 = worktree 分支 diff + porcelain,决策库
1255
+ * decisions/worktree.md)+ 变更 2026-09-15-worktree-dual-truth-gates §4 / FR-04;坑④
1256
+ * worktree-tick-guard-commit-only(2026-09-15 实证):勾选守卫 prefetchDiffFileSet
1257
+ * (run/complete.js)只算 base..head(已提交),子代理默认不 commit 时守卫恒空跳过勾选——
1258
+ * 本 helper 是「草稿归属并入段(generateTaskReviewDrafts)」与「勾选守卫(prefetchDiffFileSet)」
1259
+ * 两处的单一真相。已知残留 R-09:resolveVerifyChangedFiles(verify-postcheck.js)内另有同型
1260
+ * 补齐段(form A 专属:滤网保 .sillyspec/docs/** 交付物,与本 helper 的整前缀滤网不同),
1261
+ * 本次不动,待该函数下次触碰时统一。
1262
+ *
1263
+ * 行为契约:
1264
+ * - meta 缺省时内部 WorktreeManager.getMeta 自取(try/catch → null)。
1265
+ * - gitDir = meta.worktreePath(mode 非 in-place-fallback 且目录存在)否则 cwd——
1266
+ * in-place 退化取 cwd(保住现状 in-place 主仓 porcelain 并入,不丢,R-08);
1267
+ * meta 完全缺失时同样取 cwd(与被并入段 mergeGitDir 语义一致)。
1268
+ * - porcelain:status --porcelain --untracked-files=all(parsePorcelainFiles 解析),
1269
+ * 滤 .sillyspec 前缀(与被替换的草稿并入段同口径)。
1270
+ * - committed 补齐(仅 gitDir !== cwd,防双并):merge-base(主仓 HEAD, wtHEAD)..wtHEAD
1271
+ * 的 diff --name-only——worktree 内已 commit 的改动对主仓 base..head 与 status 双不可见
1272
+ * (「先提交则 diff 空」时序两难);git 失败 fail-open 忽略。
1273
+ * - 返回正斜杠归一去重数组;任何 git 异常 → 已收集部分或 [](fail-open,不阻断调用方)。
1274
+ *
1275
+ * @param {string} cwd 主仓根
1276
+ * @param {string} changeName 变更名
1277
+ * @param {object|null} [meta] 预取的 worktree meta(缺省内部 getMeta 自取)
1278
+ * @param {object} [opts] 预留(对齐 design 接口签名)
1279
+ * @returns {string[]} 正斜杠归一文件集(git 失败 → 已收集部分或 [],fail-open)
1280
+ */
1281
+ export function collectWorktreeChangedFiles(cwd, changeName, meta = null, opts = {}) {
1282
+ const files = new Set()
1283
+ if (!cwd || !changeName) return []
1284
+ let m = meta
1285
+ if (m == null) {
1286
+ try { m = new WorktreeManager({ cwd }).getMeta(changeName) } catch { m = null }
1287
+ }
1288
+ const gitDir = (m?.worktreePath && m.mode !== 'in-place-fallback' && existsSync(m.worktreePath))
1289
+ ? m.worktreePath
1290
+ : cwd
1291
+ const keep = (p) => p && p !== '.sillyspec' && !p.startsWith('.sillyspec/')
1292
+ // 未提交:worktree(in-place 退化=主仓)porcelain 全量(含 untracked)
1293
+ try {
1294
+ const st = runGit(gitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1295
+ for (const p of parsePorcelainFiles(st)) if (keep(p)) files.add(p)
1296
+ } catch { /* porcelain 失败 → 返回已收集部分(fail-open) */ }
1297
+ // 已提交补齐:merge-base(主仓 HEAD, wtHEAD)(= 创建锚点)..wtHEAD 的 commit diff。
1298
+ // in-place(gitDir===cwd)时已提交改动在主仓 diff 内,跳过防双并;失败 fail-open 忽略。
1299
+ if (gitDir !== cwd) {
1300
+ try {
1301
+ const mainHead = runGit(cwd, ['rev-parse', 'HEAD'])
1302
+ const wtHead = runGit(gitDir, ['rev-parse', 'HEAD'])
1303
+ const mb = (mainHead && wtHead) ? runGit(gitDir, ['merge-base', wtHead, mainHead]) : null
1304
+ if (mb) {
1305
+ const out = String(runGit(gitDir, ['diff', '--name-only', mb, wtHead]) || '')
1306
+ for (let p of out.split('\n')) {
1307
+ p = p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim()
1308
+ if (keep(p)) files.add(p)
1309
+ }
1310
+ }
1311
+ } catch { /* 已提交补齐失败忽略(fail-open,不拖垮未提交并入) */ }
1312
+ }
1313
+ return [...files]
1314
+ }
1315
+
1106
1316
  /**
1107
1317
  * worktree execute「主 agent 直接实现」模式收尾兜底:per-task review.json 缺失时,
1108
1318
  * 据 git diff base..head 按 task allowed_paths 归属,自动落盘 cannot_verify 草稿。
@@ -1170,53 +1380,54 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1170
1380
  }
1171
1381
  }
1172
1382
 
1383
+ // ── 归因模式分流(task-03 / D-004@v1):判定源=changes.isolation_mode(DB 读)──
1384
+ // worktree 模式(含 meta 缺失回退态)归因唯一源=worktree 分支 diff(绝不落主仓共享脏窗口,
1385
+ // troubleshooting §65 事件②根因);in-place 维持主仓窗口;NULL 存量按 meta 在场路由。
1386
+ // 分流自身异常 → 退回原 resolveVerifyChangedFiles 口径(fail-open,不比修复前差)。
1387
+ let attribution
1388
+ try {
1389
+ attribution = resolveAttributionDiffFiles({ cwd, changeName, specBase, platformOpts })
1390
+ } catch (e) {
1391
+ attribution = { mode: 'main-window', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes: [`归因分流解析异常(${e && e.message ? e.message : e})——退回原口径`], meta: null, mergeGitDir: null }
1392
+ }
1393
+ for (const note of attribution.notes) console.log(`[sillyspec] 归因注记:${note}`)
1173
1394
  // base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
1174
- let diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { specBase })
1395
+ let diffFiles = attribution.diffFiles
1175
1396
  // ⚠️ 并入 worktree 未提交改动(坑 draft-attribution-uncommitted-worktree,2026-08-21 实证):
1176
1397
  // 子代理默认不 commit(execute 复盘 a 同源事实),真实改动全在 worktree working-tree——只看
1177
1398
  // base..HEAD commit diff 时归属恒空,9/9 草稿全成「无归属」靠主代理手写升级。与
1178
1399
  // verifyReviewGitEvidence 的 working-tree 并入口径同源:status --porcelain 文件(排除
1179
1400
  // .sillyspec/ 运行时产物)并入归属集,按 allowed_paths 正常路径归属;review.head 仍为 HEAD
1180
1401
  // 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
1188
- const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1189
- const wtFiles = parsePorcelainFiles(wtStatus)
1190
- .map(p => String(p).replace(/\\/g, '/'))
1191
- .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1192
- // 已提交口径补齐(2026-09-10 用户反馈②「先提交则 diff 空」:worktree 内已 commit 的改动
1193
- // 对主仓 base..head 与 status 双不可见,草稿 changedFiles 恒空)——merge-base(主仓 HEAD,
1194
- // worktree HEAD)(= 创建锚点)..worktree HEAD 的 commit diff 并入,与
1195
- // resolveVerifyChangedFiles 的同款补齐同源(verify-postcheck.js,两处口径须同步改)。
1196
- // in-place 退化(wtGitDir===cwd)时已提交改动在主仓 diff 内,跳过防双并;git 失败 fail-open。
1197
- let committedFiles = []
1198
- if (wtGitDir !== cwd) {
1199
- try {
1200
- const mainHead = runGit(cwd, ['rev-parse', 'HEAD'])
1201
- const wtHead = runGit(wtGitDir, ['rev-parse', 'HEAD'])
1202
- const mb = (mainHead && wtHead) ? runGit(wtGitDir, ['merge-base', wtHead, mainHead]) : null
1203
- if (mb) {
1204
- committedFiles = String(runGit(wtGitDir, ['diff', '--name-only', mb, wtHead]) || '')
1205
- .split('\n').map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/').trim())
1206
- .filter(p => p && p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1207
- }
1208
- } catch { /* 已提交补齐失败退回 status-only(fail-open,不拖垮未提交并入) */ }
1209
- }
1210
- if (wtFiles.length > 0 || committedFiles.length > 0) {
1211
- diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles, ...committedFiles])]
1212
- console.log(`[sillyspec] 草稿归属并入 worktree 改动 ${wtFiles.length + committedFiles.length} 个(未提交 ${wtFiles.length} + 已提交 ${committedFiles.length},按 allowed_paths 归属)`)
1402
+ // D-004 后仅在归因源指向活跃 worktree 目录 / in-place meta(mergeGitDir)时执行——
1403
+ // worktree-branch/cleaned 归因的未提交并入已在 resolveAttributionDiffFiles 内按同口径完成。
1404
+ if (attribution.mergeGitDir) {
1405
+ try {
1406
+ // D-004@v1 口径单一化(坑④ worktree-tick-guard-commit-only,2026-09-15 实证):原手写
1407
+ // 并入段(porcelain 未提交 + committed merge-base 补齐两段)收口为
1408
+ // collectWorktreeChangedFiles 单一真相——勾选守卫 prefetchDiffFileSet(run/complete.js)
1409
+ // 与本草稿归属自此消费同一口径(原「两处口径须同步改」注记就此作废,单一真相在 helper)。
1410
+ // in-place(mergeGitDir===cwd)时 helper 同取 cwd porcelain(现状主仓并入不丢)。
1411
+ // resolveVerifyChangedFiles(verify-postcheck.js)内另有同型补齐段(form A 专属,滤网保
1412
+ // .sillyspec/docs/** 交付物),已知残留 R-09,本次不动。
1413
+ const mergedFiles = collectWorktreeChangedFiles(cwd, changeName, attribution.meta)
1414
+ if (mergedFiles.length > 0) {
1415
+ diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...mergedFiles])]
1416
+ console.log(`[sillyspec] 草稿归属并入 worktree 改动 ${mergedFiles.length} 个(未提交 porcelain + 已提交 merge-base 补齐,按 allowed_paths 归属)`)
1213
1417
  }
1214
- }
1215
- } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
1418
+ } catch { /* working-tree 并入失败退回 commit diff 口径(fail-open,不阻断草稿) */ }
1419
+ }
1216
1420
  // 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
1217
1421
  // 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
1422
+ // 终态空源(D-004):worktree-cleaned(DB 判 worktree 但分支 ref 已删)空集的 reason 显式
1423
+ // 注记「不可归因」,与普通「无 diff」区分——绝不回退主仓窗口补救。
1218
1424
  if (!ctx && (!diffFiles || diffFiles.length === 0)) {
1219
- return { generated: 0, skipped: 0, unattributed: [], executeRunId, reason: 'base..head 无代码 diff(改动未 commit?)' }
1425
+ return {
1426
+ generated: 0, skipped: 0, unattributed: [], executeRunId,
1427
+ reason: attribution.mode === 'worktree-cleaned'
1428
+ ? '不可归因(worktree 已清理)——DB 判 worktree 但分支 ref 已删,fail-closed 空集(不回退主仓窗口)'
1429
+ : 'base..head 无代码 diff(改动未 commit?)',
1430
+ }
1220
1431
  }
1221
1432
 
1222
1433
  // base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
@@ -1248,6 +1459,15 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1248
1459
  // 有 ctx:主仓 HEAD 失败不阻断跨仓 task;head 留 null,主仓 task 后续按空 changedFiles 跳过
1249
1460
  }
1250
1461
  }
1462
+ // worktree-branch 归因的 base/head 锚定(task-03 / D-004):meta 缺失但分支 ref 活着时用
1463
+ // merge-base(主仓 HEAD, 分支 tip)=创建锚点..分支 tip 锚定——DB 判 worktree 的回退态仍能
1464
+ // 生成带真实 git 锚点的草稿(否则上方「无 meta.baseHash」早退白费分支归因成果)。分支 ref
1465
+ // 在主仓共享 .git 内,reviewGitDir=cwd 可解析,evidence 校验的 base..head diff 与归因同源。
1466
+ if (!base && attribution.branchBase && attribution.branchHead) {
1467
+ base = attribution.branchBase
1468
+ head = attribution.branchHead
1469
+ reviewGitDir = cwd
1470
+ }
1251
1471
 
1252
1472
  const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1253
1473
  let generated = 0
@@ -1652,6 +1872,89 @@ export async function adoptTaskReviewMechanics({ changeName, cwd, platformOpts =
1652
1872
  * @param {object} [opts.platformOpts]
1653
1873
  * @returns {Promise<{ok: boolean, reviewPath?: string, executeRunId?: string, errors: string[], warnings: string[]}>}
1654
1874
  */
1875
+ // ── ql-20260915-003 修复③(坑 interrupted-residue-detect,2026-09-15 用户实证:中断的
1876
+ // execute 子代理在工作区留下半成品 import 语法坏文件,无自动检测靠人工 diff 发现)──
1877
+ //
1878
+ // 对 changedFiles 中的 .js/.mjs/.cjs 逐个 `node --check`(子进程数组形式不经 shell,单文件
1879
+ // 5s 超时;总数 >60 抽样前 60 并提示——大变更全量查的耗时不可接受)。
1880
+ //
1881
+ // 判定分层(Node v24 实测:.js 的 file-check 受最近 package.json type 影响——无 type 时
1882
+ // ESM 半成品可能静默通过、合法 ESM 又会报 import 语句误错):
1883
+ // - .mjs/.cjs:file-check 权威(对应 module/commonjs 全量解析)
1884
+ // - .js:file-check **与** stdin `--input-type=module` check 双跑,两者都报 SyntaxError
1885
+ // 才判残留(合法 ESM / 合法 CJS(含 sloppy-mode)必有一路通过,零假阳;真断档半成品
1886
+ // 两路都挂)。stdin check 不可用/无 SyntaxError 字样 → fail-open 跳过该文件。
1887
+ //
1888
+ // 语法失败 → 返回「疑似中断残留」warning(advisory:只提醒,不改 verdict / 勾选语义 /
1889
+ // 落盘行为——quick --done 的 test/lint 实测硬门才是卡点,这里补「中断半成品」的可见性)。
1890
+ // 文件在候选根下不存在(diff 里的删除态文件等)直接跳过;超时(e.killed)无法定论不报警。
1891
+ // 仅 review write 时点执行一次,非热路径。
1892
+ const SYNTAX_CHECK_EXT_RE = /\.(?:js|mjs|cjs)$/
1893
+ const SYNTAX_CHECK_MAX_FILES = 60
1894
+ // 严格 .js(.mjs/.cjs 也 endsWith('.js'),须排除——否则 sloppy-mode .cjs 会误入 stdin ESM
1895
+ // 复核路径产生假阳)
1896
+ const isPlainJs = (f) => f.endsWith('.js') && !f.endsWith('.mjs') && !f.endsWith('.cjs')
1897
+ function _syntaxErrLine(e) {
1898
+ const raw = String((e.stderr && e.stderr.toString()) || e.message || '')
1899
+ const line = raw.split('\n').find(l => l.includes('SyntaxError'))
1900
+ return line ? line.trim() : null
1901
+ }
1902
+ function detectInterruptedSyntaxResidue(changedFiles, candidateRoots) {
1903
+ const warnings = []
1904
+ const jsFiles = (Array.isArray(changedFiles) ? changedFiles : [])
1905
+ .filter(f => typeof f === 'string' && SYNTAX_CHECK_EXT_RE.test(f))
1906
+ if (jsFiles.length === 0) return warnings
1907
+ const overCap = jsFiles.length > SYNTAX_CHECK_MAX_FILES
1908
+ for (const f of overCap ? jsFiles.slice(0, SYNTAX_CHECK_MAX_FILES) : jsFiles) {
1909
+ let abs = null
1910
+ for (const root of candidateRoots) {
1911
+ if (!root) continue
1912
+ const p = join(root, f)
1913
+ if (existsSync(p)) { abs = p; break }
1914
+ }
1915
+ if (!abs) continue
1916
+ try {
1917
+ execFileSync(process.execPath, ['--check', abs], { timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
1918
+ if (isPlainJs(f)) {
1919
+ // 无 type:module 上下文的 .js:file-check 通过不代表语法完整(Node v24 实测假阴),
1920
+ // 再跑 stdin ESM 全量解析兜底——两路都挂才判残留
1921
+ let src = ''
1922
+ try { src = readFileSync(abs, 'utf8') } catch { continue }
1923
+ try {
1924
+ execFileSync(process.execPath, ['--input-type=module', '--check'], { input: src, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
1925
+ } catch (e2) {
1926
+ if (e2 && e2.killed) continue
1927
+ const line = _syntaxErrLine(e2)
1928
+ if (!line) continue // 非 SyntaxError(如版本不支持 stdin check)→ fail-open
1929
+ warnings.push(`⚠️ 疑似中断残留:${f} 语法错误(${line})——子代理异常退出可能留下半成品,请 diff 该文件确认还原或补完`)
1930
+ }
1931
+ }
1932
+ } catch (e) {
1933
+ if (e && e.killed) continue // 单文件超时:无法定论,不产假阳 warning
1934
+ if (!isPlainJs(f)) {
1935
+ const line = _syntaxErrLine(e)
1936
+ if (line) warnings.push(`⚠️ 疑似中断残留:${f} 语法错误(${line})——子代理异常退出可能留下半成品,请 diff 该文件确认还原或补完`)
1937
+ continue
1938
+ }
1939
+ // .js file-check 挂可能是模块口径误错(合法 ESM 在无 type 上下文报 import 语句)——
1940
+ // stdin ESM 解析仲裁:也挂才算真残留
1941
+ let src = ''
1942
+ try { src = readFileSync(abs, 'utf8') } catch { continue }
1943
+ try {
1944
+ execFileSync(process.execPath, ['--input-type=module', '--check'], { input: src, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] })
1945
+ } catch (e2) {
1946
+ if (e2 && e2.killed) continue
1947
+ const line = _syntaxErrLine(e2) || _syntaxErrLine(e)
1948
+ if (line) warnings.push(`⚠️ 疑似中断残留:${f} 语法错误(${line})——子代理异常退出可能留下半成品,请 diff 该文件确认还原或补完`)
1949
+ }
1950
+ }
1951
+ }
1952
+ if (overCap) {
1953
+ warnings.push(`⚠️ 中断残留检测抽样:changedFiles 含 ${jsFiles.length} 个 .js 系文件,仅语法检查前 ${SYNTAX_CHECK_MAX_FILES} 个(interrupted-residue-detect)`)
1954
+ }
1955
+ return warnings
1956
+ }
1957
+
1655
1958
  export async function writeTaskReview({
1656
1959
  changeName, cwd, taskId, specVerdict, qualityVerdict,
1657
1960
  reviewerNotes = '', requiredEvidence = [],
@@ -1758,22 +2061,30 @@ export async function writeTaskReview({
1758
2061
  changedFiles = changedFilesOverride.map(p => String(p).replace(/\\/g, '/'))
1759
2062
  warnings.push(`changedFiles 采用显式覆盖(${changedFiles.length} 个)——evidence 校验仍会与 git diff 相交比对`)
1760
2063
  } else {
1761
- let diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { specBase })
1762
- // 并入 worktree 未提交改动(与草稿生成同源口径:子代理默认不 commit)
2064
+ // ── 归因模式分流(task-03 / D-004@v1):与草稿生成同源路由(判定源=changes.isolation_mode)──
2065
+ // worktree 模式(含 meta 缺失回退态)取 worktree 分支 diff,绝不落主仓共享脏窗口;
2066
+ // in-place/NULL 存量维持主仓窗口原口径。分流异常退回原 resolveVerifyChangedFiles(fail-open)。
2067
+ let attribution
1763
2068
  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
2069
+ attribution = resolveAttributionDiffFiles({ cwd, changeName, specBase, platformOpts })
2070
+ } catch (e) {
2071
+ attribution = { mode: 'main-window', diffFiles: resolveVerifyChangedFiles(cwd, changeName, null, { specBase }), notes: [`归因分流解析异常(${e && e.message ? e.message : e})——退回原口径`], meta: null, mergeGitDir: null }
2072
+ }
2073
+ for (const note of attribution.notes) warnings.push(`归因注记:${note}`)
2074
+ let diffFiles = attribution.diffFiles
2075
+ // 并入 worktree 未提交改动(与草稿生成同源口径:子代理默认不 commit)。D-004 后仅在
2076
+ // 归因源指向活跃 worktree 目录 / in-place meta(mergeGitDir)时执行——worktree-branch/
2077
+ // cleaned 归因的未提交并入已在 resolveAttributionDiffFiles 内按同口径完成。
2078
+ if (attribution.mergeGitDir) {
2079
+ try {
2080
+ const wtGitDir = attribution.mergeGitDir
1770
2081
  const wtStatus = runGit(wtGitDir, ['status', '--porcelain', '--untracked-files=all'], { trim: false })
1771
2082
  const wtFiles = parsePorcelainFiles(wtStatus)
1772
2083
  .map(p => String(p).replace(/\\/g, '/'))
1773
2084
  .filter(p => p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
1774
2085
  if (wtFiles.length > 0) diffFiles = [...new Set([...(Array.isArray(diffFiles) ? diffFiles : []), ...wtFiles])]
1775
- }
1776
- } catch { /* 退回 commit diff 口径 */ }
2086
+ } catch { /* 退回 commit diff 口径 */ }
2087
+ }
1777
2088
  const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
1778
2089
  changedFiles = allowedPaths.length > 0
1779
2090
  ? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
@@ -1791,6 +2102,15 @@ export async function writeTaskReview({
1791
2102
  }
1792
2103
  }
1793
2104
 
2105
+ // ── ql-20260915-003 修复③(坑 interrupted-residue-detect):changedFiles 定稿后的
2106
+ // 语法检查(advisory warning,见 detectInterruptedSyntaxResidue 注释)。候选根依次试
2107
+ // reviewGitDir(meta 活跃时的 worktree 根)、cwd、worktree-branch 归因的孤儿目录兜底。
2108
+ for (const w of detectInterruptedSyntaxResidue(changedFiles, [
2109
+ reviewGitDir,
2110
+ cwd,
2111
+ join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName),
2112
+ ])) warnings.push(w)
2113
+
1794
2114
  // ── 组装 + schema 自检 + 落盘 ──
1795
2115
  const review = {
1796
2116
  schemaVersion: REVIEW_SCHEMA_VERSION,