sillyspec 3.26.13 → 3.26.15

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 (56) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
  4. package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  7. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  9. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  11. package/package.json +1 -1
  12. package/src/commit-suggest.js +202 -0
  13. package/src/config-cat.js +88 -0
  14. package/src/docs-check.js +48 -12
  15. package/src/doctor-diagnostics.js +49 -0
  16. package/src/hooks/worktree-guard.js +20 -1
  17. package/src/index.js +547 -21
  18. package/src/init.js +1 -1
  19. package/src/local-register.js +98 -0
  20. package/src/module-impact.js +146 -0
  21. package/src/progress/change-registry.js +43 -4
  22. package/src/progress/stage-machine.js +10 -5
  23. package/src/progress.js +18 -3
  24. package/src/quicklog.js +106 -1
  25. package/src/run/command.js +151 -38
  26. package/src/run/complete-handlers.js +62 -4
  27. package/src/run/complete.js +10 -8
  28. package/src/run/concurrent-detect.js +3 -1
  29. package/src/run/gates.js +86 -4
  30. package/src/run/multi-repo-context.js +3 -1
  31. package/src/run/next.js +128 -0
  32. package/src/run/prompt.js +28 -6
  33. package/src/run/scan-profile.js +35 -23
  34. package/src/run/shared.js +28 -7
  35. package/src/scan-postcheck.js +142 -2
  36. package/src/stage-contract-spec.js +1 -1
  37. package/src/stage-review.js +55 -7
  38. package/src/stages/archive.js +1 -1
  39. package/src/stages/brainstorm.js +26 -12
  40. package/src/stages/doctor.js +2 -4
  41. package/src/stages/execute.js +110 -26
  42. package/src/stages/knowledge.js +21 -5
  43. package/src/stages/plan-postcheck.js +24 -10
  44. package/src/stages/plan.js +14 -9
  45. package/src/stages/quick.js +14 -6
  46. package/src/stages/scan.js +1 -0
  47. package/src/stages/verify.js +7 -49
  48. package/src/sync.js +19 -7
  49. package/src/task-review.js +256 -10
  50. package/src/verify-probes.js +264 -0
  51. package/src/workspace.js +125 -0
  52. package/src/worktree-apply.js +15 -1
  53. package/src/worktree.js +58 -8
  54. package/templates/claude-instruction.md +1 -1
  55. package/templates/prompts/taskcard-rules.md +1 -1
  56. package/templates/prompts/verify-probes.md +13 -47
@@ -44,6 +44,8 @@ const VALUE_FLAGS = new Set([
44
44
  '--output', '--input', '--change', '--linked-changes',
45
45
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
46
46
  '--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
47
+ '--req', '--cause', '--solution', '--result', // quick 末步四字段参数(合成 --output,见 outputText 解析段)
48
+ '--ql', // quick --cancel 显式指定 qlId(缺省读会话 guard.json)
47
49
  '--base', // scan diff 基线 commit(吃值;只在 run scan --diff 转发路径消费)
48
50
  ])
49
51
 
@@ -232,6 +234,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
232
234
  const isStatus = flags.includes('--status')
233
235
  const isReset = flags.includes('--reset')
234
236
  const isReopen = flags.includes('--reopen')
237
+ // --cancel(2026-08-21 审计第四批 C-2):quick 误启动会话的官方取消口。取消 = QUICKLOG 条目
238
+ // 翻「已取消」+ tasks.md 挂载行移除 + 会话 guard 目录清理 + db 行注销(取消 quick 会话行,
239
+ // 非真实变更)。fail-closed:已完成/已勾选的拒绝(真实工作记录)。
240
+ const isCancel = flags.includes('--cancel')
241
+ if (isCancel && stageName !== 'quick') {
242
+ console.error('❌ --cancel 仅支持 quick 阶段(sillyspec run quick --cancel --change <quick-会话ID>)')
243
+ process.exit(2)
244
+ }
245
+ if (isCancel && (isDone || isSkip || isReset || isReopen || isStatus)) {
246
+ console.error('❌ --cancel 与 --done/--skip/--reset/--reopen/--status 互斥')
247
+ process.exit(2)
248
+ }
235
249
  const fromStepValue = getFlagValue('--from-step')
236
250
  const isConfirm = flags.includes('--confirm')
237
251
  const isSkipApproval = flags.includes('--skip-approval')
@@ -368,8 +382,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
368
382
  // 纯只读比较:零会话/QUICKLOG 副作用,仅解析 --base/--full/--report 后转发 scan-diff 退出码。
