sillyspec 3.26.14 → 3.27.1

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 (70) 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-explore/SKILL.md +10 -3
  7. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  8. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
  11. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  12. package/README.md +1 -1
  13. package/package.json +1 -1
  14. package/packages/dashboard/server/index.js +22 -6
  15. package/src/change-risk-profile.js +21 -6
  16. package/src/commit-suggest.js +202 -0
  17. package/src/config-cat.js +88 -0
  18. package/src/db.js +14 -3
  19. package/src/docs-check.js +598 -534
  20. package/src/doctor-diagnostics.js +102 -1
  21. package/src/endpoint-extractor.js +21 -2
  22. package/src/fs-atomic.js +4 -2
  23. package/src/git-helper.js +73 -5
  24. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  25. package/src/hooks/worktree-guard.js +42 -4
  26. package/src/index.js +557 -12
  27. package/src/init.js +1 -1
  28. package/src/local-register.js +98 -0
  29. package/src/module-impact.js +166 -0
  30. package/src/module-resolve.js +211 -0
  31. package/src/modules.js +82 -2
  32. package/src/progress/change-registry.js +43 -4
  33. package/src/progress/stage-machine.js +10 -5
  34. package/src/progress/step-store.js +12 -8
  35. package/src/progress.js +19 -3
  36. package/src/quicklog.js +106 -1
  37. package/src/run/command.js +95 -1
  38. package/src/run/complete-handlers.js +73 -5
  39. package/src/run/complete.js +47 -14
  40. package/src/run/gates.js +144 -17
  41. package/src/run/multi-repo-context.js +3 -1
  42. package/src/run/next.js +128 -0
  43. package/src/run/prompt.js +36 -4
  44. package/src/run/quick-audit.js +5 -2
  45. package/src/run/shared.js +48 -20
  46. package/src/run/stage.js +37 -3
  47. package/src/scan-postcheck.js +142 -2
  48. package/src/stage-contract-spec.js +1 -1
  49. package/src/stage-contract.js +14 -1
  50. package/src/stage-review.js +15 -6
  51. package/src/stages/archive.js +4 -2
  52. package/src/stages/brainstorm.js +1 -0
  53. package/src/stages/execute.js +275 -44
  54. package/src/stages/explore.js +11 -2
  55. package/src/stages/knowledge.js +3 -2
  56. package/src/stages/quick.js +18 -9
  57. package/src/stages/scan.js +1 -0
  58. package/src/stages/verify.js +16 -51
  59. package/src/sync.js +44 -3
  60. package/src/task-review.js +273 -11
  61. package/src/taskcard.js +52 -22
  62. package/src/verify-postcheck.js +223 -6
  63. package/src/verify-probes.js +264 -0
  64. package/src/workspace.js +125 -0
  65. package/src/worktree-apply.js +43 -11
  66. package/src/worktree-deps.js +59 -5
  67. package/src/worktree.js +128 -14
  68. package/templates/claude-instruction.md +1 -1
  69. package/templates/prompts/taskcard-rules.md +1 -1
  70. package/templates/prompts/verify-probes.md +13 -47
package/src/run/gates.js CHANGED
@@ -19,8 +19,10 @@
19
19
  *
20
20
  * 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
21
21
  */
22
- import { basename, join } from 'node:path'
23
- import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
22
+ import { basename, join, relative, dirname } from 'node:path'
23
+ import { createHash } from 'node:crypto'
24
+ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
25
+ import { writeAtomicSync } from '../fs-atomic.js'
24
26
  import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
25
27
  import { runValidators } from '../stage-contract.js'
26
28
  import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
@@ -65,12 +67,55 @@ export function validateSymbolImpactReport({ reportContent, planContent }) {
65
67
  for (const id of missing) {
66
68
  errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
67
69
  }
70
+ // 防骨架直接过门(2026-08-21 agent-手工产出审计项⑤):CLI 会代生成逐 task TODO 骨架
71
+ //(generateSymbolImpactSkeleton),占位未替换即拦——骨架消灭的是「从零手写/漏 task」错误面,
72
+ // 不是把「逐 task 结论」本身变成走过场。
73
+ const report = String(reportContent)
74
+ const todoPending = taskIds.filter(id => new RegExp(`^[-*]\\s*${id}[^\\n]*<!--TODO-->`, 'm').test(report))
75
+ for (const id of todoPending) {
76
+ errors.push(`${id} 的结论仍是骨架 <!--TODO--> 占位——替换为真实结论(无签名级变更也显式写「无」)`)
77
+ }
68
78
  return { ok: errors.length === 0, errors }
69
79
  }
70
80
 
