sillyspec 3.26.9 → 3.26.11

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 (98) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +111 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -81
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -675
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +198 -9
  62. package/src/run/complete.js +225 -24
  63. package/src/run/concurrent-detect.js +22 -0
  64. package/src/run/gates.js +755 -755
  65. package/src/run/prompt.js +761 -721
  66. package/src/run/quick-audit.js +155 -146
  67. package/src/run/shared.js +1268 -1197
  68. package/src/run/stage.js +487 -479
  69. package/src/scan-postcheck.js +371 -371
  70. package/src/setup.js +416 -416
  71. package/src/sillyhub-mcp/client.js +529 -529
  72. package/src/spec-sync.js +323 -0
  73. package/src/stage-contract.js +897 -897
  74. package/src/stage-review.js +549 -549
  75. package/src/stages/archive.js +155 -154
  76. package/src/stages/brainstorm-auto.js +236 -236
  77. package/src/stages/brainstorm.js +596 -597
  78. package/src/stages/cmd-existence.js +145 -145
  79. package/src/stages/doctor.js +371 -371
  80. package/src/stages/execute.js +1003 -991
  81. package/src/stages/explore.js +34 -34
  82. package/src/stages/index.js +29 -29
  83. package/src/stages/knowledge.js +498 -498
  84. package/src/stages/plan-postcheck.js +1302 -1281
  85. package/src/stages/plan.js +625 -604
  86. package/src/stages/quick.js +151 -143
  87. package/src/stages/scan.js +606 -605
  88. package/src/stages/status.js +64 -64
  89. package/src/stages/verify.js +264 -261
  90. package/src/sync.js +1223 -1125
  91. package/src/task-review.js +1019 -1019
  92. package/src/verify-postcheck.js +1186 -1186
  93. package/src/workflow.js +600 -600
  94. package/src/worktree-apply.js +1213 -1136
  95. package/src/worktree-deps.js +353 -353
  96. package/src/worktree.js +1481 -1437
  97. package/templates/workflows/archive-impact.yaml +79 -79
  98. package/templates/workflows/scan-docs.yaml +132 -132
@@ -26,7 +26,7 @@ import { basename, join, resolve, relative, isAbsolute } from 'node:path'
26
26
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, rmSync } from 'node:fs'
27
27
  import { renameSyncRetry, writeAtomicSync } from '../fs-atomic.js'
28
28
  import { resolveChangeDir, resolveQuickSessionsDir, safeGit, auditQuickCompletion, triggerSync, isQuickMetadata, resolveRuntimeRoot } from './shared.js'
29
- import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
29
+ import { detectConcurrentChanges, formatConcurrentWarning, resolveConcurrentAnchor } from './concurrent-detect.js'
30
30
  import { stageRegistry } from '../stages/index.js'
31
31
  import { SCAN_STATUS, POINTER_STATUS } from '../constants.js'
32
32
  import { printQuickAuditReview } from './quick-audit.js'
