sillyspec 3.26.13 → 3.26.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.26.13",
3
+ "version": "3.26.14",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/index.js CHANGED
@@ -90,7 +90,7 @@ SillySpec CLI — 规范驱动开发工具包
90
90
  sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
91
91
  sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
92
92
  sillyspec backfill-reviews --change <name> [--json] 为手动补的 task 生成 review.json 草稿(cannot_verify,解 archive 客观完成度阻断)
93
- sillyspec register-stage-review --change <name> --stage <brainstorm|plan|execute> [--from <review.json>] [--json]
93
+ sillyspec register-stage-review --change <name> (--stage <brainstorm|plan|execute> | --all) [--from <review.json>] [--refresh-hash] [--json]
94
94
  生成/adopt stage 级 review.json(docHash 自动算 + 写 marker,治 tier=independent marker 死锁)
95
95
 
96
96
  sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
@@ -574,8 +574,14 @@ async function main() {
574
574
  const rsrStage = rsrStageIdx >= 0 && args[rsrStageIdx + 1] ? args[rsrStageIdx + 1] : null;
575
575
  const rsrFromIdx = args.indexOf('--from');
576
576
  const rsrFrom = rsrFromIdx >= 0 && args[rsrFromIdx + 1] ? args[rsrFromIdx + 1] : null;
577
- if (!rsrChange || !rsrStage) {
578
- console.error('用法: sillyspec register-stage-review --change <名> --stage <brainstorm|plan|execute> [--from <review.json>] [--spec-dir <path>] [--json]\n 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker),治 tier=independent marker 死锁');
577
+ // docHash 联动(坑 stage-review-dochash-manual-resync,2026-08-21 实证):改一版 design 要
578
+ // 重算 2-3 个 stage review 的 docHash,手工易漏、gate 报错才补。--refresh-hash 就地刷新单个
579
+ // 既有 review 的 hash(保留结论);--all 一次处理三个 stage(有 review 刷 hash,无 review
580
+ // 生成骨架——改版后一条命令完成全部联动)。
581
+ const rsrRefreshHash = filteredArgs.includes('--refresh-hash');
582
+ const rsrAll = filteredArgs.includes('--all');
583
+ if (!rsrChange || (!rsrStage && !rsrAll) || (rsrAll && rsrStage)) {
584
+ console.error('用法: sillyspec register-stage-review --change <名> (--stage <brainstorm|plan|execute> | --all) [--from <review.json>] [--refresh-hash] [--spec-dir <path>] [--json]\n 生成/adopt/刷新 stage 级 review.json(docHash 自动算 + 写 marker);--all 一次处理三个 stage(有 review 刷新 hash、无 review 生成骨架);--refresh-hash 就地刷新既有 review 的 docHash(保留 verdict 待确认续用)');
579
585
  process.exit(2);
580
586
  }
581
587
  // 与 run 入口同源消毒(防路径穿越;register 下游写 marker、拼 changes/review 路径)
@@ -583,20 +589,38 @@ async function main() {
583
589
  const { registerStageReview } = await import('./stage-review.js');
584
590
  const rsrPlatformOpts = {};
585
591
  if (specDir) rsrPlatformOpts.specRoot = specDir;
586
- try {
587
- const result = registerStageReview({ changeName: rsrChange, stage: rsrStage, fromFile: rsrFrom, cwd: dir, platformOpts: rsrPlatformOpts });
588
- if (json) {
589
- process.stdout.write(JSON.stringify({ ok: true, command: 'register-stage-review', change: rsrChange, stage: rsrStage, ...result }));
590
- } else {
591
- console.log(`✅ 已注册 ${rsrStage} stage review [${result.reviewRunId}] → ${result.reviewPath}(mode: ${result.mode})`);
592
- console.log(` marker → ${result.markerPath}`);
593
- console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
592
+ const rsrStages = rsrAll ? ['brainstorm', 'plan', 'execute'] : [rsrStage];
593
+ const rsrResults = [];
594
+ let rsrFailed = 0;
595
+ for (const st of rsrStages) {
596
+ try {
597
+ // --all 的单 stage 语义:有既有 review → refresh-hash;无 → 骨架。显式 --refresh-hash
598
+ // 则强制 refresh(无既有 review 时报错,提示先生成骨架)
599
+ const { getLatestStageReviewRunId, stageReviewMarkerPath } = await import('./stage-review.js');
600
+ const { resolveRuntimeRoot } = await import('./run/shared.js');
601
+ const rsrRuntimeRoot = resolveRuntimeRoot(rsrPlatformOpts, rsrPlatformOpts.specRoot || join(dir, '.sillyspec'));
602
+ const hasExisting = !!getLatestStageReviewRunId(rsrRuntimeRoot, st, rsrChange);
603
+ const wantRefresh = rsrRefreshHash || (rsrAll && hasExisting);
604
+ const result = registerStageReview({ changeName: rsrChange, stage: st, fromFile: rsrFrom, cwd: dir, platformOpts: rsrPlatformOpts, refreshHash: wantRefresh });
605
+ rsrResults.push({ stage: st, ...result });
606
+ if (json) {
607
+ process.stdout.write(JSON.stringify({ ok: true, command: 'register-stage-review', change: rsrChange, stage: st, ...result }) + '\n');
608
+ } else {
609
+ console.log(`✅ 已注册 ${st} stage review [${result.reviewRunId}] → ${result.reviewPath}(mode: ${result.mode})`);
610
+ console.log(` marker → ${result.markerPath}`);
611
+ if (result.mode === 'refreshed') {
612
+ console.log(` docHash 已按当前 ${result.mainDoc} 重算(verdict 结论是否仍适用需人工确认)`);
613
+ } else {
614
+ console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
615
+ }
616
+ }
617
+ } catch (e) {
618
+ rsrFailed++;
619
+ if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', change: rsrChange, stage: st, error: e.message }) + '\n');
620
+ else console.error(`❌ [${st}] ` + e.message);
594
621
  }
595
- } catch (e) {
596
- if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', error: e.message }));
597
- else console.error('❌ ' + e.message);
598
- process.exitCode = 1;
599
622
  }
623
+ if (rsrFailed > 0) process.exitCode = 1;
600
624
  break;
601
625
  }
602
626
  case 'docs': {
package/src/progress.js CHANGED
@@ -1188,8 +1188,11 @@ export class ProgressManager {
1188
1188
  if (projectRow === undefined) return null;
1189
1189
  const projectName = projectRow.name;
1190
1190
 
1191
- // 2. 活跃变更(取第一个,或空)
1192
- const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY name").all();
1191
+ // 2. 活跃变更(取 last_active 最新的一个)
1192
+ // ql-20260821-003:多活跃仓(变更隔离是常态,单仓可同时存在 N 个 active)下
1193
+ // 原 ORDER BY name 恒取字典序最前(通常是最老的变更),「当前变更/最后活动」
1194
+ // 停留在历史数据。dump 语义是「当前工作流状态」→ 取最近活跃的那个。
1195
+ const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY last_active DESC").all();
1193
1196
  if (changeRows.length === 0) {
1194
1197
  // 无活跃变更 → 返回仅含 project 的骨架
1195
1198
  return {
@@ -368,8 +368,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
368
368
  // 纯只读比较:零会话/QUICKLOG 副作用,仅解析 --base/--full/--report 后转发 scan-diff 退出码。
369
369
  if (stageName === 'scan' && flags.includes('--diff')) {
370
370
  const { runScanDiff } = await import('../scan-diff.js')
371
- const diffBaseIdx = flags.indexOf('--base')
372
- const diffBase = diffBaseIdx >= 0 && flags[diffBaseIdx + 1] ? flags[diffBaseIdx + 1] : null
371
+ const diffBase = getFlagValue('--base')
373
372
  process.exitCode = runScanDiff({
374
373
  projectRoot: cwd,
375
374
  specBase,
@@ -418,26 +417,26 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
418
417
  }
419
418
 
420
419
  // 解析 --output
420
+ // F4 守卫统一走 getFlagValue(坑 raw-flag-value-bypass):裸 flags[idx+1] 会把漏值后紧跟的
421
+ // flag 名当值(--output --input x → output="--input"),--change --done 甚至会建出名为
422
+ // --done 的变更目录(assertSafeChangeName 的 [\w.-] 恰好放行)。getFlagValue 的 F4 注释
423
+ // 点名过本坑,此前只有 from-step 等少数 flag 用了它。
421
424
  let outputText = null
422
- const outputIdx = flags.indexOf('--output')
423
- if (outputIdx !== -1 && flags[outputIdx + 1]) {
424
- outputText = flags[outputIdx + 1]
425
- }
425
+ const outputValue = getFlagValue('--output')
426
+ if (outputValue !== null) outputText = outputValue
426
427
 
427
428
  // 解析 --input
428
429
  let inputText = null
429
- const inputIdx = flags.indexOf('--input')
430
- if (inputIdx !== -1 && flags[inputIdx + 1]) {
431
- inputText = flags[inputIdx + 1]
432
- }
430
+ const inputValue = getFlagValue('--input')
431
+ if (inputValue !== null) inputText = inputValue
433
432
 
434
433
  // 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
435
434
  // 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
436
435
  // null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
437
436
  let explicitLinked = null
438
- const linkedIdx = flags.indexOf('--linked-changes')
439
- if (linkedIdx !== -1 && flags[linkedIdx + 1]) {
440
- const v = flags[linkedIdx + 1].trim()
437
+ const linkedValue = getFlagValue('--linked-changes')
438
+ if (linkedValue !== null) {
439
+ const v = linkedValue.trim()
441
440
  explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
442
441
  }
443
442
 
@@ -445,9 +444,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
445
444
  // 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
446
445
  let changeName = null
447
446
  let linkedChanges = []
448
- const changeIdx = flags.indexOf('--change')
449
- if (changeIdx !== -1 && flags[changeIdx + 1]) {
450
- changeName = flags[changeIdx + 1]
447
+ const changeValue = getFlagValue('--change')
448
+ if (changeValue !== null) {
449
+ changeName = changeValue
451
450
  if (stageName === 'quick') {
452
451
  linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
453
452
  changeName = null
@@ -489,7 +488,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
489
488
  // 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName 置 null。
490
489
  // 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
491
490
  // 并撤销把它当 linkedChanges 的误判。多值或不匹配 → 维持 linkedChanges 语义(旧兼容)。
492
- const rawChange = changeIdx !== -1 && flags[changeIdx + 1] ? flags[changeIdx + 1].trim() : null
491
+ // F4 守卫同款(changeIdx 变量已随裸读清理移除,此处改 getFlagValue 复读原始值)
492
+ const rawChangeValue = getFlagValue('--change')
493
+ const rawChange = rawChangeValue !== null ? rawChangeValue.trim() : null
493
494
  const rawIsSingleSid = rawChange && rawChange.indexOf(',') === -1 && QUICK_SID_RE.test(rawChange)
494
495
  if (rawIsSingleSid) {
495
496
  // --done --change quick-<uuid8>:精确恢复(撤销 1373-1376 把它误当 linkedChanges)
@@ -549,18 +550,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
549
550
  process.exit(2) // 用法错 → exit 2
550
551
  }
551
552
  let quickFiles = []
552
- const filesIdx = flags.indexOf('--files')
553
- if (filesIdx !== -1 && flags[filesIdx + 1]) {
554
- quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
553
+ const filesValue = getFlagValue('--files')
554
+ if (filesValue !== null) {
555
+ quickFiles = filesValue.split(',').map(f => f.trim()).filter(Boolean)
555
556
  }
556
557
 
557
558
  // 解析 --file-notes "path::括注 || path::括注"(quick 专用:QUICKLOG「文件:」行落盘多行括注)。
558
559
  // 旁路注入 quicklog.js(per-process setter),不经 complete 收尾路径透传(避碰多 agent 并发改的收尾热点)。
559
560
  // completeQuicklogEntry 读后即清;不传 → '' → 回填审计到的实际文件单行(向后兼容)。
560
561
  let quickFileNotes = ''
561
- const fileNotesIdx = flags.indexOf('--file-notes')
562
- if (fileNotesIdx !== -1 && flags[fileNotesIdx + 1]) {
563
- quickFileNotes = flags[fileNotesIdx + 1]
562
+ const fileNotesValue = getFlagValue('--file-notes')
563
+ if (fileNotesValue !== null) {
564
+ quickFileNotes = fileNotesValue
564
565
  }
565
566
  setQuickFileNotes(quickFileNotes)
566
567
 
@@ -771,16 +772,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
771
772
  let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
772
773
  // archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
773
774
  // 不带 --change 回退 default 会读错变更。无活跃变更时取最新归档变更,读其现有 progress。
775
+ // 归档目录名 = 原变更名恒等迁移(archiveDestDirName 不改名),直接原名对读——此前按
776
+ // 「日期前缀过滤 + 剥日期」找,剥出的短名与 DB 行名(含日期)恒不符 → pm.read 返回
777
+ // null → initChange 凭空建幻影变更再跑 archive(坑 archive-fallback-phantom-change)。
774
778
  if (stageName === 'archive' && autoName === 'default') {
775
779
  try {
776
780
  const archiveDir = join(specBase, 'changes', 'archive')
777
781
  if (existsSync(archiveDir)) {
778
- const latest = readdirSync(archiveDir)
779
- .filter(d => /^\d{4}-\d{2}-\d{2}-.+/.test(d))
782
+ const latest = readdirSync(archiveDir, { withFileTypes: true })
783
+ .filter(d => d.isDirectory())
784
+ .map(d => d.name)
780
785
  .sort()
781
786
  .pop()
782
787
  if (latest) {
783
- autoName = latest.replace(/^\d{4}-\d{2}-\d{2}-/, '')
788
+ autoName = latest
784
789
  progress = pm.read(cwd, autoName)
785
790
  }
786
791
  }
@@ -887,12 +892,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
887
892
  // stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
888
893
  const stageDataPre = progress.stages[stageName]
889
894
  const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
890
- // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)
895
+ // execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)。
896
+ // 按名保留旧状态(坑 execute-reopen-wipes-prior-completion):reopenStage 的语义是
897
+ // 「i < fromStep 保持原状(completed)」,此前预置全 pending 会把 fromStep 之前已完成的
898
+ // 步骤一并清空——CLI 打印「从步骤 N 开始修订」、prompt 注入「之前已完成不需要重做」,
899
+ // 实际 currentIdx 却回到 0,agent 被迫从头重做,修订语义完全失效。
891
900
  if (needsInit || stageName === 'execute') {
892
901
  const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
893
902
  if (freshSteps && freshSteps.length > 0) {
894
903
  if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
895
- progress.stages[stageName].steps = freshSteps.map(s => ({ name: s.name, status: 'pending' }))
904
+ const oldSteps = progress.stages[stageName].steps || []
905
+ progress.stages[stageName].steps = freshSteps.map(s => {
906
+ const old = oldSteps.find(step => step.name === s.name)
907
+ return old ? { ...old } : { name: s.name, status: 'pending' }
908
+ })
896
909
  pm._write(cwd, progress, effectiveChange)
897
910
  progress = pm.read(cwd, effectiveChange) || progress
898
911
  }
@@ -1148,15 +1161,17 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
1148
1161
  async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
1149
1162
  const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
1150
1163
  const isDone = flags.includes('--done')
1151
- const outputIdx = flags.indexOf('--output')
1152
- const outputText = outputIdx !== -1 && flags[outputIdx + 1] ? flags[outputIdx + 1] : null
1153
- const inputIdx = flags.indexOf('--input')
1154
- const inputText = inputIdx !== -1 && flags[inputIdx + 1] ? flags[inputIdx + 1] : null
1164
+ // F4 守卫(坑 raw-flag-value-bypass,与 runCommand getFlagValue 同款):裸 flags[idx+1] 会把
1165
+ // 漏值后紧跟的 flag 名当值(--output --done → outputText="--done" 污染落盘 output)。
1166
+ const autoFlagValue = (name) => {
1167
+ const i = flags.indexOf(name)
1168
+ const next = i !== -1 ? flags[i + 1] : undefined
1169
+ return next && !String(next).startsWith('--') ? next : null
1170
+ }
1171
+ const outputText = autoFlagValue('--output')
1172
+ const inputText = autoFlagValue('--input')
1155
1173
  const skipApproval = flags.includes('--skip-approval')
1156
- const explicitMode = (() => {
1157
- const m = flags.indexOf('--mode')
1158
- return m !== -1 && flags[m + 1] ? flags[m + 1] : null
1159
- })()
1174
+ const explicitMode = autoFlagValue('--mode')
1160
1175
  const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
1161
1176
 
1162
1177
  // Helper: 在 auto 模式下获取步骤定义
@@ -1193,7 +1208,10 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1193
1208
  return progress
1194
1209
  }
1195
1210
  }
1196
- const changed = await ensureStageSteps(progress, stage, cwd)
1211
+ // specRoot 必传(坑 auto-mode-specroot-miss):平台模式 specRoot 是外部目录,漏传时
1212
+ // getStageSteps 从 cwd 推导 miss 掉 plan/execute 动态步骤(buildPlanSteps(null) 只剩
1213
+ // 2 步 fixedPrefix),种出的步骤表两次 --done 即判阶段完成,与 getAutoSteps 渲染双轨失配
1214
+ const changed = await ensureStageSteps(progress, stage, cwd, platformOpts?.specRoot || null)
1197
1215
  if (stageChanged || changed) {
1198
1216
  pm._write(cwd, progress, changeName)
1199
1217
  triggerSync(cwd, changeName, platformOpts)
@@ -1369,7 +1387,8 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
1369
1387
  progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
1370
1388
  }
1371
1389
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
1372
- await ensureStageSteps(progress, next, cwd)
1390
+ // specRoot 同上(坑 auto-mode-specroot-miss):平台模式动态步骤表必须从外部 specRoot 构建
1391
+ await ensureStageSteps(progress, next, cwd, platformOpts?.specRoot || null)
1373
1392
  pm._write(cwd, progress, changeName)
1374
1393
  triggerSync(cwd, changeName, platformOpts)
1375
1394
  progress = pm.read(cwd, changeName)
@@ -135,9 +135,14 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platform
135
135
  try {
136
136
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
137
137
  if (existsSync(runtimeRoot)) {
138
+ // stage-review marker 精确匹配(坑 marker-suffix-overmatch):marker 名是
139
+ // current-stage-review-run-id-<stage>-<change>,按 endsWith('-'+change) 后缀匹配会在归档
140
+ // 手工短名变更(如 login)时误删自动日期前缀变更(2026-08-01-login 天然以 '-login' 结尾)
141
+ // 的 marker。stage 集合与写入侧 stageReviewMarkerPath 的调用方一致。
142
+ const REVIEW_STAGES = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
138
143
  const markers = readdirSync(runtimeRoot).filter((f) =>
139
144
  f === `current-execute-run-id-${archiveChangeName}` ||
140
- (f.startsWith('current-stage-review-run-id-') && f.endsWith(`-${archiveChangeName}`)),
145
+ REVIEW_STAGES.some(st => f === `current-stage-review-run-id-${st}-${archiveChangeName}`),
141
146
  )
142
147
  for (const m of markers) {
143
148
  try { unlinkSync(join(runtimeRoot, m)) } catch {}
@@ -24,7 +24,7 @@ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveR
24
24
  import { executeScanPreflight, executeScanPostcheck, computeScanProfile } from './scan-profile.js'
25
25
  import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
26
26
  import { outputStep } from './prompt.js'
27
- import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, completeStageGates, readDesignScale } from './gates.js'
27
+ import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, warnMissingUiPrototype, completeStageGates, readDesignScale } from './gates.js'
28
28
  import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
29
29
  import { formatExecuteSummary } from '../worktree-apply.js'
30
30
  import { isEndToEndTaskText } from '../change-risk-profile.js'
@@ -229,6 +229,8 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
229
229
  // 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
230
230
  // symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
231
231
  await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
232
+ // UI 原型软提醒(非阻断):brainstorm 收尾步 design.md 命中前端文件但无 prototype → 提示用户可否决
233
+ warnMissingUiPrototype(stageName, changeName, steps[currentIdx]?.name, specBase)
232
234
 
233
235
  // ── noAI 步骤硬门(坑 noai-done-bypass):noAI 步骤的确定性校验不可被 --done 绕过 ──
234
236
  // 正常路径 agent 跑 `run <stage>` 推进到 noAI step 时,runStage 自动执行 _cliAction
@@ -321,6 +323,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
321
323
  const _drafts = await generateTaskReviewDrafts({ changeName, cwd, platformOpts, ctx: _draftCtx })
322
324
  if (_drafts.generated > 0) {
323
325
  console.log(' 📄 自动补写 ' + _drafts.generated + ' 个 per-task review.json 草稿(cannot_verify,主 agent 实现模式兜底,需 agent 复核后升级为 pass/fail)')
326
+ if (_drafts.noAttribution > 0) {
327
+ console.warn(' ⚠️ 其中 ' + _drafts.noAttribution + ' 个 task 的 allowed_paths 未命中本次 diff(无归属草稿,changedFiles 为空)——需人工确认实际改动后升级 verdict,确属未实现则回 fail')
328
+ }
324
329
  if (_drafts.unattributed && _drafts.unattributed.length > 0) {
325
330
  console.warn(' ⚠️ ' + _drafts.unattributed.length + ' 个变更文件未归属任何 task(顺带修复/非源码),草稿未覆盖:' + _drafts.unattributed.join(', '))
326
331
  }
@@ -104,8 +104,10 @@ export function detectConcurrentChanges(cwd, { changeName, linkedChanges = [], o
104
104
  if (!file) continue
105
105
 
106
106
  // rule1:落 .sillyspec/changes/<dir>/ 且 <dir> 非本变更非关联 → otherActiveChanges 去重。
107
+ // 'archive' 排除(坑 archive-dir-as-other-change):归档移动产生 .sillyspec/changes/archive/...
108
+ // 脏路径,extractChangeDir 返回 'archive',不排除会把归档动作误报成「他者活跃变更: archive」。
107
109
  const dir = extractChangeDir(file)
108
- if (dir && !ownChangeSet.has(normalizeGitPath(dir))) {
110
+ if (dir && dir !== 'archive' && !ownChangeSet.has(normalizeGitPath(dir))) {
109
111
  otherActiveSet.add(dir)
110
112
  continue
111
113
  }
package/src/run/gates.js CHANGED
@@ -92,6 +92,26 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
92
92
  process.exit(1)
93
93
  }
94
94
 
95
+ /**
96
+ * UI 原型软提醒(非阻断):brainstorm「生成规范文件」步 --done 时,design.md 命中前端文件
97
+ * 但变更目录无 prototype-*.html → console.warn 一行,用户可否决。程度判断(布局级才必须出)
98
+ * 是语义判断,CLI 不硬卡只提示——治 agent 对明显 UI 改造静默跳过原型的坑。
99
+ */
100
+ export function warnMissingUiPrototype(stageName, changeName, currentStepName, specBase) {
101
+ if (stageName !== 'brainstorm' || !currentStepName || !currentStepName.includes('生成规范文件')) return
102
+ if (!changeName || !specBase) return
103
+ const changeDir = join(specBase, 'changes', changeName)
104
+ const designPath = join(changeDir, 'design.md')
105
+ if (!existsSync(designPath)) return
106
+ try {
107
+ if (readdirSync(changeDir).some(f => /^prototype-.*\.html$/i.test(f))) return
108
+ } catch { return } // 目录不可读(平台模式 specRoot 差异等)→ 静默跳过,不制造噪音
109
+ const design = readFileSync(designPath, 'utf8')
110
+ const feExts = [...new Set((design.match(/\.(vue|svelte|astro|tsx|jsx|css|scss|sass|less|styl|html)\b/gi) || []).map(s => s.replace('.', '').toLowerCase()))]
111
+ if (feExts.length === 0) return
112
+ console.warn(` ⚠️ design.md 命中前端文件类型(${feExts.join('/')})但变更目录无 prototype-*.html——轻微样式改动可忽略;布局/交互流程级变化应补原型(sillyspec run brainstorm --reopen 回到「分段展示设计」,或直接让 agent 补生成)`)
113
+ }
114
+
95
115
  /**
96
116
  * 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
97
117
  * 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
@@ -854,6 +874,10 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
854
874
  stageData.steps = freshSteps
855
875
  stageData.status = 'pending'
856
876
  stageData.completedAt = null
877
+ // scan 的 quick 档 profile 随步骤表一并失效(坑 scan-quick-profile-step-mismatch 收尾):
878
+ // getStageSteps 感知 scanProfile 返回 3 步表,重置后 DB 是 11 步注册表,残留 profile 会让
879
+ // 下次 --status 走 ensureStageSteps 时 11 vs 3 误报漂移重播种。下次 run scan 会重算 profile。
880
+ if (stageName === 'scan' && stageData.scanProfile) delete stageData.scanProfile
857
881
  if (progress.currentStage === stageName) progress.currentStage = ''
858
882
  pm._write(cwd, progress, changeName)
859
883
  }
package/src/run/prompt.js CHANGED
@@ -183,8 +183,11 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
183
183
  console.log(`project: ${projectName}`)
184
184
  if (changeName) {
185
185
  console.log(`change: ${changeName}`)
186
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
187
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
186
+ // changeDir 根(坑 change-dir-base-mismatch):平台/漂移锚定用绝对根(specDriftAnchor 防
187
+ // worktree 副本错位),普通本地保持相对 '.sillyspec' 展示契约(test 钉死)。此前
188
+ // isPlatform 用 specRoot||runtimeRoot 判定、取值只拿 specRoot——runtimeRoot-only 平台组合
189
+ // join(null,...) 直接 TypeError。
190
+ const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
188
191
  const changeDir = join(changeDirBase, 'changes', changeName)
189
192
  console.log(`changeDir: ${changeDir}`)
190
193
  }
@@ -715,8 +718,9 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
715
718
  console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
716
719
  }
717
720
  if (changeName) {
718
- const isPlatform = platformOpts?.specRoot || platformOpts?.runtimeRoot
719
- const changeDirBase = isPlatform ? platformOpts.specRoot : '.sillyspec'
721
+ // changeDir 根与上方头部同源(坑 change-dir-base-mismatch):平台/漂移锚定绝对根,
722
+ // 普通本地保持相对 '.sillyspec' 展示;runtimeRoot-only 组合不再 join(null) 崩溃
723
+ const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
720
724
  const changeDir = join(changeDirBase, 'changes', changeName)
721
725
  console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
722
726
  }
@@ -105,21 +105,15 @@ export function computeScanProfile(cwd, platformOpts) {
105
105
  }
106
106
 
107
107
  /**
108
- * 根据 scanProfile 裁剪步骤
109
- * quick: 3 步 — CLI preflight / AI generate / CLI postcheck
110
- * standard: 跳过续扫检测(4), 跳过可选步骤(9)
108
+ * quick 档 3 步表构建(唯一真相):applyScanProfileSteps(首进程裁剪)与 getStageSteps
109
+ * (后续进程重取定义)共用。此前 quick 步骤表只在首进程 applyScanProfileSteps 里内联构建,
110
+ * getStageSteps('scan') 恒返回 11 步注册表 → 同进程 outputStep 用注册表 defSteps[1] 渲染错位
111
+ * prompt;跨进程 ensureStageSteps 见 3 vs 11 触发长度漂移重播种,quick 已完成步丢失、
112
+ * agent 从未做过的注册表步骤被 --done(坑 scan-quick-profile-step-mismatch)。
111
113
  */
112
- export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
113
- const steps = stageData.steps
114
- const mode = profile.mode
115
-
116
- if (mode === 'quick') {
117
- const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
118
- const projectName = basename(cwd)
119
- const docsRoot = join(specBase, 'docs', projectName)
120
-
121
- // Step 1: CLI preflight(不调 AI,自动完成)
122
- const step1 = {
114
+ export function buildQuickScanSteps() {
115
+ return [
116
+ {
123
117
  name: '项目概览(自动探测)',
124
118
  status: 'pending',
125
119
  noAI: true,
@@ -127,9 +121,8 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
127
121
  prompt: '',
128
122
  outputHint: 'preflight 结果',
129
123
  optional: false
130
- }
131
- // Step 2: AI 生成核心文档(唯一 AI roundtrip)
132
- const step2 = {
124
+ },
125
+ {
133
126
  name: '生成核心文档',
134
127
  status: 'pending',
135
128
  prompt: `## Quick Scan — 核心文档生成
@@ -156,13 +149,32 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
156
149
  生成的文件列表`,
157
150
  outputHint: '文件列表',
158
151
  optional: false
152
+ },
153
+ {
154
+ // 名字与注册表末步一致(按名匹配/迁移语义);prompt 清空、noAI postcheck 自动执行
155
+ name: '自检和提交',
156
+ status: 'pending',
157
+ prompt: '',
158
+ outputHint: '结果',
159
+ optional: false,
160
+ noAI: true,
161
+ _cliAction: 'scanPostcheck'
159
162
  }
160
- // Step 3: CLI postcheck(不调 AI,自动完成)
161
- const selfCheck = steps.find(s => s.name === '自检和提交') || {
162
- name: '自检和提交', status: 'pending', prompt: '', outputHint: '结果', optional: false
163
- }
164
- const step3 = { ...selfCheck, status: 'pending', noAI: true, _cliAction: 'scanPostcheck', prompt: '' }
165
- stageData.steps = [step1, step2, step3]
163
+ ]
164
+ }
165
+
166
+ /**
167
+ * 根据 scanProfile 裁剪步骤
168
+ * quick: 3 步 — CLI preflight / AI generate / CLI postcheck
169
+ * standard: 跳过续扫检测(4), 跳过可选步骤(9)
170
+ */
171
+ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
172
+ const steps = stageData.steps
173
+ const mode = profile.mode
174
+
175
+ if (mode === 'quick') {
176
+ // 3 步表构建抽至 buildQuickScanSteps(getStageSteps 同源复用,防跨进程 3 vs 11 漂移)
177
+ stageData.steps = buildQuickScanSteps()
166
178
  return
167
179
  }
168
180
 
package/src/run/shared.js CHANGED
@@ -367,8 +367,14 @@ export function writePlatformPointer(cwd, platformOpts, extra = {}) {
367
367
  declaredAt: new Date().toISOString(),
368
368
  }
369
369
  try {
370
- mkdirSync(join(platformOpts.specRoot, '.runtime'), { recursive: true })
371
- writeFileSync(join(platformOpts.specRoot, '.runtime', 'platform-scan.json'), JSON.stringify(payload, null, 2) + '\n')
370
+ // platform-scan.json 只在 specRoot 存在时可写(坑 pointer-runtime-root-only-null-join):
371
+ // runtimeRoot-only 平台组合(command.js 明确允许)下 specRoot 为 null,此前 join(null,…)
372
+ // 抛 ERR_INVALID_ARG_TYPE 落入总 catch → 指针与接管声明也一并静默未写,双入口 fail-closed
373
+ // 对该组合整体失效。分步写:specRoot 缺失只跳过 platform-scan.json,指针/声明照写。
374
+ if (platformOpts.specRoot) {
375
+ mkdirSync(join(platformOpts.specRoot, '.runtime'), { recursive: true })
376
+ writeFileSync(join(platformOpts.specRoot, '.runtime', 'platform-scan.json'), JSON.stringify(payload, null, 2) + '\n')
377
+ }
372
378
  writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify(payload, null, 2) + '\n')
373
379
  writeFileSync(join(cwd, PLATFORM_MANAGED_FILENAME), JSON.stringify(declaration, null, 2) + '\n')
374
380
  return true
@@ -913,13 +919,17 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
913
919
  // 致 flag 对 status 失效。)
914
920
  if ((!forceBaseline && result.baselineHit.length > 0) || (!allowDelete && result.deletedFiles.length > 0) || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
915
921
  result.status = 'blocked'
916
- } else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
922
+ } else if ((!allowNew && result.newFiles.some(f => !isQuickMetadata(f, guard.linkedChanges))) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
923
+ // newFiles 按元数据过滤后判定(坑 quick-metadata-warning-no-reason):.sillyspec/ 被 git
924
+ // 跟踪的仓库里 quick 新建的 quicklog/*.md 是合法元数据,不过滤会 status=warning 而
925
+ // reasons 为空(reasons 侧已过滤),打印空原因的 WARNING 噪声。
917
926
  result.status = 'warning'
918
927
  }
919
928
 
920
- // quicklog 存在性检查
929
+ // quicklog 存在性检查(specBase 感知:平台模式 quicklog 写在 specRoot 下,stage.js
930
+ // allocateQuicklogEntry 同源;硬编码 cwd/.sillyspec 会恒误报「目录不存在」抬 warning)
921
931
  try {
922
- const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
932
+ const quicklogDir = join(specBase || join(cwd, '.sillyspec'), 'quicklog')
923
933
  if (existsSync(quicklogDir)) {
924
934
  const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
925
935
  if (qlFiles.length === 0) {
@@ -1342,6 +1352,17 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
1342
1352
  const changeDir = resolveChangeDir(cwd, progress, specDir)
1343
1353
  return buildPlanSteps(changeDir)
1344
1354
  }
1355
+ if (stageName === 'scan') {
1356
+ // quick 档感知(坑 scan-quick-profile-step-mismatch):scan 的 scanProfile 在首进程
1357
+ // runStage 里算出并持久化(stageData.scanProfile),quick 档把 11 步注册表裁成 3 步。
1358
+ // 此处恒返回注册表会让后续进程 ensureStageSteps 见 3 vs 11 触发漂移重播种(quick 已完成
1359
+ // 步丢失、从未执行的注册表步骤被 --done),同进程 outputStep 也用错位 defSteps 渲染。
1360
+ // quick 档 → 与 applyScanProfileSteps 同源返回 3 步表;standard/deep 与注册表同长度零影响。
1361
+ if (progress?.stages?.scan?.scanProfile?.mode === 'quick') {
1362
+ const { buildQuickScanSteps } = await import('./scan-profile.js')
1363
+ return buildQuickScanSteps()
1364
+ }
1365
+ }
1345
1366
  const def = stageRegistry[stageName]
1346
1367
  return def ? def.steps : null
1347
1368
  }
@@ -465,7 +465,7 @@ export function printStageReviewResult(result, context = {}) {
465
465
  * @returns {{ ok: boolean, reviewRunId: string, reviewPath: string, markerPath: string, mode: 'skeleton'|'adopted', mainDoc: string, review: object }}
466
466
  * @throws {Error} changeName 空 / stage 非法 / 主文档缺失 / --from 不存在或 schema 不过
467
467
  */
468
- export function registerStageReview({ changeName, stage, fromFile, cwd, platformOpts = {} }) {
468
+ export function registerStageReview({ changeName, stage, fromFile, cwd, platformOpts = {}, refreshHash = false }) {
469
469
  if (!changeName) throw new Error('register-stage-review: changeName 不能为空')
470
470
  if (!stage || !STAGE_MAIN_DOC[stage]) {
471
471
  throw new Error(`register-stage-review: stage 无效 "${stage}"(应为 brainstorm|plan|execute)`)
@@ -486,6 +486,45 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
486
486
  }
487
487
  const reviewedFiles = [`changes/${changeName}/${mainDoc}`]
488
488
 
489
+ // ── refresh-hash 模式(坑 stage-review-dochash-manual-resync,2026-08-21 实证):改一版 design
490
+ // 要重算 2-3 个 stage review 的 docHash(brainstorm/execute 都锚 design.md),手工易漏、gate
491
+ // 报错才补。就地更新 marker 指向的已有 review.json——保留 verdict/checklist/requiredEvidence
492
+ // (结论是否仍适用由人确认),仅重算 docHash + reviewerNotes 追加刷新记录;不换 run 目录
493
+ // (换新 run 会把 marker 重定向到骨架,丢已审结论)。无既有 review → 报错(该用骨架模式生成)。
494
+ if (refreshHash) {
495
+ if (fromFile) throw new Error('register-stage-review: --refresh-hash 与 --from 互斥(refresh 就地更新既有 review,adopt 引入新文件)')
496
+ const existingRunId = getLatestStageReviewRunId(runtimeRoot, stage, changeName)
497
+ const existingPath = existingRunId
498
+ ? join(runtimeRoot, 'stage-reviews', `${stage}-${existingRunId}`, 'review.json')
499
+ : null
500
+ if (!existingPath || !existsSync(existingPath)) {
501
+ throw new Error(`register-stage-review: --refresh-hash 找不到 ${stage} 的既有 review.json(marker ${existingRunId || '无'} 无对应 run)——先去掉 --refresh-hash 生成骨架,或派审查子代理产出 review`)
502
+ }
503
+ let existing
504
+ try {
505
+ existing = JSON.parse(readFileSync(existingPath, 'utf8'))
506
+ } catch (e) {
507
+ throw new Error(`register-stage-review: 既有 review.json 解析失败 ${existingPath}: ${e.message}`)
508
+ }
509
+ const refreshedAt = new Date().toISOString().slice(0, 19).replace('T', ' ')
510
+ const review = {
511
+ ...existing,
512
+ docHash,
513
+ reviewedFiles,
514
+ reviewerNotes: `${existing.reviewerNotes || ''}\ndocHash refreshed at ${refreshedAt}(主文档改版后机械重算;verdict 结论是否仍适用需人工确认)`.trim(),
515
+ }
516
+ writeFileSync(existingPath, JSON.stringify(review, null, 2) + '\n')
517
+ const schemaRecheck = validateStageReviewSchema(review)
518
+ if (!schemaRecheck.ok) {
519
+ throw new Error(`register-stage-review: refresh 后 schema 自检失败 — ${schemaRecheck.errors.join('; ')}(原 review 字段异常,先修原文件)`)
520
+ }
521
+ const hashRecheck = verifyStageReviewDocHash(review, [specBase, changeDir, cwd])
522
+ if (!hashRecheck.ok) {
523
+ throw new Error(`register-stage-review: refresh 后 docHash 自检失败(不应发生)— ${hashRecheck.errors.join('; ')}`)
524
+ }
525
+ return { ok: true, reviewRunId: existingRunId, reviewPath: existingPath, markerPath: stageReviewMarkerPath(runtimeRoot, stage, changeName), mode: 'refreshed', mainDoc, review }
526
+ }
527
+
489
528
  let review
490
529
  let mode
491
530
  if (fromFile) {
@@ -218,32 +218,44 @@ export const definition = {
218
218
  migratedFrom: ['HTML 原型生成'],
219
219
  requiresWait: true,
220
220
  waitReason: '等待用户确认设计方案',
221
- waitOptions: ['确认', '需要修改', '推翻重来'],
222
- prompt: `展示完整设计方案供用户确认;如适合可视化,顺带生成 HTML 原型。
221
+ waitOptions: ['确认', '需要修改', '补原型', '推翻重来'],
222
+ prompt: `展示完整设计方案供用户确认;涉及 UI 改动时按下方分级决定是否生成 HTML 原型——原型动作必须在暂停之前落定,不能"等确认后再补"。
223
223
 
224
224
  ### 操作
225
225
  1. 简单项目:几句话整体描述
226
226
  2. 复杂项目:按模块/Phase 分段展示,每段 200-300 字
227
227
  3. 展示完整设计方案(不要逐段停顿,一次性展示)
228
228
  4. 确认变更名(格式:\`YYYY-MM-DD-<简短描述>\`,例如 \`2026-05-13-user-auth\`)
229
- 5. 暂停等待用户确认或修改意见
230
-
231
- ### HTML 原型生成(适合可视化时)
232
- 判断本次设计是否适合生成 HTML 原型:
233
- - 适合:有 UI 组件/布局/交互流程/状态转换/架构图
234
- - 不适合:纯后端逻辑/配置修改/无可视化意义
235
- 如果适合,生成一个独立的 HTML 文件(内联 CSS + JS),保存到:
236
- \`{SPEC_ROOT}/changes/<change-name>/prototype-<名称>.html\`
229
+ 5. 按「HTML 原型分级」生成原型或写出跳过原因(二选一必填,见下)
230
+ 6. 暂停等待用户确认或修改意见
231
+
232
+ ### HTML 原型分级(按改动程度,不是"是否碰前端文件")
233
+ 判定对象是**改造后的界面相对现状的变化程度**:
234
+ - **必须生成**(布局/结构/流程级变化):
235
+ - 新增页面、视图、路由
236
+ - 页面布局变化:区域增删、骨架重排、信息层级调整(如单栏改多栏、列表改看板)
237
+ - 新交互流程:多步流程、跨页流转、状态驱动的界面变化
238
+ - 用户提供了截图/设计稿要求按其改造(原型即对照基准)
239
+ - **建议生成**(组件级变化、页面骨架不变):
240
+ - 表单重构、列表列调整、弹窗/抽屉流程等局部组件变化
241
+ - 拿不准时默认生成——宁多勿漏,原型在 execute 阶段可直接对照复用
242
+ - **跳过**(无需生成,但必须声明原因):
243
+ - 文案、颜色、间距、字号、图标等样式微调
244
+ - 纯后端逻辑/CLI/配置/文档,无任何界面变化
245
+
246
+ ### 生成要求
247
+ 生成一个独立的 HTML 文件(内联 CSS + JS),保存到:
248
+ \`{SPEC_ROOT}/changes/<change-name>/prototype-<名称>.html\`(目录不存在则先创建)
237
249
  - 单文件,浏览器直接打开
238
250
  - 高保真:布局/组件/交互流程按真实效果呈现,后续 execute 阶段可直接对照复用
239
251
  - 与项目代码风格一致:优先复用项目现有技术栈/组件库/设计 token(先查 scan 文档与现有前端代码),不另起风格
240
- 展示给用户确认设计方向。不适合则跳过。
241
252
 
242
253
  ### 铁律 — 必须等待用户确认设计
243
254
  - 展示完整设计方案后必须暂停等用户确认,**不要自己说"设计已充分确认"然后推进**(命令由 CLI 在下方注入)
255
+ - 原型生成或跳过声明必须在暂停**之前**完成——暂停是本步骤最后动作,用户确认后直接进入写 design.md,不会回头补
244
256
 
245
257
  ### 输出
246
- 完整设计方案 + 变更名(+ 原型文件路径,如生成)
258
+ 完整设计方案 + 变更名 + 原型文件路径 或 跳过原因(二选一必填;跳过时写明分级依据,用户可当场否决要求补)
247
259
 
248
260
  ### 注意
249
261
  - 不要一次输出大段文字,按模块/Phase 分段
@@ -333,6 +345,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
333
345
  - design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——「生成规范文件」步完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
334
346
  - 如存在 decisions.md,design.md 是否引用所有当前版本 D-xxx@vN
335
347
  - 涉及 session/lease/agent_run/daemon/lifecycle 等关键词时,是否含「生命周期契约表」
348
+ - UI 原型核对:文件清单含前端文件(.vue/.tsx/.jsx/.svelte/.html/.css 等)且改动达到「必须生成/建议生成」原型级别(分级见「分段展示设计」步)时,变更目录应已有 \`prototype-*.html\`——没有则本步补生成;确不生成时把跳过原因记入 design.md 风险登记,不得静默缺位
336
349
  5. 缺章节 → 补齐后重检;章节齐全 → 进入下一步(Design Grill 做语义层交叉审查)
337
350
 
338
351
  ### 输出
@@ -73,16 +73,14 @@ const dir = '.sillyspec/changes';
73
73
  const subs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => { try { return fs.statSync(dir+'/'+f).isDirectory() && f !== 'archive'; } catch { return false; } }) : [];
74
74
  let known;
75
75
  try { const db = new DatabaseSync('.sillyspec/.runtime/sillyspec.db', { readOnly: true }); known = new Set(db.prepare(\"SELECT name FROM changes WHERE status='active'\").all().map(r => r.name)); db.close(); } catch (e) { console.log('⚠️ db 读取失败: ' + e.message + '(跳过孤儿判定,不做任何清理建议)'); known = null; }
76
+ // known 为 null(db 不可读)时只列目录不判定孤儿,后续僵尸检查同样跳过——不守卫会 TypeError 半途崩
76
77
  if (known) subs.forEach(s => {
77
78
  console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(先 dump 取证再手工归位,勿直接 rm)');
78
79
  });
79
- subs.forEach(s => {
80
- console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(可清理)');
81
- });
82
80
  // 反向检查(multi-agent-review Q1):DB active 且匹配 quick-<hex> 但无目录 → 僵尸 quick 会话
83
81
  // (quick 收尾未注销,active 行累积污染 listChanges/doctor)。新代码已修收尾,此项清理历史残留。
84
82
  const QUICK_RE = /^quick-[0-9a-f]{8}$/;
85
- const zombieQuick = [...known].filter(n => QUICK_RE.test(n) && !subs.includes(n));
83
+ const zombieQuick = known ? [...known].filter(n => QUICK_RE.test(n) && !subs.includes(n)) : [];
86
84
  if (zombieQuick.length > 0) {
87
85
  console.log('⚠️ 检测到 ' + zombieQuick.length + ' 个僵尸 quick 会话(DB active 但无目录):');
88
86
  zombieQuick.forEach(n => console.log(' - ' + n));
@@ -118,7 +118,8 @@ export function validatePlanForExecute(tasksContent, planContent) {
118
118
  if (count > 1) errors.push(`task id 重复: ${id} 在 tasks.md 出现 ${count} 次`)
119
119
  }
120
120
 
121
- // 检查 3: task id 连续性(从 1 开始)
121
+ // 检查 3: task id 连续性(从 1 开始;ids[0]!==1 时整体跳过 = 兼容旧变更编号不从 1 起,
122
+ // 契约由 test/plan-execute-contract.test.mjs Case 10 钉死,勿改)
122
123
  const ids = allTasks.map(t => t.index).sort((a, b) => a - b)
123
124
  if (ids.length > 0 && ids[0] === 1) {
124
125
  for (let i = 0; i < ids.length; i++) {
@@ -577,6 +578,10 @@ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
577
578
  } catch {
578
579
  return false
579
580
  }
581
+ // CRLF 归一(坑 base-commit-crlf-frontmatter,plan-postcheck.js 同款):跨仓 task 卡被
582
+ // 编辑器/子代理写成 CRLF 后 `^---\n` 匹配失败 → base_commit 锚点静默不落盘,而 Wave
583
+ // prompt 仍声称「CLI 已落盘」。归一后按 LF 写回(taskcard 生成侧本就强制 LF)。
584
+ content = content.replace(/\r\n?/g, '\n')
580
585
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
581
586
  if (!fmMatch) return false
582
587
  const fm = fmMatch[1]
@@ -276,10 +276,25 @@ export async function cmdRefresh(dir, args, opts = {}) {
276
276
  // refresh 只写 generated/ 区
277
277
  mkdirSync(generatedDir, { recursive: true })
278
278
 
279
- // 扫描 scan 文档
279
+ // 扫描 scan 文档。项目名用 DB 注册名优先(坑 knowledge-basename-project-mismatch):
280
+ // scan 写侧 docs/<project> 的 project 来自 dbProjectName || basename(cwd),注册名 ≠ 目录名
281
+ // (projects yaml name/path 分离、平台模式 specRoot≠sourceRoot)时,用 basename(dir) 拼
282
+ // scanDir 永远找不到 → refresh 恒报 scan_docs_missing。DB 读失败回退 basename(与写侧
283
+ // default 同口径)。查不到注册名对应目录时再回退 basename 试一次(双形态兼容)。
280
284
  const base = opts.specDir || join(dir, '.sillyspec')
281
- const projectName = basename(dir)
282
- const scanDir = join(base, 'docs', projectName, 'scan')
285
+ let projectName = null
286
+ try {
287
+ const { DatabaseSync } = await import('node:sqlite')
288
+ const db = new DatabaseSync(join(base, '.runtime', 'sillyspec.db'), { readOnly: true })
289
+ const row = db.prepare('SELECT name FROM project LIMIT 1').get()
290
+ db.close()
291
+ if (row && row.name) projectName = row.name
292
+ } catch { /* db 不可读 → 走 basename 兜底 */ }
293
+ if (!projectName) projectName = basename(dir)
294
+ let scanDir = join(base, 'docs', projectName, 'scan')
295
+ if (!existsSync(scanDir) && projectName !== basename(dir)) {
296
+ scanDir = join(base, 'docs', basename(dir), 'scan')
297
+ }
283
298
 
284
299
  if (!existsSync(scanDir)) {
285
300
  output(false, {}, { code: 'scan_docs_missing', path: scanDir })
@@ -348,7 +348,10 @@ function parseTaskWavesFromPlan(planPath) {
348
348
  // (旧变更读侧兼容——新格式由 validatePlanForExecute 拦旧形态,此处不重复把关)
349
349
  const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?(task-\d+)\s*[::\s]*$/i)
350
350
  || line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
351
- if (tm && currentWave !== null) map.set(tm[1], currentWave)
351
+ // id 归一补零(与卡片/注册表 task-01 口径一致,坑 wave-ref-unpadded):plan.md 写
352
+ // `- task-1` 时裸用捕获原文做 key,永远匹配不到卡片 id task-01 → 被判「未列入任何
353
+ // Wave」,同 Wave 共享文件检测静默跳过。execute.js parseWavesFromPlan 已补零,对齐。
354
+ if (tm && currentWave !== null) map.set(String(tm[1]).replace(/^task-(\d+)$/i, (_, n) => `task-${n.padStart(2, '0')}`), currentWave)
352
355
  }
353
356
  return map
354
357
  }
@@ -614,16 +617,19 @@ export function parseLocalYamlModules(yamlText) {
614
617
  const modules = {}
615
618
  for (let i = startIdx + 1; i < lines.length; i++) {
616
619
  const line = lines[i]
617
- const entry = line.match(/^ ([A-Za-z0-9_.\-]+):\s*(.*)$/)
620
+ const entry = line.match(/^([ \t]+)([A-Za-z0-9_.\-]+):\s*(.*)$/)
618
621
  if (!entry) {
619
622
  // 遇到新的顶层 key(行首非空格且非注释)→ modules 块结束
620
623
  if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
621
624
  continue
622
625
  }
623
- const rest = entry[2] || ''
626
+ // 条目缩进放宽为「任意非零空白」(坑 modules-indent-hardcoded):此前硬编码恰 2 空格,
627
+ // 4 空格/Tab 缩进的条目不匹配且不触发块结束 → 被静默跳过 → modules 丢失 →
628
+ // validateScriptCommands 退化为只查根 package.json,monorepo 子包命令被误报不存在。
629
+ const rest = entry[3] || ''
624
630
  // path 值:带引号或不带引号(flow mapping),取第一个匹配
625
631
  const pathMatch = rest.match(/path:\s*"([^"]+)"/) || rest.match(/path:\s*([^,\s}]+)/)
626
- if (pathMatch) modules[entry[1]] = { path: pathMatch[1] }
632
+ if (pathMatch) modules[entry[2]] = { path: pathMatch[1] }
627
633
  }
628
634
  return Object.keys(modules).length > 0 ? modules : null
629
635
  }
@@ -669,9 +675,14 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
669
675
  continue // 非法 YAML 由 feasibility 处理,不重复
670
676
  }
671
677
 
672
- // 合并 verify + implementation 文本(命令可能出现在任一字段)
673
- const verifyText = typeof fm.verify === 'string' ? fm.verify : ''
674
- const implText = typeof fm.implementation === 'string' ? fm.implementation : ''
678
+ // 合并 verify + implementation 文本(命令可能出现在任一字段)。
679
+ // 字段两形态都收(坑 taskcard-block-list-noop):规范格式(taskcard.js 骨架 / plan 模板)的
680
+ // verify/implementation 是 YAML 块列表 → js-yaml 解析为数组;此前只收 string,规范卡片
681
+ // 恒落空 → 「命令存在性校验」(hard-error)对合规 TaskCard 整体 no-op,只有违规写成
682
+ // 纯标量的卡片才被校验。数组按行 join 回文本,与标量同管道。
683
+ const toCmdText = (v) => Array.isArray(v) ? v.map(String).join('\n') : (typeof v === 'string' ? v : '')
684
+ const verifyText = toCmdText(fm.verify)
685
+ const implText = toCmdText(fm.implementation)
675
686
  const text = `${verifyText}\n${implText}`
676
687
  if (!text.trim()) continue
677
688
 
@@ -1047,8 +1058,10 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
1047
1058
  if (allTaskIds.length > 0) {
1048
1059
  const nums = allTaskIds.map(id => {
1049
1060
  const m = id.match(/task-(\d+)/i)
1050
- return m ? parseInt(m[1]) : null
1061
+ return m ? parseInt(m[1], 10) : null
1051
1062
  }).filter(n => n !== null).sort((a, b) => a - b)
1063
+ // nums[0]!==1 时跳过 = 兼容旧变更编号不从 1 起(契约同 validatePlanForExecute 检查 3,
1064
+ // test/plan-execute-contract.test.mjs Case 10 钉死,勿改)
1052
1065
  if (nums.length > 0 && nums[0] === 1) {
1053
1066
  for (let i = 0; i < nums.length; i++) {
1054
1067
  if (nums[i] !== i + 1) {
@@ -1280,11 +1293,12 @@ export async function executePlanPostcheck(context) {
1280
1293
  currentWaveTasks = null
1281
1294
  continue
1282
1295
  }
1283
- // 2026-08-20-task-truth-unify:Wave 段下纯 ID 引用行(- task-NN);旧 checkbox 行同收
1296
+ // 2026-08-20-task-truth-unify:Wave 段下纯 ID 引用行(- task-NN);旧 checkbox 行同收。
1297
+ // 编号补零对齐卡片口径(坑 wave-ref-unpadded,同 parseTaskWavesFromPlan)
1284
1298
  const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?task-(\d+)\s*[::\s]*$/i)
1285
1299
  || line.match(/^[-*]\s*\[[ x]\]\s*task-(\d+)/i)
1286
1300
  if (tm && currentWaveTasks) {
1287
- currentWaveTasks.push(`task-${tm[1]}`)
1301
+ currentWaveTasks.push(`task-${String(tm[1]).padStart(2, '0')}`)
1288
1302
  }
1289
1303
  }
1290
1304
  if (currentWaveTasks) existingWaves.push(currentWaveTasks)
@@ -564,7 +564,9 @@ export const fixedSuffix = [] // postcheck 是动态生成的(需要 changeDir
564
564
  */
565
565
  function parseTaskCount(registryContent) {
566
566
  if (!registryContent || typeof registryContent !== 'string') return 0
567
- const matches = registryContent.match(/^[-*]\s*\[[ x]\]\s*task-\d+/gm)
567
+ // [ xX] + i(坑 taskcount-uppercase-x):parseTaskNames / gates.js extractTaskIdsFromRegistry
568
+ // 均收大写 [X],此处漏收 → 全大写勾选的注册表计数 0 → 误走「无任务」分支丢 coordinator 步
569
+ const matches = registryContent.match(/^[-*]\s*\[[ xX]\]\s*task-\d+/gim)
568
570
  return matches ? matches.length : 0
569
571
  }
570
572
 
@@ -624,15 +626,18 @@ export function buildPlanSteps(changeDir = null, planContent = null) {
624
626
  return [...fixedPrefix, ...postcheck]
625
627
  }
626
628
 
627
- // 解析任务名
629
+ // 解析任务名(源与计数同口径,坑 plan-tasknames-empty-under-registry-contract):新契约
630
+ // (2026-08-20-task-truth-unify)下 plan.md Wave 段是纯 ID 引用行(无 checkbox 无任务名),
631
+ // 此前只从 planContent/plan.md 解析 → taskNames 恒空 → coordinator 步骤任务清单空白,
632
+ // 主 agent 只能自行读 tasks.md 兜底易漏派。tasks.md 优先(与 readTaskRegistryContent 同序)。
628
633
  let taskNames = []
629
- if (planContent) {
630
- taskNames = parseTaskNames(planContent)
631
- } else if (changeDir) {
632
- const planFile = path.join(changeDir, 'plan.md')
633
- if (existsSync(planFile)) {
634
- taskNames = parseTaskNames(readFileSync(planFile, 'utf8'))
635
- }
634
+ let namesSource = planContent || null
635
+ if (changeDir) {
636
+ const registryContent = readTaskRegistryContent(changeDir)
637
+ if (registryContent != null) namesSource = registryContent
638
+ }
639
+ if (namesSource) {
640
+ taskNames = parseTaskNames(namesSource)
636
641
  }
637
642
 
638
643
  // 生成协调器步骤(TaskCard 生成)+ postcheck
package/src/sync.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
12
+ import { writeAtomicSync } from './fs-atomic.js';
12
13
  import { join } from 'path';
13
14
  import { resolvePlatformSpecDir } from './progress.js';
14
15
  import { safeGit } from './git-helper.js';
@@ -131,12 +132,14 @@ function replaceTopLevelSection(text, name, body) {
131
132
  return `${stripped}\n\n${name}:\n${body}\n`;
132
133
  }
133
134
 
134
- /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。 */
135
+ /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。
136
+ * 原子写(fs-atomic 契约:local.yaml 被 hook/probe 等其他进程并发读,裸 writeFileSync 的
137
+ * 写入窗口可读到半截文件 → mcp 段解析失败回退 env → 误判 no-config 降级)。 */
135
138
  function writeLocalYamlRaw(cwd, text) {
136
139
  const dir = join(cwd, '.sillyspec');
137
140
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
138
141
  const yamlPath = join(cwd, LOCAL_YAML);
139
- writeFileSync(yamlPath, text, 'utf8');
142
+ writeAtomicSync(yamlPath, text, 'utf8');
140
143
  // 体检 SEC-04:local.yaml 含明文 token,收紧为仅属主可读写。POSIX chmod 0600 生效;
141
144
  // Windows 上 Node chmod 只映射只读位(近似 no-op),属可接受 best-effort
142
145
  try { chmodSync(yamlPath, 0o600); } catch { /* chmod 失败不阻断连接流程 */ }
@@ -364,7 +367,7 @@ export class SyncManager {
364
367
  // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
365
368
  try { unlinkSync(p); } catch { /* best effort */ }
366
369
  } else {
367
- writeFileSync(p, newText, 'utf8');
370
+ writeAtomicSync(p, newText, 'utf8');
368
371
  }
369
372
  }
370
373
  }
@@ -609,7 +612,10 @@ export class SyncManager {
609
612
  return { synced: 0, errors: ['未指定变更名称'] };
610
613
  }
611
614
 
612
- const changeDir = join(this.cwd, CHANGES_DIR, changeName);
615
+ // 树根与进度/spec 树同源(safePlatformSpecDir,BUG-01 同族):此前硬编码 cwd/.sillyspec/changes,
616
+ // 平台模式(specRoot 指向外部)下路径不存在 → syncDocuments 恒报「变更不存在」且调用方不查
617
+ // 返回值 → 文档在平台模式下静默永不同步。
618
+ const changeDir = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), 'changes', changeName);
613
619
  if (!existsSync(changeDir)) {
614
620
  console.warn(`[sync] 变更不存在: ${changeName}`);
615
621
  return { synced: 0, errors: [`变更不存在: ${changeName}`] };
@@ -997,7 +1003,10 @@ export class SyncManager {
997
1003
  const { ProgressManager } = await import('./progress.js');
998
1004
  const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
999
1005
  const db = pm._ensureDB(this.cwd).getDb();
1000
- db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
1006
+ // 参数侧同样 COALESCE(坑 keep-local-base-ts-null-param):SQLite 标量 MAX() 任一参数
1007
+ // 为 NULL 即返回 NULL——冲突文件 platform_last_pushed_at 为 null 时 MAX(NULL, 旧值)
1008
+ // 会把已回填的 base_ts 清空,恰好违背本注释的单调防回退意图。
1009
+ db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
1001
1010
  .run(platformPushedAt, platformPushedAt, changeName);
1002
1011
  } catch (err) {
1003
1012
  return { ok: false, resolved: false, reason: `keep-local 更新 base_ts 失败: ${err.message}` };
@@ -1024,9 +1033,12 @@ export class SyncManager {
1024
1033
  }
1025
1034
 
1026
1035
  if (mode === 'take-platform' && cf) {
1027
- // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)
1036
+ // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)。
1037
+ // fail-closed(坑 take-platform-empty-import):缺 platform_progress 时必须直接返回——
1038
+ // { ...null } 展开为 {},import 会照常跑完整事务(DELETE stages 后按空 JSON 重建 =
1039
+ // 清空本地进度),返回值却报 ok:false「未执行」,用户以为无事发生实则数据已丢。
1028
1040
  if (!cf.platform_progress) {
1029
- progressOutcome = { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
1041
+ return { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
1030
1042
  }
1031
1043
  try {
1032
1044
  const { ProgressManager } = await import('./progress.js');
@@ -1023,6 +1023,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1023
1023
  const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
1024
1024
  let generated = 0
1025
1025
  let skipped = 0
1026
+ let noAttributionCount = 0
1026
1027
  const attributed = new Set()
1027
1028
 
1028
1029
  for (const tf of taskFiles) {
@@ -1081,11 +1082,6 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1081
1082
  : []
1082
1083
  }
1083
1084
 
1084
- // 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
1085
- if (taskChangedFiles.length === 0) {
1086
- skipped++
1087
- continue
1088
- }
1089
1085
  // 主仓 task 缺 base/head(meta 缺失 / HEAD 解析失败,见上方 :968-981 注释承诺的"后续跳过"):
1090
1086
  // 跳过留给 agent 手补。此前 null.slice(0,8) 直接 TypeError 崩掉整个草稿生成(体检 BUG-06)
1091
1087
  if (!crossEntry && (!taskBase || !taskHead)) {
@@ -1097,6 +1093,42 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1097
1093
  taskChangedFiles.forEach(f => attributed.add(f))
1098
1094
  }
1099
1095
 
1096
+ // 空 changedFiles 的 task 也生成「无归属」草稿(坑 task-review-draft-skip-leak,2026-08-21
1097
+ // 实证:task-08/task-10 两次都因 allowed_paths 未命中 diff 被静默跳过,--done gate 拦截后
1098
+ // agent 只能从零手写 review.json)。cannot_verify 草稿本就待复核(verify 阶段兑现
1099
+ // requiredEvidence),空 changedFiles 不触发 emptyDiff 伪造误判(该判定看真实 git diff 而非
1100
+ // review.changedFiles),shouldAutoCheckTask 零 diff 守卫也保证不会被自动勾选——草稿从
1101
+ // 「漏了让 agent 从零写」变成「给了骨架让 agent 升级 verdict」。
1102
+ if (taskChangedFiles.length === 0) {
1103
+ const draft = {
1104
+ schemaVersion: REVIEW_SCHEMA_VERSION,
1105
+ task: taskId,
1106
+ base: taskBase,
1107
+ head: taskHead,
1108
+ changedFiles: [],
1109
+ specVerdict: 'cannot_verify',
1110
+ qualityVerdict: 'cannot_verify',
1111
+ requiredEvidence: ['auto-generated draft (no-attributed-diff): ' + taskId +
1112
+ ' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 / 路径不匹配)——' +
1113
+ '需人工确认实际改动后升级 verdict,确属未实现则回 fail'],
1114
+ reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) +
1115
+ ';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
1116
+ }
1117
+ if (draftRepo) draft.repo = draftRepo
1118
+ // fail-open(对齐函数头契约 + execute-run-dir-fail-loud 测试锁定的「写失败返回统计不抛」):
1119
+ // 无归属草稿写入失败(如 tasks/ 被普通文件占位 ENOTDIR)降级 skipped,不崩整个草稿生成
1120
+ try {
1121
+ mkdirSync(reviewDir, { recursive: true })
1122
+ writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
1123
+ generated++
1124
+ noAttributionCount++
1125
+ } catch (e) {
1126
+ console.error(`[sillyspec] task ${taskId} 无归属草稿落盘失败(降级留给 agent 手补): ${e.message}`)
1127
+ skipped++
1128
+ }
1129
+ continue
1130
+ }
1131
+
1100
1132
  const draft = {
1101
1133
  schemaVersion: REVIEW_SCHEMA_VERSION,
1102
1134
  task: taskId,
@@ -1115,7 +1147,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
1115
1147
  }
1116
1148
 
1117
1149
  const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
1118
- return { generated, skipped, unattributed, executeRunId }
1150
+ return { generated, skipped, noAttribution: noAttributionCount, unattributed, executeRunId }
1119
1151
  }
1120
1152
 
1121
1153
  /**