81
+ /**
82
+ * 生成 symbol-impact.md 逐 task TODO 骨架(2026-08-21 审计项⑤「报错即生成」)。
83
+ *
84
+ * gate 此前只报错不代填 → agent 从零手写整份报告,忘一个 task 就被拦、再补再撞。
85
+ * 骨架从 tasks.md 注册表生成逐 task 占位行,agent 只需逐行填结论;占位 <!--TODO-->
86
+ * 由 validateSymbolImpactReport 拒绝,骨架不能直接过门(防偷懒)。
87
+ *
88
+ * @param {string} tasksContent - 任务注册表(tasks.md,回退 plan.md)内容
89
+ * @returns {string|null} 骨架全文(LF);注册表无 task 行 → null
90
+ */
91
+ export function generateSymbolImpactSkeleton(tasksContent) {
92
+ const taskIds = extractTaskIdsFromRegistry(tasksContent)
93
+ if (taskIds.length === 0) return null
94
+ // tasks.md 内容指纹(token 成本优化 P2a):中断续跑/阶段重开时若指纹一致且结论完整,
95
+ // 「加载上下文」步按 prompt 指引直接沿用既有报告,不重做调用点扫描(team-session-unify
96
+ // 实测该步 3h06min,重入重做是纯浪费)。指纹只作提示锚,gate 校验逻辑不变。
97
+ let fingerprint = ''
98
+ try {
99
+ fingerprint = createHash('sha256').update(String(tasksContent)).digest('hex').slice(0, 16)
100
+ } catch { /* 指纹失败不阻断骨架生成 */ }
101
+ const lines = [
102
+ '# 符号影响面报告',
103
+ '',
104
+ `> tasks.md 内容指纹(生成时): ${fingerprint}——重入本步时若与当前 tasks.md 指纹一致且结论已填全,直接沿用不重做扫描。`,
105
+ '> 骨架由 CLI 生成(`sillyspec symbol-impact --change <变更名>`,gate 失败时也会自动落一份)。',
106
+ '> 逐行把 `<!--TODO-->` 替换为真实结论:涉及签名级变更(构造函数参数/接口/DTO/方法签名增删改)',
107
+ '> 写变更类型 + 受影响调用点 + 是否在任务范围内;无签名级变更也要显式写「无签名级变更」。',
108
+ '> **gate 拒绝仍含 <!--TODO--> 的行**——骨架不能直接过门。',
109
+ '',
110
+ ]
111
+ for (const id of taskIds) lines.push(`- ${id}: <!--TODO-->`)
112
+ return lines.join('\n') + '\n'
113
+ }
114
+
71
115
  /**
72
116
  * 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
73
- * fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
117
+ * fail-closed:不通过 → exit 1(进度不推进;步骤状态保持 pending,无 steps 句柄不标 blocked)。
118
+ * 非目标步骤/阶段直接放行。
74
119
  */
75
120
  export async function enforceSymbolImpactGate(stageName, changeName, currentStepName, specBase) {
76
121
  if (stageName !== 'execute') return
@@ -88,7 +133,19 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
88
133
  console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
89
134
  console.error(` 期望路径:${reportPath}`)
90
135
  for (const e of result.errors) console.error(` • ${e}`)
91
- console.error(' 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论(无签名级变更也要显式写「无」),落盘后重跑 execute --done。')
136
+ // 报错即生成(2026-08-21 审计项⑤):报告缺失时自动落一份逐 task TODO 骨架,agent 从
137
+ // 「从零手写整份」变「逐行填结论」;TODO 占位由 validate 拒绝,骨架不能直接过门。
138
+ let skeletonNote = ''
139
+ if (!existsSync(reportPath)) {
140
+ const skeleton = generateSymbolImpactSkeleton(tasksContent)
141
+ if (skeleton) {
142
+ try {
143
+ writeAtomicSync(reportPath, skeleton)
144
+ skeletonNote = `\n 📄 已代生成逐 task 骨架:${reportPath}(逐行替换 <!--TODO--> 为结论,无签名级变更也显式写「无」)`
145
+ } catch { /* 写失败只影响提示,不掩盖原始错误 */ }
146
+ }
147
+ }
148
+ console.error(` 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论后重跑 execute --done。${skeletonNote}`)
92
149
  process.exit(1)
93
150
  }
94
151
 
@@ -195,7 +252,7 @@ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase,
195
252
  }
196
253
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
197
254
  const reviewResult = validateStageReview({ stage: 'execute', reviewType: 'acceptance', runtimeRoot, reviewRunId, searchDirs })
198
- printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot })
255
+ printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot, changeName })
199
256
  if (!reviewResult.ok) return true // printStageReviewResult 已给明细,不再重复列