369
383
  if (stageName === 'scan' && flags.includes('--diff')) {
370
384
  const { runScanDiff } = await import('../scan-diff.js')
371
- const diffBaseIdx = flags.indexOf('--base')
372
- const diffBase = diffBaseIdx >= 0 && flags[diffBaseIdx + 1] ? flags[diffBaseIdx + 1] : null
385
+ const diffBase = getFlagValue('--base')
373
386
  process.exitCode = runScanDiff({
374
387
  projectRoot: cwd,
375
388
  specBase,
@@ -418,26 +431,48 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
418
431
  }
419
432
 
420
433
  // 解析 --output
434
+ // F4 守卫统一走 getFlagValue(坑 raw-flag-value-bypass):裸 flags[idx+1] 会把漏值后紧跟的
435
+ // flag 名当值(--output --input x → output="--input"),--change --done 甚至会建出名为
436
+ // --done 的变更目录(assertSafeChangeName 的 [\w.-] 恰好放行)。getFlagValue 的 F4 注释
437
+ // 点名过本坑,此前只有 from-step 等少数 flag 用了它。
421
438
  let outputText = null
422
- const outputIdx = flags.indexOf('--output')
423
- if (outputIdx !== -1 && flags[outputIdx + 1]) {
424
- outputText = flags[outputIdx + 1]
439
+ const outputValue = getFlagValue('--output')
440
+ if (outputValue !== null) outputText = outputValue
441
+
442
+ // 解析 quick 末步四字段参数(2026-08-21 agent-手工产出审计第二批 F6):--req/--cause/
443
+ // --solution/--result 各传一项,CLI 合成单行四字段 outputText(quicklog 落盘侧
444
+ // splitSingleLineFields 会归一为多行字段块)。消灭 agent 手拼「需求:… 根因:…」模板的
445
+ // 格式事故面(嵌套全角冒号拆分误判 / 缺字段被 --done 拒 / 标题截断规则踩坑)。
446
+ // 任一四参数出现即要求全四;与 --output 互斥(两路同时给说明意图不明,fail-fast)。
447
+ {
448
+ const FOUR = [['--req', '需求:'], ['--cause', '根因:'], ['--solution', '方案:'], ['--result', '结果:']]
449
+ const fourVals = FOUR.map(([f]) => [f, getFlagValue(f)])
450
+ if (fourVals.some(([, v]) => v !== null)) {
451
+ if (outputText !== null) {
452
+ console.error('❌ --req/--cause/--solution/--result 与 --output 互斥——四参数由 CLI 合成结构化 output,勿混用');
453
+ process.exit(2)
454
+ }
455
+ const missing = fourVals.filter(([, v]) => v === null).map(([f]) => f)
456
+ if (missing.length > 0) {
457
+ console.error(`❌ quick 末步四字段参数缺项:${missing.join(' ')}(--req/--cause/--solution/--result 必须全给,或缺字段 --done 会被拒)`);
458
+ process.exit(2)
459
+ }
460
+ outputText = FOUR.map(([f, label]) => `${label}${getFlagValue(f)}`).join(' ')
461
+ }
425
462
  }
426
463
 
427
464
  // 解析 --input
428
465
  let inputText = null
429
- const inputIdx = flags.indexOf('--input')
430
- if (inputIdx !== -1 && flags[inputIdx + 1]) {
431
- inputText = flags[inputIdx + 1]
432
- }
466
+ const inputValue = getFlagValue('--input')
467
+ if (inputValue !== null) inputText = inputValue
433
468
 
434
469
  // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
435
470
  // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
436
471
  // null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
437
472
  let explicitLinked = null
438
- const linkedIdx = flags.indexOf('--linked-changes')
439
- if (linkedIdx !== -1 && flags[linkedIdx + 1]) {
440
- const v = flags[linkedIdx + 1].trim()
473
+ const linkedValue = getFlagValue('--linked-changes')
474
+ if (linkedValue !== null) {
475
+ const v = linkedValue.trim()
441
476
  explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
442
477
  }
443
478
 
@@ -445,9 +480,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
445
480
  // 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
446
481
  let changeName = null
447
482
  let linkedChanges = []
448
- const changeIdx = flags.indexOf('--change')
449
- if (changeIdx !== -1 && flags[changeIdx + 1]) {
450
- changeName = flags[changeIdx + 1]
483
+ const changeValue = getFlagValue('--change')
484
+ if (changeValue !== null) {
485
+ changeName = changeValue
451
486
  if (stageName === 'quick') {
452
487
  linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
453
488
  changeName = null
@@ -489,7 +524,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
489
524
  // 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName 置 null。
490
525
  // 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
491
526
  // 并撤销把它当 linkedChanges 的误判。多值或不匹配 → 维持 linkedChanges 语义(旧兼容)。
492
- const rawChange = changeIdx !== -1 && flags[changeIdx + 1] ? flags[changeIdx + 1].trim() : null
527
+ // F4 守卫同款(changeIdx 变量已随裸读清理移除,此处改 getFlagValue 复读原始值)
528
+ const rawChangeValue = getFlagValue('--change')
529
+ const rawChange = rawChangeValue !== null ? rawChangeValue.trim() : null
493
530
  const rawIsSingleSid = rawChange && rawChange.indexOf(',') === -1 && QUICK_SID_RE.test(rawChange)
494
531
  if (rawIsSingleSid) {
495
532
  // --done --change quick-<uuid8>:精确恢复(撤销 1373-1376 把它误当 linkedChanges)
@@ -549,18 +586,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
549
586
  process.exit(2) // 用法错 → exit 2
550
587
  }
551
588
  let quickFiles = []
552
- const filesIdx = flags.indexOf('--files')
553
- if (filesIdx !== -1 && flags[filesIdx + 1]) {
554
- quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
589
+ const filesValue = getFlagValue('--files')
590
+ if (filesValue !== null) {
591
+ quickFiles = filesValue.split(',').map(f => f.trim()).filter(Boolean)
555
592
  }
556
593
 
557
594
  // 解析 --file-notes "path::括注 || path::括注"(quick 专用:QUICKLOG「文件:」行落盘多行括注)。
558
595
  // 旁路注入 quicklog.js(per-process setter),不经 complete 收尾路径透传(避碰多 agent 并发改的收尾热点)。
559
596
  // completeQuicklogEntry 读后即清;不传 → '' → 回填审计到的实际文件单行(向后兼容)。
560
597
  let quickFileNotes = ''
561
- const fileNotesIdx = flags.indexOf('--file-notes')
562
- if (fileNotesIdx !== -1 && flags[fileNotesIdx + 1]) {
563
- quickFileNotes = flags[fileNotesIdx + 1]
598
+ const fileNotesValue = getFlagValue('--file-notes')
599
+ if (fileNotesValue !== null) {
600
+ quickFileNotes = fileNotesValue
564
601
  }
565
602
  setQuickFileNotes(quickFileNotes)
566
603
 
@@ -587,10 +624,11 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
587
624
 
588
625
  // 未知参数 fail-fast
589
626
  const knownFlags = new Set([
590
- '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
627
+ '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval', '--cancel', '--ql',
591
628
  '--wait', '--continue', '--non-interactive', '--interactive',
592
629
  '--reason', '--options', '--answer', '--confirm-mode',
593
630
  '--output', '--input', '--change', '--linked-changes',
631
+ '--req', '--cause', '--solution', '--result',
594
632
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
595
633
  '--files', '--file-notes', '--allow-new', '--allow-delete', '--force-baseline', '--force-rescan',
596
634
  '--json', '--dir', '--help',
@@ -771,16 +809,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
771
809
  let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
772
810
  // archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
773
811
  // 不带 --change 回退 default 会读错变更。无活跃变更时取最新归档变更,读其现有 progress。
812
+ // 归档目录名 = 原变更名恒等迁移(archiveDestDirName 不改名),直接原名对读——此前按
813
+ // 「日期前缀过滤 + 剥日期」找,剥出的短名与 DB 行名(含日期)恒不符 → pm.read 返回
814
+ // null → initChange 凭空建幻影变更再跑 archive(坑 archive-fallback-phantom-change)。
774
815
  if (stageName === 'archive' && autoName === 'default') {
775
816
  try {
776
817
  const archiveDir = join(specBase, 'changes', 'archive')
777
818
  if (existsSync(archiveDir)) {
778
- const latest = readdirSync(archiveDir)
779
- .filter(d => /^\d{4}-\d{2}-\d{2}-.+/.test(d))
819
+ const latest = readdirSync(archiveDir, { withFileTypes: true })
820
+ .filter(d => d.isDirectory())
821
+ .map(d => d.name)
780
822
  .sort()
781
823
  .pop()
782
824
  if (latest) {
783
- autoName = latest.replace(/^\d{4}-\d{2}-\d{2}-/, '')
825
+ autoName = latest
784
826
  progress = pm.read(cwd, autoName)
785
827
  }
786
828
  }
@@ -825,6 +867,15 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
825
867
  changeName = autoName
826
868
  } else {
827
869
  console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
870
+ // 自愈引导(坑 suggestion-command-missing-change 同族):多活跃变更仓不带 --change 时
871
+ // pm.read 无法自动定位(单活跃可自动),列出候选让 agent 一步补对,而非盲猜变更名
872
+ try {
873
+ const activeChanges = pm.listChanges(cwd)
874
+ if (activeChanges.length > 1) {
875
+ console.error(` 当前有 ${activeChanges.length} 个活跃变更(无法自动定位),请显式指定:`)
876
+ for (const c of activeChanges) console.error(` - --change ${c}`)
877
+ }
878
+ } catch { /* 列举失败不掩盖原报错 */ }
828
879
  process.exit(2) // 环境错(未 init / 未传 --change)→ exit 2
829
880
  }
830
881
  }
@@ -887,12 +938,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
887
938
  // stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
888
939
  const stageDataPre = progress.stages[stageName]
889
940
  const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
890
- // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)
941
+ // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)。
942
+ // 按名保留旧状态(坑 execute-reopen-wipes-prior-completion):reopenStage 的语义是
943
+ // 「i < fromStep 保持原状(completed)」,此前预置全 pending 会把 fromStep 之前已完成的
944
+ // 步骤一并清空——CLI 打印「从步骤 N 开始修订」、prompt 注入「之前已完成不需要重做」,
945
+ // 实际 currentIdx 却回到 0,agent 被迫从头重做,修订语义完全失效。
891
946
  if (needsInit || stageName === 'execute') {
892
947
  const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
893
948
  if (freshSteps && freshSteps.length > 0) {
894
949
  if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
895
- progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
950
+ const oldSteps = progress.stages[stageName].steps || []
951
+ progress.stages[stageName].steps = freshSteps.map(s => {
952
+ const old = oldSteps.find(step => step.name === s.name)
953
+ return old ? { ...old } : { name: s.name, status: 'pending' }
954
+ })
896
955
  pm._write(cwd, progress, effectiveChange)
897
956
  progress = pm.read(cwd, effectiveChange) || progress
898
957
  }
@@ -937,6 +996,54 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
937
996
  }
938
997
  }
939
998
 
999
+ // ── quick --cancel:误启动会话的取消口(数据源 quicklog.js cancelQuickSession)──
1000
+ // --change 取 quick-<uuid8> 会话 ID(quick 启动时 CLI echo 的 sessionId);qlId 从会话 guard
1001
+ // 或 --ql 显式参数解析。取消后 db 行注销(quick 会话行非真实变更,直接 unregister 安全)。
1002
+ if (stageName === 'quick' && isCancel) {
1003
+ const cancelSessionRaw = quickSessionId || changeName
1004
+ const qlFlag = getFlagValue('--ql')
1005
+ if (!cancelSessionRaw || cancelSessionRaw === 'quick-unknown') {
1006
+ console.error('用法: sillyspec run quick --cancel --change <quick-会话ID> [--ql <ql-xxx>]\n 取消误启动的 quick 会话:QUICKLOG 条目翻「已取消」+ tasks.md 挂载行移除 + 会话目录清理 + db 行注销。\n 已完成/已勾选的会话拒绝取消(真实工作记录)')
1007
+ process.exit(2)
1008
+ }
1009
+ const specBase = resolveSpecDir(cwd, { specDir })
1010
+ // qlId 优先 --ql 显式;否则读会话 guard.json(allocate 时 CLI 写入);再退 current marker 旁的会话
1011
+ let qlId = qlFlag
1012
+ if (!qlId) {
1013
+ const guardPath = join(specBase, '.runtime', 'quick-sessions', cancelSessionRaw, 'guard.json')
1014
+ try {
1015
+ if (existsSync(guardPath)) qlId = JSON.parse(readFileSync(guardPath, 'utf8')).qlId
1016
+ } catch { /* guard 损坏 → 下面按找不到报错 */ }
1017
+ }
1018
+ if (!qlId) {
1019
+ console.error(`❌ 无法定位会话 ${cancelSessionRaw} 的 qlId(guard.json 缺失/损坏)——显式传 --ql <ql-xxx>(QUICKLOG 条目头)`)
1020
+ process.exit(1)
1021
+ }
1022
+ let gitUser = 'unknown'
1023
+ try { gitUser = (await import('../git-helper.js')).git(cwd, ['config', 'user.name']) || 'unknown' } catch {}
1024
+ const { cancelQuickSession } = await import('../quicklog.js')
1025
+ let result
1026
+ try {
1027
+ result = await cancelQuickSession({ specBase, gitUser, qlId, sessionId: cancelSessionRaw })
1028
+ } catch (e) {
1029
+ console.error(`❌ 取消失败: ${e.message}`)
1030
+ process.exit(1)
1031
+ }
1032
+ if (!result.ok) {
1033
+ console.error(`❌ 取消失败: ${result.reason}`)
1034
+ process.exit(1)
1035
+ }
1036
+ // db 侧:quick 会话行注销(quick-<uuid8> 是进度库里的会话行,非真实变更目录)
1037
+ try {
1038
+ pm.unregisterChange(cwd, cancelSessionRaw)
1039
+ } catch { /* 行不存在/平台模式 → 会话目录清理已完成,db 无行可注销不算失败 */ }
1040
+ console.log(`🗑️ 已取消 quick 会话 ${cancelSessionRaw}(qlId ${qlId})`)
1041
+ console.log(` QUICKLOG 条目翻「已取消」: ${result.quicklogFile}`)
1042
+ if (result.removedTaskRows.length > 0) console.log(` tasks.md 挂载行已移除: ${result.removedTaskRows.join('、')}`)
1043
+ console.log(' 会话 guard 目录与 current marker 已清理(已完成条目不会被取消)')
1044
+ return
1045
+ }
1046
+
940
1047
  // quick 启动(非 --done):reset steps + 写 current-quick-run-id(本会话 sessionId,作 --done fallback)。
941
1048
  // sessionId 已在参数解析阶段生成(quickSessionId == changeName == quick-<uuid8>),
942
1049
  // 这里复用同一值写 current-quick-run-id,保证两处一致。
@@ -1148,15 +1255,17 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
1148
1255
  async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
1149
1256
  const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
1150
1257
  const isDone = flags.includes('--done')
1151
- const outputIdx = flags.indexOf('--output')
1152
- const outputText = outputIdx !== -1 && flags[outputIdx + 1] ? flags[outputIdx + 1] : null
1153
- const inputIdx = flags.indexOf('--input')
1154
- const inputText = inputIdx !== -1 && flags[inputIdx + 1] ? flags[inputIdx + 1] : null
1258
+ // F4 守卫(坑 raw-flag-value-bypass,与 runCommand getFlagValue 同款):裸 flags[idx+1] 会把
1259
+ // 漏值后紧跟的 flag 名当值(--output --done → outputText="--done" 污染落盘 output)。
1260
+ const autoFlagValue = (name) => {
1261
+ const i = flags.indexOf(name)
1262
+ const next = i !== -1 ? flags[i + 1] : undefined
1263
+ return next && !String(next).startsWith('--') ? next : null
1264
+ }
1265
+ const outputText = autoFlagValue('--output')
1266
+ const inputText = autoFlagValue('--input')
1155
1267
  const skipApproval = flags.includes('--skip-approval')
1156
- const explicitMode = (() => {
1157
- const m = flags.indexOf('--mode')
1158
- return m !== -1 && flags[m + 1] ? flags[m + 1] : null
1159
- })()
1268
+ const explicitMode = autoFlagValue('--mode')
1160
1269
  const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
1161
1270
 
1162
1271
  // Helper: 在 auto 模式下获取步骤定义
@@ -1193,7 +1302,10 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1193
1302
  return progress
1194
1303
  }
1195
1304
  }
1196
- const changed = await ensureStageSteps(progress, stage, cwd)
1305
+ // specRoot 必传(坑 auto-mode-specroot-miss):平台模式 specRoot 是外部目录,漏传时
1306
+ // getStageSteps 从 cwd 推导 miss 掉 plan/execute 动态步骤(buildPlanSteps(null) 只剩
1307
+ // 2 步 fixedPrefix),种出的步骤表两次 --done 即判阶段完成,与 getAutoSteps 渲染双轨失配
1308
+ const changed = await ensureStageSteps(progress, stage, cwd, platformOpts?.specRoot || null)
1197
1309
  if (stageChanged || changed) {
1198
1310
  pm._write(cwd, progress, changeName)
1199
1311
  triggerSync(cwd, changeName, platformOpts)
@@ -1369,7 +1481,8 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1369
1481
  progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
1370
1482
  }
1371
1483
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
1372
- await ensureStageSteps(progress, next, cwd)
1484
+ // specRoot 同上(坑 auto-mode-specroot-miss):平台模式动态步骤表必须从外部 specRoot 构建
1485
+ await ensureStageSteps(progress, next, cwd, platformOpts?.specRoot || null)
1373
1486
  pm._write(cwd, progress, changeName)
1374
1487
  triggerSync(cwd, changeName, platformOpts)
1375
1488
  progress = pm.read(cwd, changeName)
@@ -135,9 +135,14 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platform
135
135
  try {
136
136
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
137
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']
138
143
  const markers = readdirSync(runtimeRoot).filter((f) =>
139
144
  f === `current-execute-run-id-${archiveChangeName}` ||
140
- (f.startsWith('current-stage-review-run-id-') && f.endsWith(`-${archiveChangeName}`)),
145
+ REVIEW_STAGES.some(st => f === `current-stage-review-run-id-${st}-${archiveChangeName}`),
141
146
  )
142
147
  for (const m of markers) {
143
148
  try { unlinkSync(join(runtimeRoot, m)) } catch {}
@@ -278,7 +283,10 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
278
283
  const alreadyArchivedDir = findAlreadyArchivedDir(archiveDir, archiveChangeName)
279
284
  if (alreadyArchivedDir) {
280
285
  console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
281
- pm.unregisterChange(cwd, archiveChangeName)
286
+ // 终态一致化(坑 manual-archive-desync-status-only):手动搬目录绕过标准归档后,本自愈路径
287
+ // 不能只翻 status——同时收尾 archive 阶段(steps/stages/current_stage),否则推送平台的终态
288
+ // 是「已归档 + 归档 0/5 + 停在 execute」的矛盾体,详情页渲染成「进度丢失」
289
+ pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
282
290
  await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
283
291
  console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
284
292
  return alreadyArchivedDir
@@ -327,7 +335,9 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
327
335
  process.exit(1)
328
336
  }
329
337
 
330
- pm.unregisterChange(cwd, archiveChangeName)
338
+ // 正常路径同样走终态一致化(坑 manual-archive-desync-status-only):标准 --done --confirm 流程
339
+ // 中 completeStep 随后也会逐项完成,此处先收尾是幂等写同值——保证任何走出本函数的归档终态一致
340
+ pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
331
341
 
332
342
  // CLI 下沉 git add(坑4,FR-04):确定性暂存归档目录 + 模块文档,不靠 step5 prompt 驱动。
333
343
  // step5 prompt 的 git add 保留作幂等兜底;POSIX 路径跨平台(git 接受正斜杠)。
@@ -338,6 +348,53 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
338
348
  safeGit(cwd, ['add', '--', '.sillyspec/docs/'])
339
349
  } catch {}
340
350
 
351
+ // ── 他者半归档残留探测(坑 archive-other-residual-rename,2026-08-21 实证)──
352
+ // 并行变更的手动归档把 R 残留(源目录 rename 目标行)留在暂存区;本变更归档提交时
353
+ // git status 显示它们,agent 会误判「还没提交完 / 要做第二次提交」。区分:
354
+ // 本变更:未暂存的源侧移动(` D changes/<me>/...` + 未跟踪 archive/<me>/...)→ 补暂存
355
+ // 让归档成单次原子提交;
356
+ // 他者:已暂存的 rename(`R changes/<他人>/... -> changes/archive/<他人>/...`)→ 只 warn
357
+ // 提示归属(不 stage 不动——别人的归别人的)。
358
+ try {
359
+ const raw = gitQuiet(cwd, ['status', '--porcelain'], { trim: false, timeout: 30000 })
360
+ if (raw && raw.trim()) {
361
+ const changesPrefix = '.sillyspec/changes/'
362
+ const minePaths = []
363
+ const othersResidual = []
364
+ for (const line of raw.split('\n')) {
365
+ if (!line || line.length < 4) continue
366
+ const x = line[0], y = line[1]
367
+ const body = line.slice(3).trim()
368
+ const arrow = body.indexOf(' -> ')
369
+ const src = arrow !== -1 ? body.slice(0, arrow).replace(/^"|"$/g, '') : null
370
+ const dst = (arrow !== -1 ? body.slice(arrow + 4) : body).replace(/^"|"$/g, '')
371
+ if (x === 'R' && y === ' ' && arrow !== -1 && dst.startsWith(changesPrefix + 'archive/')) {
372
+ // 已暂存 rename → 半归档残留;按目标目录名归属他者变更
373
+ const m = /^\.sillyspec\/changes\/archive\/([^/]+)\//.exec(dst)
374
+ const owner = m ? m[1] : '?'
375
+ if (owner !== archiveChangeName) othersResidual.push(owner)
376
+ } else if (x === ' ' && (y === 'D' || y === 'A' || y === 'M')) {
377
+ // 未暂存的源侧移动:源目录 D / 新位置 A——指向本变更的补暂存
378
+ const p = dst || src
379
+ if (p && (p.startsWith(changesPrefix + archiveChangeName + '/')
380
+ || p.startsWith(changesPrefix + 'archive/' + archiveChangeName + '/'))) {
381
+ minePaths.push(p)
382
+ }
383
+ }
384
+ }
385
+ if (minePaths.length > 0) {
386
+ try { safeGit(cwd, ['add', '-A', '--', changesPrefix]) } catch {}
387
+ console.log(`🧾 已补暂存本变更归档的源侧移动(${minePaths.length} 项,归档成单次原子提交)`)
388
+ }
389
+ if (othersResidual.length > 0) {
390
+ const owners = [...new Set(othersResidual)].filter(Boolean)
391
+ console.warn(`⚠️ 检测到「他者半归档」残留(暂存区存在其他变更的 rename 记录):${owners.join('、')}`)
392
+ console.warn(` 这些是别的变更此前手动归档留下的暂存项,不属于本次归档——git status 里看到它们是正常的,`)
393
+ console.warn(` 本变更归档已完成,无需为其做第二次提交;如需清理走它们自己的收尾(或 git restore --staged 后核对)。`)
394
+ }
395
+ }
396
+ } catch { /* 探测失败不阻断归档(advisory) */ }
397
+
341
398
  // 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
342
399
  await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
343
400
 
@@ -1032,7 +1089,8 @@ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, pla
1032
1089
  return { closed: false, reason: `移动目录失败:${e.message}` }
1033
1090
  }
1034
1091
 
1035
- pm.unregisterChange(cwd, changeName)
1092
+ // 终态一致化同标准归档(坑 manual-archive-desync-status-only):轻量归档路径不能只翻 status
1093
+ pm.unregisterChange(cwd, changeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
1036
1094
  await archiveWorktreeCleanup(cwd, changeName, specBase, platformOpts)
1037
1095
  safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
1038
1096
 
@@ -24,7 +24,7 @@ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveR
24
24
  import { executeScanPreflight, executeScanPostcheck, computeScanProfile } from './scan-profile.js'
25
25
  import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
26
26
  import { outputStep } from './prompt.js'
27
- import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, completeStageGates, readDesignScale } from './gates.js'
27
+ import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, warnMissingUiPrototype, completeStageGates, readDesignScale } from './gates.js'
28
28
  import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
29
29
  import { formatExecuteSummary } from '../worktree-apply.js'
30
30
  import { isEndToEndTaskText } from '../change-risk-profile.js'
@@ -229,6 +229,8 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
229
229
  // 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
230
230
  // symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
231
231
  await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
232
+ // UI 原型软提醒(非阻断):brainstorm 收尾步 design.md 命中前端文件但无 prototype → 提示用户可否决
233
+ warnMissingUiPrototype(stageName, changeName, steps[currentIdx]?.name, specBase)
232
234
 
233
235
  // ── noAI 步骤硬门(坑 noai-done-bypass):noAI 步骤的确定性校验不可被 --done 绕过 ──
234
236
  // 正常路径 agent 跑 `run <stage>` 推进到 noAI step 时,runStage 自动执行 _cliAction
@@ -321,6 +323,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
321
323
  const _drafts = await generateTaskReviewDrafts({ changeName, cwd, platformOpts, ctx: _draftCtx })
322
324
  if (_drafts.generated > 0) {
323
325
  console.log(' 📄 自动补写 ' + _drafts.generated + ' 个 per-task review.json 草稿(cannot_verify,主 agent 实现模式兜底,需 agent 复核后升级为 pass/fail)')
326
+ if (_drafts.noAttribution > 0) {
327
+ console.warn(' ⚠️ 其中 ' + _drafts.noAttribution + ' 个 task 的 allowed_paths 未命中本次 diff(无归属草稿,changedFiles 为空)——需人工确认实际改动后升级 verdict,确属未实现则回 fail')
328
+ }
324
329
  if (_drafts.unattributed && _drafts.unattributed.length > 0) {
325
330
  console.warn(' ⚠️ ' + _drafts.unattributed.length + ' 个变更文件未归属任何 task(顺带修复/非源码),草稿未覆盖:' + _drafts.unattributed.join(', '))
326
331
  }
@@ -451,7 +456,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
451
456
  console.log(`\n${summary}`)
452
457
  } catch (e) {
453
458
  // summary 失败不影响主流程
454
- console.log('\n👉 下一步:sillyspec run verify(验证通过后才能归档)')
459
+ console.log(`\n👉 下一步:sillyspec run verify${changeName ? ` --change ${changeName}` : ''}(验证通过后才能归档)`)
455
460
  }
456
461
  } else if (stageName === 'archive') {
457
462
  console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
@@ -529,12 +534,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
529
534
  // 易被 tail 截断),agent 常到 --done 被拦才第一次见到模板。推进到末步的此刻用底部短块预告
530
535
  // 模板 + 可照抄命令,省一轮拦截往返(拦截路径保留兜底)。
531
536
  if (stageName === 'quick' && nextPendingIdx === steps.length - 1) {
532
- console.log(`\n📌 本步是 quick 末步:--done 的 --output 必须含四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。预告模板:`)
533
- console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --output "需求:一句话语义化短标题(即 QUICKLOG 条目标题)`)
534
- console.log(` 根因:为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)`)
535
- console.log(` 方案:怎么改的`)
536
- console.log(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
537
- console.log(` 四字段逐项一句话、不可「见前述」;可选 --file-notes "path::括注 || path2" 落多行文件括注`)
537
+ console.log(`\n📌 本步是 quick 末步:--done 必须给全四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。推荐四参数形式(CLI 自动合成,无格式事故面):`)
538
+ console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --req "一句话语义化短标题(即 QUICKLOG 条目标题)" --cause "为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)" --solution "怎么改的" --result "验证情况(测试数 / lint / typecheck / 部署状态)"`)
539
+ console.log(` 兼容旧形式:--output "需求:… 根因:… 方案:… 结果:…"(四字段逐项一句话、不可「见前述」,正文内禁嵌套全角冒号);可选 --file-notes "path::括注 || path2" 落多行文件括注`)
538
540
  }
539
541
  }
540
542
  return { stageCompleted: false, currentIdx, nextPendingIdx }
@@ -104,8 +104,10 @@ export function detectConcurrentChanges(cwd, { changeName, linkedChanges = [], o
104
104
  if (!file) continue
105
105
 
106
106
  // rule1:落 .sillyspec/changes/<dir>/ 且 <dir> 非本变更非关联 → otherActiveChanges 去重。
107
+ // 'archive' 排除(坑 archive-dir-as-other-change):归档移动产生 .sillyspec/changes/archive/...
108
+ // 脏路径,extractChangeDir 返回 'archive',不排除会把归档动作误报成「他者活跃变更: archive」。
107
109
  const dir = extractChangeDir(file)
108
- if (dir && !ownChangeSet.has(normalizeGitPath(dir))) {
110
+ if (dir && dir !== 'archive' && !ownChangeSet.has(normalizeGitPath(dir))) {
109
111
  otherActiveSet.add(dir)
110
112
  continue
111
113
  }
package/src/run/gates.js CHANGED
@@ -65,9 +65,43 @@ export function validateSymbolImpactReport({ reportContent, planContent }) {
65
65
  for (const id of missing) {
66
66
  errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
67
67
  }
68
+ // 防骨架直接过门(2026-08-21 agent-手工产出审计项⑤):CLI 会代生成逐 task TODO 骨架
69
+ //(generateSymbolImpactSkeleton),占位未替换即拦——骨架消灭的是「从零手写/漏 task」错误面,
70
+ // 不是把「逐 task 结论」本身变成走过场。
71
+ const report = String(reportContent)
72
+ const todoPending = taskIds.filter(id => new RegExp(`^[-*]\\s*${id}[^\\n]*<!--TODO-->`, 'm').test(report))
73
+ for (const id of todoPending) {
74
+ errors.push(`${id} 的结论仍是骨架 <!--TODO--> 占位——替换为真实结论(无签名级变更也显式写「无」)`)
75
+ }
68
76
  return { ok: errors.length === 0, errors }
69
77
  }
70
78
 
79
+ /**
80
+ * 生成 symbol-impact.md 逐 task TODO 骨架(2026-08-21 审计项⑤「报错即生成」)。
81
+ *
82
+ * gate 此前只报错不代填 → agent 从零手写整份报告,忘一个 task 就被拦、再补再撞。
83
+ * 骨架从 tasks.md 注册表生成逐 task 占位行,agent 只需逐行填结论;占位 <!--TODO-->
84
+ * 由 validateSymbolImpactReport 拒绝,骨架不能直接过门(防偷懒)。
85
+ *
86
+ * @param {string} tasksContent - 任务注册表(tasks.md,回退 plan.md)内容
87
+ * @returns {string|null} 骨架全文(LF);注册表无 task 行 → null
88
+ */
89
+ export function generateSymbolImpactSkeleton(tasksContent) {
90
+ const taskIds = extractTaskIdsFromRegistry(tasksContent)
91
+ if (taskIds.length === 0) return null
92
+ const lines = [
93
+ '# 符号影响面报告',
94
+ '',
95
+ '> 骨架由 CLI 生成(`sillyspec symbol-impact --change <变更名>`,gate 失败时也会自动落一份)。',
96
+ '> 逐行把 `<!--TODO-->` 替换为真实结论:涉及签名级变更(构造函数参数/接口/DTO/方法签名增删改)',
97
+ '> 写变更类型 + 受影响调用点 + 是否在任务范围内;无签名级变更也要显式写「无签名级变更」。',
98
+ '> **gate 拒绝仍含 <!--TODO--> 的行**——骨架不能直接过门。',
99
+ '',
100
+ ]
101
+ for (const id of taskIds) lines.push(`- ${id}: <!--TODO-->`)
102
+ return lines.join('\n') + '\n'
103
+ }
104
+
71
105
  /**
72
106
  * 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
73
107
  * fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
@@ -88,10 +122,42 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
88
122
  console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
89
123
  console.error(` 期望路径:${reportPath}`)
90
124
  for (const e of result.errors) console.error(` • ${e}`)
91
- console.error(' 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论(无签名级变更也要显式写「无」),落盘后重跑 execute --done。')
125
+ // 报错即生成(2026-08-21 审计项⑤):报告缺失时自动落一份逐 task TODO 骨架,agent 从
126
+ // 「从零手写整份」变「逐行填结论」;TODO 占位由 validate 拒绝,骨架不能直接过门。
127
+ let skeletonNote = ''
128
+ if (!existsSync(reportPath)) {
129
+ const skeleton = generateSymbolImpactSkeleton(tasksContent)
130
+ if (skeleton) {
131
+ try {
132
+ writeFileSync(reportPath, skeleton)
133
+ skeletonNote = `\n 📄 已代生成逐 task 骨架:${reportPath}(逐行替换 <!--TODO--> 为结论,无签名级变更也显式写「无」)`
134
+ } catch { /* 写失败只影响提示,不掩盖原始错误 */ }
135
+ }
136
+ }
137
+ console.error(` 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论后重跑 execute --done。${skeletonNote}`)
92
138
  process.exit(1)
93
139
  }
94
140
 
141
+ /**
142
+ * UI 原型软提醒(非阻断):brainstorm「生成规范文件」步 --done 时,design.md 命中前端文件
143
+ * 但变更目录无 prototype-*.html → console.warn 一行,用户可否决。程度判断(布局级才必须出)
144
+ * 是语义判断,CLI 不硬卡只提示——治 agent 对明显 UI 改造静默跳过原型的坑。
145
+ */
146
+ export function warnMissingUiPrototype(stageName, changeName, currentStepName, specBase) {
147
+ if (stageName !== 'brainstorm' || !currentStepName || !currentStepName.includes('生成规范文件')) return
148
+ if (!changeName || !specBase) return
149
+ const changeDir = join(specBase, 'changes', changeName)
150
+ const designPath = join(changeDir, 'design.md')
151
+ if (!existsSync(designPath)) return
152
+ try {
153
+ if (readdirSync(changeDir).some(f => /^prototype-.*\.html$/i.test(f))) return
154
+ } catch { return } // 目录不可读(平台模式 specRoot 差异等)→ 静默跳过,不制造噪音
155
+ const design = readFileSync(designPath, 'utf8')
156
+ const feExts = [...new Set((design.match(/\.(vue|svelte|astro|tsx|jsx|css|scss|sass|less|styl|html)\b/gi) || []).map(s => s.replace('.', '').toLowerCase()))]
157
+ if (feExts.length === 0) return
158
+ console.warn(` ⚠️ design.md 命中前端文件类型(${feExts.join('/')})但变更目录无 prototype-*.html——轻微样式改动可忽略;布局/交互流程级变化应补原型(sillyspec run brainstorm --reopen 回到「分段展示设计」,或直接让 agent 补生成)`)
159
+ }
160
+
95
161
  /**
96
162
  * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
97
163
  * 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
@@ -175,7 +241,7 @@ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase,
175
241
  }
176
242
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
177
243
  const reviewResult = validateStageReview({ stage: 'execute', reviewType: 'acceptance', runtimeRoot, reviewRunId, searchDirs })
178
- printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot })
244
+ printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot, changeName })
179
245
  if (!reviewResult.ok) return true // printStageReviewResult 已给明细,不再重复列
180
246
  }
181
247
  } catch (e) {
@@ -272,6 +338,18 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
272
338
  */
273
339
  export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
274
340
  if (stageName !== 'execute' || !changeName) return true
341
+ // head 锡点自动落盘(2026-08-21 审计项③,D-010 补对称):跨仓 task base_commit 已在派发时
342
+ // CLI 落盘,head_commit 此前靠主 agent 按 prompt 手跑 rev-parse 手写(漏抄/抄错炸 review gate)。
343
+ // 每次 --done 时机幂等补齐(已存在不覆盖)。best-effort:失败只 warn,不阻断 --done 主流程。
344
+ try {
345
+ const { stampCrossRepoHeadCommits } = await import('../stages/execute.js')
346
+ const stamped = await stampCrossRepoHeadCommits({ changeName, cwd, specBase, platformOpts })
347
+ if (stamped && stamped.stamped > 0) {
348
+ console.log(`📌 已自动落盘跨仓 task head_commit 锡点:${stamped.stampedTasks.join(', ')}(幂等,此后 --done 不再重复写)`)
349
+ }
350
+ } catch (e) {
351
+ console.warn(`⚠️ head_commit 锡点自动落盘失败(降级留给 agent 手写): ${e.message}`)
352
+ }
275
353
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
276
354
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
277
355
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
@@ -320,7 +398,7 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
320
398
  console.error(` • ${f.taskId}(${kindLabel}): ${f.reviewPath}`)
321
399
  for (const e of f.errors) console.error(` - ${e}`)
322
400
  }
323
- console.error(' 修复:补全上述 review.json 字段后重跑 execute --done。')
401
+ console.error(' 修复:mechanics 字段(base/head/changedFiles/schemaVersion)缺错可跑 sillyspec backfill-reviews --change ' + changeName + ' --adopt 一键代填(verdict 保留);verdict/证据缺失需人工补全后重跑 execute --done。')
324
402
  process.exit(1)
325
403
  }
326
404
 
@@ -511,7 +589,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
511
589
  : 'acceptance'
512
590
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
513
591
  const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
514
- printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot })
592
+ printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot, changeName })
515
593
  if (!reviewResult.ok) {
516
594
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
517
595
  }
@@ -854,6 +932,10 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
854
932
  stageData.steps = freshSteps
855
933
  stageData.status = 'pending'
856
934
  stageData.completedAt = null
935
+ // scan 的 quick 档 profile 随步骤表一并失效(坑 scan-quick-profile-step-mismatch 收尾):
936
+ // getStageSteps 感知 scanProfile 返回 3 步表,重置后 DB 是 11 步注册表,残留 profile 会让
937
+ // 下次 --status 走 ensureStageSteps 时 11 vs 3 误报漂移重播种。下次 run scan 会重算 profile。
938
+ if (stageName === 'scan' && stageData.scanProfile) delete stageData.scanProfile
857
939
  if (progress.currentStage === stageName) progress.currentStage = ''
858
940
  pm._write(cwd, progress, changeName)
859
941
  }