@@ -719,7 +719,9 @@ export async function handleWorkflowPostCheck({ stageName, steps, currentIdx, cw
719
719
  const icon = impactResult.status === 'pass' ? '✅' : '❌'
720
720
  console.log(`${icon} module-impact.md 检查${impactResult.status === 'pass' ? '通过' : '失败'}`)
721
721
  for (const f of (result.failures || []).filter(f => f.role_id === 'impact-analyzer')) {
722
- console.log(` └─ ${f}`)
722
+ // f 是 {level,role_id,output,check,message} 对象——直接 ${f} 打印裸 [object Object](ql-20260819-006);
723
+ // message 才是人类可读 detail(如「缺少章节: …」),缺字段时 stringify 兜底防再退化成不可诊断输出
724
+ console.log(` └─ ${f.message ?? JSON.stringify(f)}`)
723
725
  }
724
726
  }
725
727
  const saved = saveWorkflowRun(result, {
@@ -812,10 +814,15 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
812
814
  // D-003),用 ?. + ?? [] 兜底防 spread undefined 抛 TypeError(B-005)。整钩子 try/catch 隔离
813
815
  // ——detect 本就 fail-open,保守再包一层,任何异常只吞不 bubble,主完成流程不受影响。
814
816
  try {
815
- const ownFiles = [
816
- ...(review?.changedFiles ?? []),
817
- ...(mergedGuard?.baselineFiles ?? []),
818
- ]
817
+ // ownFiles 锚点(2026-08-18 误归属修复):声明会话 = baselineFiles ∪ allowedFiles(声明即归属),
818
+ // 不再用 changedFiles 全量——他者窗口文件已被污染进 changedFiles,旧口径自吞致预检对
819
+ // ql-20260818-003 形态结构性失明。未声明会话维持旧口径(changed ∪ baseline):无声明无归属
820
+ // 信息,收窄锚点会把自身未声明改动全误报他者。resolveConcurrentAnchor 纯函数(concurrent-detect)。
821
+ const ownFiles = resolveConcurrentAnchor({
822
+ changedFiles: review?.changedFiles ?? [],
823
+ baselineFiles: mergedGuard?.baselineFiles ?? [],
824
+ allowedFiles: mergedGuard?.allowedFiles ?? [],
825
+ })
819
826
  const detected = detectConcurrentChanges(cwd, {
820
827
  changeName,
821
828
  linkedChanges: mergedGuard?.linkedChanges ?? [],
@@ -892,13 +899,37 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
892
899
  try {
893
900
  // 回填实际改动文件:review.changedFiles 含 quick 自身元数据(quicklog/.runtime/modules 等),
894
901
  // 用 isQuickMetadata 过滤掉,只留真实业务文件。brownfield 无 review → 空数组,文件行不动。
895
- const realFiles = Array.isArray(review?.changedFiles)
896
- ? review.changedFiles.filter(f => !isQuickMetadata(f, linkedChanges))
897
- : []
902
+ // 归属口径(2026-08-18 误归属修复):声明会话优先 attributedFiles(窗口∩声明∪同文件并发命中),
903
+ // 他者窗口文件不进文件行;未声明会话/旧 review 无 attributedFiles → 兜底 changedFiles 全量。
904
+ const auditFiles = Array.isArray(review?.attributedFiles)
905
+ ? review.attributedFiles
906
+ : (Array.isArray(review?.changedFiles) ? review.changedFiles : [])
907
+ const realFiles = auditFiles.filter(f => !isQuickMetadata(f, linkedChanges))
908
+ // D-8 落盘(2026-08-18 修):advisory 欠账信号从「纯打印」升级为「随条目落盘」——修复
909
+ // 「欠账已记录(QUICKLOG reasons)」的不实承诺(交叉审查实证 reasons 纯 stdout,事后不可审计)。
910
+ // 两周实测(2026-08-31 裁决,doc-consistency-debt §七)需要分母:信号触发次数必须可追溯。
911
+ const auditNotes = []
912
+ if (review?.docSyncHint && review.docSyncHint.touchedSource > 0 && review.docSyncHint.docFiles.length === 0) {
913
+ const mods = Array.isArray(review.docSyncHint.modules) && review.docSyncHint.modules.length > 0
914
+ ? `(涉及模块:${review.docSyncHint.modules.map((m) => m.id).join(' · ')})` : ''
915
+ auditNotes.push(`📝 文档欠账(D-8):${review.docSyncHint.touchedSource} 个源码文件改动未同步任何模块文档${mods}`)
916
+ }
917
+ if (review?.docsCheckHint && review.docsCheckHint.invalid > 0) {
918
+ auditNotes.push(`📎 文档引用失效:${review.docsCheckHint.invalid}/${review.docsCheckHint.total} 处 file:line 失效(sillyspec docs check 可复现)`)
919
+ }
920
+ // 归属切分注(2026-08-18 误归属修复):窗口内未声明脏文件不进「文件:」行,但必须落盘可追溯
921
+ // (多 agent 并发仓他者窗口改动 / 本会话漏声明均可能),防真实改动被静默挤走。
922
+ if (Array.isArray(review?.undeclaredFiles) && review.undeclaredFiles.length > 0) {
923
+ const undeclared = review.undeclaredFiles.filter(f => !isQuickMetadata(f, linkedChanges))
924
+ if (undeclared.length > 0) {
925
+ auditNotes.push(`⚖️ 归属切分:${undeclared.length} 个窗口内未声明脏文件未计入文件行(并行会话改动或本会话漏声明):${undeclared.join(', ')}`)
926
+ }
927
+ }
898
928
  await completeQuicklogEntry(specBase, gitUser, qlId, {
899
929
  resultText: outputText || '',
900
930
  linkedChanges,
901
931
  changedFiles: realFiles,
932
+ auditNotes,
902
933
  })
903
934
  console.log(`📝 QUICKLOG 条目 ${qlId} 已标记完成`)
904
935
  // 刷新 DB title:从 step3「需求:」提取(agent 可改 title 的途径),覆盖启动时的兜底快照。
@@ -911,6 +942,21 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
911
942
  console.warn(`⚠️ QUICKLOG 完成态写入失败: ${e.message}`)
912
943
  }
913
944
 
945
+ // 轻量归档任务已全勾选的关联真实变更
946
+ try {
947
+ const closeResult = await closeQuickLinkedChanges({ pm, cwd, specBase, linkedChanges, platformOpts })
948
+ if (closeResult.closed.length > 0) {
949
+ console.log(`📦 已自动归档 ${closeResult.closed.length} 个关联变更:${closeResult.closed.join(', ')}`)
950
+ }
951
+ if (closeResult.skipped.length > 0) {
952
+ for (const s of closeResult.skipped) {
953
+ console.log(`⏭️ 关联变更 ${s.name} 未归档:${s.reason}`)
954
+ }
955
+ }
956
+ } catch (e) {
957
+ console.warn(`⚠️ 关联变更轻量归档失败(不阻断 quick 完成): ${e.message}`)
958
+ }
959
+
914
960
  // 清理 session 目录(rmSync/unlinkSync 容忍不存在)。路径与写入对齐(Q4 resolveQuickSessionsDir)。
915
961
  try {
916
962
  if (changeName) {
@@ -933,8 +979,151 @@ export async function handleQuickStageCompletion({ stageName, steps, currentIdx,
933
979
  }
934
980
  }
935
981
  }
982
+
936
983
  return null
937
984
  }
985
+
986
+ /**
987
+ * 判定变更 tasks.md 是否全部勾选完成。
988
+ * 无 tasks.md 或有未勾选项均返回 false(保守策略)。
989
+ *
990
+ * @param {string} specBase - .sillyspec 根目录
991
+ * @param {string} changeName - 变更名
992
+ * @returns {boolean}
993
+ */
994
+ function isChangeTasksComplete(specBase, changeName) {
995
+ const tasksPath = join(specBase, 'changes', changeName, 'tasks.md')
996
+ if (!existsSync(tasksPath)) return false
997
+ try {
998
+ const content = readFileSync(tasksPath, 'utf8').replace(/\r\n/g, '\n')
999
+ return !/^-\s*\[\s*\]\s+/m.test(content)
1000
+ } catch {
1001
+ return false
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * 对单个已完成的关联真实变更执行轻量归档(不校验 plan.md/module-impact.md)。
1007
+ *
1008
+ * @param {Object} opts
1009
+ * @param {ProgressManager} opts.pm
1010
+ * @param {string} opts.cwd
1011
+ * @param {string} opts.specBase
1012
+ * @param {string} opts.changeName
1013
+ * @param {Object} [opts.platformOpts]
1014
+ * @returns {Promise<{closed:boolean, destDir?:string, reason?:string}>}
1015
+ */
1016
+ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, platformOpts = {} }) {
1017
+ const srcDir = join(specBase, 'changes', changeName)
1018
+ if (!existsSync(srcDir)) {
1019
+ return { closed: false, reason: '源目录不存在' }
1020
+ }
1021
+ const changesDir = join(specBase, 'changes')
1022
+ const archiveDir = join(changesDir, 'archive')
1023
+ const date = new Date().toISOString().slice(0, 10)
1024
+ const destName = archiveDestDirName(date, changeName)
1025
+ const destDir = join(archiveDir, destName)
1026
+
1027
+ // 幂等:目标目录已存在或已在 archive/ 中
1028
+ if (existsSync(destDir) || findAlreadyArchivedDir(archiveDir, changeName)) {
1029
+ return { closed: false, reason: `目标目录已存在(${destName})` }
1030
+ }
1031
+
1032
+ mkdirSync(archiveDir, { recursive: true })
1033
+ try {
1034
+ renameSyncRetry(srcDir, destDir)
1035
+ } catch (e) {
1036
+ return { closed: false, reason: `移动目录失败:${e.message}` }
1037
+ }
1038
+
1039
+ pm.unregisterChange(cwd, changeName)
1040
+ await archiveWorktreeCleanup(cwd, changeName, specBase, platformOpts)
1041
+ safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
1042
+
1043
+ console.log(`📦 关联变更已自动归档:${changeName} → archive/${destName}/`)
1044
+ return { closed: true, destDir }
1045
+ }
1046
+
1047
+ /**
1048
+ * quick 轻量归档的阶段闸允许集:仅「从未进入完整流程」的变更可被 quick --done 自动归档
1049
+ * (d192f89 原始场景:评估 small 转 quick、停在 brainstorm 的僵尸变更)。execute 完成后
1050
+ * tasks.md 必然全勾选而流程未收尾——不看 current_stage 会把 verify/中途的完整流程变更
1051
+ * 绕过 verify/archive 校验直接归档注销(quick-close-midflight 缺陷)。
1052
+ *
1053
+ * 阶段名允许集之外还有「阶段完成态」闸(ql-20260819-010,quick-done-autoarchive-misfire
1054
+ * 缺陷①):current_stage=brainstorm 但该阶段 status=completed(brainstorm 收尾到 plan
1055
+ * 开始之间的空窗)≠ 僵尸变更——此时关联 quick --done 会把即将进 plan 的进行中变更误
1056
+ * 轻量归档。stage_status=completed 一律走原流程收尾。
1057
+ */
1058
+ const QUICK_CLOSE_ALLOWED_STAGES = new Set(['', 'scan', 'brainstorm'])
1059
+
1060
+ /**
1061
+ * quick --done 完成后,自动关闭任务已全部完成的关联真实变更。
1062
+ * quick-<hex> sessionId 自身不在此处理(由调用方单独注销)。
1063
+ * 单个归档失败 catch warn,不阻断 quick 完成。
1064
+ *
1065
+ * 阶段闸(fail-closed):先查 changes.current_stage——无 DB 记录(未注册目录桩)或停在
1066
+ * scan/brainstorm 才继续 tasks 判定;plan/execute/verify/archive 一律 skip 走原流程收尾。
1067
+ * pm 缺 getChangeStage 接口或查询抛错同样 skip,不静默放行。
1068
+ *
1069
+ * @param {Object} opts
1070
+ * @param {ProgressManager} opts.pm
1071
+ * @param {string} opts.cwd
1072
+ * @param {string} opts.specBase
1073
+ * @param {string[]} [opts.linkedChanges]
1074
+ * @param {Object} [opts.platformOpts]
1075
+ * @returns {Promise<{closed:string[], skipped:{name:string,reason:string}[]}>}
1076
+ */
1077
+ export async function closeQuickLinkedChanges({ pm, cwd, specBase, linkedChanges = [], platformOpts = {} }) {
1078
+ const closed = []
1079
+ const skipped = []
1080
+ // 只处理真实变更,跳过 quick 会话 sessionId
1081
+ const realChanges = linkedChanges.filter((name) => !/^quick-[0-9a-f]{8}$/.test(name))
1082
+ for (const changeName of realChanges) {
1083
+ try {
1084
+ if (typeof pm.getChangeStage !== 'function') {
1085
+ skipped.push({ name: changeName, reason: '进度库接口缺失(getChangeStage),无法判定流程阶段,不自动归档' })
1086
+ continue
1087
+ }
1088
+ const stageInfo = pm.getChangeStage(cwd, changeName)
1089
+ if (stageInfo !== null && !QUICK_CLOSE_ALLOWED_STAGES.has(stageInfo.current_stage || '')) {
1090
+ skipped.push({
1091
+ name: changeName,
1092
+ reason: `变更处于完整流程「${stageInfo.current_stage}」阶段(tasks.md 全勾不等于流程收尾),不自动归档——请走原流程收尾(sillyspec progress show 查看进度)`,
1093
+ })
1094
+ continue
1095
+ }
1096
+ // ql-20260819-010(quick-done-autoarchive-misfire 缺陷①):当前阶段已完成 ≠
1097
+ // 从未进入完整流程的僵尸。brainstorm 收尾到 plan 开始之间的空窗里 current_stage
1098
+ // 仍读 brainstorm,只看阶段名会把即将进 plan 的变更误轻量归档(propose 骨架
1099
+ // tasks.md 除 quick 追加的 ql 行外没有任务行,「无未勾选框=全勾」恒真)。
1100
+ // stage_status=completed 一律原流程收尾;null(无阶段行/brownfield)与旧调用方
1101
+ // mock 缺字段(undefined)均按未完成放行,僵尸逃生通道行为不变。
1102
+ if (stageInfo !== null && stageInfo.stage_status === 'completed') {
1103
+ skipped.push({
1104
+ name: changeName,
1105
+ reason: `变更当前阶段「${stageInfo.current_stage || '(空)'}」已完成(推进/收尾中,非僵尸变更),不自动归档——请走原流程收尾(sillyspec progress show 查看进度)`,
1106
+ })
1107
+ continue
1108
+ }
1109
+ if (!isChangeTasksComplete(specBase, changeName)) {
1110
+ skipped.push({ name: changeName, reason: 'tasks.md 未全勾选或不存在' })
1111
+ continue
1112
+ }
1113
+ const result = await closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, platformOpts })
1114
+ if (result.closed) {
1115
+ closed.push(changeName)
1116
+ } else {
1117
+ skipped.push({ name: changeName, reason: result.reason || '未知原因' })
1118
+ }
1119
+ } catch (e) {
1120
+ console.warn(`⚠️ 关联变更 ${changeName} 轻量归档失败(不阻断 quick 完成): ${e.message}`)
1121
+ skipped.push({ name: changeName, reason: e.message })
1122
+ }
1123
+ }
1124
+ return { closed, skipped }
1125
+ }
1126
+
938
1127
  /**
939
1128
  * execute「Wave N 执行」步骤完成后扫 worktree 提取 provider endpoint artifact(W6 Step6c 从
940
1129
  * completeStep 内联块抽出)。供 verify 阶段 parity 对账 + consumer task 上游契约注入。
@@ -29,11 +29,25 @@ import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProject
29
29
  import { formatExecuteSummary } from '../worktree-apply.js'
30
30
  import { isEndToEndTaskText } from '../change-risk-profile.js'
31
31
  import { deriveTitleFromLinkedChange } from '../quicklog.js'
32
+ import { gitQuiet } from '../git-helper.js'
32
33
 
33
34
  // validateMetadata / readDesignScale / validateFileLocations 已迁至 ./gates.js(completeStageGates
34
35
  // 共享收尾管线,消除 noAI 末步 / continueStep 完成分支绕过 gate 的 S1/S2/S3 不对称)。
35
36
  // completeStep 阶段完成分支(task-02 接入 completeStageGates)+ brainstorm 下一步提示改 import 自 gates.js。
36
37
 
38
+ // 完整流程 change title 刷新:从 proposal/design 首个 # 标题提取中文描述写 changes.title(人类可读
39
+ // 展示元信息)。proposal/design 落盘后的**任意步骤持久化点**都调用——单步 --done(brainstorm
40
+ // step6 design.md 已落盘但阶段未完)与阶段完成同样刷新,治「brainstorm 全程 title 存英文 autoName
41
+ // 兜底」。quick-<hex> 无 proposal/design 目录,deriveTitleFromLinkedChange 返回 '' 不刷新
42
+ // (quick 走 handleQuickStageCompletion 的 extractTitleFromResult)。失败静默(不阻断流程)。
43
+ function refreshChangeTitleFromArtifacts(pm, cwd, specBase, changeName) {
44
+ if (!changeName || /^quick-[0-9a-f]{8}$/.test(changeName)) return
45
+ try {
46
+ const refinedTitle = deriveTitleFromLinkedChange(specBase, changeName)
47
+ if (refinedTitle) pm.updateChangeMeta(cwd, changeName, { title: refinedTitle })
48
+ } catch { /* title 刷新失败不阻断 */ }
49
+ }
50
+
37
51
  /**
38
52
  * 坑1:用 --done --answer 解掉「已 waiting 的步骤」。
39
53
  *
@@ -114,8 +128,28 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
114
128
  console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
115
129
  }
116
130
  if (currentIdx === -1) {
117
- console.error(`没有待完成的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
118
- process.exit(1)
131
+ // ── reopen stale 收尾路径(坑 reopen-done-escape-hatch-unreachable)──
132
+ // 全 completed+stale(无 pending/in-progress)时若只有 exit(1),--done --confirm 逃生门
133
+ // 永远到不了下方回填门控。修复:有 stale 时——无 confirm 报拦截指引(与门控同文案);
134
+ // 带 confirm 把首个 stale 拉回 pending 走完整完成管线,末步后的回填门控 confirm 分支
135
+ // 收尾其余 stale(--confirm 语义 = 全部 stale 一并确认)。
136
+ const _staleIdx = steps.findIndex(s => s.status === 'stale')
137
+ if (_staleIdx !== -1) {
138
+ const _staleNames = steps.filter(s => s.status === 'stale').map(s => s.name)
139
+ if (!confirm) {
140
+ console.error(`\n⏸️ 检测到 ${_staleNames.length} 个 stale 步骤(reopen 后未执行):${_staleNames.join('、')}`)
141
+ console.error(` 两条出路:`)
142
+ console.error(` ① sillyspec run ${stageName}${changeName ? ` --change ${changeName}` : ''} 逐个真实执行(stale 会被转为 pending)`)
143
+ console.error(` ② 确认方案未变,一次性回填收尾:sillyspec run ${stageName} --done --confirm${changeName ? ` --change ${changeName}` : ''} --output "..."`)
144
+ process.exit(1)
145
+ }
146
+ steps[_staleIdx].status = 'pending'
147
+ currentIdx = _staleIdx
148
+ console.log(`⚠️ --confirm 收尾:把 stale 步骤 "${steps[_staleIdx].name}" 拉回当前步骤走完成管线,其余 stale 由回填门控一并确认。`)
149
+ } else {
150
+ console.error(`没有待完成的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
151
+ process.exit(1)
152
+ }
119
153
  }
120
154
 
121
155
  // ── requiresWait 硬门控 ──
@@ -285,15 +319,40 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
285
319
  // completed,否则进度数字与实际步骤明细矛盾(阶段已完成 + 6/8)。
286
320
  // 安全边界:仅进入阶段完成分支时才回填(stale 本就被跳过、不回填也照常标 completed),
287
321
  // 不改变单步推进行为;stale 若确需重跑,运行流(stage.js:141-148)会先于本分支转 pending 执行。
322
+ // FR-01 门控:无 --confirm 时不回填,阻断并给两条出路;带 --confirm 时回填并审计。
288
323
  const staleSteps = steps.filter(s => s.status === 'stale')
289
324
  if (staleSteps.length > 0) {
325
+ if (!confirm) {
326
+ // 无 confirm:不回填,阶段不完成,返回 staleBlocked 标记
327
+ const staleNames = staleSteps.map(s => `Step ${steps.indexOf(s) + 1}(${s.name})`).join(', ')
328
+ console.error(`⏸️ 检测到 ${staleSteps.length} 个 stale 步骤(reopen 后未执行)——两条出路:`)
329
+ console.error(` ① sillyspec run ${stageName} 逐个真实执行(首个 stale 自动转 pending)`)
330
+ console.error(` ② 确认方案未变用 --done --confirm 一次性回填收尾`)
331
+ console.error(` stale 步骤:${staleNames}`)
332
+ progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
333
+ pm._write(cwd, progress, changeName)
334
+ return { stageCompleted: false, currentIdx, nextPendingIdx: -1, staleBlocked: true }
335
+ }
336
+ // 带 confirm:按现行回填逻辑 + 审计记录
290
337
  const nowStr = new Date().toLocaleString('zh-CN',{hour12:false})
291
338
  for (const st of staleSteps) {
292
339
  st.status = 'completed'
293
340
  st.completedAt = st.completedAt || nowStr
294
341
  }
295
342
  pm._write(cwd, progress, changeName)
296
- console.log(` ⚠️ 同步回填 ${staleSteps.length} 个 stale 步骤为 completed(reopen --from-step N 后 --done,方案未变)`)
343
+ console.log(` ⚠️ 同步回填 ${staleSteps.length} 个 stale 步骤为 completed(reopen --from-step N 后 --done --confirm,方案未变)`)
344
+ // 审计记录 reopen-stale-backfill
345
+ try {
346
+ pm._appendAuditLog(cwd, {
347
+ action: 'reopen-stale-backfill',
348
+ stage: stageName,
349
+ change: changeName,
350
+ steps: staleSteps.map(s => s.name),
351
+ confirmedAt: nowStr
352
+ })
353
+ } catch (e) {
354
+ console.warn('⚠️ 回填审计日志写入失败(不阻断):', e.message)
355
+ }
297
356
  }
298
357
 
299
358
  stageData.status = 'completed'
@@ -331,15 +390,8 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
331
390
  if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
332
391
  if (_stageGatesResult) return _stageGatesResult
333
392
 
334
- // 完整流程 change title 刷新:proposal/design 落盘后(brainstorm/plan 完成),从 proposal 首个 #
335
- // 标题刷新 changes.title(人类可读中文标题,用户可随时刷新)。quick-<hex> 无 proposal 目录 →
336
- // deriveTitleFromLinkedChange 返回 '' 不刷新(quick 走 handleQuickStageCompletion 的 extractTitleFromResult)。
337
- if (changeName && !/^quick-[0-9a-f]{8}$/.test(changeName)) {
338
- try {
339
- const refinedTitle = deriveTitleFromLinkedChange(specBase, changeName)
340
- if (refinedTitle) pm.updateChangeMeta(cwd, changeName, { title: refinedTitle })
341
- } catch { /* title 刷新失败不阻断完成 */ }
342
- }
393
+ // 完整流程 change title 刷新(阶段完成时;proposal/design 已落盘,机制见 refreshChangeTitleFromArtifacts)
394
+ refreshChangeTitleFromArtifacts(pm, cwd, specBase, changeName)
343
395
 
