sillyspec 3.28.7 → 3.28.8

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 (51) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +224 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +117 -116
  4. package/.husky/pre-push +3 -1
  5. package/.patch16-tmp.mjs +169 -0
  6. package/package.json +1 -1
  7. package/src/agent-session-log.js +446 -35
  8. package/src/config-cat.js +6 -3
  9. package/src/constants.js +3 -0
  10. package/src/db.js +12 -3
  11. package/src/docs-check.js +137 -12
  12. package/src/docs-gate.js +3 -0
  13. package/src/doctor-diagnostics.js +19 -7
  14. package/src/friction-tally.js +7 -2
  15. package/src/git-helper.js +6 -4
  16. package/src/hooks/worktree-guard.js +59 -29
  17. package/src/index.js +168 -64
  18. package/src/local-detect.js +30 -15
  19. package/src/local-register.js +13 -4
  20. package/src/mcp-server.js +38 -8
  21. package/src/progress/change-registry.js +41 -35
  22. package/src/progress/consistency-doctor.js +11 -4
  23. package/src/progress/stage-machine.js +3 -1
  24. package/src/progress/step-store.js +30 -17
  25. package/src/progress.js +23 -0
  26. package/src/quicklog.js +44 -18
  27. package/src/run/command.js +17 -3
  28. package/src/run/complete-handlers.js +3 -3
  29. package/src/run/complete.js +28 -9
  30. package/src/run/concurrent-detect.js +1 -1
  31. package/src/run/gate-snapshot.js +115 -5
  32. package/src/run/gates.js +35 -5
  33. package/src/run/prompt.js +9 -1
  34. package/src/run/quick-audit.js +25 -2
  35. package/src/run/shared.js +98 -10
  36. package/src/run/verify-quality-scan.js +14 -3
  37. package/src/spec-sync.js +19 -14
  38. package/src/stage-contract-spec.js +4 -3
  39. package/src/stage-review.js +6 -5
  40. package/src/stages/brainstorm.js +1 -1
  41. package/src/stages/execute.js +6 -1
  42. package/src/stages/plan-postcheck.js +57 -12
  43. package/src/sync.js +66 -4
  44. package/src/task-review.js +41 -6
  45. package/src/verify-facts-schema.js +10 -3
  46. package/src/verify-postcheck.js +21 -6
  47. package/src/verify-probes.js +8 -3
  48. package/src/worktree-apply.js +56 -18
  49. package/src/worktree-cross.js +12 -18
  50. package/src/worktree.js +138 -25
  51. package/src/wt-commit.js +128 -0
