sillyspec 3.26.11 → 3.26.14

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 (62) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +200 -27
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +187 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +88 -46
  31. package/src/run/complete-handlers.js +27 -15
  32. package/src/run/complete.js +1186 -1121
  33. package/src/run/concurrent-detect.js +3 -1
  34. package/src/run/gates.js +172 -25
  35. package/src/run/prompt.js +17 -9
  36. package/src/run/scan-profile.js +35 -23
  37. package/src/run/shared.js +164 -63
  38. package/src/run/stage.js +41 -1
  39. package/src/setup.js +8 -10
  40. package/src/sillyhub-mcp/client.js +74 -6
  41. package/src/spec-dir-typo.js +3 -24
  42. package/src/spec-sync.js +43 -8
  43. package/src/stage-contract.js +12 -38
  44. package/src/stage-review.js +40 -1
  45. package/src/stages/archive.js +4 -3
  46. package/src/stages/brainstorm-auto.js +1 -1
  47. package/src/stages/brainstorm.js +27 -13
  48. package/src/stages/doctor.js +29 -16
  49. package/src/stages/execute.js +170 -154
  50. package/src/stages/knowledge.js +18 -3
  51. package/src/stages/plan-postcheck.js +72 -37
  52. package/src/stages/plan.js +70 -48
  53. package/src/stages/quick.js +3 -1
  54. package/src/stages/verify.js +1 -1
  55. package/src/sync.js +339 -225
  56. package/src/task-review.js +281 -64
  57. package/src/taskcard.js +178 -0
  58. package/src/verify-postcheck.js +48 -35
  59. package/src/worktree-apply.js +58 -34
  60. package/src/worktree-deps.js +120 -20
  61. package/src/worktree.js +55 -9
  62. package/.npmrc-publish +0 -2
@@ -23,7 +23,7 @@ import { basename, join, resolve, dirname } from 'node:path'
23
23
  import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
24
24
  import { randomBytes, randomUUID } from 'node:crypto'
25
25
  import { writeAtomicSync } from '../fs-atomic.js'
26
- import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, warnApprovalUnknown, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot, writePlatformPointer, checkPlatformManaged, PLATFORM_MANAGED_FILENAME } from './shared.js'
27
27
  import { resolveQuickLinkedChanges } from './quick-audit.js'
28
28
  import { outputStep } from './prompt.js'
29
29
  import { completeStep, skipStep, waitStep, continueStep } from './complete.js'
@@ -108,6 +108,11 @@ export async function ensureStageSteps(progress, stageName, cwd, specDir = null)
108
108
  // 检查步骤数量是否匹配(execute 动态步骤可能变化)