344
396
  // gate 全过:persist completed(task-01 移后)。此处到 _write 之间若崩,DB 仍 in-progress(内存已 completed 但未落盘),下次进 CLI 读 DB 即 in-progress,不产生"假 completed"。
345
397
  pm._write(cwd, progress, changeName)
@@ -404,6 +456,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
404
456
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
405
457
  pm._write(cwd, progress, changeName)
406
458
  triggerSync(cwd, changeName, platformOpts)
459
+ // 单步完成也刷新 change title:design.md 在 brainstorm step6 落盘,此后每次 --done 都该让
460
+ // changes.title 反映中文描述,不等阶段收尾(机制见 refreshChangeTitleFromArtifacts)。
461
+ refreshChangeTitleFromArtifacts(pm, cwd, specBase, changeName)
407
462
 
408
463
  // Append to user-inputs.md
409
464
  if (outputText) {
@@ -453,21 +508,90 @@ function readTaskCardText(changeDir, taskNum) {
453
508
  try { return readFileSync(join(changeDir, 'tasks', `task-${taskNum}.md`), 'utf8') } catch { return '' }
454
509
  }
455
510
 
511
+ /**
512
+ * W2 task-04/05 共用 helper:构造草稿零 diff 校验用的 ctx(与 task-review.js generateTaskReviewDrafts 同源)。
513
+ * 用于 autoCheckPlanFromReviews(勾选层)和 detectExecuteBatchFinish(批量层)。
514
+ * 失败返回 null(调用方降级:勾选层不勾、批量层不过滤——向后兼容)。
515
+ *
516
+ * @returns {Promise<{gitDir:string, base:string, head:string}|null>}
517
+ */
518
+ async function buildDraftContext(cwd, changeName) {
519
+ try {
520
+ const { WorktreeManager } = await import('../worktree.js')
521
+ const meta = new WorktreeManager({ cwd }).getMeta(changeName)
522
+ const base = meta?.baselineCommit || meta?.baseHash || null
523
+ let gitDir = cwd
524
+ let head = null
525
+ if (meta?.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
526
+ gitDir = meta.worktreePath
527
+ }
528
+ if (base) {
529
+ try {
530
+ head = gitQuiet(gitDir, ['rev-parse', 'HEAD'])
531
+ } catch (e) {
532
+ console.warn(`⚠️ ctx 构造失败(git rev-parse HEAD 失败:${e && e.message ? e.message : e}),跳过草稿 diff 校验`)
533
+ return null
534
+ }
535
+ }
536
+ if (base && head && gitDir) {
537
+ return { gitDir, base, head }
538
+ }
539
+ return null
540
+ } catch (e) {
541
+ console.warn(`⚠️ ctx 构造失败(${e && e.message ? e.message : e}),跳过草稿 diff 校验`)
542
+ return null
543
+ }
544
+ }
545
+
456
546
  /**
457
547
  * 判定 task 是否该被 autoCheckPlanFromReviews 自动勾选。
458
548
  * 端到端/deployment-critical task 要求 review spec+quality 双 pass(cannot_verify 不算,防批量完成
459
549
  * 放行未真验的端到端 task);普通 task 非 fail 即可(保主 agent 直接实现模式体验,其 cannot_verify
460
550
  * 草稿仍可批量收尾)。坑 execute-batch-complete-endtoend-checkbox。
461
- * @param {{ok?:boolean, review?:{specVerdict?:string, qualityVerdict?:string}}} r readReview 结果
551
+ *
552
+ * W2 task-04 FR-03:草稿勾选层零 diff 守卫——review 为自动草稿且 ctx 给定时,额外要求
553
+ * changedFiles 非空且 git diff 实测非空。防 allowed_paths 误归属他人 diff 或陈旧 review.json 导致
554
+ * 空任务被自动勾选/批量放行。ctx 缺省时保持现行判定(向后兼容)。
555
+ *
556
+ * @param {{ok?:boolean, review?:{specVerdict?:string, qualityVerdict?:string, reviewerNotes?:string, changedFiles?:string[]}}} r readReview 结果
462
557
  * @param {boolean} endToEnd 是否端到端/deployment-critical task
558
+ * @param {{gitDir?:string, base?:string, head?:string}|null} [ctx] 实测 diff 上下文;缺省保持现行判定
463
559
  * @returns {boolean}
464
560
  */
465
- export function shouldAutoCheckTask(r, endToEnd) {
561
+ export function shouldAutoCheckTask(r, endToEnd, ctx = null) {
466
562
  if (!r?.ok) return false
467
563
  const spec = r.review?.specVerdict
468
564
  const quality = r.review?.qualityVerdict
469
565
  if (spec === 'fail' || quality === 'fail') return false
470
566
  if (endToEnd) return spec === 'pass' && quality === 'pass'
567
+
568
+ // W2 task-04 草稿零 diff 守卫(FR-03)
569
+ // ctx 给定且 review 为自动草稿时,要求 changedFiles 非空且实测 diff 非空
570
+ if (ctx && r.review?.reviewerNotes && r.review.reviewerNotes.includes('auto-generated draft')) {
571
+ const changedFiles = r.review.changedFiles || []
572
+ if (changedFiles.length === 0) {
573
+ console.warn(`⚠️ 草稿 review changedFiles 为空,跳过自动勾选`)
574
+ return false
575
+ }
576
+ if (!ctx.gitDir || !ctx.base || !ctx.head) {
577
+ console.warn(`⚠️ ctx 信息不完整(gitDir=${ctx.gitDir}, base=${ctx.base}, head=${ctx.head}),跳过草稿 diff 校验,保守不勾`)
578
+ return false
579
+ }
580
+ try {
581
+ // 实测 diff:git diff --name-only <base>..<head> -- <changedFiles>
582
+ // 用数组参数避免 shell 拆词,复用 git-helper 安全模式
583
+ const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
584
+ if (!diffResult || diffResult.trim() === '') {
585
+ console.warn(`⚠️ 草稿 review 实测 diff 为空(base=${ctx.base.slice(0,8)}, head=${ctx.head.slice(0,8)}, files=${changedFiles.length}),跳过自动勾选`)
586
+ return false
587
+ }
588
+ console.log(` ✓ 草稿 diff 校验通过(${diffResult.trim().split('\n').length} 个文件有改动)`)
589
+ } catch (e) {
590
+ console.warn(`⚠️ 草稿 diff 校验失败(${e && e.message ? e.message : e}),保守不勾`)
591
+ return false
592
+ }
593
+ }
594
+
471
595
  return true
472
596
  }
473
597
  async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOpts }) {
@@ -491,6 +615,10 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
491
615
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
492
616
  }