package/src/run/shared.js CHANGED
@@ -299,7 +299,7 @@ export function detectWorktreeSpecDrift(specBase) {
299
299
  const seg = resolve(specBase).split(sep)
300
300
  // specBase 恒以 .sillyspec 结尾(command.js specBase=join(cwd,'.sillyspec'))。worktree 副本形如
301
301
  // <mainRepo>/.sillyspec/.runtime/worktrees/<change>/.sillyspec —— 尾段也须是 .sillyspec,
302
- // 否则 worktree 根目录本身(<.../worktrees/<change>)会被误判(它不是 spec,不会作 specBase 传入)。
302
+ // 否则 worktree 根目录本身(<.../worktrees/<change>>)会被误判(它不是 spec,不会作 specBase 传入)。
303
303
  if (seg[seg.length - 1] !== '.sillyspec') return null
304
304
  for (let i = 0; i + 3 < seg.length; i++) {
305
305
  if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees') {
@@ -317,6 +317,33 @@ export function detectWorktreeSpecDrift(specBase) {
317
317
  return null
318
318
  }
319
319
 
320
+ /**
321
+ * 判定「目录在 sillyspec 管理的隔离 worktree 内」(坑 worktree-cwd-silent-split,2026-08-29 用户实证:
322
+ * Bash cwd 残留两次误在 worktree 内跑 sillyspec——第一次走 status 类命令完全静默,进度险些写进
323
+ * worktree 副本 .sillyspec 与主仓进度库分裂)。
324
+ *
325
+ * 与 detectWorktreeSpecDrift 的分工:那边判 specBase(恒以 .sillyspec 结尾,runCommand 内自动
326
+ * 锚定用,只覆盖 plan/execute/verify/archive 四 stage);这边判任意目录(CLI 入口硬拦用,目录
327
+ * 可以是 worktree 根或其任意子目录,无尾段要求)。
328
+ *
329
+ * @param {string} dir 目标目录(通常为 process.cwd() 或 --dir 显式值)
330
+ * @returns {{ changeName: string, mainRepoRoot: string, worktreeRoot: string } | null} null=不在 worktree 内
331
+ */
332
+ export function detectCwdInsideWorktree(dir) {
333
+ if (!dir) return null
334
+ const seg = resolve(dir).split(sep)
335
+ for (let i = 0; i + 3 < seg.length; i++) {
336
+ if (seg[i] === '.sillyspec' && seg[i + 1] === '.runtime' && seg[i + 2] === 'worktrees' && seg[i + 3]) {
337
+ return {
338
+ changeName: seg[i + 3],
339
+ mainRepoRoot: seg.slice(0, i).join(sep),
340
+ worktreeRoot: seg.slice(0, i + 4).join(sep),
341
+ }
342
+ }
343
+ }
344
+ return null
345
+ }
346
+
320
347
  /**
321
348
  * 统一解析 .runtime 根目录(坑 execute-runs-isolation)。
322
349
  *
@@ -791,14 +818,15 @@ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {})
791
818
  * 与 triggerSync 同款契约:quick 会话降级跳过(无 progress 实体)、未连接平台在
792
819
  * sync() 内部静默跳过、8s 总熔断(raceWithAbort + abort 在飞请求)、失败只 warn 不抛。
793
820
  *
794
- * 接线说明(本变更 allowed_paths 约束):步骤 prompt 渲染点(run/stage.js outputStep
795
- * 前段 / run/prompt.js)不在本变更可改文件集内——本导出即「步骤开始钩子」本体,
796
- * 渲染侧一行 `triggerStepStartSync(cwd, changeName, platformOpts)` 的接线由后续
797
- * 变更补上;execute 阶段的任务粒度信号已由 X4(stages/execute.js Wave prompt
821
+ * 接线说明:已接线——run/prompt.js outputStep 步骤渲染前段 fire-and-forget 调用本钩子
822
+ * (task-13 当时 allowed_paths 不含渲染层,遗留活跃坑由 2026-08-29 docs/sillyspec
823
+ * 定期收口补上);execute 阶段的任务粒度信号另由 X4(stages/execute.js Wave prompt
798
824
  * 每任务上报指引)覆盖。
799
825
  */
800
826
  export async function triggerStepStartSync(cwd, changeName, platformOpts = {}, opts = {}) {
801
827
  try {
828
+ // 无 changeName(渲染层个别路径/测试 fixture)直接静默——sync(null) 只会打 warn 噪音
829
+ if (!changeName) return
802
830
  const tsSpecBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
803
831
  if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(tsSpecBase, 'changes', changeName))) {
804
832
  return // quick 会话无 progress 实体(与 triggerSync 同判),无步骤可报
@@ -948,16 +976,20 @@ export function warnApprovalUnknown(cwd, changeName, reason) {
948
976
  /**
949
977
  * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
950
978
  * 平台模式走自己的链路,跳过;否则 await import sync.js。
979
+ * 返回语义:null = 平台模式有意跳过;{status:'unknown'} = 检查链路意外失败——调用方走
980
+ * warnApprovalUnknown 横幅 + approval-unknown.log 留痕(HUB-07)。异常折叠成 null 会绕过
981
+ * unknown 留痕静默放行(2026-09-11 审查)。
951
982
  * @returns {{ status: string, reason?: string } | null}
952
- */export async function checkApproval(cwd, changeName, platformOpts = {}) {
983
+ */export async function checkApproval(cwd, changeName, platformOpts = {}, { loadSyncMod = null } = {}) {
953
984
  // 平台模式不需要 CLI 内置审批检查;自指回环(repo-native junction)按本地模式处理
954
985
  if (isPlatformMode(platformOpts, cwd)) return null
986
+ const load = loadSyncMod || (() => import('../sync.js'))
955
987
  try {
956
- // shared.js 在 src/run/,sync.js 在 src/ → 退一层
957
- const syncMod = await import('../sync.js')
988
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层;loadSyncMod 为测试缝(注入抛错验证 unknown 路由)
989
+ const syncMod = await load()
958
990
  return await syncMod.checkApproval(changeName, cwd)
959
991
  } catch (e) {
960
- return null
992
+ return { status: 'unknown', reason: `审批检查异常: ${e?.message || e}` }
961
993
  }
962
994
  }
963
995
 
@@ -1249,6 +1281,23 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1249
1281
  // 不对整段 .trim():会削首行前导空格致首文件路径丢首字符(见 parsePorcelainPath 注释)。
1250
1282
  const currentEntries = gitStatus.split('\n').filter(Boolean)
1251
1283
 
1284
+ // EOL-only 噪声过滤(坑 eol-rewrite-fake-mtime-noise,2026-09-12 驾驭第十六批②,用户实证
1285
+ // gen:types 重写行尾制造假 M):git diff --ignore-cr-at-eol HEAD 的变更名单是「真实内容变更」
1286
+ // 集——tracked 修改出现在 porcelain 但不在此集 = 纯行尾重写。单次对照调用,剔出 changedFiles
1287
+ // 归 eolOnlyFiles 软警告(.gitattributes 指引),不再计入文件行/触发各门。
1288
+ // 注意:gitQuiet 不在本模块 import 面(safeGit 才是)——首版用 gitQuiet 触发
1289
+ // ReferenceError 被 catch 吞成 null,又被 new Set(null) 包装成空集 → 全部 tracked 修改
1290
+ // 被误判 EOL-only 剔除(audit-quick-completion 回归抓出)。改用 safeGit(已 import,
1291
+ // 返回 {value,error}),失败显式落 null 透传不过滤。
1292
+ const eolRawList = (() => {
1293
+ try {
1294
+ const res = safeGit(cwd, ['diff', '--name-only', '--ignore-cr-at-eol', 'HEAD'], { timeout: 30000 })
1295
+ if (!res || res.error) return null
1296
+ return String(res.value || '').split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/'))
1297
+ } catch { return null }
1298
+ })()
1299
+ const eolRealChanged = eolRawList === null ? null : new Set(eolRawList)
1300
+
1252
1301
  const normalizeGitPath = (p) => p.replace(/\\/g, '/')
1253
1302
  // step1 启动时记录的全量脏文件 = 预存改动(非本次 quick 产生)。审计必须排除它们,
1254
1303
  // 否则脏工作区下预存文件持续留在 git status → 命中 baselineFiles → 误判「覆盖 baseline」
@@ -1321,6 +1370,14 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1321
1370
  continue
1322
1371
  }
1323
1372
 
1373
+ // EOL-only(坑 eol-rewrite-fake-mtime-noise):tracked 修改但真实内容零变化(仅行尾重写)
1374
+ // → 剔出审计归属(changedFiles/各门),归 eolOnlyFiles 软警告。untracked(??)不经此径。
1375
+ if (eolRealChanged instanceof Set && status !== '??' && rawStatus[0] !== 'D' && rawStatus[1] !== 'D'
1376
+ && !eolRealChanged.has(file)) {
1377
+ result.eolOnlyFiles = result.eolOnlyFiles || []
1378
+ result.eolOnlyFiles.push(file)
1379
+ continue
1380
+ }
1324
1381
  result.changedFiles.push(file)
1325
1382
  if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.push(file)
1326
1383
  if (status === '??') result.newFiles.push(file)
@@ -1357,8 +1414,28 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1357
1414
  }
1358
1415
 
1359
1416
  // 检查 deleted files(--allow-delete 显式放行:删除是破坏性操作,默认 fail-closed,flag 即知情 opt-in)
1417
+ // spec 共享面降级(坑 foreign-spec-churn-fail-closed,2026-09-12 驾驭第十六批①,用户实证
1418
+ // 并行会话归档移动 docs/sillyspec + changes/ 被拦只能 --allow-delete):.sillyspec/ 与 docs/
1419
+ // 是多会话共享 spec 面——非本会话声明的删除在此面发生时,归因「并行他者 spec 收尾/归档移动」
1420
+ // 降级软警告不阻断(src/test 交付面删除仍 fail-closed——那是本会话可支配域)。
1421
+ const isSpecSharedPath = (p) => {
1422
+ const n = p.replace(/\\/g, '/')
1423
+ return n.startsWith('.sillyspec/') || n.startsWith('docs/')
1424
+ }
1360
1425
  if (!allowDelete) {
1426
+ const stillBlocked = []
1361
1427
  for (const f of result.deletedFiles) {
1428
+ if (isSpecSharedPath(f) && !ownDeclaredNorm.has(f.replace(/\\/g, '/'))) {
1429
+ result.foreignSpecChurn = result.foreignSpecChurn || []
1430
+ result.foreignSpecChurn.push(f)
1431
+ continue
1432
+ }
1433
+ stillBlocked.push(f)
1434
+ }
1435
+ // 整栈退(对齐 linkedChangeLeftovers 模式):归他者的 spec 面删除从 deletedFiles 移除——
1436
+ // 下方 status 判定与 --allow-delete 渲染均不再见它(否则 blocked-with-empty-reasons)
1437
+ result.deletedFiles = stillBlocked
1438
+ for (const f of stillBlocked) {
1362
1439
  result.reasons.push(`删除文件: ${f}`)
1363
1440
  }
1364
1441
  }
@@ -1374,7 +1451,18 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1374
1451
  if (!allowNew) {
1375
1452
  for (const f of result.newFiles) {
1376
1453
  if (!isQuickMetadata(f, guard.linkedChanges)) {
1377
- result.reasons.push(`新增文件(需 --allow-new): ${f}`)
1454
+ // mtime 归因(坑 foreign-spec-churn-fail-closed 配套,用户建议「按 quick 启动后
1455
+ // mtime 软判定」):早于会话启动 = 预存残留(baseline 漏记或并行会话预存,非本
1456
+ // 会话窗口新建)——reason 标注供裁决,门语义不变(新增本就 warning 档)。
1457
+ let mtimeNote = ''
1458
+ try {
1459
+ const st = statSync(join(cwd, f))
1460
+ const startedAtMs = guard.startedAt ? new Date(guard.startedAt).getTime() : 0
1461
+ if (startedAtMs && st.mtimeMs < startedAtMs) {
1462
+ mtimeNote = '(mtime 早于会话启动——预存残留/并行预存,非本会话新建;--files 声明归属或忽略)'
1463
+ }
1464
+ } catch { /* stat 失败无注记 */ }
1465
+ result.reasons.push(`新增文件(需 --allow-new): ${f}${mtimeNote}`)
1378
1466
  }
1379
1467
  }
1380
1468
  }
@@ -276,7 +276,7 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
276
276
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
277
277
  snap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
278
278
  if (snap) {
279
- console.log(`🧪 noAI 扫描隔离快照:HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
279
+ console.log(`🧪 noAI 扫描隔离快照(根:${snap.snapshotRoot}):HEAD + 本变更 ${snap.changeFileCount} 个文件${snap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与判定)`)
280
280
  }
281
281
  } catch { /* 快照链路异常 → 主仓现行为 */ }
282
282
  }
@@ -292,7 +292,14 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
292
292
  try {
293
293
  testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName })
294
294
  printVerifyTestCheck(testCheck)
295
- lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase })
295
+ lintCheck = runVerifyLintCheck({ cwd: gateCwd, specBase: gateSpecBase, timeoutMs: snap ? 5 * 60 * 1000 : undefined })
296
+ // 快照 lint 超时回退主仓(2026-09-12 dogfood 两连实证:junction I/O 病态慢,3min/5min 均被
297
+ // 超时杀——主仓 60~110s 正常。按用户建议「自动回退」而非假败/advisory:主仓复跑保硬门,
298
+ // 并行噪声混入时失败输出带归属鉴定提示)
299
+ if (snap && lintCheck.status === 'failed' && /超时/.test(String(lintCheck.reason || ''))) {
300
+ console.warn('⚠️ 快照 lint 超时(node_modules junction I/O 慢)→ 主仓复跑 lint(并行噪声可能混入——失败先做归属鉴定)')
301
+ lintCheck = runVerifyLintCheck({ cwd, specBase })
302
+ }
296
303
  if (lintCheck.status !== 'skipped') {
297
304
  console.log(`\n⏳ noAI 质量扫描:CLI 亲自实测 commands.lint…`)
298
305
  }
@@ -315,9 +322,13 @@ export async function executeVerifyQualityScan({ cwd, specBase, changeName, plat
315
322
  // lint 失败(SILLYSPEC_VERIFY_LINT_GATE=advisory 时不 throw)同样是摩擦信号,计数不应随
316
323
  // 逃生阀漂移。recordFrictionEvent 自带静默降级,不影响本步推进/throw 语义。
317
324
  if (testFailed || lintCheck.status === 'failed') {
318
- recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : 'lint' })
325
+ await recordFrictionEvent({ cwd, changeName, platformOpts, type: 'verify_run_failed', detail: testFailed ? 'test' : 'lint' })
319
326
  }
320
327
  if (testFailed || lintBlocked) {
328
+ if (snap) {
329
+ try { const { printSnapshotFailureHint } = await import('./gate-snapshot.js')
330
+ printSnapshotFailureHint(snap) } catch { /* 提示失败不影响阻断 */ }
331
+ }
321
332
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
322
333
  await renderVerifyTestAttribution({ cwd, changeName, specBase, runtimeRoot })
323
334
  const which = testFailed ? '测试' : 'lint'
package/src/spec-sync.js CHANGED
@@ -124,6 +124,9 @@ export function hashFiles(entries) {
124
124
  hash: _hashBuffer(buf),
125
125
  mtime: Math.floor(e.mtimeMs / 1000),
126
126
  absPath: e.absPath,
127
+ // 批 E-①:buf 随行携带(computeSpecOps 的 update/add 直接取用)——消除变更文件二次
128
+ // readFileSync(大文件双读)。docs 树为文本内容、量级有限,整批驻留可接受。
129
+ buf,
127
130
  };
128
131
  });
129
132
  }
@@ -177,20 +180,22 @@ export function computeSpecOps(serverManifest, localFiles) {
177
180
  return [];
178
181
  }
179
182
 
180
- // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
183
+ // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同。
184
+ // 批 E-①:候选新路径按 hash 建索引 O(N+M)——旧双重循环 O(N×M),万级文件树明显变慢。
181
185
  const renames = [];
182
- const consumedNew = new Set();
186
+ const newByHash = new Map(); // hash → 未被服务器收录且未被消费的新路径队列
187
+ for (const [newPath, entry] of localMap) {
188
+ if (serverSet.has(newPath)) continue
189
+ const list = newByHash.get(entry.hash)
190
+ if (list) list.push(newPath); else newByHash.set(entry.hash, [newPath])
191
+ }
183
192
  for (const oldPath of serverPaths) {
184
- if (localSet.has(oldPath)) continue;
185
- const cachedEntry = server[oldPath];
186
- for (const newPath of localMap.keys()) {
187
- if (serverSet.has(newPath)) continue;
188
- if (consumedNew.has(newPath)) continue;
189
- if (localMap.get(newPath).hash === cachedEntry.hash) {
190
- renames.push({ oldPath, newPath });
191
- consumedNew.add(newPath);
192
- break;
193
- }
193
+ if (localSet.has(oldPath)) continue
194
+ const candidates = newByHash.get(server[oldPath].hash)
195
+ if (candidates && candidates.length > 0) {
196
+ const newPath = candidates.shift()
197
+ renames.push({ oldPath, newPath })
198
+ if (candidates.length === 0) newByHash.delete(server[oldPath].hash)
194
199
  }
195
200
  }
196
201
  const renamedOld = new Set(renames.map((r) => r.oldPath));
@@ -212,7 +217,7 @@ export function computeSpecOps(serverManifest, localFiles) {
212
217
  if (localSet.has(p)) {
213
218
  const localEntry = localMap.get(p);
214
219
  if (localEntry.hash !== server[p].hash) {
215
- const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
220
+ const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
216
221
  ops.push({
217
222
  op: 'update',
218
223
  path: p,
@@ -235,7 +240,7 @@ export function computeSpecOps(serverManifest, localFiles) {
235
240
  if (renamedNew.has(p)) continue;
236
241
  if (!serverSet.has(p)) {
237
242
  const localEntry = localMap.get(p);
238
- const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
243
+ const content = (localEntry.buf ?? readFileSync(localEntry.absPath ?? join('.', p))).toString('base64');
239
244
  ops.push({
240
245
  op: 'add',
241
246
  path: p,
@@ -317,10 +317,11 @@ const PLAN_RULES = [
317
317
  target: { root: 'change', path: 'design.md', scope: 'full' },
318
318
  data: {
319
319
  messageMissingList: 'design.md 缺少「文件变更清单」章节(或清单解析为空),无法做文件覆盖对账。该章节在 brainstorm 模板中为必填;请在 design.md 补上完整的文件变更清单(列出本次新增/修改/删除的源码文件)后重试。',
320
- messageUncovered: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。',
320
+ messageUncovered: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。\n 若跨仓文件被标为 [main]:多为仓变更段头未被识别(段头须为「## <repo-key> 仓变更」),先核对段头格式再补 task。',
321
+ messageMalformedHeader: 'design.md「文件变更清单」内疑似仓变更段头但格式无法解析:「${line}」\n 该段下文件会被记到上一段仓(或 main)→ 文件覆盖对账整段错位(误报未覆盖或漏检)。\n 修复:段头写成「## <repo-key> 仓变更」(repo-key 限字母数字._-,可加编号前缀与(…)备注后缀);若这是下一章节标题而非段头,请改名避免形如「<词> 仓变更…」。',
321
322
  },
322
- spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)。',
323
- failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。',
323
+ spec: 'design.md「文件变更清单」中的每个源码文件,必须被至少一个 task 的 allowed_paths 覆盖(前缀/glob 容差匹配),否则 execute 子代理无权改它→必然漏改。有 task 卡片但 design 缺「文件变更清单」章节也阻断(清单是覆盖对账的基准)。疑似仓变更段头但格式解析不了(repo-key 非法/仓变更后接自由文本)同样阻断并点名该行——段头错记仓会让对账整段错位。',
324
+ failMessage: 'design.md 文件变更清单中 ${count} 个文件未被任何 task 的 allowed_paths 覆盖:\n${files}\n 这些文件在 execute 阶段将无 task 有权修改 → 必然漏改。\n 修复:为每个遗漏文件新建/补充 task 并在其 allowed_paths 声明,或在 design.md「不修改文件」章节说明不改原因。\n 若跨仓文件被标为 [main]:多为仓变更段头未被识别(段头须为「## <repo-key> 仓变更」),先核对段头格式再补 task。',
324
325
  },
325
326
  // task-plan-reconciliation(custom):plan.md 声明任务 ↔ tasks/ 卡片双向对账(债单 D-2b)。
326
327
  // 修复场景:plan 列 17 个任务只生成 12 张卡(尾部文档同步类任务漏卡),execute 审计只看
@@ -13,7 +13,8 @@
13
13
  * (平台模式落 <runtimeRoot>/stage-reviews/...,与 execute-runs 同构)
14
14
  */
15
15
 
16
- import { existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync } from 'fs'
16
+ import { existsSync, readFileSync, mkdirSync, readdirSync } from 'fs'
17
+ import { writeAtomicSync } from './fs-atomic.js'
17
18
  import { join } from 'path'
18
19
  import { createHash } from 'crypto'
19
20
  import jsYaml from 'js-yaml'
@@ -561,7 +562,7 @@ export function validateStageReviewWithAutoRefresh(opts) {
561
562
  reviewerNotes: `${result.review.reviewerNotes || ''}\ndocHash auto-refreshed at ${refreshedAt}(gate 检测主文档改版,机械重算;verdict 保留,结论是否仍适用需人工确认)`.trim(),
562
563
  }
563
564
  try {
564
- writeFileSync(reviewPath, JSON.stringify(refreshed, null, 2) + '\n')
565
+ writeAtomicSync(reviewPath, JSON.stringify(refreshed, null, 2) + '\n')
565
566
  } catch {
566
567
  return { result, autoRefreshed: false } // 写失败回落原失败结果,不静默放行
567
568
  }
@@ -698,7 +699,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
698
699
  reviewedFiles,
699
700
  reviewerNotes: `${existing.reviewerNotes || ''}\ndocHash refreshed at ${refreshedAt}(主文档改版后机械重算;verdict 结论是否仍适用需人工确认)`.trim(),
700
701
  }
701
- writeFileSync(existingPath, JSON.stringify(review, null, 2) + '\n')
702
+ writeAtomicSync(existingPath, JSON.stringify(review, null, 2) + '\n')
702
703
  const schemaRecheck = validateStageReviewSchema(review)
703
704
  if (!schemaRecheck.ok) {
704
705
  throw new Error(`register-stage-review: refresh 后 schema 自检失败 — ${schemaRecheck.errors.join('; ')}(原 review 字段异常,先修原文件)`)
@@ -750,7 +751,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
750
751
  const reviewDir = join(runtimeRoot, 'stage-reviews', `${stage}-${reviewRunId}`)
751
752
  const reviewPath = join(reviewDir, 'review.json')
752
753
  mkdirSync(reviewDir, { recursive: true })
753
- writeFileSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
754
+ writeAtomicSync(reviewPath, JSON.stringify(review, null, 2) + '\n')
754
755
  // stage-reviews 回收不在此处(ql-20260908-005 已定分野):变更归属类证据走归档时精确
755
756
  // 回收(pruneArchivedChangeRuntime 按 reviewedFiles 归属);写入侧滚动是启发式,
756
757
  // 活跃变更的 review 可能因他端高频写而跌出 keep 窗,不进证据类目录。
@@ -760,7 +761,7 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
760
761
  console.warn(`⚠️ register-stage-review: marker 已存在 ${markerPath},将被覆盖为 ${reviewRunId}`)
761
762
  }
762
763
  mkdirSync(runtimeRoot, { recursive: true })
763
- writeFileSync(markerPath, reviewRunId + '\n')
764
+ writeAtomicSync(markerPath, reviewRunId + '\n')
764
765
 
765
766
  // self-check(fail-closed):刚写的 review 必过 schema + docHash 真实性。只验 mechanics,
766
767
  // 不判 verdict(verdict 是 agent/子代理的审查结论,even fail 也如实落盘,由 Stage Review Gate 裁决)。
@@ -293,7 +293,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
293
293
  - 仅改内部实现、无对外字段变动时,说明列照常写「新增 xx 方法」即可,无需数据流。
294
294
 
295
295
  **跨仓变更写法**(本次变更涉及 local.yaml repos: 注册的其他仓时必读;纯主仓变更跳过):
296
- - 清单**按仓分段**:每个仓一个小节,标题格式固定 \`## <repo-key> 仓变更\`(如 \`## sub-grid-security 仓变更\`)。plan-postcheck 文件覆盖对账按此段头解析仓归属,写成「前端仓 / backend」等其他标题 → 对账不上
296
+ - 清单**按仓分段**:每个仓一个小节,标题格式固定 \`## <repo-key> 仓变更\`(如 \`## sub-grid-security 仓变更\`;可加编号前缀或(跨仓,X1-X4)这类括号备注后缀,其余后缀/写法解析不了)。plan-postcheck 文件覆盖对账按此段头解析仓归属,写成「前端仓 / backend」等其他标题 → 对账不上且会点名段头格式错误
297
297
  - 段内路径相对**该仓根**写(\`src/routes/x.js\`);❌ 禁止带仓库名前缀(\`sub-grid-security/src/routes/x.js\`)、❌ 禁止绝对路径/盘符路径(\`C:/repo/src/...\`)——后续 TaskCard 的 allowed_paths 与 review 对账(\`git -C <仓根> diff\` 产仓根相对路径)都用同一口径,前缀/绝对路径永不命中
298
298
  - 主仓文件放在第一个段头之前(无段头区域 = main),或单独写 \`## main 仓变更\` 段
299
299
  - repo-key 必须是 local.yaml repos: 段已注册的键(未注册先跑 \`sillyspec local register-repo <key> <仓根路径>\`,main 隐式不用注册)
@@ -1074,6 +1074,10 @@ ${crossLines}${crossWorktreeSection}${crossLegacySection}
1074
1074
  ` - task-${i.taskNum} (repo: ${i.repo}) → workdir: "${i.workdir}"`
1075
1075
  ).join('\n')
1076
1076
  worktreeSection = `
1077
+ ### task 进行中标记(中断续跑的半成品归属,2026-09-12 起)
1078
+
1079
+ 每个 task **开工/完工各跑一条命令**(低摩擦,主代理或子代理均可):开工「sillyspec task start --change {CHANGE_NAME} --task task-NN --note 一句话在做什么」;完工「sillyspec task finish --change {CHANGE_NAME} --task task-NN」。中断续跑时主代理用「sillyspec task list --change {CHANGE_NAME}」点名接管(>2h 标红为中断遗留),不再靠翻 git 工作区猜哪个半成品是谁的。
1080
+
1077
1081
  ### 工作目录(per-task)
1078
1082
 
1079
1083
  调用 Task 工具启动子代理时,**workdir 参数是强制必传的**,且本 Wave 内不同 task 的 workdir 不同(主仓 task vs 跨仓 task)。
@@ -1213,7 +1217,8 @@ ${taskList}
1213
1217
  - 最后一个 Wave 完成后做一次全量编译验证
1214
1218
  - 用户明确要求编译时
1215
1219
  4. 每个任务完成后:
1216
- - **在 worktree 内 git add -A && git commit -m "<task-NN 摘要>"**(坑 subagent-uncommitted-newfile-apply3way,2026-08-22 实证:纯新增文件不 commit 时 apply 的 git apply --3way 报 "does not exist in index" 直接炸——未 commit 的新文件不在 base commit 也不在 index,patch 生成取不到。commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点)
1220
+ - **使用 \`sillyspec wt-commit --change <change-name> -- <task-files>\` 串行提交**(坑 wt-parallel-commit-race:同一 Wave 多子代理共享 worktree,裸 \`git add -A\` 互卷 WIP / 撞 index.lock;\`git add -A\` 还会把并行子代理未提交的半成品一并暂存——**禁 \`git add -A\`**;sillyspec wt-commit 自动处理分支切换与序列化,无需手动 git 操作;未 commit 的新文件不在 base commit 也不在 index,apply --3way 报 "does not exist in index")
1221
+ - commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点
1217
1222
  - **写 review.json 即可**(checkbox 由 CLI 自动勾选,见下方 Task Review Gate)
1218
1223
  - **任务边界上报(每任务一次,主仓根目录)**:review write 落盘(CLI 已自动勾选)后跑一次 \`sillyspec platform sync --change <change-name>\`——以任务粒度把「最后信号」(last_pushed_at)与 tasks.md 勾选状态推上平台(变更中心「进行中」可见性);未连接平台时该命令静默跳过,无需先检查连接状态
1219
1224
  - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
@@ -840,10 +840,21 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
840
840
 
841
841
  /**
842
842
  * design §6 按仓分段段头识别(D-014):`## <repo> 仓变更`(如 `## sillyspec 仓变更`)。
843
- * 段头为 h2,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀与尾随空白/冒号。
843
+ * 段头为 h2/h3,repo 名取段头首 token(去空白/编号前缀)。容忍可选编号前缀、(…)备注后缀
844
+ * (`(跨仓,X1-X4)` 等,全/半角括号均可)与尾随空白/冒号——后缀不容忍时(用户实证
845
+ * 2026-08-29)`### sillyspec 仓变更(跨仓,X1-X4)` 整段判 main、跨仓文件全量误报未覆盖。
844
846
  * 不命中(非仓变更段头)→ null。
845
847
  */
846
- const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更\s*[::]?\s*$/
848
+ const REPO_SECTION_HEADER_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?([A-Za-z0-9_.\-]+)\s*仓变更(?:\s*[((][^()()]*[))])?\s*[::]?\s*$/
849
+
850
+ /**
851
+ * 疑似段头宽匹配:标题行含「<token> 仓变更」字样(token 与仓变更间有空白)。
852
+ * 宽匹配命中而严格式(REPO_SECTION_HEADER_RE)不命中 = 段头意图明确但格式解析不了
853
+ * (repo-key 含中文等非法字符 / 仓变更后接自由文本)。此时该段文件会被错记到上一段仓
854
+ * (或 main)→ 覆盖对账整段错位,validateDesignFileCoverage 据此点名报错,
855
+ * 不让用户对着「未覆盖」清单反推是段头问题。
856
+ */
857
+ const REPO_SECTION_HEADER_LOOSE_RE = /^#{2,3}\s+(?:\d+[.)]\s*)?\S+\s+仓变更/
847
858
 
848
859
  /**
849
860
  * design §6 文件清单章节标题(与 change-list.js FILE_LIST_SECTION_RE 同源,避免 import 私有常量)。
@@ -867,19 +878,20 @@ const FILE_LIST_SECTION_RE = /^#{2,3}\s*(?:\d+[.)]\s*)?(文件变更清单|变
867
878
  * 外加 _hasSegmentHeader 标记(调用方据此决定是否走分段对账路径)
868
879
  *
869
880
  * @param {string} designPath - design.md 绝对路径
870
- * @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[] }}
881
+ * @returns {{ byRepo: Map<string, Set<string>>, hasSegmentHeader: boolean, allFiles: string[], malformedHeaders: string[] }}
871
882
  */
872
883
  function parseDesignCoverageByRepo(designPath) {
873
884
  const byRepo = new Map()
874
885
  const allFiles = []
886
+ const malformedHeaders = []
875
887
  if (!designPath || !existsSync(designPath)) {
876
- return { byRepo, hasSegmentHeader: false, allFiles }
888
+ return { byRepo, hasSegmentHeader: false, allFiles, malformedHeaders }
877
889
  }
878
890
  const content = readFileSync(designPath, 'utf8')
879
891
 
880
892
  const sectionMatch = content.match(FILE_LIST_SECTION_RE)
881
893
  if (!sectionMatch) {
882
- return { byRepo, hasSegmentHeader: false, allFiles }
894
+ return { byRepo, hasSegmentHeader: false, allFiles, malformedHeaders }
883
895
  }
884
896
 
885
897
  // 主章节起点(match index)→ 扫描到下一个非段头的 `## ` 标题或文件末尾。
@@ -891,10 +903,14 @@ function parseDesignCoverageByRepo(designPath) {
891
903
  for (let i = 0; i < lines.length; i++) {
892
904
  const line = lines[i]
893
905
  if (i > 0 && /^##\s/.test(line) && !REPO_SECTION_HEADER_RE.test(line)) {
894
- // 遇到非段头的 h2 标题(如 `## 7. 接口定义`)→ 主章节结束
906
+ // 遇到非段头的 h2 标题(如 `## 7. 接口定义`)→ 主章节结束。
907
+ // 但若它疑似仓变更段头(宽匹配命中),先记格式错误再结束——静默截断会让
908
+ // 该段文件整段脱离对账基准,比报错更危险。
909
+ if (REPO_SECTION_HEADER_LOOSE_RE.test(line)) malformedHeaders.push(line.trim())
895
910
  break
896
911
  }
897
912
  if (REPO_SECTION_HEADER_RE.test(line)) hasSegmentHeader = true
913
+ else if (REPO_SECTION_HEADER_LOOSE_RE.test(line)) malformedHeaders.push(line.trim())
898
914
  sectionLines.push(line)
899
915
  }
900
916
 
@@ -905,7 +921,7 @@ function parseDesignCoverageByRepo(designPath) {
905
921
  const main = new Set(parseFileChangeList(designPath, { keepSillyspecDocs: true }))
906
922
  if (main.size > 0) byRepo.set('main', main)
907
923
  for (const p of main) allFiles.push(p)
908
- return { byRepo, hasSegmentHeader: false, allFiles }
924
+ return { byRepo, hasSegmentHeader: false, allFiles, malformedHeaders }
909
925
  }
910
926
 
911
927
  // 有段头 → 按 repo 切片,每段构造临时 design 调 parseFileChangeList
@@ -951,7 +967,7 @@ function parseDesignCoverageByRepo(designPath) {
951
967
  rmSync(tmpDir, { recursive: true, force: true })
952
968
  }
953
969
 
954
- return { byRepo, hasSegmentHeader: true, allFiles }
970
+ return { byRepo, hasSegmentHeader: true, allFiles, malformedHeaders }
955
971
  }
956
972
 
957
973
  /**
@@ -996,9 +1012,16 @@ export function validateDesignFileCoverage(changeDir) {
996
1012
  }
997
1013
 
998
1014
  // 按仓分段解析 design §6(约束③):段头 `## <repo> 仓变更` → 段内路径归该 repo;无段头 → 全 main
999
- const { byRepo: designByRepo, hasSegmentHeader, allFiles: designFiles } = parseDesignCoverageByRepo(designPath)
1015
+ const {
1016
+ byRepo: designByRepo, hasSegmentHeader, allFiles: designFiles, malformedHeaders,
1017
+ } = parseDesignCoverageByRepo(designPath)
1000
1018
  // 两种断裂文案(缺清单章节 / 文件未覆盖)从 manifest 同源(plan.design-file-coverage.data)。
1001
1019
  const dcRule = getRule('plan.design-file-coverage')
1020
+ // 疑似仓变更段头但格式解析不了 → 点名报错(manifest 同源文案)。不拦的话该段文件被错记
1021
+ // 到别的仓,下方未覆盖对账整段错位,报错又不指向段头本身(用户实证 2026-08-29:排查靠猜)。
1022
+ for (const h of malformedHeaders) {
1023
+ errors.push(dcRule.data.messageMalformedHeader.replaceAll('${line}', h))
1024
+ }
1002
1025
  if (designFiles.length === 0) {
1003
1026
  // 走到 plan-postcheck 说明已生成 task 卡片(light/full),brainstorm 模板规定清单必填。
1004
1027
  // 无清单 = design↔execute 偏差温床(覆盖对账无从对起),阻断,不让它静默放过。
@@ -1047,6 +1070,13 @@ export function validateDesignFileCoverage(changeDir) {
1047
1070
  return { ok: errors.length === 0, errors, warnings, designFiles, uncovered }
1048
1071
  }
1049
1072
 
1073
+ // acceptance 标识符 grep 停用词:工具域/格式名词——acceptance 行文高频出现但不是代码实体,
1074
+ // 不排除会给几乎每张卡稳定制造一条误报(宁漏不噪,漏一条提示的代价远低于满屏噪音淹没人)。
1075
+ const ACCEPT_IDENT_STOPWORDS = new Set([
1076
+ 'snake_case', 'camelCase', 'kebab_case', // 格式名词(acceptance 常写「输出 snake_case 格式」)
1077
+ 'allowed_paths', 'related_tests', 'depends_on', 'expects_from', 'title_zh', // TaskCard 字段名
1078
+ ])
1079
+
1050
1080
  /**
1051
1081
  * 流程产物前缀(target_files 不该声明的路径域)。
1052
1082
  * 与 worktree-apply filterDeliverableFiles 同口径硬编码——不 import 复用是因为
@@ -1189,6 +1219,16 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
1189
1219
  const allTaskIds = []
1190
1220
  const depMap = new Map()
1191
1221
 
1222
+ // acceptance grep 降噪依据:design.md 全文——标识符已在 design 声明 = 本次计划要新增的
1223
+ // 字段/函数(此刻自然不在源码里),不是噪音(用户实证 2026-08-29:15 卡 20+ 条 warning
1224
+ // 全是这类误报)。无 design.md(none/light)→ 空串退化为纯源码 grep。
1225
+ let designText = ''
1226
+ try {
1227
+ designText = readFileSync(pJoin(changeDir, 'design.md'), 'utf8')
1228
+ } catch {
1229
+ // design.md 不存在 → 跳过该降噪门,行为退化为只有源码 grep
1230
+ }
1231
+
1192
1232
  for (const file of taskFiles) {
1193
1233
  const content = readFileSync(pJoin(tasksDir, file), 'utf8')
1194
1234
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
@@ -1274,8 +1314,11 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
1274
1314
 
1275
1315
  // 6. acceptance best-effort 字段 grep(D-05 软约束,warning 不阻断)
1276
1316
  // 从 acceptance 文本提取 snake_case/camelCase 标识符,grep allowed_paths 指向的源文件;
1277
- // 找不到 → warning(给 LLM 审查提线索,不阻断 execute)。宁漏不噪:只取 snake/camel,
1278
- // 正则天然避开命令(无 _ 或大写)/路径(无 /)/中文;glob allowed_path / 目录 / 不存在文件一律跳过。
1317
+ // 找不到 → warning(给 LLM 审查提线索,不阻断 execute)。宁漏不噪,三道降噪门:
1318
+ // ① 停用词(格式名词/TaskCard 字段名,ACCEPT_IDENT_STOPWORDS);
1319
+ // ② design.md 已声明的标识符跳过——本次计划新增的字段/函数此刻不在源码里是正常的;
1320
+ // ③ 正则天然避开命令(无 _ 或大写)/路径(无 /)/中文;glob allowed_path / 目录 /
1321
+ // 不存在文件一律跳过,且至少读到一个源文件才比对(防全读不到 → 全标识符误报)。
1279
1322
  if (projectRoot && hasAcceptance && allowedPaths.length > 0) {
1280
1323
  let acceptanceText = ''
1281
1324
  try {
@@ -1301,8 +1344,10 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
1301
1344
  // 至少读到一个源文件才比对,避免「全没读到 → 全部标识符误报」噪声
1302
1345
  if (readableFiles.length > 0) {
1303
1346
  for (const ident of idents) {
1347
+ if (ACCEPT_IDENT_STOPWORDS.has(ident)) continue
1348
+ if (designText.includes(ident)) continue // design 已声明 → 本次计划新增,非噪音
1304
1349
  if (!readableFiles.some(c => c.includes(ident))) {
1305
- warnings.push(`${taskId}: acceptance 提到 ${ident} 但 allowed_paths 源文件未命中`)
1350
+ warnings.push(`${taskId}: acceptance 提到 ${ident} 但 allowed_paths 源文件与 design.md 均未命中`)
1306
1351
  }
1307
1352
  }
1308
1353
  }