200
257
  }
201
258
  } catch (e) {
@@ -240,9 +297,10 @@ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase,
240
297
  /**
241
298
  * execute deps 验证硬门(change 2026-06-28-worktree-deps-provision / D-001@v1, D-003@v1, D-006@v2)。
242
299
  * depsStatus 不达标且非 wave 级 opt-out 时阻断 --done:置 step=blocked + exit(1),与 requiresWait 同范式。
243
- * 放行返回 true;阻断时 process.exit(1) 不返回。
300
+ * 放行返回 true;阻断时经 persist 回调落盘 blocked 后 exit(1)(此前只改内存不落库,
301
+ * progress show/doctor 看到的仍是 pending,与 docstring 承诺不符、误导诊断)。
244
302
  */
245
- export async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
303
+ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts, persist) {
246
304
  if (stageName !== 'execute') return true
247
305
  let meta = null
248
306
  let wm = null
@@ -278,6 +336,7 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
278
336
  console.error(' 或在 worktree 内手动安装依赖后重试。')
279
337
  if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
280
338
  }
339
+ if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
281
340
  process.exit(1)
282
341
  }
283
342
 
@@ -287,11 +346,24 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
287
346
  * Task Review Gate(validateTaskReviews)只在 execute 整阶段完成时跑(complete.js 阶段完成分支的
288
347
  * actualCompleted===actualTotal 守卫),单 task --done 不校验 → 子代理勾 checkbox 却漏写/漏字段
289
348
  * review.json,要到收尾才暴露,用户被迫事后批量补。本门提前到每次 --done:校验 plan 里所有已勾
290
- * task 的 review.json,缺字段/不存在/JSON 坏 → 置 step=blocked + exit(1),与 enforceDepsGate 同范式。
349
+ * task 的 review.json,缺字段/不存在/JSON 坏 → 置 step=blocked + exit(1),与 enforceDepsGate 同范式
350
+ * (阻断前经 persist 回调落盘 blocked)。
291
351
  * 未勾 task 不校验(还没做)。平台模式/无 marker/无 plan 时放行(下游 Task Review Gate 兜底)。
292
352
  */
293
- export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
353
+ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts, persist) {
294
354
  if (stageName !== 'execute' || !changeName) return true
355
+ // head 锡点自动落盘(2026-08-21 审计项③,D-010 补对称):跨仓 task base_commit 已在派发时
356
+ // CLI 落盘,head_commit 此前靠主 agent 按 prompt 手跑 rev-parse 手写(漏抄/抄错炸 review gate)。
357
+ // 每次 --done 时机幂等补齐(已存在不覆盖)。best-effort:失败只 warn,不阻断 --done 主流程。
358
+ try {
359
+ const { stampCrossRepoHeadCommits } = await import('../stages/execute.js')
360
+ const stamped = await stampCrossRepoHeadCommits({ changeName, cwd, specBase, platformOpts })
361
+ if (stamped && stamped.stamped > 0) {
362
+ console.log(`📌 已自动落盘跨仓 task head_commit 锡点:${stamped.stampedTasks.join(', ')}(幂等,此后 --done 不再重复写)`)
363
+ }
364
+ } catch (e) {
365
+ console.warn(`⚠️ head_commit 锡点自动落盘失败(降级留给 agent 手写): ${e.message}`)
366
+ }
295
367
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
296
368
  const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
297
369
  const planPath = join(specBase, 'changes', changeName, 'plan.md')
@@ -340,7 +412,8 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
340
412
  console.error(` • ${f.taskId}(${kindLabel}): ${f.reviewPath}`)
341
413
  for (const e of f.errors) console.error(` - ${e}`)
342
414
  }
343
- console.error(' 修复:补全上述 review.json 字段后重跑 execute --done。')
415
+ console.error(' 修复:mechanics 字段(base/head/changedFiles/schemaVersion)缺错可跑 sillyspec backfill-reviews --change ' + changeName + ' --adopt 一键代填(verdict 保留);verdict/证据缺失需人工补全后重跑 execute --done。')
416
+ if (persist) { try { await persist() } catch { /* 落盘失败不吞阻断语义 */ } }
344
417
  process.exit(1)
345
418
  }
346
419
 
@@ -428,6 +501,14 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
428
501
  console.error(' 请修复失败的测试并更新 verify-result.md 后重新完成此步骤。')
429
502
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
430
503
  }
