sillyspec 3.25.9 → 3.26.0

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 (76) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
package/src/quicklog.js CHANGED
@@ -117,6 +117,36 @@ function sanitizeResult(resultText) {
117
117
  return String(resultText || '').trim()
118
118
  }
119
119
 
120
+ // ── quick step3 --file-notes 旁路通道 ──
121
+ // flag 在 command.js 解析(--file-notes),completeQuicklogEntry 在 quick 收尾时消费。
122
+ // 不经 completeStep → handleQuickStageCompletion → completeQuicklogEntry 三层透传(会扩 3 处签名,
123
+ // 且 quick 收尾路径是多 agent 并发改热点);用 per-process setter,CLI 单进程生命周期内有效,
124
+ // completeQuicklogEntry 读后即清。无 --file-notes 时为 '' → 回退 changedFiles 单行(向后兼容)。
125
+ let _pendingFileNotes = ''
126
+ export function setQuickFileNotes(raw) { _pendingFileNotes = raw == null ? '' : String(raw) }
127
+ export function getQuickFileNotes() { return _pendingFileNotes }
128
+
129
+ /**
130
+ * 解析 --file-notes 原文 → [{path, note}]。
131
+ * 格式:「path1::括注1 || path2::括注2」——`||` 分隔条目,`::` 分隔路径与括注(首个 `::` 为界,
132
+ * 括注内含 `::` 不误切);路径反斜杠归一正斜杠(匹配 git 路径风格)。无 `::` 的段 → note=''。
133
+ * 空 / 全空段 → [](调用方回退 changedFiles 单行)。导出供测试 + 未来直接调用。
134
+ */
135
+ export function parseFileNotes(raw) {
136
+ if (!raw) return []
137
+ return String(raw)
138
+ .split('||')
139
+ .map((seg) => {
140
+ const s = seg.trim()
141
+ if (!s) return null
142
+ const idx = s.indexOf('::')
143
+ const path = (idx === -1 ? s : s.slice(0, idx)).replace(/\\/g, '/').trim()
144
+ const note = idx === -1 ? '' : s.slice(idx + 2).trim()
145
+ return path ? { path, note } : null
146
+ })
147
+ .filter(Boolean)
148
+ }
149
+
120
150
  // 结果块必填字段(quick step3 --done --output 的结构化结果模板,见 src/stages/quick.js)。
121
151
  // 4 个必填字段标签从 manifest 同源(stage-contract-spec.js quick.result-labels),prompt 事前契约与本校验单源。
122
152
  const RESULT_REQUIRED_LABELS = getRule('quick.result-labels').data.literals
@@ -284,7 +314,7 @@ function splitSingleLineFields(body) {
284
314
  return scanFields(body, findBoundaryLabel) ?? scanFields(body, (b, l, f) => b.indexOf(l, f))
285
315
  }
286
316
 