493
617
  const executeRunId = c
618
+
619
+ // W2 task-04 FR-03:构造 ctx 供草稿零 diff 守卫用(与 task-review.js generateTaskReviewDrafts 同源)
620
+ const ctx = await buildDraftContext(cwd, changeName)
621
+
494
622
  // plan.md 是 agent 与 CLI 都会写的共享文件(agent 勾 checkbox、此处 autoCheck 也勾选)。
495
623
  // 读-改-写必须整体持锁(withFileLock 串行化多进程),否则并发 execute --done / 手动勾选互相覆盖
496
624
  // (后到者覆盖先到者);写入用 writeAtomicSync(tmp+rename 原子),防 Windows 整文件覆盖被读半截
@@ -506,7 +634,7 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
506
634
  const r = readReview(reviewPath)
507
635
  // 端到端 task:必须 pass(cannot_verify 不算);普通 task:非 fail 即可(坑 execute-batch-complete-endtoend-checkbox)
508
636
  const endToEnd = isEndToEndTaskText(match + ' ' + readTaskCardText(changeDir, taskNum))
509
- if (shouldAutoCheckTask(r, endToEnd)) {
637
+ if (shouldAutoCheckTask(r, endToEnd, ctx)) {
510
638
  checkedCount++
511
639
  return `${p1}x${p2}` // 勾选
512
640
  }
@@ -530,8 +658,11 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
530
658
  * 复用现成能力:readPlanCheckboxStatus(plan 勾选状态)+ checkExecuteCodeEvidence(代码客观核验,
531
659
  * 与 doctor --align-execute-progress 同源,D-002/D-004)。仅在 stageName==='execute' && changeName 触发。
532
660
  *
661
+ * W2 task-05 FR-04:plan 全勾后逐 task 复核——review 缺失或草稿零 diff → 阻断批量,
662
+ * 返回 blockedTasks 列表(task id 数组),调用方打印 reason。
663
+ *
533
664
  * 安全门:plan 零 checkbox / 未全勾 / 代码零变更(unchanged)均不批量——信任 plan 声明但用代码核验兜底。
534
- * @returns {Promise<{batched:boolean, aligned:number, reason?:string}>}
665
+ * @returns {Promise<{batched:boolean, aligned:number, reason?:string, blockedTasks?:string[]}>}
535
666
  */
536
667
  async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBase, steps }) {
537
668
  if (stageName !== 'execute' || !changeName) return { batched: false, aligned: 0 }
@@ -548,6 +679,78 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
548
679
  if (evidence.status === 'unchanged') {
549
680
  return { batched: false, aligned: 0, reason: `代码零变更(${evidence.detail})` }
550
681
  }
682
+
683
+ // W2 task-05 FR-04:逐 task 复核(草稿零 diff 守卫——批量层)
684
+ // 读取 plan.md 提取已勾 task id 列表(正则 `- [x] task-NN`)
685
+ const planPath = join(changeDir, 'plan.md')
686
+ const planContent = readFileSync(planPath, 'utf8')
687
+ const taskCheckboxRegex = /-\s\[x\]\s+task-(\d+)/gi
688
+ const taskMatches = [...planContent.matchAll(taskCheckboxRegex)]
689
+ const taskIds = taskMatches.map(m => `task-${m[1].padStart(2, '0')}`)
690
+
691
+ if (taskIds.length === 0) {
692
+ return { batched: false, aligned: 0, reason: 'plan.md 无已勾 task checkbox' }
693
+ }
694
+
695
+ // 构造 ctx 供草稿零 diff 校验用(与 task-04 同源)
696
+ const ctx = await buildDraftContext(cwd, changeName)
697
+ const runtimeRoot = resolveRuntimeRoot({ specRoot: specBase }, specBase)
698
+ const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
699
+ const blockedTasks = []
700
+
701
+ for (const taskId of taskIds) {
702
+ let review = null
703
+ try {
704
+ const { readReview, isValidExecuteRunId } = await import('../task-review.js')
705
+ const c = readFileSync(runIdFile, 'utf8').trim()
706
+ if (!c || !isValidExecuteRunId(c)) {
707
+ blockedTasks.push(taskId)
708
+ continue
709
+ }
710
+ const executeRunId = c
711
+ const reviewPath = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId, 'review.json')
712
+ const r = readReview(reviewPath)
713
+ if (!r?.ok) {
714
+ blockedTasks.push(taskId)
715
+ continue
716
+ }
717
+ review = r.review
718
+ } catch {
719
+ blockedTasks.push(taskId)
720
+ continue
721
+ }
722
+
723
+ // review 存在:判定是否为自动草稿且零 diff
724
+ if (review?.reviewerNotes && review.reviewerNotes.includes('auto-generated draft')) {
725
+ const changedFiles = review.changedFiles || []
726
+ // 草稿且 changedFiles 为空 → 阻断
727
+ if (changedFiles.length === 0) {
728
+ blockedTasks.push(taskId)
729
+ continue
730
+ }
731
+ // 草稿且有 changedFiles → 实测 diff 校验(ctx 与 task-04 同源)
732
+ if (ctx && ctx.gitDir && ctx.base && ctx.head) {
733
+ try {
734
+ const diffResult = gitQuiet(ctx.gitDir, ['diff', '--name-only', `${ctx.base}..${ctx.head}`, '--', ...changedFiles], { trim: true })
735
+ if (!diffResult || diffResult.trim() === '') {
736
+ blockedTasks.push(taskId)
737
+ }
738
+ } catch (e) {
739
+ // diff 校验失败保守处理:阻断(避免误批量)
740
+ blockedTasks.push(taskId)
741
+ }
742
+ }
743
+ }
744
+ }
745
+
746
+ // blockedTasks 非空 → 阻断批量
747
+ if (blockedTasks.length > 0) {
748
+ const reason = `以下 task 复核未过(review 缺失或草稿零 diff):${blockedTasks.join(', ')}`
749
+ console.warn(`⚠️ 批量完成被阻断:${reason}`)
750
+ return { batched: false, aligned: 0, reason, blockedTasks }
751
+ }
752
+
753
+ // 全部复核通过 → 批量标 completed
551
754
  const now = new Date().toLocaleString('zh-CN', { hour12: false })
