sillyspec 3.28.0 → 3.28.3

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 (60) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -93
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -47
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -214
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -270
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -87
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -70
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -64
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -175
  11. package/package.json +1 -1
  12. package/src/archive-delta.js +462 -0
  13. package/src/constants.js +6 -0
  14. package/src/design-facts.js +418 -0
  15. package/src/dispatch/backends/sillyhub-mcp.js +17 -5
  16. package/src/docs-check.js +197 -34
  17. package/src/docs-migrate.js +124 -0
  18. package/src/doctor-diagnostics.js +11 -2
  19. package/src/endpoint-baseline.js +109 -0
  20. package/src/git-helper.js +31 -0
  21. package/src/index.js +381 -22
  22. package/src/modules.js +41 -12
  23. package/src/progress/change-registry.js +39 -0
  24. package/src/progress/consistency-doctor.js +124 -5
  25. package/src/progress/stage-machine.js +13 -2
  26. package/src/progress.js +5 -0
  27. package/src/quicklog.js +51 -0
  28. package/src/run/command.js +199 -5
  29. package/src/run/complete-handlers.js +60 -4
  30. package/src/run/complete.js +105 -6
  31. package/src/run/gates.js +353 -2
  32. package/src/run/progress-confirm.js +66 -0
  33. package/src/run/prompt.js +193 -15
  34. package/src/run/quick-audit.js +12 -5
  35. package/src/run/scan-profile.js +117 -7
  36. package/src/run/shared.js +22 -6
  37. package/src/run/stage.js +14 -1
  38. package/src/scan-diff.js +54 -1
  39. package/src/scan-facts.js +173 -0
  40. package/src/scan-postcheck.js +114 -3
  41. package/src/spec-sync.js +19 -2
  42. package/src/stage-review.js +62 -3
  43. package/src/stages/archive.js +5 -5
  44. package/src/stages/brainstorm-auto.js +2 -1
  45. package/src/stages/brainstorm.js +12 -20
  46. package/src/stages/execute.js +19 -21
  47. package/src/stages/plan-postcheck.js +194 -1
  48. package/src/stages/plan.js +8 -3
  49. package/src/stages/quick.js +4 -2
  50. package/src/stages/scan.js +44 -102
  51. package/src/stages/verify.js +18 -18
  52. package/src/task-review.js +200 -0
  53. package/src/taskcard.js +8 -1
  54. package/src/verify-postcheck.js +771 -29
  55. package/src/verify-probes.js +89 -10
  56. package/src/workflow.js +30 -0
  57. package/templates/agents-instruction.md +1 -1
  58. package/templates/prompts/taskcard-rules.md +5 -0
  59. package/templates/workflows/scan-docs.yaml +6 -0
  60. package/.claude/skills/verify-per-user/SKILL.md +0 -6
package/src/run/prompt.js CHANGED
@@ -185,7 +185,20 @@ export function collectStageWaitHistory(progress, stageName) {
185
185
  /**
186
186
  * 输出当前步骤的 prompt
187
187
  */
188
- export async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null, waitHistory = null) {
188
+ /**
189
+ * requiresUser —— auto driver 元数据(change: 2026-09-08-auto-driver,D-002@v1)的四源判定纯函数:
190
+ * step.requiresWait / conditionalWait / requiresConfirm 三键(step 定义权威信号)+ WAIT_MARKER_RE 正文扫描
191
+ * (execute/verify 的 step 定义无三键,纯三源会漏真 wait 步)。全缺兜底 **false**——auto 默认语义是
192
+ * 继续驱动(Grill P1-①:保守 true 会让 execute/verify 逐步人工确认,推翻 FR-01)。
193
+ * 与 outputStep 内既有 mayNeedWait 计算同源(:1019 附近),仅多 requiresConfirm 一源。
194
+ */
195
+ export function requiresUser(step, promptText) {
196
+ if (!step) return false
197
+ if (step.requiresWait === true || step.conditionalWait === true || step.requiresConfirm === true) return true
198
+ return WAIT_MARKER_RE.test(String(promptText || ''))
199
+ }
200
+
201
+ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjectName, platformOpts = {}, prevStepAnswer = null, waitHistory = null, autoMeta = null) {
189
202
  const step = steps[stepIndex]
190
203
  const total = steps.length
191
204
  // ── 越界防御 ──
@@ -199,6 +212,16 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
199
212
  console.error(` 请检查 --change 指定的变更目录是否存在、specRoot 是否正确,然后重试。`)
200
213
  return false
201
214
  }
215
+
216
+ // ── 首个 agent 可见步索引(「首步一次建立」类注入的锚点)──
217
+ // step0 为 noAI(brainstorm/execute/verify 的 step1「进度确认」,2026-09-07)时不渲染 prompt,
218
+ // persona/护栏/完成契约/铁律/平台路径约束等 step0 专属注入须顺延到首个渲染步,否则整段丢失
219
+ //(spec-dir.test.mjs Test4 平台 specDir 断言即此坑的行为探针)。全 noAI 防御性回退 0。
220
+ const firstRenderableIdx = (() => {
221
+ const i = steps.findIndex(s => s?.noAI !== true)
222
+ return i === -1 ? 0 : i
223
+ })()
224
+
202
225
  const projectName = dbProjectName || basename(cwd)
203
226
 
204
227
  // ── Revision context injection ──
@@ -244,8 +267,8 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
244
267
  console.log(`changeDir: ${changeDir}`)
245
268
  }
246
269
  console.log(`---\n`)