504
+ // lint 对账(2026-08-21 审查 CLI-1):test 侧"自报告 PASS 但实测失败→阻断"已闭环,
505
+ // lint 侧此前纯口头——CLI 亲自执行 commands.lint,advisory 起步(失败打印不阻断,观察期后升级)
506
+ const { runVerifyLintCheck, printVerifyLintCheck } = await import('../verify-postcheck.js')
507
+ const lintCheck = runVerifyLintCheck({ cwd, specBase })
508
+ if (lintCheck.status !== 'skipped') {
509
+ console.log(`\n⏳ Verify lint 对账:CLI 亲自执行 local.yaml 的 commands.lint…`)
510
+ }
511
+ printVerifyLintCheck(lintCheck)
431
512
  // 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
432
513
  // 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
433
514
  const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
@@ -462,6 +543,31 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
462
543
  }
463
544
  }
464
545
  }
546
+ // ── verify 服务进程回收(坑 verify-service-process-leak,2026-08-21 实证:真实启动验证
547
+ // 起的 uvicorn 漏挂一天多)。verify prompt 要求长驻服务 PID 登记 .runtime/verify-services.pids,
548
+ // 此处收尾逐个 kill + 清文件。best-effort:ESRCH(已退出)静默,kill 失败 warn 不阻断 verify。
549
+ try {
550
+ const servicesPath = join(resolveRuntimeRoot(platformOpts, specBase), 'verify-services.pids')
551
+ if (existsSync(servicesPath)) {
552
+ const pids = readFileSync(servicesPath, 'utf8').split('\n').map(l => l.trim()).filter(l => /^\d+$/.test(l))
553
+ const reaped = []; const failed = []
554
+ for (const pid of pids) {
555
+ try { process.kill(parseInt(pid, 10), 'SIGTERM'); reaped.push(pid) }
556
+ catch (e) { if (e.code !== 'ESRCH') failed.push(`${pid}(${e.code || e.message})`) }
557
+ }
558
+ try { unlinkSync(servicesPath) } catch {}
559
+ if (reaped.length > 0) console.log(`🧹 verify 服务进程已回收 ${reaped.length} 个(PID: ${reaped.join(', ')})`)
560
+ // CLI 回执(坑 verify-literal-evidence-mismatch):回收即真实启动的结构化证据——
561
+ // checkIntegrationEvidence(stage-contract)读回执注入匹配,agent 措辞差异不再误拦
562
+ try {
563
+ writeFileSync(join(dirname(servicesPath), 'verify-services.receipt.json'), JSON.stringify({
564
+ change: changeName || null, reapedPidCount: reaped.length, reapedAt: new Date().toISOString(),
565
+ }, null, 2) + '\n')
566
+ } catch {}
567
+ if (failed.length > 0) console.warn(`⚠️ 服务进程回收失败 ${failed.length} 个:${failed.join(', ')} — 手动处理:taskkill /PID <pid> /F(Windows)/ kill -9 <pid>`)
568
+ }
569
+ } catch (e) { console.warn(`⚠️ verify 服务进程回收异常(不阻断): ${e.message}`) }
570
+
465
571
  console.log('\n✅ 验证通过,下一步:sillyspec run archive')
466
572
  }
467
573
 
@@ -523,7 +629,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
523
629
  reviewRunId = generateStageReviewRunId()
524
630
  try {
525
631
  mkdirSync(runtimeRoot, { recursive: true })
526
- writeFileSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
632
+ writeAtomicSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
527
633
  } catch {}
528
634
  }
529
635
  const reviewType = stageName === 'brainstorm' ? 'design'
@@ -531,7 +637,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
531
637
  : 'acceptance'
532
638
  const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
533
639
  const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
534
- printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot })
640
+ printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot, changeName })
535
641
  if (!reviewResult.ok) {
536
642
  return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
537
643
  }
@@ -543,6 +649,26 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
543
649
  }
544
650
  }
545
651
 
652
+ // ── module-impact pending 死信 execute 收尾提示(坑 module-impact-debt-late-warn,
653
+ // 2026-08-22 实证:死信门在 verify 才硬拦,文档债拖到 verify 末尾才暴露、上次也拦)。
654
+ // execute --done 即 advisory 提示(不阻断 execute——execute 期间可能仍在迭代模块文档;
655
+ // verify 的硬拦不变),让 agent 当场清债而非攒到 verify 撞门。
656
+ if (stageName === 'execute') {
657
+ try {
658
+ const execChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
659
+ const execImpactPath = execChangeDir ? join(execChangeDir, 'module-impact.md') : null
660
+ if (execImpactPath && existsSync(execImpactPath)) {
661
+ const { extractPendingDocSyncRows } = await import('./complete-handlers.js')
662
+ const execPendingRows = extractPendingDocSyncRows(readFileSync(execImpactPath, 'utf8'))
663
+ if (execPendingRows.length > 0) {
664
+ console.warn(`\n⚠️ module-impact.md「更新结果」表有 ${execPendingRows.length} 个 pending/待办项(文档同步债)——verify 阶段会硬拦:`)
665
+ for (const row of execPendingRows) console.warn(` - ${row}`)
666
+ console.warn(' 建议 execute 收尾就完成模块文档同步并回填 done/skipped(说明原因),别把债拖到 verify 末尾。')
667
+ }
668
+ }
669
+ } catch { /* 提示失败不阻断 execute */ }
670
+ }
671
+
546
672
  // ── Execute Task Review Gate:所有 task 必须有 review.json 且 verdict 通过 ──