287
- function flipEntryInContent(content, qlId, result, changedFiles = []) {
317
+ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = []) {
288
318
  const lines = content.split('\n')
289
319
  const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
290
320
  if (startIdx === -1) return null
@@ -303,11 +333,16 @@ function flipEntryInContent(content, qlId, result, changedFiles = []) {
303
333
  for (let i = startIdx + 1; i < endIdx; i++) {
304
334
  if (/^状态:进行中\r?$/.test(lines[i])) { lines[i] = '状态:已完成'; flipped = true }
305
335
  if (lines[i].startsWith('结果:')) hasResult = true
306
- // 回填实际改动文件(调用方已用 isQuickMetadata 过滤 quick 自身元数据,只留业务文件)。
307
- // 原地替换 lines[i],不改数组长度 → 与下方状态/结果 splice 的索引完全解耦。
308
- // changedFiles 空(brownfield 无 guard / 调用方未传)→ 不动文件行,保持「(见实际改动)」。
309
- if (changedFiles.length > 0 && lines[i].startsWith('文件:')) {
310
- lines[i] = `文件:${changedFiles.join(', ')}`
336
+ // 文件行落盘:--file-notes 优先(多行 bullet 带括注),否则回填审计到的实际改动文件单行,
337
+ // 都空则不动(保持「(见实际改动)」)。bullet 用「数组元素内嵌 \n」写——lines.join('\n') 展平为
338
+ // 多行,数组长度不变 → 与下方状态/结果 splice 的索引完全解耦(changedFiles 单行同理原地替换)。
339
+ if (lines[i].startsWith('文件:')) {
340
+ if (fileNotes.length > 0) {
341
+ const bullets = fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`)
342
+ lines[i] = `文件:\n${bullets.join('\n')}`
343
+ } else if (changedFiles.length > 0) {
344
+ lines[i] = `文件:${changedFiles.join(', ')}`
345
+ }
311
346
  }
312
347
  }
313
348
  if (!flipped && !lines.slice(startIdx, endIdx).some(l => /^状态:已完成\r?$/.test(l))) {
@@ -330,11 +365,19 @@ function flipEntryInContent(content, qlId, result, changedFiles = []) {
330
365
  }
331
366
  // 多行结果(结构化 需求/根因/方案/结果 字段块):逐行插入为独立字段行。
332
367
  // 单行结果:保持「结果:<一句话>」一行,向后兼容简单用例。
333
- if (/\r?\n/.test(body)) {
334
- const resultLines = body.split(/\r?\n/).filter(l => l.trim() !== '')
335
- lines.splice(endIdx, 0, ...resultLines)
336
- } else {
337
- lines.splice(endIdx, 0, `结果:${body}`)
368
+ const resultLines = /\r?\n/.test(body)
369
+ ? body.split(/\r?\n/).filter(l => l.trim() !== '')
370
+ : [`结果:${body}`]
371
+ // 结果块属本条目:从 endIdx 往前跳过本条目尾部空行,在最后一个非空行之后插入。
372
+ // 否则结果块会落在尾空行「之后」,与下一条目标题紧贴(缺空行分隔),且被空行从本条目
373
+ // 「文件:」行隔开——视觉上像属于下一条目(用户实证多条目 QUICKLOG 间距 bug)。
374
+ let insertAt = endIdx
375
+ while (insertAt - 1 > startIdx && lines[insertAt - 1].trim() === '') insertAt--
376
+ lines.splice(insertAt, 0, ...resultLines)
377
+ // 兜底:结果块之后、下一个 ## 标题之间若无空行(条目原本无尾空行 / 轮转归档手改等),补一个。
378
+ const after = insertAt + resultLines.length
379
+ if (after < lines.length && lines[after].startsWith('## ')) {
380
+ lines.splice(after, 0, '')
338
381
  }
339
382
  }
340
383
  return lines.join('\n')
@@ -397,6 +440,10 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
397
440
  const linked = Array.isArray(linkedChanges) ? linkedChanges : []
398
441
  // 实际改动文件(调用方 complete-handlers 已用 isQuickMetadata 过滤 quick 自身元数据)。空 → 不动文件行。
399
442
  const realFiles = Array.isArray(changedFiles) ? changedFiles.filter(Boolean) : []
443
+ // --file-notes(command.js 经 setQuickFileNotes 注入):有则文件行写多行 bullet 带括注。
444
+ // 读后即清(per-process,防残留跨调用)。flipEntryInContent 优先用 fileNotes,空则回退 realFiles。
445
+ const fileNotes = parseFileNotes(_pendingFileNotes)
446
+ _pendingFileNotes = ''
400
447
 
401
448
  await withFileLock(lockPath, async () => {
402
449
  // 条目可能在主文件或轮转归档中
@@ -404,7 +451,7 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
404
451
  const filePath = join(quicklogDir, f)
405
452
  let content = ''
406
453
  try { content = readFileSync(filePath, 'utf8') } catch { continue }
407
- const updated = flipEntryInContent(content, qlId, result, realFiles)
454
+ const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes)
408
455
  if (updated !== null) {
409
456
  await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
410
457
  break
@@ -23,7 +23,7 @@ import { 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 } from './shared.js'
26
+ import { resolveSpecDir, countAncestorSpecDirs, resolveChangeDir, triggerSync, getStageSteps, formatWaitOptions, checkApproval, didYouMean, assertSafeChangeName, detectQuickSessionDrift, detectWorktreeSpecDrift, resolveRuntimeRoot } 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'
@@ -32,6 +32,7 @@ import { ProgressManager } from '../progress.js'
32
32
  import { validateChangeExists } from '../stage-contract.js'
33
33
  import { stageRegistry, auxiliaryStages } from '../stages/index.js'
34
34
  import { definition as brainstormAutoDef } from '../stages/brainstorm-auto.js'
35
+ import { setQuickFileNotes } from '../quicklog.js'
35
36
 
36
37
  // F2/F4: 哪些 flag 吃下一个 token 作「值」(其余 --flag 都是布尔,不吞值)。
37
38
  // 校验循环只对 VALUE_FLAGS 跳下一个 token(否则 --done 后跟 typo 会被当 --done 的值吞掉)。
@@ -40,7 +41,7 @@ const VALUE_FLAGS = new Set([
40
41
  '--reason', '--options', '--answer', '--confirm-mode',
41
42
  '--output', '--input', '--change', '--linked-changes',
42
43
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
43
- '--files', '--from-step', '--mode', '--dir', '--confirm-mode',
44
+ '--files', '--file-notes', '--from-step', '--mode', '--dir', '--confirm-mode',
44
45
  ])
45
46
 
46
47
  /**
@@ -214,7 +215,6 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
214
215
  const waitReason = getFlagValue('--reason')
215
216
  const waitOptions = getFlagValue('--options')
216
217
  const continueAnswer = getFlagValue('--answer')
217
- const confirmMode = getFlagValue('--confirm-mode')
218
218
  const resolvedSpecDir = specDir || getFlagValue('--spec-dir') || getFlagValue('--spec-root')
219
219
  const platformOpts = {
220
220
  specRoot: resolvedSpecDir ? resolve(resolvedSpecDir) : null,
@@ -291,7 +291,8 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
291
291
 
292
292
  // 统一规范基路径:平台模式用 specRoot,本地模式用 cwd/.sillyspec
293
293
  // runCommand 后续所有 .sillyspec/ 操作必须用 specBase
294
- const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
294
+ // let:下方 worktree 副本漂移守卫命中时锚回 wt.mainSpecBase(task-05/D-03)
295
+ let specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
295
296
 
296
297
  // 漂移提醒:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
297
298
  // 可能不是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)。
@@ -407,6 +408,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
407
408
  // 3. 仍读不到 → 生成新 UUID(兼容旧行为;进度可能命中空行,由后续 pm.read 兜底)
408
409
  const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
409
410
  let quickSessionId = null
411
+ let quickFallbackUsed = false // Q7: --done 未带 --change 时 fallback 读 current-quick-run-id 命中(区别于显式 --change quick-<hex>)
410
412
  if (stageName === 'quick') {
411
413
  // 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName 置 null。
412
414
  // 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
@@ -423,11 +425,11 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
423
425
  const isDoneLike = isDone || isStatus || isSkip || isReset || isReopen
424
426
  if (isDoneLike) {
425
427
  try {
426
- const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
428
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specRoot)
427
429
  const idFile = join(runtimeRoot, 'current-quick-run-id')
428
430
  if (existsSync(idFile)) {
429
431
  const v = readFileSync(idFile, 'utf8').trim()
430
- if (QUICK_SID_RE.test(v)) quickSessionId = v
432
+ if (QUICK_SID_RE.test(v)) { quickSessionId = v; quickFallbackUsed = true }
431
433
  }
432
434
  } catch {}
433
435
  }
@@ -448,6 +450,16 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
448
450
  quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
449
451
  }
450
452
 
453
+ // 解析 --file-notes "path::括注 || path::括注"(quick 专用:QUICKLOG「文件:」行落盘多行括注)。
454
+ // 旁路注入 quicklog.js(per-process setter),不经 complete 收尾路径透传(避碰多 agent 并发改的收尾热点)。
455
+ // completeQuicklogEntry 读后即清;不传 → '' → 回填审计到的实际文件单行(向后兼容)。
456
+ let quickFileNotes = ''
457
+ const fileNotesIdx = flags.indexOf('--file-notes')
458
+ if (fileNotesIdx !== -1 && flags[fileNotesIdx + 1]) {
459
+ quickFileNotes = flags[fileNotesIdx + 1]
460
+ }
461
+ setQuickFileNotes(quickFileNotes)
462
+
451
463
  const isAllowNew = flags.includes('--allow-new')
452
464
  const isForceBaseline = flags.includes('--force-baseline')
453
465
  const isForceRescan = flags.includes('--force-rescan')
@@ -459,7 +471,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
459
471
  '--reason', '--options', '--answer', '--confirm-mode',
460
472
  '--output', '--input', '--change', '--linked-changes',
461
473
  '--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
462
- '--files', '--allow-new', '--force-baseline', '--force-rescan',
474
+ '--files', '--file-notes', '--allow-new', '--force-baseline', '--force-rescan',
463
475
  '--json', '--dir', '--help',
464
476
  '--reopen', '--from-step', '--mode',
465
477
  '--deep', '--quick', '--standard', // scan profile 三档显式选择(scan-profile.js 从 argv 读;互斥见下方 PROFILE_FLAGS 检测)
@@ -484,7 +496,8 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
484
496
  const isAuxiliary = auxiliaryStages.includes(stageName)
485
497
  // scan 元数据追踪(存储在 stageData.scanMeta 中,completeStep 通过 progress 访问)
486
498
 
487
- const pm = new ProgressManager({ specDir: specRoot })
499
+ // let:下方 worktree 副本漂移守卫命中时用主仓 specRoot 重建 pm(task-05/D-03)
500
+ let pm = new ProgressManager({ specDir: specRoot })
488
501
 
489
502
  // quick 阶段:确定关联变更
490
503
  // 优先级:--linked-changes / --change 显式 > 已持久化 quick-guard.json(--done 复用)> 交互式 > 非交互 fallback
@@ -522,18 +535,29 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
522
535
  process.exit(2) // 用法错(execute 必须显式 --change)→ exit 2
523
536
  }
524
537
 
525
- // worktree 副本漂移硬守卫(坑 worktree-execute-spec-drift):specBase 命中 worktree 内 .sillyspec
526
- // checkout 副本 → 进度/产出会写分裂库。必须在 validateChangeExists 之前——副本里 change 目录真实
527
- // 存在,存在性校验会被骗放行。覆盖 plan/execute/verify/archive(validateChangeExists 同 Set):
528
- // 都要求 change 已存在、都被副本骗、都写 .sillyspec 资产(archive 尤甚:在副本里移动 change+commit)。
529
- // 平台模式/显式 --spec-dir 跳过(已明确指定,与 line~285 warn / ~529 quick 守卫条件对齐)。
538
+ // worktree 副本漂移自动锚定守卫(坑 worktree-execute-spec-drift,D-03@v1):specBase 命中 worktree 内
539
+ // .sillyspec checkout 副本 = agent 误 cd 进 worktree 跑 plan/execute/verify/archive(100% 误操作场景)。
540
+ // 不 exit:自动把 specBase 锚回主仓 wt.mainSpecBase 后继续——进度/产出落主仓,不再写分裂副本(副本随
541
+ // 工作树清理丢失)。必须在 validateChangeExists 之前:副本里 change 目录真实存在,存在性校验会被骗
542
+ // 放行;锚定后用主仓 specBase 复查。覆盖 plan/execute/verify/archive(validateChangeExists 同 Set)。
543
+ // 关键坑:连带重写 specBase/specRoot/specDir + 重建 pm——四者同源派生(pm._customSpecDir 持旧 specRoot),
544
+ // 只改 specBase 会让下游 validateChangeExists(specBase) 与 pm.read(走 pm._customSpecDir) 仍指副本。
545
+ // 平台模式/显式 --spec-dir 跳过(已明确指定,与 line~285 warn / quick 守卫条件对齐)。
546
+ // 其他漂移(下方 changeMissing 守卫 / quick session drift 守卫)非副本场景,仍 exit(2) 不自动纠正。
530
547
  if (!platformOpts.specRoot && !specDir
531
548
  && ['plan', 'execute', 'verify', 'archive'].includes(stageName)) {
532
549
  const wt = detectWorktreeSpecDrift(specBase)
533
550
  if (wt) {
534
- console.error(`❌ ${wt.message}`)
535
- console.error(` 当前 cwd=${cwd},命中的 spec=${specBase}。`)
536
- process.exit(2) // 环境错(cwd/spec 漂移)→ exit 2
551
+ specBase = wt.mainSpecBase
552
+ specRoot = wt.mainSpecBase
553
+ specDir = wt.mainSpecBase
554
+ pm = new ProgressManager({ specDir: specRoot })
555
+ // 【坑 execute-runs-isolation】下游 runtimeRoot 解析(execute-runs/stage-reviews 落点)经
556
+ // platformOpts 透传读此锚点,落主仓 .runtime,不随 worktree cleanup 整目录删消失。
557
+ // D-02:只参与 runtimeRoot 解析(resolveRuntimeRoot),绝不设 specRoot/runtimeRoot——
558
+ // 否则触发平台 sentinel 副作用(triggerSync/checkApproval 跳过 + prompt 误进平台渲染分支)。
559
+ platformOpts.specDriftAnchor = wt.mainSpecBase
560
+ console.warn(`⚠️ 已自动锚定主仓 spec:${wt.mainSpecBase}(原 cwd 命中 worktree 副本 ${wt.changeName},已纠正,流程继续)`)
537
561
  }
538
562
  }
539
563
 
@@ -564,13 +588,13 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
564
588
  }
565
589
  }
566
590
 
567
- let progress = await pm.read(cwd, changeName)
591
+ let progress = pm.read(cwd, changeName)
568
592
 
569
593
  if (!progress) {
570
594
  // 如果指定了变更名或有变更目录,自动初始化变更的 progress
571
595
  const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
572
596
  if (autoChange) {
573
- progress = await pm.initChange(cwd, autoChange)
597
+ progress = pm.initChange(cwd, autoChange)
574
598
  } else if (isAuxiliary) {
575
599
  let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
576
600
  // archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
@@ -585,14 +609,14 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
585
609
  .pop()
586
610
  if (latest) {
587
611
  autoName = latest.replace(/^\d{4}-\d{2}-\d{2}-/, '')
588
- progress = await pm.read(cwd, autoName)
612
+ progress = pm.read(cwd, autoName)
589
613
  }
590
614
  }
591
615
  } catch {}
592
616
  }
593
617
  changeName = autoName
594
618
  if (!progress) {
595
- progress = await pm.initChange(cwd, autoName)
619
+ progress = pm.initChange(cwd, autoName)
596
620
  // initChange 可能因 project 表为空返回 null
597
621
  if (!progress) {
598
622
  progress = { currentStage: stageName, stages: {}, lastActive: new Date().toLocaleString('zh-CN', { hour12: false }), project: '' }
@@ -612,7 +636,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
612
636
  console.log(`🔄 自动创建变更:${autoName}`)
613
637
  console.log(` 提示:可以用 --change <名称> 指定自定义变更名`)
614
638
  console.log(` 或事后重命名:sillyspec change-rename ${autoName} <新名称>`)
615
- progress = await pm.initChange(cwd, autoName)
639
+ progress = pm.initChange(cwd, autoName)
616
640
  changeName = autoName
617
641
  } else {
618
642
  console.error('❌ 未找到进度数据,请先运行 sillyspec init 或指定 --change <变更名>')
@@ -624,6 +648,21 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
624
648
  // 确保 progress 有 currentChange
625
649
  const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
626
650
 
651
+ // ── Q7:quick --done 不带 --change 时 fallback 读 current-quick-run-id 可能命中他者会话 ──
652
+ // 并发两 quick 会话:B 后启动覆盖 A 的 id(last-writer-wins),A 的 --done 不带 --change → 读到 B 的 sessionId
653
+ // → 误操作 B 的 progress/QUICKLOG。fallback 命中的会话若已完成或无可推进步骤(id stale / 他者已收尾),
654
+ // 拒绝推进并要求显式 --change。(live A/B race 需 per-session 锚 commit,见 review Q2/D2,本守卫只兜 stale/completed。)
655
+ if (stageName === 'quick' && isDone && quickFallbackUsed) {
656
+ const qs = progress.stages.quick
657
+ const hasActionable = qs?.steps && qs.steps.some(s => s.status === 'pending' || s.status === 'waiting' || s.status === 'in-progress')
658
+ if (!qs || qs.status === 'completed' || !hasActionable) {
659
+ console.error(`\n❌ --done 未带 --change,回退读到的 quick 会话 ${changeName} 已不可推进(已完成或无待办步骤)。`)
660
+ console.error(` 并发多 quick 会话时 .runtime/current-quick-run-id 可能指向他者会话(last-writer-wins)。`)
661
+ console.error(` 请显式带 --change <quick-session-id> 指定要完成的会话(sillyspec run quick --status 查各会话进度)。`)
662
+ process.exit(2) // 用法错(fallback 命中 stale/他者会话)→ exit 2
663
+ }
664
+ }
665
+
627
666
  // -- auto 模式:自动推进所有流程阶段
628
667
  if (stageName === 'auto') {
629
668
  return await runAutoMode(pm, progress, cwd, flags, effectiveChange, platformOpts)
@@ -632,7 +671,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
632
671
  // --change 只作为变更名标识,不再拦截流程
633
672
  // 注册变更到全局活跃列表(如果尚未注册)
634
673
  if (effectiveChange) {
635
- await pm.registerChange(cwd, effectiveChange)
674
+ pm.registerChange(cwd, effectiveChange)
636
675
  }
637
676
 
638
677
  // --reset
@@ -669,12 +708,12 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
669
708
  if (freshSteps && freshSteps.length > 0) {
670
709
  if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
671
710
  progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
672
- await pm._write(cwd, progress, effectiveChange)
673
- progress = await pm.read(cwd, effectiveChange) || progress
711
+ pm._write(cwd, progress, effectiveChange)
712
+ progress = pm.read(cwd, effectiveChange) || progress
674
713
  }
675
714
  }
676
715
 
677
- const result = await pm.reopenStage(cwd, stageName, {
716
+ const result = pm.reopenStage(cwd, stageName, {
678
717
  fromStep: fromStepValue,
679
718
  changeName: effectiveChange,
680
719
  })
@@ -692,7 +731,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
692
731
  console.log('')
693
732
 
694
733
  // 重新读取 progress
695
- progress = await pm.read(cwd, effectiveChange) || progress
734
+ progress = pm.read(cwd, effectiveChange) || progress
696
735
 
697
736
  // 注入 revision context 到 platformOpts,供 outputStep 使用
698
737
  const stageData = progress.stages[stageName]
@@ -724,7 +763,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
724
763
  // currentIdx === -1 自动重置(~1944 行);in-progress 输出当前 step;全新由
725
764
  // ensureStageSteps 初始化;显式从头用 --reset(已由 !isReset 排除出本分支)。
726
765
  try {
727
- const runtimeRoot = platformOpts.runtimeRoot || join(specRoot, '.runtime')
766
+ const runtimeRoot = resolveRuntimeRoot(platformOpts, specRoot)
728
767
  mkdirSync(runtimeRoot, { recursive: true })
729
768
  writeAtomicSync(join(runtimeRoot, 'current-quick-run-id'), quickSessionId + '\n')
730
769
  } catch (e) {
@@ -740,9 +779,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
740
779
  // 确保步骤已初始化
741
780
  const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
742
781
  if (changed && effectiveChange) {
743
- await pm._write(cwd, progress, effectiveChange)
782
+ pm._write(cwd, progress, effectiveChange)
744
783
  triggerSync(cwd, effectiveChange, platformOpts)
745
- progress = await pm.read(cwd, effectiveChange) || progress
784
+ progress = pm.read(cwd, effectiveChange) || progress
746
785
  }
747
786
 
748
787
  // --status
@@ -772,7 +811,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
772
811
  // --done
773
812
  if (isDone) {
774
813
  const doneAnswer = getFlagValue('--answer')
775
- return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, confirmMode, doneAnswer, isForceBaseline, isAllowNew })
814
+ return await completeStep(pm, progress, stageName, cwd, outputText, inputText, { confirm: isConfirm, changeName: effectiveChange, nonInteractive: isNonInteractive && !isInteractive, platformOpts, doneAnswer, isForceBaseline, isAllowNew })
776
815
  }
777
816
 
778
817
  // 默认:输出当前步骤
@@ -898,7 +937,7 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
898
937
  steps: defSteps ? defSteps.map(s => ({ name: s.name, status: 'pending' })) : []
899
938
  }
900
939
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
901
- await pm._write(cwd, progress, changeName)
940
+ pm._write(cwd, progress, changeName)
902
941
  triggerSync(cwd, changeName, platformOpts)
903
942
  console.log(`🔄 ${stageName} 阶段已重置`)
904
943
  }
@@ -948,18 +987,18 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
948
987
  completedAt: null,
949
988
  steps: brainstormAutoDef.steps.map(s => ({ name: s.name, status: 'pending' }))
950
989
  }
951
- await pm._write(cwd, progress, changeName)
990
+ pm._write(cwd, progress, changeName)
952
991
  triggerSync(cwd, changeName, platformOpts)
953
- progress = await pm.read(cwd, changeName)
992
+ progress = pm.read(cwd, changeName)
954
993
  return progress
955
994
  }
956
995
  }
957
996
  const changed = await ensureStageSteps(progress, stage, cwd)
958
997
  if (stageChanged || changed) {
959
- await pm._write(cwd, progress, changeName)
998
+ pm._write(cwd, progress, changeName)
960
999
  triggerSync(cwd, changeName, platformOpts)
961
1000
  }
962
- progress = await pm.read(cwd, changeName)
1001
+ progress = pm.read(cwd, changeName)
963
1002
  return progress
964
1003
  }
965
1004
 
@@ -1051,7 +1090,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1051
1090
 
1052
1091
  const result = await completeStep(pm, progress, currentStage, cwd, outputText, inputText, { printNext: false, changeName, platformOpts })
1053
1092
  if (!result) return
1054
- progress = await pm.read(cwd, changeName)
1093
+ progress = pm.read(cwd, changeName)
1055
1094
 
1056
1095
  const nextPendingIdx = progress.stages[currentStage]?.steps?.findIndex(step => step.status === 'pending' || step.status === 'in-progress') ?? -1
1057
1096
  if (nextPendingIdx !== -1) {
@@ -1084,7 +1123,7 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1084
1123
  if (currentStage === 'brainstorm' && next === 'plan') {
1085
1124
  const changeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot || null)
1086
1125
  if (changeDir) {
1087
- const nextActionFile = join(changeDir, 'brainstorm', 'next-action.json')
1126
+ const nextActionFile = join(changeDir, 'next-action.json')
1088
1127
  try {
1089
1128
  const nextAction = JSON.parse(readFileSync(nextActionFile, 'utf8'))
1090
1129
  if (nextAction.has_blocking_questions === true) {
@@ -1115,9 +1154,9 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1115
1154
  }
1116
1155
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
1117
1156
  await ensureStageSteps(progress, next, cwd)
1118
- await pm._write(cwd, progress, changeName)
1157
+ pm._write(cwd, progress, changeName)
1119
1158
  triggerSync(cwd, changeName, platformOpts)
1120
- progress = await pm.read(cwd, changeName)
1159
+ progress = pm.read(cwd, changeName)
1121
1160
 
1122
1161
  console.log(`\n${currentStage} complete. Auto advanced to ${next}.`)
1123
1162
  const nextSteps = await getAutoSteps(next)