247
- // persona 只在 stage 首步注入(step0)——角色设定一次即可,后续 step 重复注入纯属 token 浪费
248
- if (personas[stageName] && stepIndex === 0) {
270
+ // persona 只在首个 agent 可见步注入——角色设定一次即可,后续 step 重复注入纯属 token 浪费
271
+ if (personas[stageName] && stepIndex === firstRenderableIdx) {
249
272
  console.log(personas[stageName])
250
273
  console.log('')
251
274
  }
@@ -255,7 +278,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
255
278
 
256
279
  console.log(`## Step ${stepIndex + 1}/${total}: ${step.name}\n`)
257
280
  if (guardrails) {
258
- if (stepIndex === 0) {
281
+ if (stepIndex === firstRenderableIdx) {
259
282
  console.log(guardrails.trim())
260
283
  console.log('')
261
284
  } else {
@@ -398,13 +421,15 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
398
421
  platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
399
422
  }
400
423
  // 平台 directives 前置条件:
401
- // - step0(对齐 persona 仅 step0 策略):路径列表/Write 规则/占位符映射一次建立即可;
424
+ // - 首个 agent 可见步(对齐 persona 仅 step0 策略):路径列表/Write 规则/占位符映射
425
+ // 一次建立即可——step0 为 noAI(brainstorm/execute/verify 的 step1「进度确认」,
426
+ // 2026-09-07)时首个可见步顺延,按 defSteps 计算(坑同 scan quick 档 noAI preflight);
402
427
  // - scan 阶段所有 step:scan 的 profileDirectives 本就每步注入(见下方 L917),平台路径
403
428
  // 约束也需每步可见——否则 quick profile 的 step0 是 noAI preflight(自动执行),run scan
404
429
  // 输出的首个可见 step 是 step1,会漏掉 specDir 路径,导致平台模式写入约束丢失。
405
430
  // 后续 step 靠 changeDir header + footer 平台规则(L1077+)维持,platformDirectives
406
431
  // 每步重复约 500 tokens 仅在 scan(步数有限)可接受。
407
- if (stepIndex === 0 || stageName === 'scan') {
432
+ if (stepIndex === firstRenderableIdx || stageName === 'scan') {
408
433
  promptText = platformDirectives.join('\n') + '\n\n' + promptText
409
434
  }
410
435
  } else {
@@ -477,6 +502,109 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
477
502
  }
478
503
  }
479
504
 
505
+ // 机械事实底稿注入(IR P3c task-03,D-003@v1 三件套之三):brainstorm「加载项目上下文」Step2
506
+ // 的 {SCAN_FACTS} → docs/<project>/scan/_facts.md 全文(scan facts CLI 确定性抽取的端点/依赖/
507
+ // 规模底稿)+ 红线一句(措辞对齐 scan 子代理先例 stages/scan.js——禁止重新 grep 发现底稿覆盖
508
+ // 的机械事实,冲突以底稿为准),探索期直接消费省 token。>15KB 按 UTF-8 字节截断并提示跑
509
+ // `sillyspec scan facts` 刷新(design R-04);文件不存在/读取失败空注入(fail-soft,不留残留
510
+ // 占位符)。只挂 brainstorm:双重条件(stageName 判定 + 占位符仅存在于 brainstorm Step2
511
+ // prompt),与下方 buildModuleContextInjection(横跨 brainstorm/plan/execute 三阶段)相互独立。
512
+ if (stageName === 'brainstorm' && promptText.includes('{SCAN_FACTS}')) {
513
+ let factsInjected = ''
514
+ try {
515
+ const factsSpecBase = resolvePromptSpecBase(platformOpts, cwd)
516
+ const factsProjectName = dbProjectName || basename(cwd)
517
+ const factsPath = join(factsSpecBase, 'docs', factsProjectName, 'scan', '_facts.md')
518
+ if (existsSync(factsPath)) {
519
+ const factsBuf = readFileSync(factsPath) // Buffer:按 UTF-8 字节判 15KB 阈值
520
+ const FACTS_LIMIT = 15 * 1024
521
+ const factsBody = factsBuf.length > FACTS_LIMIT
522
+ ? factsBuf.subarray(0, FACTS_LIMIT).toString('utf8') +
523
+ `\n\n(⚠️ 底稿超过 15KB 已截断——完整内容可读 \`${factsPath}\`;底稿已过期时可跑 \`sillyspec scan facts\` 刷新)`
524
+ : factsBuf.toString('utf8')
525
+ factsInjected =
526
+ '\n\n### 🧾 机械事实底稿(scan facts CLI 抽取,docs/' + factsProjectName + '/scan/_facts.md 全文注入)\n\n' +
527
+ factsBody +
528
+ '\n\n⛔ 红线(同 scan 子代理先例):禁止重新 grep 发现底稿覆盖的机械事实(端点/依赖/规模),冲突以底稿为准。\n'
529
+ }
530
+ } catch { /* fail-soft:读取失败空注入,不阻断 prompt 输出 */ }
531
+ promptText = promptText.replace(/\{SCAN_FACTS\}/g, factsInjected)
532
+ }
533
+
534
+ // ── 机械事实注入三件(2026-09-07 注入缺口批次):{LOCAL_COMMANDS} / {GIT_DIRTY} / {TASKS_CHECKBOX} ──
535
+ // CLI 渲染 prompt 时已能确定性拿到的机械事实,替代 agent 各自跑一趟 cat local.yaml /
536
+ // git status / 手数勾选(全流程 7+ 处步骤的高频机械读)。全部 fail-soft:异常注入单行
537
+ // 说明,不留残留占位符;替换后占位符即消失(不存在的档位自然零输出)。
538
+
539
+ // ① {LOCAL_COMMANDS}:local.yaml commands 段原文(brainstorm/plan/execute/verify/quick 多步消费)
540
+ if (promptText.includes('{LOCAL_COMMANDS}')) {
541
+ let localBlock = ''
542
+ try {
543
+ const lcSpecBase = resolvePromptSpecBase(platformOpts, cwd)
544
+ const localPath = join(lcSpecBase, 'local.yaml')
545
+ if (existsSync(localPath)) {
546
+ const lcLines = String(readFileSync(localPath, 'utf8')).replace(/\r\n?/g, '\n').split('\n')
547
+ const start = lcLines.findIndex(l => /^commands:\s*(#.*)?$/.test(l))
548
+ if (start !== -1) {
549
+ const body = []
550
+ for (let i = start + 1; i < lcLines.length && !/^\S/.test(lcLines[i]); i++) body.push(lcLines[i])
551
+ // unavailable 标记的条目按未配置语义剔除行(与 extractTestCommand 同口径)
552
+ const avail = body.filter(l => !/^\s*[\w.-]+:\s*['"]?unavailable['"]?\s*(#.*)?$/i.test(l))
553
+ const trimmed = avail.join('\n').trim()
554
+ localBlock = trimmed
555
+ ? 'commands:\n' + trimmed
556
+ : '(local.yaml 存在但 commands 段为空——可跑 `sillyspec local detect` 补默认骨架)'
557
+ } else {
558
+ localBlock = '(local.yaml 存在但无 commands 段——可跑 `sillyspec local detect` 补默认骨架)'
559
+ }
560
+ } else {
561
+ localBlock = '(未配置 local.yaml——先跑 `sillyspec local detect` 生成骨架,以命令输出为准)'
562
+ }
563
+ } catch (e) {
564
+ localBlock = `(构建命令注入失败:${e.message}——可自行读 local.yaml)`
565
+ }
566
+ promptText = promptText.replace(/\{LOCAL_COMMANDS\}/g, localBlock)
567
+ }
568
+
569
+ // ② {GIT_DIRTY}:工作区脏文件清单(git status --porcelain,quick 收尾步消费)
570
+ if (promptText.includes('{GIT_DIRTY}')) {
571
+ let dirtyBlock = ''
572
+ try {
573
+ const { gitQuiet } = await import('../git-helper.js')
574
+ // gitQuiet 返回裸 string(失败 null)——非 safeGit 的 {value,error} 形状
575
+ const lines = String(gitQuiet(cwd, ['status', '--porcelain']) || '').split('\n').filter(Boolean)
576
+ dirtyBlock = lines.length === 0
577
+ ? '(工作区干净,无脏文件)'
578
+ : lines.slice(0, 40).join('\n') + (lines.length > 40 ? `\n(…共 ${lines.length} 行,超出 40 行截断——完整清单可自行跑 git status --porcelain)` : '')
579
+ } catch (e) {
580
+ dirtyBlock = `(脏文件注入失败:${e.message}——可自行跑 git status --porcelain)`
581
+ }
582
+ promptText = promptText.replace(/\{GIT_DIRTY\}/g, dirtyBlock)
583
+ }
584
+
585
+ // ③ {TASKS_CHECKBOX}:tasks.md 任务勾选状态投影(verify 逐项检查步消费——省 agent 手数)
586
+ if (promptText.includes('{TASKS_CHECKBOX}')) {
587
+ let tasksBlock = ''
588
+ try {
589
+ const tcSpecBase = resolvePromptSpecBase(platformOpts, cwd)
590
+ const tasksPath = changeName ? join(tcSpecBase, 'changes', changeName, 'tasks.md') : null
591
+ if (tasksPath && existsSync(tasksPath)) {
592
+ const re = /^(\s*[-*]\s+\[([ xX])\]\s+task-\d+.*)$/
593
+ const rows = String(readFileSync(tasksPath, 'utf8')).replace(/\r\n?/g, '\n').split('\n')
594
+ .map(l => l.match(re)).filter(Boolean).map(m => m[1].trim())
595
+ const checked = rows.filter(r => /\[x\]/i.test(r)).length
596
+ tasksBlock = rows.length === 0
597
+ ? '(tasks.md 无 task-NN checkbox 行)'
598
+ : `已勾 ${checked}/${rows.length}:\n` + rows.slice(0, 60).join('\n') + (rows.length > 60 ? `\n(…共 ${rows.length} 条,超出 60 条截断)` : '')
599
+ } else {
600
+ tasksBlock = '(tasks.md 不存在——跳过勾选对照)'
601
+ }
602
+ } catch (e) {
603
+ tasksBlock = `(勾选状态注入失败:${e.message}——可自行读 tasks.md)`
604
+ }
605
+ promptText = promptText.replace(/\{TASKS_CHECKBOX\}/g, tasksBlock)
606
+ }
607
+
480
608
  // 决策防复潮注入(W1.1 第 3 点,task-04,FR-05):brainstorm「加载项目上下文」Step2 的
481
609
  // {DECISION_HITS} → matchKnowledge decisionHits——命中 rejected 条目时渲染「否决决策提示」段
482
610
  // (ID/标题/否决理由/复潮条件),无命中替换为空串零输出(不留残留占位符);注入结果同步落
@@ -683,6 +811,35 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
683
811
  }
684
812
  }
685
813
 
814
+ // 三主阶段 Step1「进度确认」快照注入(2026-09-05 全流程审计)已随 step1 整体 noAI 化退役
815
+ // (2026-09-07):brainstorm/execute/verify 的 step1 不再渲染 prompt,快照改由
816
+ // run/progress-confirm.js 的 progressConfirm 动作 console 直出(占位符 {PROGRESS_SNAPSHOT}
817
+ // 无消费者,注入分支删除)。
818
+
819
+ // execute Step「确认 worktree 路径」meta 注入(2026-09-05 全流程审计):worktree meta 由 CLI
820
+ // 读出直接注入,agent 省一趟 worktree meta 工具往返;工具链可用性检查(真实工作)仍归 agent。
821
+ if (stageName === 'execute' && promptText.includes('{WORKTREE_META}')) {
822
+ let metaInfo
823
+ try {
824
+ const { WorktreeManager } = await import('../worktree.js')
825
+ const wm = new WorktreeManager({ cwd })
826
+ const meta = changeName ? wm.getMeta(changeName) : null
827
+ if (meta && meta.worktreePath) {
828
+ const dirOk = existsSync(meta.worktreePath)
829
+ metaInfo =
830
+ `- worktreePath:${meta.worktreePath}\n` +
831
+ `- branch:${meta.branch || '(meta 未记录)'}\n` +
832
+ `- mode:${meta.mode || 'worktree'}\n` +
833
+ `- worktree 目录存在:${dirOk ? '是' : '否——in-place 模式属正常;worktree/native-worktree 模式缺失则停止并报错'}`
834
+ } else {
835
+ metaInfo = '(worktree meta 不可读——meta.json 不存在说明创建失败,停止并报错;可运行 sillyspec worktree meta ' + (changeName || '<change-name>') + ' 自查)'
836
+ }
837
+ } catch (e) {
838
+ metaInfo = '(注入失败:' + e.message + '——运行 sillyspec worktree meta 自查,meta.json 不存在则停止并报错)'
839
+ }
840
+ promptText = promptText.split('{WORKTREE_META}').join(metaInfo)
841
+ }
842
+
686
843
  // archive Step1「任务完成度检查」客观真相源注入:以 review.json verdict 算完成度,
687
844
  // 替代「机械数 plan.md checkbox」(checkbox 依赖 autoCheckPlanFromReviews 回填,runId marker /
688
845
  // review 缺失时回填静默 no-op,会停在未勾态导致完成度失真 → archive 误判「全未完成」)。
@@ -818,9 +975,9 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
818
975
  }
819
976
 
820
977
  // 完成契约(事前预知):该 stage 的机械校验通过条件,从 stage-contract-spec.js manifest 渲染,
821
- // 与 CLI 完成校验严格同源(事前给的 == 事后查的)。仅 step0 注入(与 persona/guardrails 同模式,
822
- // 一次建立即可;后续步靠 context 保留)。
823
- if (stepIndex === 0) {
978
+ // 与 CLI 完成校验严格同源(事前给的 == 事后查的)。仅首个 agent 可见步注入(与 persona/guardrails
979
+ // 同模式,一次建立即可;后续步靠 context 保留)。
980
+ if (stepIndex === firstRenderableIdx) {
824
981
  const stageContract = renderStageContract(stageName)
825
982
  if (stageContract) {
826
983
  promptText = `${promptText}\n\n${stageContract}`
@@ -828,10 +985,10 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
828
985
  }
829
986
 
830
987
  console.log(promptText)
831
- // 铁律拆分(W3 token 效率):通用流程纪律(文档优先/不跳步/不编造命令)只在首步注入——
988
+ // 铁律拆分(W3 token 效率):通用流程纪律(文档优先/不跳步/不编造命令)只在首个 agent 可见步注入——
832
989
  // 每步重复 ~800B 纯耗 context。但【平台写入规则 + 路径规则】是安全关键(防写错目录/绕过 Write),
833
990
  // 且依赖 changeName/platformOpts,必须【每步注入】(context 压缩丢失会让 agent 越界写源码)。
834
- if (stepIndex === 0) {
991
+ if (stepIndex === firstRenderableIdx) {
835
992
  console.log(`\n### ⚠️ 铁律`)
836
993
  console.log('- 文档优先:代码产出必须先有对应的设计/规范文档支撑。')
837
994
  console.log('- 聚焦本步骤:只执行本步骤描述的操作并完整做完;自行扩展或跳步会破坏状态机推进,后续步骤再做后续事。')
@@ -879,21 +1036,42 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
879
1036
  // archive-batch-31-tool-notes ②:通用 --done 模板不带该 flag,agent 照抄执行撞
880
1037
  // 「请添加 --confirm」确认门,误以为参数没带。
881
1038
  const confirmFlag = step.requiresConfirm === true ? ' --confirm' : ''
1039
+ // auto driver(2026-09-08-auto-driver D-002/P1-③):auto 模式下命令一律 run auto 形态——
1040
+ // 与 SS-META 的 doneCommand 同源(同一 cmdStage 变量),消灭「正文 run <stage> / 元数据 run auto」双命令
1041
+ const cmdStage = autoMeta ? 'auto' : stageName
1042
+ const doneCommand = `sillyspec run ${cmdStage} --done${confirmFlag}${changeFlag} --output "你的摘要"`
882
1043
  if (requiresWait) {
883
1044
  console.log(`本步骤必须等待用户输入,不能直接 --done:`)
884
- console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
1045
+ console.log(`sillyspec run ${cmdStage} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
885
1046
  console.log(``)
886
1047
  console.log(`用户回答后执行:`)
887
- console.log(`sillyspec run ${stageName} --continue --answer "用户回答"${changeFlag}`)
1048
+ console.log(`sillyspec run ${cmdStage} --continue --answer "用户回答"${changeFlag}`)
888
1049
  console.log(``)
889
1050
  console.log(`收到回答并完成本步骤总结后,再执行:`)
890
1051
  } else if (mayNeedWait) {
891
1052
  console.log(`如果需要用户决策(选择方案/确认设计等):`)
892
- console.log(`sillyspec run ${stageName} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
1053
+ console.log(`sillyspec run ${cmdStage} --wait --reason "${step.waitReason || '等待原因'}" --options "${(step.waitOptions || ['选项1', '选项2']).join(',')}"${changeFlag} --output "你的摘要"`)
893
1054
  console.log(``)
894
1055
  console.log(`如果不需要用户决策,正常完成:`)
895
1056
  }
896
- console.log(`sillyspec run ${stageName} --done${confirmFlag}${changeFlag} --input "用户原始需求/反馈" --output "你的摘要"`)
1057
+ console.log(doneCommand + (autoMeta ? '' : ' --input "用户原始需求/反馈"'))
1058
+
1059
+ // ── SS-META 机器可读元数据块(change: 2026-09-08-auto-driver,D-002@v1,FR-01)──
1060
+ // 仅 auto 模式(autoMeta 非空)渲染:单行 HTML 注释内 JSON,agent 低噪可读、脚本一行正则
1061
+ // 可提取(<!--SS-META:(.*)-->)。requiresUser 四源见上方纯函数;doneCommand 与正文同源。
1062
+ if (autoMeta) {
1063
+ const meta = {
1064
+ stage: stageName,
1065
+ stepIndex: stepIndex + 1,
1066
+ stepName: step.name,
1067
+ requiresUser: requiresWait || mayNeedWait || step.requiresConfirm === true,
1068
+ doneCommand,
1069
+ waitHint: (requiresWait || mayNeedWait)
1070
+ ? 'requiresUser=true:先与用户交互(wait/continue 或 --wait-interactive 直通),再 done'
1071
+ : '直接执行任务后逐字跑 doneCommand',
1072
+ }
1073
+ console.log('<!--SS-META:' + JSON.stringify(meta) + '-->')
1074
+ }
897
1075
  }
898
1076
  /**
899
1077
  * 替换 prompt 文本中的路径根占位符 {SPEC_ROOT}/{DOCS_ROOT}/{PROJECTS_ROOT}/
@@ -209,12 +209,15 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
209
209
  * npm test + npm run lint」从 agent 自律下沉为 CLI 卡点。
210
210
  *
211
211
  * 语义(对齐规则 8 + verify-postcheck 既有降级哲学):
212
- * - changedFiles 未触及 src/ 或 test/(纯 doc/配置改动)→ skip(不跑不阻断)
212
+ * - 变更文件未触及 src/ 或 test/(纯 doc/配置改动)→ skip(不跑不阻断)
213
213
  * - 触及 → 亲自执行 local.yaml 的 commands.test / commands.lint(复用 verify 阶段
214
214
  * 对账引擎 runVerifyTestCheck/runVerifyLintCheck,含 test_strategy/known_failures/
215
215
  * 超时语义);未配置命令 → 内部 skipped,不阻断(兼容无测试项目)
216
216
  * - 任一实测 failed → fail(调用方阻断 --done,step 回 pending)
217
217
  * - 逃生门:SILLYSPEC_QUICK_TEST_GATE=skip 显式跳过(CI/特殊场景,审计留痕)
218
+ * - 倒推 B 模式兜底(2026-09-07,quick-f9138c2f 实证):代码先行会话的文件全部早于
219
+ * 启动时间窗 → 审计 changedFiles 为空 → 此前静默 skip;--files 声明边界
220
+ * (declaredFiles = guard.allowedFiles)明确触及 src/test 时实测仍应跑。
218
221
  *
219
222
  * verify-postcheck 顶层 import 链较重(contract-matrix 等),且本函数仅 quick --done
220
223
  * 收尾路径调用——用动态 import,不进 run 命令通用启动路径(与上方 @inquirer/prompts 同款纪律)。
@@ -222,18 +225,22 @@ export async function resolveQuickLinkedChanges({ pm, cwd, specDir, quickFiles,
222
225
  * @param {object} opts
223
226
  * @param {string} opts.cwd - 仓库根(命令执行 cwd)
224
227
  * @param {string} opts.specBase - .sillyspec 目录(local.yaml 读取源,信任边界见 runVerifyTestCheck SEC-02 注释)
225
- * @param {string[]} [opts.changedFiles] - 审计口径的本轮变更文件(仓库根相对 POSIX 路径;空/null → skip)
228
+ * @param {string[]} [opts.changedFiles] - 审计口径的本轮变更文件(仓库根相对 POSIX 路径;空/null → 回退 declaredFiles)
229
+ * @param {string[]} [opts.declaredFiles] - 会话声明边界(guard.allowedFiles;changedFiles 为空时的兜底判定源)
226
230
  * @param {string} [opts.changeName] - quick 会话名(evidence-auto 策略解析用)
227
231
  * @returns {Promise<{action:'pass'|'fail'|'skip', failed:string[], reason:string, test:object|null, lint:object|null}>}
228
232
  */
229
- export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], changeName = null }) {
233
+ export async function runQuickTestLintGate({ cwd, specBase, changedFiles = [], declaredFiles = [], changeName = null }) {
230
234
  if (process.env.SILLYSPEC_QUICK_TEST_GATE === 'skip') {
231
235
  return { action: 'skip', failed: [], reason: 'SILLYSPEC_QUICK_TEST_GATE=skip 显式跳过(审计留痕)', test: null, lint: null }
232
236
  }
233
- const files = Array.isArray(changedFiles) ? changedFiles : []
237
+ const audited = Array.isArray(changedFiles) ? changedFiles : []
238
+ // 倒推 B 模式兜底:审计口径为空时回退声明边界(文件早于会话启动被基线吸收的场景)
239
+ const files = audited.length > 0 ? audited : (Array.isArray(declaredFiles) ? declaredFiles : [])
240
+ const fileSource = audited.length > 0 ? '审计' : (files.length > 0 ? '声明边界兜底(倒推 B:文件早于会话启动被基线吸收)' : '无')
234
241
  const codeFiles = files.filter(f => typeof f === 'string' && (f.startsWith('src/') || f.startsWith('test/')))
235
242
  if (files.length === 0) {
236
- return { action: 'skip', failed: [], reason: '无变更文件清单(brownfield 无 guard 或空审计),跳过', test: null, lint: null }
243
+ return { action: 'skip', failed: [], reason: `无变更文件清单(${fileSource}口径均空——brownfield 无 guard 或空审计),跳过`, test: null, lint: null }
237
244
  }
238
245
  if (codeFiles.length === 0) {
239
246
  return { action: 'skip', failed: [], reason: `纯 doc/配置改动(${files.length} 个文件均未触及 src/test,规则 8 语义跳过 test+lint)`, test: null, lint: null }
@@ -9,8 +9,9 @@
9
9
  * - executeScanPostcheck 原冗余动态 import('fs'/'path'/'child_process') 删除,改顶部静态(execSync 实际未直接用,纯遗留)
10
10
  */
11
11
  import { join, basename, extname } from 'node:path'
12
- import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
13
- import { safeGit } from './shared.js'
12
+ import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs'
13
+ import { safeGit, resolveRuntimeRoot } from './shared.js'
14
+ import { SCAN_REQUIRED_DOCS } from '../constants.js'
14
15
 
15
16
  /**
16
17
  * 估算源码规模(文件数 + 字节数)—— 跨平台 Node 遍历,替代 Unix `find`(原生 Windows 无 find,
@@ -130,19 +131,21 @@ export function buildQuickScanSteps() {
130
131
  当前为 quick profile(小项目自动判定 或 用户显式 --quick),一次性生成核心文档用于快速接入。
131
132
 
132
133
  ### 操作
133
- 1. 读取项目结构和关键文件(package.json / pyproject.toml / README / 入口文件)
134
- 2. 生成以下 4 份文档并写入 \`{DOCS_ROOT}/scan/\`:
134
+ 1. 先跑 \`sillyspec scan facts\`(CLI 确定性抽取端点/依赖/规模,产物 {DOCS_ROOT}/scan/_facts.md,零 token)——PROJECT/ARCHITECTURE 的技术栈与集成事实以底稿为准,不重复 grep 发现
135
+ 2. 读取项目结构和关键文件(package.json / pyproject.toml / README / 入口文件)
136
+ 3. 生成以下 4 份文档并写入 \`{DOCS_ROOT}/scan/\`:
135
137
  - **PROJECT.md** — 项目简介、技术栈、模块划分
136
138
  - **ARCHITECTURE.md** — 架构概览、模块关系、技术决策
137
139
  - **CONVENTIONS.md** — 代码风格、框架隐形规则
138
140
  - **STRUCTURE.md** — 目录树 + 模块说明
139
- 3. 如发现子项目,注册到 \`{PROJECTS_ROOT}/\` 下
141
+ 4. 如发现子项目,注册到 \`{PROJECTS_ROOT}/\` 下
140
142
 
141
- 每份文档 frontmatter 必须包含:\`author\`、\`created_at\`、\`scan_depth: quick\`(标记快速接入的浅层版本;后续深度扫描 --deep 会识别此标记并覆盖升级为完整文档)。
143
+ 每份文档正文第 1 行直接写中文标题;frontmatter(author / created_at / source_commit / updated_at / generator / scan_depth: quick——标记快速接入的浅层版本,后续深度扫描 --deep 会识别此标记并覆盖升级)**由 CLI 在终检时自动注入,你不需要写 frontmatter、也不要跑 git 取值**。
142
144
 
143
145
  ### ⛔ 硬约束
144
146
  - **严禁使用子代理(Agent/Task 工具)。** 所有文档在一个 turn 内完成。
145
147
  - 不要搜索 .sillyspec/ .claude/ .git/ node_modules/ dist/ build/
148
+ - 关键事实(模块职责/技术栈组件/外部集成)须带反引号 path:line 引用(如 \`src/db.js:42\`,真实可核验——CLI postcheck 逐条核验);不确定就不写行号,**禁止编造**
146
149
  - --output 只需要列出生成的文件名,不要写长篇总结
147
150
 
148
151
  ### 输出
@@ -219,8 +222,11 @@ export async function executeScanPreflight(cwd, platformOpts, scanProfile) {
219
222
  */
220
223
  export async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
221
224
  // scan-postcheck.js 在 src/,本模块在 src/run/ → 退一层(真环依赖,保留动态 import)
222
- const { runScanPostCheck, printScanPostCheckResult } = await import('../scan-postcheck.js')
225
+ const { runScanPostCheck, printScanPostCheckResult, stampScanDocHeaders } = await import('../scan-postcheck.js')
223
226
  const specDir = platformOpts?.specRoot || null
227
+ // ② frontmatter CLI 盖章(幂等,只补缺不覆盖):quick 档注入含 scan_depth: quick
228
+ const stamped = stampScanDocHeaders({ cwd, specDir, mode: scanProfile?.mode })
229
+ if (stamped.fixed.length > 0) console.log(` 📝 frontmatter 已由 CLI 注入 ${stamped.fixed.length} 份文档`)
224
230
  const result = runScanPostCheck({
225
231
  cwd,
226
232
  specDir,
@@ -268,3 +274,107 @@ export async function executeScanPostcheck(cwd, platformOpts, scanProfile) {
268
274
  }
269
275
  }
270
276
  }
277
+
278
+ // ─── noAI 化步骤动作(2026-09-05 ①:ls 级操作不值整轮 agent 往返) ─────────────
279
+
280
+ /** 已注册项目清单(projects/*.yaml 文件名去后缀);无注册时回退 [仓库根 basename](单项目口径) */
281
+ function listRegisteredProjects(specBase, cwd) {
282
+ const projectsRoot = join(specBase, 'projects')
283
+ try {
284
+ const names = readdirSync(projectsRoot).filter(f => f.endsWith('.yaml')).map(f => f.replace(/\.yaml$/, ''))
285
+ if (names.length > 0) return names
286
+ } catch { /* 无 projects 目录 → 回退单项目 */ }
287
+ return [basename(cwd)]
288
+ }
289
+
290
+ /**
291
+ * CLI-only: 子项目探测(scan 步骤 1 的 noAI 化)。
292
+ * 顶层目录 × 构建文件存在性 → 打印建议清单;**只建议不注册**(原红线语义不变,注册仍由用户确认)。
293
+ */
294
+ export async function executeScanDetectProjects(cwd, platformOpts) {
295
+ const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
296
+ const projectsRoot = join(specBase, 'projects')
297
+ const buildFiles = [
298
+ ['package.json', 'Node.js'], ['pom.xml', 'Java/Maven'], ['build.gradle', 'Java/Gradle'],
299
+ ['pyproject.toml', 'Python'], ['requirements.txt', 'Python'], ['go.mod', 'Go'], ['Cargo.toml', 'Rust'],
300
+ ]
301
+ const skip = new Set(['node_modules', '.git', '.sillyspec', 'dist', 'build', 'out', 'target', 'coverage', '.claude'])
302
+ console.log(' 📁 子项目探测(CLI,只建议不注册——注册需用户在后续步骤确认)')
303
+ let found = 0
304
+ try {
305
+ for (const e of readdirSync(cwd, { withFileTypes: true })) {
306
+ if (!e.isDirectory() || skip.has(e.name) || e.name.startsWith('.')) continue
307
+ const hit = buildFiles.find(([f]) => existsSync(join(cwd, e.name, f)))
308
+ if (!hit) continue
309
+ found++
310
+ const registered = existsSync(join(projectsRoot, `${e.name}.yaml`))
311
+ console.log(` - ${e.name}:${hit[1]} · ${registered ? '已注册' : '未注册(是否注册由用户确认)'}`)
312
+ }
313
+ } catch { /* 顶层目录读取失败 → 空清单 */ }
314
+ if (found === 0) console.log(' (未发现独立子项目,按单项目扫描)')
315
+ console.log('')
316
+ }
317
+
318
+ /**
319
+ * CLI-only: 断点续扫检测(scan 步骤 4 的 noAI 化)。existsSync 级检查,一次遍历全部已注册项目。
320
+ */
321
+ export async function executeScanResumeCheck(cwd, platformOpts) {
322
+ const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
323
+ for (const p of listRegisteredProjects(specBase, cwd)) {
324
+ const scanDir = join(specBase, 'docs', p, 'scan')
325
+ const states = SCAN_REQUIRED_DOCS.map(d => existsSync(join(scanDir, d)) ? `✅${d.replace('.md', '')}` : `⬜${d.replace('.md', '')}`)
326
+ const facts = existsSync(join(scanDir, '_facts.md')) ? '· 底稿✅' : '· 底稿⬜'
327
+ console.log(` 📂 ${p}: ${states.join(' ')} ${facts}`)
328
+ }
329
+ console.log('')
330
+
331
+ // IR 增量回灌 advisory(change: 2026-09-07-ir-hardening,D-006@v1,FR-03):读
332
+ // .runtime/last-delta.json(归档/delta 补跑写入的最近变更快照),14 天窗口内提示本轮
333
+ // scan 优先核对其涉及模块——advisory 不阻断不改变步骤结构;缺失/过期/解析失败静默。
334
+ try {
335
+ const sidecarPath = join(resolveRuntimeRoot(platformOpts, specBase), 'last-delta.json')
336
+ if (existsSync(sidecarPath)) {
337
+ const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'))
338
+ const ageMs = Date.now() - Date.parse(sidecar.updatedAt || '')
339
+ const FOURTEEN_DAYS = 14 * 24 * 60 * 60 * 1000
340
+ if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= FOURTEEN_DAYS
341
+ && Array.isArray(sidecar.affectedModules) && sidecar.affectedModules.length > 0) {
342
+ console.log(`🔁 增量回灌提示(上次归档变更 ${sidecar.change},${new Date(sidecar.updatedAt).toISOString().slice(0, 10)}):`)
343
+ console.log(` 其涉及模块 ${sidecar.affectedModules.join('、')} 的文档与 staleRefs 本轮 scan 优先核对(advisory,不据此跳过其他模块)`)
344
+ console.log('')
345
+ }
346
+ }
347
+ } catch { /* sidecar 读取/解析失败静默跳过(advisory 缺席零影响) */ }
348
+ }
349
+
350
+ /**
351
+ * CLI-only: scan 终检(步骤 11「自检和提交」的 noAI 化,含 ② frontmatter 盖章)。
352
+ * 顺序:frontmatter 注入(幂等)→ 清理 _env-detect.md → runScanPostCheck(本地轻量/平台严格)
353
+ * → failed_post_check 时 throw(step 保持 pending)→ 非平台 git add 扫描产物(不 commit)。
354
+ */
355
+ export async function executeScanFinalize(cwd, platformOpts) {
356
+ const specDir = platformOpts?.specRoot || null
357
+ const specBase = specDir || join(cwd, '.sillyspec')
358
+ const { stampScanDocHeaders, runScanPostCheck, printScanPostCheckResult } = await import('../scan-postcheck.js')
359
+
360
+ const stamped = stampScanDocHeaders({ cwd, specDir })
361
+ if (stamped.fixed.length > 0) console.log(` 📝 frontmatter 已由 CLI 注入 ${stamped.fixed.length} 份文档(${stamped.stampedKeys} 个键,只补缺不覆盖)`)
362
+
363
+ for (const p of listRegisteredProjects(specBase, cwd)) {
364
+ const tmp = join(specBase, 'docs', p, 'scan', '_env-detect.md')
365
+ try { if (existsSync(tmp)) { unlinkSync(tmp); console.log(` 🧹 已清理 ${p}/scan/_env-detect.md`) } } catch { /* 清理失败不阻断 */ }
366
+ }
367
+
368
+ const result = runScanPostCheck({ cwd, specDir })
369
+ printScanPostCheckResult(result)
370
+ // 不 throw:平台模式的 manifest/指针/exit(1) 由 handleScanStageCompleted 在阶段完成时统一落
371
+ //(throw 会拦在完成前 → manifest.json 永不写入,破坏 SillyHub 消费契约,实测
372
+ // run-complete-step-scan-platform Case 1);本地模式的告警打印/postcheck-result.json 同理
373
+ // 由 complete-handlers 非平台收尾分支负责。本动作只负责:盖章 + 清理 + 即时报告 + 暂存。
374
+
375
+ if (!specDir) {
376
+ const r = safeGit(cwd, ['add', '.sillyspec/docs', '.sillyspec/knowledge'])
377
+ if (r && r.error) console.warn(` ⚠️ git add 扫描产物失败(可手动处理)`)
378
+ else console.log(' 📦 已暂存扫描产物 .sillyspec/docs 与 .sillyspec/knowledge(未提交,由统一提交工具处理)')
379
+ }
380
+ }
package/src/run/shared.js CHANGED
@@ -107,19 +107,24 @@ export function resolvePromptIncludes(text) {
107
107
  export function resolveSpecDir(cwd, opts = {}) {
108
108
  if (opts.specDir) return resolve(opts.specDir)
109
109
  const home = os.homedir()
110
- let dir = resolve(cwd)
110
+ const origin = resolve(cwd)
111
+ const originBelowHome = origin.startsWith(home + sep) // 严格子目录,不含 home 自身
112
+ let dir = origin
113
+ let passedHome = false
111
114
  while (true) {
112
- // home 拒绝守卫:cwd 本身就在 home 下(含恰好在 home 跑命令)时,home 层不匹配 .sillyspec,
113
- // 继续向上只会离项目更远(home 的父目录不可能有真项目 .sillyspec),直接回退 cwd/.sillyspec。
114
- if (dir !== home) {
115
+ // home 拒绝守卫:起点在 home 子树内时,home 层不检查 .sillyspec,
116
+ // 防止 home 子目录遍历经过 home 误命中 ~/.sillyspec;
117
+ // passedHome:经过 home 后不再检查(覆盖遍历到真实 home 的场景)。
118
+ if (!(passedHome || (dir === home && originBelowHome))) {
115
119
  const candidate = join(dir, '.sillyspec')
116
120
  if (existsSync(candidate)) return candidate
117
121
  }
122
+ if (dir === home) passedHome = true
118
123
  const parent = dirname(dir)
119
124
  if (parent === dir) break
120
125
  dir = parent
121
126
  }
122
- return join(resolve(cwd), '.sillyspec')
127
+ return join(origin, '.sillyspec')
123
128
  }
124
129
 
125
130
  /**
@@ -614,13 +619,24 @@ export function resolveChangeDir(cwd, progress, specDir = null) {
614
619
  // 仍在跑,平台可能已接受推送而客户端当作超时放弃(spec 树推送无 base_ts 自愈兜底)。
615
620
  const SYNC_TOTAL_TIMEOUT_MS = 8_000
616
621
 
622
+ // 熔断总预算 env 开关(坑 spec-sync-aborted-looks-exception / 债 E22b 场景实证,2026-09-07):
623
+ // 真实平台执行环境后端偶发 >8s 响应,8s 熔断间歇触发(warn 文案见 spec-sync.js
624
+ // describeSyncError)。SILLYSPEC_SYNC_TIMEOUT_MS 允许平台环境放宽总预算;代价是 --done
625
+ // 最坏等待同比例变长(e2c9f90 治的体感 hang 需自行权衡)。整数毫秒,[1000, 120000],
626
+ // 非法/越界一律回退默认 8s。raceWithAbort 默认参数逐次求值——进程内改 env 即时生效。
627
+ export function resolveSyncTotalTimeoutMs() {
628
+ const raw = Number(process.env.SILLYSPEC_SYNC_TIMEOUT_MS)
629
+ if (Number.isInteger(raw) && raw >= 1_000 && raw <= 120_000) return raw
630
+ return SYNC_TOTAL_TIMEOUT_MS
631
+ }
632
+
617
633
  /**
618
634
  * 熔断 race + 确定性取消:超时触发 controller.abort() 并 resolve,在飞 fetch 收到
619
635
  * AbortError 后自然失败(各调用方 catch 返回降级值,进程不挂起)。
620
636
  * @param {() => Promise} op 接收 { signal } 的异步操作
621
637
  * @param {number} timeoutMs 熔断时长(测试可缩短)
622
638
  */
623
- async function raceWithAbort(op, timeoutMs = SYNC_TOTAL_TIMEOUT_MS) {
639
+ async function raceWithAbort(op, timeoutMs = resolveSyncTotalTimeoutMs()) {
624
640
  const controller = new AbortController()
625
641
  let timer
626
642
  try {
package/src/run/stage.js CHANGED
@@ -20,7 +20,8 @@ import { join, dirname } from 'node:path'
20
20
  import { existsSync, readdirSync, readFileSync, mkdirSync } from 'node:fs'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
22
  import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown, predictProtectedQuickFiles, mergeQuickBoundaryFiles, detectEmptyShellQuickSessions } from './shared.js'
23
- import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
23
+ import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck, executeScanDetectProjects, executeScanResumeCheck, executeScanFinalize } from './scan-profile.js'
24
+ import { executeProgressConfirm } from './progress-confirm.js'
24
25
  import { outputStep, collectStageWaitHistory } from './prompt.js'
25
26
  import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
26
27
  import { createHash } from 'node:crypto'
@@ -529,8 +530,18 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
529
530
  await executeScanPreflight(cwd, platformOpts, scanProfile)
530
531
  } else if (cliAction === 'scanPostcheck') {
531
532
  await executeScanPostcheck(cwd, platformOpts, scanProfile)
533
+ } else if (cliAction === 'scanDetectProjects') {
534
+ await executeScanDetectProjects(cwd, platformOpts)
535
+ } else if (cliAction === 'scanResumeCheck') {
536
+ await executeScanResumeCheck(cwd, platformOpts)
537
+ } else if (cliAction === 'scanFinalize') {
538
+ await executeScanFinalize(cwd, platformOpts)
532
539
  } else if (cliAction === 'planPostcheck') {
533
540
  await executePlanPostcheck(cwd, platformOpts, progress)
541
+ } else if (cliAction === 'progressConfirm') {
542
+ // 主流程 step1「进度确认」noAI 化(2026-09-07):brainstorm/execute/verify——
543
+ // 快照本就 CLI 注入、阶段路由已由 run 落定,省一轮「复述摘要→--done」往返
544
+ executeProgressConfirm({ stageName, cwd, stageData, changeName, pm })
534
545
  } else {
535
546
  throw new Error(`noAI 步骤 ${stepName} 的未知 _cliAction: ${cliAction}——请在 stage.js 注册对应分支`)
536
547
  }
@@ -546,6 +557,8 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
546
557
  // 所有步骤完成
547
558
  stageData.status = 'completed'
548
559
  stageData.completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
560
+ // 主阶段完成钉 currentStage(troubleshooting #56 根因修复,2026-09-08;与 complete.js 两处同语义)
561
+ if (!AUXILIARY_STAGES.includes(stageName)) progress.currentStage = stageName
549
562
  // persist _write 移到 completeStageGates 成功之后(task-02 / review-2026-08-09 #2):gate 异常/失败 → rollback 回 in-progress 落盘,此处未到 _write,DB 不留假 completed。
550
563
  // 阶段完成收尾共享管线(noAI 末步核心修复 S1:plan postcheck independent-tier review verdict=fail /
551
564
  // 平台 scan manifest 此前被绕过)。gate 失败已 rollback 为 in-progress,early-return(不 fall through 到末尾 return)。