547
673
  if (stageName === 'execute') {
548
674
  try {
@@ -587,7 +713,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
587
713
  // marker 在则目录在)。不 try/catch——异常直穿外层 catch 走 fail-closed 阻断
588
714
  //(gate 自身写 run 目录失败不能静默放行完成)。
589
715
  mkdirSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'), { recursive: true })
590
- writeFileSync(runIdFile, executeRunId + '\n')
716
+ writeAtomicSync(runIdFile, executeRunId + '\n')
591
717
  stampExecuteRunChange(runtimeRoot, executeRunId, changeName)
592
718
  }
593
719
  }
@@ -695,7 +821,7 @@ export function validateMetadata(cwd, stageName, specBase) {
695
821
  const unique = [...new Set(missing)]
696
822
  if (unique.length > 0) {
697
823
  console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
698
- unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
824
+ unique.forEach(f => console.log(` - ${relative(cwd, f) || f}`))
699
825
  console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
700
826
  }
701
827
  }
@@ -736,13 +862,14 @@ export function validateFileLocations(cwd, stageName, progress, changeName, spec
736
862
 
737
863
  if (missing.length > 0) {
738
864
  console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
739
- console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
865
+ // relative(cwd, x):Windows 下 join() 产物是反斜杠绝对路径,正斜杠拼接的 cwd 前缀裁剪永不命中
866
+ console.log(` 变更目录:${relative(cwd, changeDir) || changeDir}/`)
740
867
  for (const f of missing) {
741
868
  // 检查是否写到了错误的位置
742
869
  const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
743
870
  if (existsSync(wrongPath)) {
744
- console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
745
- console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
871
+ console.log(` ❌ ${f} — 不存在,但发现了错误路径:${relative(cwd, wrongPath) || wrongPath}`)
872
+ console.log(` 提示:应该写入 ${relative(cwd, changeDir) || changeDir}/${f}`)
746
873
  } else {
747
874
  console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
748
875
  }
@@ -79,7 +79,9 @@ export class MultiRepoContext {
79
79
  throw new Error(
80
80
  `MultiRepoContext: 以下 repo 未在 local.yaml repos: 段注册:[${unregistered.join(', ')}]。` +
81
81
  `当前已注册 repo:[${registered.join(', ')}]。` +
82
- `请在 local.yaml repos: 段补注册后再启动 execute(约束② fail-closed,跨仓 apply 走错仓=数据所有权事故)。`
82
+ `一键注册(每个缺的 key 各跑一次,勿手编 YAML):` +
83
+ unregistered.map(k => `sillyspec local register-repo ${k} <${k} 仓根路径>`).join(';') +
84
+ `。补注册后再启动 execute(约束② fail-closed,跨仓 apply 走错仓=数据所有权事故)。`
83
85
  );
84
86
  }
85
87
 
@@ -0,0 +1,128 @@
1
+ /**
2
+ * next.js — `sillyspec next` 项目状态探测(吸收 continue/resume 两个 skill 的手工探测表,
3
+ * 2026-08-21 agent-手工产出审计第二批 G7/G8)。
4
+ *
5
+ * 两个 skill 各自维护一套「文件存在性状态机」推断表且内容重复,agent 每次手工跑
6
+ * ls/cat 探测再查表——纯机械探测是 CLI 的本职。本模块输出「状态描述 + 下一步命令 +
7
+ * 依据文件」,agent 按输出执行即可;skill 保留作兜底(CLI 不可用/输出异常时)。
8
+ *
9
+ * 纯 fs 探测(零 DB 依赖、零 token、快):进度库的权威状态归 `sillyspec progress show`,
10
+ * 本命令管「没有活跃进度时从产物文件反推该干什么」。决策表(两个 skill 合并):
11
+ *
12
+ * 1. HANDOFF.json 存在 → 交接恢复
13
+ * 2. 活跃变更(changes/ 非 archive)逐个推断:
14
+ * 无文件→补 proposal / 无 design.md→brainstorm / 无 tasks.md→brainstorm 末步 /
15
+ * 无 plan.md→plan / tasks.md 有未勾→execute / 全勾无 verify-result.md→verify /
16
+ * 有 verify-result.md→archive
17
+ * 3. 有 docs/<p>/scan/ 无活跃变更 → brainstorm(已扫描待开变更)
18
+ * 4. 有 REQUIREMENTS.md/ROADMAP.md 无变更 → brainstorm(绿地已有需求)
19
+ * 5. 什么都没有 → init(新项目)或 scan(棕地)
20
+ */
21
+ import { existsSync, readdirSync, readFileSync } from 'fs'
22
+ import { join } from 'path'
23
+ import { resolveSpecDir } from './shared.js'
24
+
25
+ /**
26
+ * 数 tasks.md checkbox:返回 { checked, total }(无文件 → {checked:0,total:0})
27
+ */
28
+ function countTasks(tasksPath) {
29
+ if (!existsSync(tasksPath)) return { checked: 0, total: 0 }
30
+ let checked = 0
31
+ let total = 0
32
+ for (const line of readFileSync(tasksPath, 'utf8').split('\n')) {
33
+ const m = line.match(/^[-*]\s*\[([ xX])\]\s*task-\d+\b/)
34
+ if (!m) continue
35
+ total++
36
+ if (m[1] !== ' ') checked++
37
+ }
38
+ return { checked, total }
39
+ }
40
+
41
+ /**
42
+ * 单个活跃变更的产物推断:返回 { state, next, evidence }
43
+ */
44
+ function inferChange(changeDir, name) {
45
+ const has = (f) => existsSync(join(changeDir, f))
46
+ const ev = (f) => join('.sillyspec', 'changes', name, f)
47
+ const t = countTasks(join(changeDir, 'tasks.md'))
48
+
49
+ if (!has('proposal.md') && !has('design.md') && !has('tasks.md') && !has('plan.md')) {
50
+ return { state: `${name}:变更目录为空`, next: 'sillyspec run brainstorm(完善 proposal)或清理该空目录', evidence: [ev('(无产物)')] }
51
+ }
52
+ if (!has('design.md')) {
53
+ return { state: `${name}:有 proposal 缺 design.md`, next: `sillyspec run brainstorm --change ${name}(推进到设计产物)`, evidence: [ev('proposal.md')] }
54
+ }
55
+ if (!has('tasks.md')) {
56
+ return { state: `${name}:有 design 缺 tasks.md`, next: `sillyspec run brainstorm --change ${name}(末步生成规范文件)`, evidence: [ev('design.md')] }
57
+ }
58
+ if (!has('plan.md')) {
59
+ return { state: `${name}:有 tasks.md 缺 plan.md`, next: `sillyspec run plan --change ${name}`, evidence: [ev('tasks.md')] }
60
+ }
61
+ if (t.total > 0 && t.checked < t.total) {
62
+ return {
63
+ state: `${name}:执行中(task ${t.checked}/${t.total})`,
64
+ next: `sillyspec run execute --change ${name}`,
65
+ evidence: [ev('plan.md'), ev('tasks.md')],
66
+ }
67
+ }
68
+ if (!has('verify-result.md')) {
69
+ return { state: `${name}:task 全勾,待验证`, next: `sillyspec run verify --change ${name}`, evidence: [ev('tasks.md'), ev('plan.md')] }
70
+ }
71
+ return { state: `${name}:已验证,待归档`, next: `sillyspec run archive --change ${name}`, evidence: [ev('verify-result.md')] }
72
+ }
73
+
74
+ /**
75
+ * 探测当前项目状态与下一步命令。
76
+ * @param {{ cwd: string, specDir?: string|null }} opts
77
+ * @returns {{ state: string, next: string, evidence: string[], activeChanges: Array<{name,checked,total}> }}
78
+ * activeChanges 附带各变更 task 勾选进度(供 --json 消费);evidence 为相对 spec 根的路径
79
+ */
80
+ export function detectNextStep({ cwd, specDir = null }) {
81
+ const specBase = resolveSpecDir(cwd, { specDir })
82
+ const changesRoot = join(specBase, 'changes')
83
+
84
+ // 1. HANDOFF 交接
85
+ for (const handoff of [join(cwd, 'HANDOFF.json'), join(specBase, 'HANDOFF.json')]) {
86
+ if (existsSync(handoff)) {
87
+ return { state: '存在 HANDOFF.json(上次会话交接)', next: '读 HANDOFF.json 后从交接点继续(sillyspec progress show 看活跃进度)', evidence: [handoff], activeChanges: [] }
88
+ }
89
+ }
90
+
91
+ // 2. 活跃变更(排除 archive)
92
+ let active = []
93
+ try {
94
+ active = readdirSync(changesRoot, { withFileTypes: true })
95
+ .filter(e => e.isDirectory() && e.name !== 'archive')
96
+ .map(e => e.name)
97
+ .sort()
98
+ } catch { /* 无 changes/ 目录 */ }
99
+ if (active.length > 0) {
100
+ // 多活跃时逐个推断全报告,next 取第一个(字典序最前)的;调用方输出含全部状态行
101
+ const inferences = active.map(name => inferChange(join(changesRoot, name), name))
102
+ const progress = active.map((name, i) => {
103
+ const t = countTasks(join(changesRoot, name, 'tasks.md'))
104
+ return { name, checked: t.checked, total: t.total, state: inferences[i].state }
105
+ })
106
+ return { state: inferences.map(i => i.state).join(';'), next: inferences[0].next, evidence: inferences[0].evidence, activeChanges: progress }
107
+ }
108
+
109
+ // 3. 已扫描无变更
110
+ const docsDir = join(specBase, 'docs')
111
+ try {
112
+ const scanned = readdirSync(docsDir, { withFileTypes: true })
113
+ .filter(e => e.isDirectory() && existsSync(join(docsDir, e.name, 'scan')))
114
+ if (scanned.length > 0) {
115
+ return { state: `已扫描(${scanned.length} 个项目文档),无活跃变更`, next: 'sillyspec run brainstorm "你的想法"', evidence: [join('.sillyspec', 'docs', scanned[0].name, 'scan')], activeChanges: [] }
116
+ }
117
+ } catch { /* 无 docs/ */ }
118
+
119
+ // 4. 绿地已有需求
120
+ for (const f of ['REQUIREMENTS.md', 'ROADMAP.md']) {
121
+ if (existsSync(join(specBase, f))) {
122
+ return { state: `绿地项目已有 ${f},无活跃变更`, next: 'sillyspec run brainstorm "你的想法"', evidence: [join('.sillyspec', f)], activeChanges: [] }
123
+ }
124
+ }
125
+
126
+ // 5. 什么都没有
127
+ return { state: '未初始化(无 .sillyspec 产物)', next: 'sillyspec init(新项目)或 sillyspec scan(棕地项目)', evidence: [], activeChanges: [] }
128
+ }
package/src/run/prompt.js CHANGED
@@ -21,7 +21,7 @@
21
21
  import { basename, join } from 'node:path'
22
22
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
23
23
  import { stageRegistry } from '../stages/index.js'
24
- import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE } from './shared.js'
24
+ import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE, QUICK_SID_RE } from './shared.js'
25
25
  import { renderStageContract } from '../stage-contract-spec.js'
26
26
  import { parseModuleMapSimple } from '../modules.js'
27
27
  import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
@@ -236,6 +236,16 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
236
236
  promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
237
237
  promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
238
238
  promptText = promptText.replace(/<now-date>/g, nowDate)
239
+ // <now-iso-datetime>(scan frontmatter updated_at 用;与 scan-fix-headers 同款秒级格式)
240
+ promptText = promptText.replace(/<now-iso-datetime>/g, now.toISOString().slice(0, 19).replace('T', ' '))
241
+ // <git-head-short>(scan frontmatter source_commit 用;CLI 代查,agent 勿自跑 rev-parse)
242
+ if (promptText.includes('<git-head-short>')) {
243
+ let headShort = 'unknown'
244
+ try {
245
+ headShort = safeGit(cwd, ['rev-parse', '--short', 'HEAD']).value || 'unknown'
246
+ } catch { /* git 不可用 → unknown,scan-postcheck 失配时再核 */ }
247
+ promptText = promptText.replace(/<git-head-short>/g, headShort)
248
+ }
239
249
  // 替换 {REVIEW_SCHEMA_VERSION} 占位符(task review 示例模板用,值=CLI 当前 REVIEW_SCHEMA_VERSION 常量,
240
250
  // 避免 agent 照抄 design 目标版本与 CLI 写侧常量漂移;与 stage review 契约 renderReviewJsonContract 动态注入同源)
241
251
  if (promptText.includes('{REVIEW_SCHEMA_VERSION}')) {
@@ -500,6 +510,21 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
500
510
  }
501
511
  }