109
109
  if (progress.stages[stageName].steps.length !== steps.length) {
110
110
  const oldSteps = progress.stages[stageName].steps
111
+ // 漂移显式化(坑 execute-step-table-drift,2026-08-20 实证):plan.md Wave 数在 execute
112
+ // 中途被改后 DB 步骤表与重算定义错位(17/12 交替),重播种本身按名保留完成态是对的,
113
+ // 但此前完全静默——agent 只见各处计数打架的报错,不知道发生了什么、该不该继续
114
+ console.warn(`⚠️ ${stageName} 步骤表与当前定义漂移(DB ${oldSteps.length} 步 → 定义 ${steps.length} 步,常见原因:execute 中途修改了 plan.md 的 Wave 结构)`)
115
+ console.warn(` 已按步骤名保留完成态重播种(新增步骤置 pending;Wave 重编号时同名步骤继承旧状态,勾选真相以 tasks.md/review.json 为准)`)
111
116
  progress.stages[stageName].steps = steps.map(s => {
112
117
  const old = oldSteps.find(step => step.name === s.name)
113
118
  if (old) return old // 精确名命中 → 保留旧状态
@@ -195,10 +200,13 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
195
200
  // ── cwd 纠正:向上查找真实项目根 ──
196
201
  // 防止多 project 工作区中 cwd 停在子目录(如 backend/)时
197
202
  // 状态写入子目录下误建的 .sillyspec,导致状态分裂
198
- if (!specDir && !existsSync(join(cwd, '.sillyspec-platform.json'))) {
199
- // 平台模式(cwd 有 .sillyspec-platform.json 指针)不做 cwd 纠正:指针已明确 specDir,
200
- // 向上找 .sillyspec 反而会撞到无关项目(如用户 home 的 .sillyspec),导致 --done 恢复
201
- // 读写 ~/.sillyspec-platform.json 出错的 specRoot。
203
+ // HUB-12c:守卫须同时查平台接管声明——指针被 cleanup、声明还在、祖先有别的 .sillyspec
204
+ //(monorepo 嵌套)时,cwd 被上移后 checkPlatformManaged 在新 cwd 扑空 → 静默落本地库,
205
+ // 双入口 fail-closed 被绕过(状态分裂正是它要防的)
206
+ if (!specDir && !existsSync(join(cwd, '.sillyspec-platform.json')) && !checkPlatformManaged(cwd)) {
207
+ // 平台模式(cwd 有 .sillyspec-platform.json 指针或接管声明)不做 cwd 纠正:指针已明确
208
+ // specDir,向上找 .sillyspec 反而会撞到无关项目(如用户 home 的 .sillyspec),导致
209
+ // --done 恢复读写 ~/.sillyspec-platform.json 出错的 specRoot。
202
210
  const resolvedRoot = resolveSpecDir(cwd)
203
211
  if (resolvedRoot && resolvedRoot !== join(cwd, '.sillyspec')) {
204
212
  const realRoot = dirname(resolvedRoot)
@@ -360,8 +368,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
360
368
  // 纯只读比较:零会话/QUICKLOG 副作用,仅解析 --base/--full/--report 后转发 scan-diff 退出码。
361
369
  if (stageName === 'scan' && flags.includes('--diff')) {
362
370
  const { runScanDiff } = await import('../scan-diff.js')
363
- const diffBaseIdx = flags.indexOf('--base')
364
- const diffBase = diffBaseIdx >= 0 && flags[diffBaseIdx + 1] ? flags[diffBaseIdx + 1] : null
371
+ const diffBase = getFlagValue('--base')
365
372
  process.exitCode = runScanDiff({
366
373
  projectRoot: cwd,
367
374
  specBase,
@@ -410,26 +417,26 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
410
417
  }
411
418
 
412
419
  // 解析 --output
420
+ // F4 守卫统一走 getFlagValue(坑 raw-flag-value-bypass):裸 flags[idx+1] 会把漏值后紧跟的
421
+ // flag 名当值(--output --input x → output="--input"),--change --done 甚至会建出名为
422
+ // --done 的变更目录(assertSafeChangeName 的 [\w.-] 恰好放行)。getFlagValue 的 F4 注释
423
+ // 点名过本坑,此前只有 from-step 等少数 flag 用了它。
413
424
  let outputText = null
414
- const outputIdx = flags.indexOf('--output')
415
- if (outputIdx !== -1 && flags[outputIdx + 1]) {
416
- outputText = flags[outputIdx + 1]
417
- }
425
+ const outputValue = getFlagValue('--output')
426
+ if (outputValue !== null) outputText = outputValue
418
427
 
419
428
  // 解析 --input
420
429
  let inputText = null
421
- const inputIdx = flags.indexOf('--input')
422
- if (inputIdx !== -1 && flags[inputIdx + 1]) {
423
- inputText = flags[inputIdx + 1]
424
- }
430
+ const inputValue = getFlagValue('--input')
431
+ if (inputValue !== null) inputText = inputValue
425
432
 
426
433
  // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
427
434
  // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
428
435
  // null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
429
436
  let explicitLinked = null
430
- const linkedIdx = flags.indexOf('--linked-changes')
431
- if (linkedIdx !== -1 && flags[linkedIdx + 1]) {
432
- const v = flags[linkedIdx + 1].trim()
437
+ const linkedValue = getFlagValue('--linked-changes')
438
+ if (linkedValue !== null) {
439
+ const v = linkedValue.trim()
433
440
  explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
434
441
  }
435
442
 
@@ -437,9 +444,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
437
444
  // 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
438
445
  let changeName = null
439
446
  let linkedChanges = []
440
- const changeIdx = flags.indexOf('--change')
441
- if (changeIdx !== -1 && flags[changeIdx + 1]) {
442
- changeName = flags[changeIdx + 1]
447
+ const changeValue = getFlagValue('--change')
448
+ if (changeValue !== null) {
449
+ changeName = changeValue
443
450
  if (stageName === 'quick') {
444
451
  linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
445
452
  changeName = null
@@ -481,7 +488,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
481
488
  // 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName 置 null。
482
489
  // 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
483
490
  // 并撤销把它当 linkedChanges 的误判。多值或不匹配 → 维持 linkedChanges 语义(旧兼容)。
484
- const rawChange = changeIdx !== -1 && flags[changeIdx + 1] ? flags[changeIdx + 1].trim() : null
491
+ // F4 守卫同款(changeIdx 变量已随裸读清理移除,此处改 getFlagValue 复读原始值)
492
+ const rawChangeValue = getFlagValue('--change')
493
+ const rawChange = rawChangeValue !== null ? rawChangeValue.trim() : null
485
494
  const rawIsSingleSid = rawChange && rawChange.indexOf(',') === -1 && QUICK_SID_RE.test(rawChange)
486
495
  if (rawIsSingleSid) {
487
496
  // --done --change quick-<uuid8>:精确恢复(撤销 1373-1376 把它误当 linkedChanges)
@@ -541,18 +550,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
541
550
  process.exit(2) // 用法错 → exit 2
542
551
  }
543
552
  let quickFiles = []
544
- const filesIdx = flags.indexOf('--files')
545
- if (filesIdx !== -1 && flags[filesIdx + 1]) {
546
- quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
553
+ const filesValue = getFlagValue('--files')
554
+ if (filesValue !== null) {
555
+ quickFiles = filesValue.split(',').map(f => f.trim()).filter(Boolean)
547
556
  }
548
557
 
549
558
  // 解析 --file-notes "path::括注 || path::括注"(quick 专用:QUICKLOG「文件:」行落盘多行括注)。
550
559
  // 旁路注入 quicklog.js(per-process setter),不经 complete 收尾路径透传(避碰多 agent 并发改的收尾热点)。
551
560
  // completeQuicklogEntry 读后即清;不传 → '' → 回填审计到的实际文件单行(向后兼容)。
552
561
  let quickFileNotes = ''
553
- const fileNotesIdx = flags.indexOf('--file-notes')
554
- if (fileNotesIdx !== -1 && flags[fileNotesIdx + 1]) {
555
- quickFileNotes = flags[fileNotesIdx + 1]
562
+ const fileNotesValue = getFlagValue('--file-notes')
563
+ if (fileNotesValue !== null) {
564
+ quickFileNotes = fileNotesValue
556
565
  }
557
566
  setQuickFileNotes(quickFileNotes)
558
567
 
@@ -763,16 +772,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
763
772
  let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
764
773
  // archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
765
774
  // 不带 --change 回退 default 会读错变更。无活跃变更时取最新归档变更,读其现有 progress。
775
+ // 归档目录名 = 原变更名恒等迁移(archiveDestDirName 不改名),直接原名对读——此前按
776
+ // 「日期前缀过滤 + 剥日期」找,剥出的短名与 DB 行名(含日期)恒不符 → pm.read 返回
777
+ // null → initChange 凭空建幻影变更再跑 archive(坑 archive-fallback-phantom-change)。
766
778
  if (stageName === 'archive' && autoName === 'default') {
767
779
  try {
768
780
  const archiveDir = join(specBase, 'changes', 'archive')
769
781
  if (existsSync(archiveDir)) {
770
- const latest = readdirSync(archiveDir)
771
- .filter(d => /^\d{4}-\d{2}-\d{2}-.+/.test(d))
782
+ const latest = readdirSync(archiveDir, { withFileTypes: true })
783
+ .filter(d => d.isDirectory())
784
+ .map(d => d.name)
772
785
  .sort()
773
786
  .pop()
774
787
  if (latest) {
775
- autoName = latest.replace(/^\d{4}-\d{2}-\d{2}-/, '')
788
+ autoName = latest
776
789
  progress = pm.read(cwd, autoName)
777
790
  }
778
791
  }
@@ -879,12 +892,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
879
892
  // stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
880
893
  const stageDataPre = progress.stages[stageName]
881
894
  const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
882
- // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)
895
+ // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)。
896
+ // 按名保留旧状态(坑 execute-reopen-wipes-prior-completion):reopenStage 的语义是
897
+ // 「i < fromStep 保持原状(completed)」,此前预置全 pending 会把 fromStep 之前已完成的
898
+ // 步骤一并清空——CLI 打印「从步骤 N 开始修订」、prompt 注入「之前已完成不需要重做」,
899
+ // 实际 currentIdx 却回到 0,agent 被迫从头重做,修订语义完全失效。
883
900
  if (needsInit || stageName === 'execute') {
884
901
  const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
885
902
  if (freshSteps && freshSteps.length > 0) {
886
903
  if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
887
- progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
904
+ const oldSteps = progress.stages[stageName].steps || []
905
+ progress.stages[stageName].steps = freshSteps.map(s => {
906
+ const old = oldSteps.find(step => step.name === s.name)
907
+ return old ? { ...old } : { name: s.name, status: 'pending' }
908
+ })
888
909
  pm._write(cwd, progress, effectiveChange)
889
910
  progress = pm.read(cwd, effectiveChange) || progress
890
911
  }
@@ -1140,15 +1161,17 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
1140
1161
  async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
1141
1162
  const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
1142
1163
  const isDone = flags.includes('--done')
1143
- const outputIdx = flags.indexOf('--output')
1144
- const outputText = outputIdx !== -1 && flags[outputIdx + 1] ? flags[outputIdx + 1] : null
1145
- const inputIdx = flags.indexOf('--input')
1146
- const inputText = inputIdx !== -1 && flags[inputIdx + 1] ? flags[inputIdx + 1] : null
1164
+ // F4 守卫(坑 raw-flag-value-bypass,与 runCommand getFlagValue 同款):裸 flags[idx+1] 会把
1165
+ // 漏值后紧跟的 flag 名当值(--output --done → outputText="--done" 污染落盘 output)。
1166
+ const autoFlagValue = (name) => {
1167
+ const i = flags.indexOf(name)
1168
+ const next = i !== -1 ? flags[i + 1] : undefined
1169
+ return next && !String(next).startsWith('--') ? next : null
1170
+ }
1171
+ const outputText = autoFlagValue('--output')
1172
+ const inputText = autoFlagValue('--input')
1147
1173
  const skipApproval = flags.includes('--skip-approval')
1148
- const explicitMode = (() => {
1149
- const m = flags.indexOf('--mode')
1150
- return m !== -1 && flags[m + 1] ? flags[m + 1] : null
1151
- })()
1174
+ const explicitMode = autoFlagValue('--mode')
1152
1175
  const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
1153
1176
 
1154
1177
  // Helper: 在 auto 模式下获取步骤定义
@@ -1185,7 +1208,10 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1185
1208
  return progress
1186
1209
  }
1187
1210
  }
1188
- const changed = await ensureStageSteps(progress, stage, cwd)
1211
+ // specRoot 必传(坑 auto-mode-specroot-miss):平台模式 specRoot 是外部目录,漏传时
1212
+ // getStageSteps 从 cwd 推导 miss 掉 plan/execute 动态步骤(buildPlanSteps(null) 只剩
1213
+ // 2 步 fixedPrefix),种出的步骤表两次 --done 即判阶段完成,与 getAutoSteps 渲染双轨失配
1214
+ const changed = await ensureStageSteps(progress, stage, cwd, platformOpts?.specRoot || null)
1189
1215
  if (stageChanged || changed) {
1190
1216
  pm._write(cwd, progress, changeName)
1191
1217
  triggerSync(cwd, changeName, platformOpts)
@@ -1271,8 +1297,9 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1271
1297
  console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1272
1298
  console.log(' 提示:使用 --skip-approval 跳过审批检查')
1273
1299
  }
1300
+ // HUB-07:unknown 醒目警告 + 留痕(原单行 warn 不显眼且无记录)
1274
1301
  if (approval.status === 'unknown') {
1275
- console.warn(`⚠️ 变更 ${changeName} 的审批状态未知(${approval.reason || '请求失败'}),按本地模式放行(非审批中,无需等待)`)
1302
+ warnApprovalUnknown(cwd, changeName, approval.reason)
1276
1303
  }
1277
1304
  }
1278
1305
  }
@@ -1288,6 +1315,12 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1288
1315
  const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
1289
1316
  if (!result) return
1290
1317
  progress = pm.read(cwd, changeName)
1318
+ // change 行被并发归档/删除时 read 返回 null(体检 BUG-05):引导排查而非 TypeError 崩溃
1319
+ if (!progress) {
1320
+ console.error(`❌ 完成步骤后进度数据消失(变更 ${changeName} 可能被并发归档/删除),请用 sillyspec progress show 核对当前状态`)
1321
+ process.exitCode = 1
1322
+ return
1323
+ }
1291
1324
 
1292
1325
  const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1293
1326
  if (nextPendingIdx !== -1) {
@@ -1304,8 +1337,9 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1304
1337
  console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1305
1338
  console.log(' 提示:使用 --skip-approval 跳过审批检查')
1306
1339
  }
1340
+ // HUB-07:unknown 醒目警告 + 留痕(原单行 warn 不显眼且无记录)
1307
1341
  if (approval.status === 'unknown') {
1308
- console.warn(`⚠️ 变更 ${changeName} 的审批状态未知(${approval.reason || '请求失败'}),按本地模式放行(非审批中,无需等待)`)
1342
+ warnApprovalUnknown(cwd, changeName, approval.reason)
1309
1343
  }
1310
1344
  }
1311
1345
  }
@@ -1353,10 +1387,17 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1353
1387
  progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
1354
1388
  }
1355
1389
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
1356
- await ensureStageSteps(progress, next, cwd)
1390
+ // specRoot 同上(坑 auto-mode-specroot-miss):平台模式动态步骤表必须从外部 specRoot 构建
1391
+ await ensureStageSteps(progress, next, cwd, platformOpts?.specRoot || null)
1357
1392
  pm._write(cwd, progress, changeName)
1358
1393
  triggerSync(cwd, changeName, platformOpts)
1359
1394
  progress = pm.read(cwd, changeName)
1395
+ // 同 BUG-05:写入后被并发删除的窗口,null 直接解引用会 TypeError
1396
+ if (!progress) {
1397
+ console.error(`❌ 阶段推进后进度数据消失(变更 ${changeName} 可能被并发归档/删除),请用 sillyspec progress show 核对当前状态`)
1398
+ process.exitCode = 1
1399
+ return
1400
+ }
1360
1401
 
1361
1402
  console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
1362
1403
  const nextSteps = await getAutoSteps(next)
@@ -1374,8 +1415,9 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1374
1415
  console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
1375
1416
  console.log(' 提示:使用 --skip-approval 跳过审批检查')
1376
1417
  }
1418
+ // HUB-07:unknown 醒目警告 + 留痕(原单行 warn 不显眼且无记录)
1377
1419
  if (approval.status === 'unknown') {
1378
- console.warn(`⚠️ 变更 ${changeName} 的审批状态未知(${approval.reason || '请求失败'}),按本地模式放行(非审批中,无需等待)`)
1420
+ warnApprovalUnknown(cwd, changeName, approval.reason)
1379
1421
  }
1380
1422
  }
1381
1423
  }
@@ -98,10 +98,9 @@ export function validateParsedProjects(projects, sourceRoot) {
98
98
  /**
99
99
  * 在 changes/archive/ 下查找变更 <changeName> 是否已被归档(archive 脱钩自愈用)。
100
100
  *
101
- * 归档目录名 = archiveDestDirName(date, changeName) = `<归档日期>-<剥前导日期的描述>`;
102
- * 但手动 / 部分流程归档可能保留原 changeName(含前导日期)或用别的日期前缀。
103
- * 按「描述部分」(剥前导 YYYY-MM-DD-)匹配,并要求目录含 plan.md(归档必备产物,
104
- * 防止同名巧合误判)。issue: archive-stage-physical-tracking-desync。
101
+ * 归档目录名 = 原变更名(直接移入 archive/,不重命名)。
102
+ * 精确匹配 + 要求目录含 plan.md(归档必备产物,防止同名巧合误判)。
103
+ * issue: archive-stage-physical-tracking-desync。
105
104
  *
106
105
  * @param {string} archiveDir - changes/archive 绝对路径
107
106
  * @param {string} changeName - 变更名(currentChange)
@@ -115,15 +114,9 @@ export function findAlreadyArchivedDir(archiveDir, changeName) {
115
114
  } catch {
116
115
  return null
117
116
  }
118
- const descOf = (n) => String(n).replace(/^\d{4}-\d{2}-\d{2}-/, '')
119
- const targetDesc = descOf(changeName)
120
- if (!targetDesc) return null
121
- // 1. 精确原名命中(手动 mv 保留原 changeName)
117
+ // 精确原名命中
122
118
  const exact = entries.find((e) => e === changeName && existsSync(join(archiveDir, e, 'plan.md')))
123
119
  if (exact) return join(archiveDir, exact)
124
- // 2. 描述部分命中(archiveDestDirName 产出的 <date>-<desc>,或手动剥前导日期的目录)
125
- const descMatch = entries.find((e) => descOf(e) === targetDesc && existsSync(join(archiveDir, e, 'plan.md')))
126
- if (descMatch) return join(archiveDir, descMatch)
127
120
  return null
128
121
  }
129
122
 
@@ -142,9 +135,14 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platform
142
135
  try {
143
136
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
144
137
  if (existsSync(runtimeRoot)) {
138
+ // stage-review marker 精确匹配(坑 marker-suffix-overmatch):marker 名是
139
+ // current-stage-review-run-id-<stage>-<change>,按 endsWith('-'+change) 后缀匹配会在归档
140
+ // 手工短名变更(如 login)时误删自动日期前缀变更(2026-08-01-login 天然以 '-login' 结尾)
141
+ // 的 marker。stage 集合与写入侧 stageReviewMarkerPath 的调用方一致。
142
+ const REVIEW_STAGES = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
145
143
  const markers = readdirSync(runtimeRoot).filter((f) =>
146
144
  f === `current-execute-run-id-${archiveChangeName}` ||
147
- (f.startsWith('current-stage-review-run-id-') && f.endsWith(`-${archiveChangeName}`)),
145
+ REVIEW_STAGES.some(st => f === `current-stage-review-run-id-${st}-${archiveChangeName}`),
148
146
  )
149
147
  for (const m of markers) {
150
148
  try { unlinkSync(join(runtimeRoot, m)) } catch {}
@@ -506,9 +504,12 @@ export async function handleScanProjectListStep({ stageName, steps, currentIdx,
506
504
  const yamlContent = readFileSync(join(projectsDir, yf), 'utf8')
507
505
  const pathMatch = yamlContent.match(/^path:\s*(.+)/m)
508
506
  const pPath = pathMatch ? pathMatch[1].trim() : pName
509
- // 校验 path 是否存在且在 source_root 内
507
+ // 校验 path 是否存在且在 source_root 内。
508
+ // 用 relative 判越界(与 validateParsedProjects 同口径):startsWith 前缀比较
509
+ // 无分隔符,C:\repo 会放行兄弟目录 C:\repository2(体检 BUG-13)
510
510
  const absPath = resolve(cwd, pPath)
511
- if (pPath.includes('..') || (!absPath.startsWith(resolve(cwd)) && absPath !== resolve(cwd))) {
511
+ const rel = relative(resolve(cwd), absPath)
512
+ if (rel.startsWith('..') || isAbsolute(rel)) {
512
513
  fallbackSkipped.push(`${pName} (path 越界: ${pPath})`)
513
514
  continue
514
515
  }
@@ -1162,7 +1163,9 @@ export async function handleExecuteWaveArtifact({ stageName, steps, currentIdx,
1162
1163
  export async function collectExecuteChangedFiles({ runtimeRoot, changeName }) {
1163
1164
  if (!runtimeRoot || !changeName) return []
1164
1165
  const { resolveLatestExecuteRunIdWithTasks, readReview, normalizeRepoKey } = await import('../task-review.js')
1165
- const runId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
1166
+ // changeName 透传:run 解析按 change 戳优先归属(坑 worktree-cleanup-marker-chain),
1167
+ // 避免 marker 断裂后聚合到其他变更 run 的 changedFiles 误报 missing
1168
+ const runId = resolveLatestExecuteRunIdWithTasks({ runtimeRoot, changeName })
1166
1169
  if (!runId) return []
1167
1170
  const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks')
1168
1171
  if (!existsSync(tasksDir)) return []
@@ -1300,6 +1303,15 @@ export async function handleExecuteWorktreeCleanup({ stageName, changeName, cwd
1300
1303
  export async function handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts }) {
1301
1304
  // 平台模式:scan 完成后生成 manifest.json + post-check
1302
1305
  if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
1306
+ if (!platformOpts.specRoot) {
1307
+ // 只传 runtimeRoot 无 specRoot(command.js 允许的组合):manifest 无处落盘。
1308
+ // 显式 fail-closed 报错——此前 mkdirSync(null) 抛 TypeError 被外层 catch 吞掉,
1309
+ // postcheck/指针升级/阻断全部静默跳过,scan 失败也"干净成功"(体检 BUG-12)
1310
+ console.error(`❌ 平台模式缺少 --spec-root,无法写 manifest.json(scan postcheck 中止)`)
1311
+ stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false
1312
+ stageData.scanMeta.manifestError = 'missing specRoot'
1313
+ return
1314
+ }
1303
1315
  try {
1304
1316
  stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.manifestWritten = false; // 默认失败
1305
1317
  const manifestDir = platformOpts.specRoot