552
755
  let aligned = 0
553
756
  for (const step of steps) {
@@ -558,8 +761,9 @@ async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd, specBa
558
761
  }
559
762
  }
560
763
  return { batched: true, aligned }
561
- } catch {
562
- return { batched: false, aligned: 0 }
764
+ } catch (e) {
765
+ // 异常 fail-open:不批量,reason 带错误信息
766
+ return { batched: false, aligned: 0, reason: `批量完成检测异常:${e && e.message ? e.message : e}` }
563
767
  }
564
768
  }
565
769
 
@@ -759,6 +963,8 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
759
963
  progress.lastActive = now
760
964
  pm._write(cwd, progress, changeName)
761
965
  triggerSync(cwd, changeName, platformOpts)
966
+ // wait 解除持久化点(含 repeatable 多轮)同样刷新 change title——多轮确认期间 design.md 已存在
967
+ refreshChangeTitleFromArtifacts(pm, cwd, specBase, changeName)
762
968
 
763
969
  console.log(`✅ Step ${currentIdx + 1}/${stageData.steps.length} 已继续:${currentStep.name}`)
764
970
  console.log(` 回答:${answer}`)
@@ -815,12 +1021,7 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
815
1021
  if (_stageGatesResult?.stageCompleted === false) process.exitCode = 1