502
512
 
513
+ // 模块卡分级表注入(token 成本优化 P0a,2026-08-22-token-cost-optimization):execute
514
+ // 「加载上下文」步 {MODULE_RESOLVE_TABLE} → resolveChangeModuleCards(tasks 卡 allowed_paths
515
+ // × 全部 _module-map.yaml 跨层最长前缀匹配,子项目细卡优先于根层大卡)。全降级不抛
516
+ // (异常 → 单行说明),无 map 由渲染函数给出跳过提示——占位符恒被替换。
517
+ if (stageName === 'execute' && promptText.includes('{MODULE_RESOLVE_TABLE}')) {
518
+ try {
519
+ const { renderModuleResolveTable } = await import('../module-resolve.js')
520
+ const mrSpecBase = resolvePromptSpecBase(platformOpts, cwd)
521
+ const table = renderModuleResolveTable({ cwd, specBase: mrSpecBase, changeName })
522
+ promptText = promptText.replace(/\{MODULE_RESOLVE_TABLE\}/g, table)
523
+ } catch (e) {
524
+ promptText = promptText.replace(/\{MODULE_RESOLVE_TABLE\}/g, `[modules-resolve] 模块卡分级解析异常(${e.message}),跳过——可手跑 sillyspec modules resolve --change <变更名>`)
525
+ }
526
+ }
527
+
503
528
  // Execute: 注入 currentExecuteRunId(从变更专属标记文件读取)
504
529
  if (stageName === 'execute' && promptText.includes('{EXECUTE_RUN_ID}')) {
505
530
  let runId = ''
@@ -722,7 +747,15 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
722
747
  // 普通本地保持相对 '.sillyspec' 展示;runtimeRoot-only 组合不再 join(null) 崩溃
723
748
  const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
724
749
  const changeDir = join(changeDirBase, 'changes', changeName)
725
- console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
750
+ // quick 会话(quick-<hex8>)按设计无实体变更目录(progress.js initChange 同款跳过),
751
+ // 纯代码 quick 根本不产 spec 文档——「所有变更文件必须写入 changes/<change>/」的硬规则
752
+ // 对它是误导(ql-20260821-011 实证:CLI 反复提示一个用不到的目录)。改述条件化:
753
+ // 仅「若本 quick 决定补文档」才落该目录,代码改动照常写源码目录。
754
+ if (QUICK_SID_RE.test(changeName)) {
755
+ console.log(`- **文件路径规则(quick 会话):纯代码改动直接写源码目录,无目录限制。仅当本 quick 需要落 spec 文档(design/plan 等,复杂任务建议升级完整流程)时才写 \`${changeDir}/\`;QUICKLOG/tasks.md 记录由 CLI 接管,不要手建。**`)
756
+ } else {
757
+ console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
758
+ }
726
759
  }