816
1022
  if (_stageGatesResult) return _stageGatesResult
817
1023
  // 完整流程 change title 刷新(同 completeStep 完成分支,wait 解除后阶段完成也刷新)。
818
- if (changeName && !/^quick-[0-9a-f]{8}$/.test(changeName)) {
819
- try {
820
- const refinedTitle = deriveTitleFromLinkedChange(specBase, changeName)
821
- if (refinedTitle) pm.updateChangeMeta(cwd, changeName, { title: refinedTitle })
822
- } catch { /* title 刷新失败不阻断完成 */ }
823
- }
1024
+ refreshChangeTitleFromArtifacts(pm, cwd, specBase, changeName)
824
1025
  // gate 全过:persist completed(task-03 移后;此处无 triggerSync)。
825
1026
  pm._write(cwd, progress, changeName)
826
1027
  console.log(`\n✅ ${stageName} 阶段已完成(${stageData.steps.length}/${stageData.steps.length} 步)`)
@@ -30,6 +30,28 @@ function normalizeGitPath(p) {
30
30
  return String(p).replace(/\\/g, '/')
31
31
  }
32
32
 
33
+ /**
34
+ * 并发预检 ownFiles 锚点(2026-08-18 误归属修复)。
35
+ *
36
+ * 未声明会话(allowedFiles 空)维持旧口径 changedFiles ∪ baselineFiles——无声明即无归属信息,
37
+ * 收窄锚点会把本会话每次未声明的自身改动全误报他者。声明会话锚点 = baselineFiles ∪ allowedFiles
38
+ * (声明即归属):窗口 diff(changedFiles)在多 agent 并发仓库可能含他者污染(ql-20260818-003
39
+ * 实证:并行会话窗口内改的文件被算进本会话 changedFiles 再被 ownFiles 自吞),超出声明集的
40
+ * 窗口新脏文件才作为 foreign 信号报出(= 他者或漏声明,歧义由 advisory 文案兜住)。
41
+ *
42
+ * 纯函数:不查盘不调 git,调用点(complete-handlers task-02 钩子)传入三个列表。
43
+ * @param {{ changedFiles?: string[], baselineFiles?: string[], allowedFiles?: string[] }} opts
44
+ * @returns {string[]} ownFiles 锚点集合(去重)
45
+ */
46
+ export function resolveConcurrentAnchor({ changedFiles = [], baselineFiles = [], allowedFiles = [] } = {}) {
47
+ const arr = (x) => (Array.isArray(x) ? x : [])
48
+ const declared = arr(allowedFiles).map(normalizeGitPath).filter(Boolean)
49
+ if (declared.length === 0) {
50
+ return [...arr(changedFiles).map(normalizeGitPath), ...arr(baselineFiles).map(normalizeGitPath)]
51
+ }
52
+ return [...new Set([...arr(baselineFiles).map(normalizeGitPath), ...declared])]
53
+ }
54
+
33
55
  /**
34
56
  * 检测工作树里的并发他者改动(非阻塞 advisory 用)。
35
57
  *