727
760
  }
728
761
  const changeFlag = changeName ? ` --change ${changeName}` : ''
@@ -764,6 +797,5 @@ export function applyRootPlaceholders(text, roots) {
764
797
  .replaceAll('{DOCS_ROOT}', roots.docsRoot)
765
798
  .replaceAll('{PROJECTS_ROOT}', roots.projectsRoot)
766
799
  .replaceAll('{WORKFLOWS_ROOT}', roots.workflowsRoot)
767
- .replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)
768
- }
800
+ .replaceAll('{KNOWLEDGE_ROOT}', roots.knowledgeRoot)}
769
801
 
@@ -7,9 +7,10 @@
7
7
  * 路径修正(相对 src/run/):
8
8
  * - resolveQuickLinkedChanges 的 quick-recommend.js 动态 import './quick-recommend.js' → '../quick-recommend.js'
9
9
  * - 脏文件信号改用 safeGit(带 safe.directory,Q3);不再 import execSync
10
- * - parsePorcelainPath 从 ./shared.js 重 import;checkbox 顶部 import
10
+ * - parsePorcelainPath 从 ./shared.js 重 import;checkbox 改交互分支内动态 import——
11
+ * 本模块被 command.js 静态 import,顶部静态拉 @inquirer/prompts 会进**每条** run 命令
12
+ * 启动路径(实测冷加载 100-150ms),而 checkbox 仅"≥2 活跃变更 + TTY"分支用到
11
13
  */
12
- import { checkbox } from '@inquirer/prompts'
13
14
  import { parsePorcelainPath, safeGit } from './shared.js'
14
15
 
15
16
  /**
@@ -150,6 +151,8 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
150
151
  if (recommendedSet.size > 0) {
151
152
  console.log(' ⭐ = 基于脏文件/任务描述推荐,已默认勾选')
152
153
  }
154
+ // 动态 import:@inquirer/prompts 只在本交互分支加载,不进 run 命令通用启动路径
155
+ const { checkbox } = await import('@inquirer/prompts')
153
156
  const selected = await checkbox({ message: '关联变更(空格切换,回车确认)', choices })
154
157
  return selected
155
158
  }