sillyspec 3.22.1 → 3.22.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.
package/src/index.js CHANGED
@@ -297,6 +297,20 @@ async function main() {
297
297
  break;
298
298
  }
299
299
  case 'run': {
300
+ // doctor --json 等价 doctor 顶层 --json(结构化诊断),与 case 'doctor' 行为一致——
301
+ // 否则 sillyspec doctor --json 走 runDoctorDiagnostics,sillyspec run doctor --json 走 runCommand
302
+ // prompt 自检,两者输出不等价(违反"顶层别名 == run <stage>"契约)。
303
+ if (filteredArgs[1] === 'doctor' && json) {
304
+ const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir)
305
+ const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js')
306
+ const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir })
307
+ const output = formatDoctorJson(result, { source_root: dir })
308
+ const written = writeDoctorDiagnosis(output, result.authoritySpecDir)
309
+ if (written) console.error(`📁 诊断结果已写入: ${written}`)
310
+ console.log(JSON.stringify(output, null, 2))
311
+ process.exitCode = output.overall_status === 'pass' ? 0 : 1
312
+ break
313
+ }
300
314
  const { runCommand } = await import('./run.js')
301
315
  // 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
302
316
  const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
@@ -317,6 +331,57 @@ async function main() {
317
331
  const pathIdx = filteredArgs.indexOf('--path');
318
332
  const dbPath = pathIdx >= 0 && filteredArgs[pathIdx + 1] ? filteredArgs[pathIdx + 1] : null;
319
333
 
334
+ // --align-execute-progress:基于 plan.md 声明对齐 execute 派生戳(仿 --cleanup-remnant 范式)
335
+ // 默认 dry-run(只报告将补哪些 step),加 --confirm 才写。命中即 break,绝不 fall-through。
336
+ const alignFlag = filteredArgs.includes('--align-execute-progress');
337
+ if (alignFlag) {
338
+ // --change 解析:显式优先,缺省用单活跃变更自动兜底(与 run.js resolveChangeNameAuto 同逻辑)
339
+ const alignChangeIdx = filteredArgs.indexOf('--change');
340
+ let alignChange = alignChangeIdx >= 0 && filteredArgs[alignChangeIdx + 1] ? filteredArgs[alignChangeIdx + 1] : null;
341
+ if (!alignChange) {
342
+ const changesDir = join(resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec'), 'changes');
343
+ if (existsSync(changesDir)) {
344
+ const activeChanges = readdirSync(changesDir, { withFileTypes: true })
345
+ .filter(e => e.isDirectory() && e.name !== 'archive')
346
+ .map(e => e.name);
347
+ if (activeChanges.length === 1) alignChange = activeChanges[0];
348
+ }
349
+ }
350
+ if (!alignChange) {
351
+ console.error('❌ 无法确定变更名:请用 --change <name>,或确保仅有一个活跃变更');
352
+ process.exitCode = 2;
353
+ break;
354
+ }
355
+
356
+ // 复用顶层静态 import(line 12),不动态 await import
357
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(doctorEffectiveDir, specDir) });
358
+ const specBase = resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec');
359
+ let r;
360
+ try {
361
+ r = await pm.alignExecuteToPlan(doctorEffectiveDir, alignChange, specBase, { confirm: doctorConfirm });
362
+ } catch (e) {
363
+ console.error(`❌ 对齐失败:${e.message}`);
364
+ process.exitCode = 1;
365
+ break;
366
+ }
367
+
368
+ if (json) {
369
+ console.log(JSON.stringify(r, null, 2));
370
+ } else if (r.ok === false) {
371
+ console.error(`❌ ${r.reason || '对齐未执行'}`);
372
+ } else {
373
+ console.log(`✅ 已基于 plan.md 声明对齐 ${r.aligned} 个 step(plan: ${r.planChecked}/${r.planTotal})。`);
374
+ if (!doctorConfirm) {
375
+ console.log(` (dry-run:未写盘。加 --confirm 实际落盘,并置 execute 阶段 status=completed。)`);
376
+ } else {
377
+ console.log(` 已落盘,execute 阶段 status=completed。请确认 verify 通过。`);
378
+ }
379
+ }
380
+ // ok=false 或 reason 非空 → 1;否则 0(与 --cleanup-remnant 的 r.errors 逻辑同构)
381
+ process.exitCode = (r.ok === false || r.reason) ? 1 : 0;
382
+ break;
383
+ }
384
+
320
385
  if (cleanupRemnant) {
321
386
  const { cleanupRemnantDbs } = await import('./doctor-diagnostics.js');
322
387
  const r = await cleanupRemnantDbs({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
package/src/progress.js CHANGED
@@ -1799,4 +1799,115 @@ export class ProgressManager {
1799
1799
  writeFileSync(gitignorePath, rule + '\n');
1800
1800
  }
1801
1801
  }
1802
+
1803
+ // ── plan.md 对齐(doctor --align-execute-progress 入口)──
1804
+
1805
+ /**
1806
+ * 解析 changeDir/plan.md(回退 tasks.md)的 task checkbox 统计。
1807
+ * 仅匹配 `- [ ] task-NN` / `- [x] task-NN` 形态的行(task- 前缀锚定,避免误捞非任务项)。
1808
+ * @param {string} changeDir - 变更目录绝对路径(含 plan.md/tasks.md)
1809
+ * @returns {{ total: number, checked: number }}
1810
+ */
1811
+ readPlanCheckboxStatus(changeDir) {
1812
+ if (!changeDir || typeof changeDir !== 'string') {
1813
+ throw new Error('changeDir 不能为空');
1814
+ }
1815
+ const planPath = join(changeDir, 'plan.md');
1816
+ const tasksPath = join(changeDir, 'tasks.md');
1817
+ let content = null;
1818
+ if (existsSync(planPath)) {
1819
+ content = readFileSync(planPath, 'utf8');
1820
+ } else if (existsSync(tasksPath)) {
1821
+ content = readFileSync(tasksPath, 'utf8');
1822
+ } else {
1823
+ return { total: 0, checked: 0 };
1824
+ }
1825
+ // 匹配 `- [ ] task-NN` / `- [x] task-NN`(允许 [x] 大小写、空格弹性、task- 前缀)
1826
+ const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm;
1827
+ let total = 0;
1828
+ let checked = 0;
1829
+ let m;
1830
+ while ((m = re.exec(content)) !== null) {
1831
+ total++;
1832
+ if (m[1] === 'x' || m[1] === 'X') checked++;
1833
+ }
1834
+ return { total, checked };
1835
+ }
1836
+
1837
+ /**
1838
+ * 按 plan.md 声明对齐 execute 阶段派生进度戳。
1839
+ * 仅当 plan.md 所有 task checkbox 全勾时,把 execute 阶段所有非 completed step 标 completed,
1840
+ * 并显式置 execute stageData.status='completed' + completedAt(绕过 completeStep 推导,D-003@v2)。
1841
+ * 不复核代码,信任 plan.md 声明(与 archive 同源,verify 阶段兜底,D-002/D-004)。
1842
+ *
1843
+ * @param {string} cwd
1844
+ * @param {string} changeName
1845
+ * @param {string} specBase - platformOpts.specRoot || join(cwd, '.sillyspec'),用于定位 changes 目录
1846
+ * @param {object} [opts]
1847
+ * @param {boolean} [opts.confirm=false] - 默认 dry-run,仅当 confirm=true 才落盘
1848
+ * @returns {Promise<{ ok: boolean, aligned: number, skipped: number, planTotal: number, planChecked: number, reason?: string, dryRun?: boolean }>}
1849
+ */
1850
+ async alignExecuteToPlan(cwd, changeName, specBase, opts = {}) {
1851
+ if (!changeName) throw new Error('changeName 不能为空');
1852
+ const { confirm = false } = opts;
1853
+
1854
+ // 1. 读 progress;无 progress / 无 execute 阶段 → 拒绝
1855
+ const progress = await this.read(cwd, changeName);
1856
+ if (!progress || !progress.stages || !progress.stages.execute) {
1857
+ return { ok: false, aligned: 0, skipped: 0, planTotal: 0, planChecked: 0, reason: 'execute 阶段无进度数据' };
1858
+ }
1859
+ const executeStage = progress.stages.execute;
1860
+ const steps = Array.isArray(executeStage.steps) ? executeStage.steps : [];
1861
+ if (steps.length === 0) {
1862
+ return { ok: false, aligned: 0, skipped: 0, planTotal: 0, planChecked: 0, reason: 'execute 阶段无进度数据' };
1863
+ }
1864
+
1865
+ // 2. 读 plan.md checkbox;未全勾 → 拒绝(D-002)
1866
+ const specRoot = specBase || this._getSpecDir(cwd);
1867
+ const changeDir = join(specRoot, CHANGES_SUBDIR, changeName);
1868
+ const { total: planTotal, checked: planChecked } = this.readPlanCheckboxStatus(changeDir);
1869
+ if (planTotal === 0) {
1870
+ return { ok: false, aligned: 0, skipped: 0, planTotal, planChecked, reason: 'plan.md 无 task checkbox(无法判定完成度)' };
1871
+ }
1872
+ if (planChecked < planTotal) {
1873
+ return {
1874
+ ok: false,
1875
+ aligned: 0,
1876
+ skipped: 0,
1877
+ planTotal,
1878
+ planChecked,
1879
+ reason: `plan.md 有未勾选 task(${planChecked}/${planTotal}),拒绝对齐`,
1880
+ };
1881
+ }
1882
+
1883
+ // 3. 全勾:计算将补哪些 step
1884
+ const now = new Date().toISOString();
1885
+ let aligned = 0;
1886
+ let skipped = 0;
1887
+ for (const step of steps) {
1888
+ if (step.status === 'completed') {
1889
+ skipped++;
1890
+ continue;
1891
+ }
1892
+ aligned++;
1893
+ if (confirm) {
1894
+ step.status = 'completed';
1895
+ step.completedAt = now;
1896
+ }
1897
+ }
1898
+
1899
+ // dry-run:只报告,不落盘
1900
+ if (!confirm) {
1901
+ return { ok: true, aligned, skipped, planTotal, planChecked, dryRun: true };
1902
+ }
1903
+
1904
+ // 4. confirm:显式置 execute stageData.status='completed' + completedAt(D-003@v2,复刻 run.js:2303-2304)
1905
+ executeStage.status = 'completed';
1906
+ executeStage.completedAt = now;
1907
+ progress.currentStage = progress.currentStage || 'execute';
1908
+ progress.lastActive = now;
1909
+ await this._write(cwd, progress, changeName);
1910
+
1911
+ return { ok: true, aligned, skipped, planTotal, planChecked };
1912
+ }
1802
1913
  }
package/src/run.js CHANGED
@@ -4,7 +4,7 @@
4
4
  * CLI 成为流程引擎,AI 变成步骤执行器。
5
5
  * 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
6
6
  */
7
- import { basename, join, resolve, dirname } from 'path'
7
+ import { basename, join, resolve, dirname, relative, isAbsolute } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
9
9
  import { createRequire } from 'module'
10
10
  const require = createRequire(import.meta.url)
@@ -61,7 +61,8 @@ export function validateParsedProjects(projects, sourceRoot) {
61
61
  errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
62
62
  } else {
63
63
  const absPath = resolve(safeRoot, proj.path)
64
- if (!absPath.startsWith(safeRoot + '/') && absPath !== safeRoot) {
64
+ const rel = relative(safeRoot, absPath)
65
+ if (rel.startsWith('..') || isAbsolute(rel)) {
65
66
  errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
66
67
  }
67
68
  if (!existsSync(absPath)) {
@@ -1585,7 +1586,7 @@ export async function runCommand(args, cwd, specDir = null) {
1585
1586
 
1586
1587
  // --skip
1587
1588
  if (isSkip) {
1588
- return await skipStep(pm, progress, stageName, cwd, effectiveChange)
1589
+ return await skipStep(pm, progress, stageName, cwd, effectiveChange, platformOpts)
1589
1590
  }
1590
1591
 
1591
1592
  // --wait: 将 step 设为 waiting(独立于 --done)
@@ -2388,19 +2389,35 @@ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
2388
2389
  async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
2389
2390
  if (stageName !== 'execute') return true
2390
2391
  let meta = null
2392
+ let wm = null
2391
2393
  try {
2392
2394
  const { WorktreeManager } = await import('./worktree.js')
2393
- meta = new WorktreeManager({ cwd }).getMeta(changeName)
2395
+ wm = new WorktreeManager({ cwd })
2396
+ meta = wm.getMeta(changeName)
2394
2397
  } catch {}
2395
2398
  const depsStatus = meta?.depsStatus
2396
2399
  if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
2397
2400
  const changeDir = changeName ? join(specBase, 'changes', changeName) : null
2398
2401
  if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
2399
2402
  if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
2400
- console.error(`❌ 拒绝 --done:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
2401
- console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
2402
- console.error(` 或在 worktree 内手动安装依赖后重试。`)
2403
- if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
2403
+ // ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
2404
+ // getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 用物理目录存在性判定。
2405
+ let worktreeGone = true
2406
+ try {
2407
+ worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
2408
+ } catch {}
2409
+ // ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
2410
+ console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
2411
+ if (worktreeGone) {
2412
+ console.error(' worktree 不可用(已 cleanup 或目录不存在)。')
2413
+ console.error(` 修复:sillyspec doctor --align-execute-progress${changeName ? ` --change ${changeName}` : ''} 按 plan.md 对齐进度`)
2414
+ console.error(` 或: sillyspec worktree create ${changeName || '<change>'} 重建 worktree 继续跑`)
2415
+ } else {
2416
+ console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
2417
+ console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
2418
+ console.error(' 或在 worktree 内手动安装依赖后重试。')
2419
+ if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
2420
+ }
2404
2421
  process.exit(1)
2405
2422
  }
2406
2423
 
@@ -2481,7 +2498,19 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2481
2498
  if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
2482
2499
  // 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
2483
2500
  const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
2484
- if (doneAnswer) {
2501
+ // B4: 前置 step 已对同一问题确认则自动跳过重复 wait。
2502
+ // 归一化去掉「最终/再次/重复」等修饰词,避免 step N「确认 X」与 step M「最终确认 X」重复打断。
2503
+ const normalizeReason = (r) => (r || '').replace(/(最终|再次|重复|最后|首|初次|首次)/g, '').trim()
2504
+ const currentReason = normalizeReason(currentStepDef.waitReason)
2505
+ const priorConfirmed = currentReason && steps.slice(0, currentIdx).some(s =>
2506
+ s.waitAnswer && s.status === 'completed' && normalizeReason(s.waitReason) === currentReason
2507
+ )
2508
+ if (priorConfirmed) {
2509
+ currentStep.status = 'waiting'
2510
+ currentStep.waitAnswer = '前置步骤已对同一问题确认 — 自动跳过重复 wait'
2511
+ currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
2512
+ console.log(`⚠️ Step "${currentStep.name}" 的确认(${currentStepDef.waitReason})已在前置步骤完成,自动跳过。`)
2513
+ } else if (doneAnswer) {
2485
2514
  currentStep.status = 'waiting'
2486
2515
  currentStep.waitAnswer = doneAnswer
2487
2516
  currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
@@ -3096,7 +3125,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3096
3125
  executeRunId = generateExecuteRunId()
3097
3126
  }
3098
3127
 
3099
- const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId })
3128
+ const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile })
3100
3129
  printReviewResult(reviewResult)
3101
3130
 
3102
3131
  if (!reviewResult.ok) {
@@ -3292,7 +3321,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3292
3321
  return { stageCompleted: false, currentIdx, nextPendingIdx }
3293
3322
  }
3294
3323
 
3295
- async function skipStep(pm, progress, stageName, cwd, changeName) {
3324
+ async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
3296
3325
  const stageData = progress.stages[stageName]
3297
3326
  if (!stageData || !stageData.steps) {
3298
3327
  console.error(`❌ 阶段 ${stageName} 未初始化`)
@@ -253,14 +253,21 @@ function validateBrainstormOutputs(cwd, changeName, context = {}) {
253
253
  warnings.push('design.md 缺少「自审」章节')
254
254
  }
255
255
 
256
- // P1: 涉及生命周期关键词时,design.md 必须包含生命周期契约表
256
+ // P1: 涉及生命周期关键词时,design.md 必须包含生命周期契约表(除非显式声明不涉及)
257
257
  const hasLifecycleKeyword = /\b(session|lease|agent[._-]?run|daemon|lifecycle|state[._-]?transition|claim|heartbeat)\b/i.test(content)
258
258
  if (hasLifecycleKeyword) {
259
- const hasLifecycleTable =
260
- /生命周期契约表|lifecycle[._-]?contract|lifecycle[._-]?matrix|Lifecycle Contract/i.test(content) ||
261
- /事件.*发起方.*接收方.*必需字段.*状态变化/.test(content)
262
- if (!hasLifecycleTable) {
263
- errors.push('design.md 涉及生命周期关键词(session/lease/agent_run/daemon/lifecycle)但缺少「生命周期契约表」— 必须列出完整的事件×状态转换矩阵')
259
+ // 显式声明本变更不涉及生命周期契约(覆盖字段名/错误码/否定声明场景):
260
+ // 历史教训:design 提到 daemon_id 字段名或 daemon_not_owned 错误码就触发,被迫加空表(B3a)
261
+ const declaresNotApplicable = /(生命周期|lifecycle)[^\n]{0,40}(n\/?a|不涉及|无|none|not[ _-]?applicable)|(不涉及|无需|没有)[^\n]{0,40}(生命周期|lifecycle)/i.test(content)
262
+ if (declaresNotApplicable) {
263
+ warnings.push('design.md 显式声明不涉及生命周期契约 — 已豁免「生命周期契约表」要求')
264
+ } else {
265
+ const hasLifecycleTable =
266
+ /生命周期契约表|lifecycle[._-]?contract|lifecycle[._-]?matrix|Lifecycle Contract/i.test(content) ||
267
+ /事件.*发起方.*接收方.*必需字段.*状态变化/.test(content)
268
+ if (!hasLifecycleTable) {
269
+ errors.push('design.md 涉及生命周期关键词(session/lease/agent_run/daemon/lifecycle)但缺少「生命周期契约表」— 必须列出完整的事件×状态转换矩阵;或显式声明「不涉及生命周期契约」并附理由豁免')
270
+ }
264
271
  }
265
272
  }
266
273
  }
@@ -380,6 +387,22 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
380
387
 
381
388
  return { ok: errors.length === 0, errors, warnings }
382
389
  }
390
+ /**
391
+ * 从 verify-result.md 提取结论关键词(PASS / PASS WITH NOTES / FAIL)。
392
+ * 标题放宽:含「结论/Conclusion/Result/结果」的二级标题均可(B3c),
393
+ * PASS/FAIL 可在标题行本身(如「## 验收结论:✅ PASS」)或紧邻标题的正文里。
394
+ * 历史教训:原正则锚定确切「## 结论」,用户写「## 验收结论:✅ PASS」不被识别。
395
+ */
396
+ function extractVerifyConclusion(verify) {
397
+ const headingRe = /^##\s[^\n]*(?:结论|conclusion|result|结果)/im
398
+ const headingMatch = verify.match(headingRe)
399
+ if (!headingMatch) return ''
400
+ const start = headingMatch.index
401
+ const slice = verify.slice(start, start + 400)
402
+ const kw = slice.match(/\b(PASS(?:\s+WITH\s+NOTES)?|FAIL)\b/i)
403
+ return kw ? kw[1].toUpperCase().replace(/\s+/g, ' ') : ''
404
+ }
405
+
383
406
  function validateVerifyOutputs(cwd, changeName, context = {}) {
384
407
  const { specRoot } = context
385
408
  const changeDir = resolveChangeDir(cwd, changeName, specRoot)
@@ -419,12 +442,11 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
419
442
  // ── FAIL 结论门控(适用于所有变更,不限风险等级)──
420
443
  // verify-result.md 结论为 FAIL 时,verify 阶段不能 completed。
421
444
  // 历史教训:CLI 曾不校验结论,AI 写 FAIL 后 verify 仍被标记完成并提示"验证通过可以归档"。
422
- const conclusionLine = verify.match(/^##\s*结论\s*\n\s*(PASS(?:\s+WITH\s+NOTES)?|FAIL)/im)
423
- const conclusionStr = conclusionLine ? conclusionLine[1].toUpperCase().replace(/\s+/g, ' ') : ''
445
+ const conclusionStr = extractVerifyConclusion(verify)
424
446
  if (conclusionStr === 'FAIL') {
425
447
  errors.push('verify-result.md 结论为 FAIL — 验证未通过,不能标记 verify 完成;请修复后重新运行验证')
426
448
  } else if (!conclusionStr) {
427
- warnings.push('verify-result.md 未识别到「## 结论」章节(应为 PASS / PASS WITH NOTES / FAIL)')
449
+ warnings.push('verify-result.md 未识别到结论章节(含 结论/Conclusion/Result/结果 的二级标题,后跟 PASS / PASS WITH NOTES / FAIL)')
428
450
  }
429
451
 
430
452
  // ── P0: Change Risk Gate — 核心功能缺少真实集成验证时 FAIL ──
@@ -433,8 +455,7 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
433
455
  planContent: readIfExists(join(changeDir, 'plan.md')),
434
456
  })
435
457
  if (['integration-critical', 'deployment-critical'].includes(changeRiskProfile.level)) {
436
- const conclusionMatch = verify.match(/^## 结论\s*\n\s*(PASS|PASS WITH NOTES|FAIL)/im)
437
- const conclusion = conclusionMatch ? conclusionMatch[1] : ''
458
+ const conclusion = extractVerifyConclusion(verify)
438
459
  if (conclusion === 'PASS WITH NOTES' || conclusion === 'PASS') {
439
460
  const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification)
440
461
  if (!evidenceCheck.ok) {
@@ -128,8 +128,26 @@ export function readReview(reviewPath) {
128
128
  * @param {boolean} [opts.allowCannotVerify=true] - 是否允许 cannot_verify(默认允许,给 warning)
129
129
  * @returns {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array<{task: string, verdict: string, evidence: string[]}> }}
130
130
  */
131
+ /**
132
+ * 读取 task-XX.md frontmatter,判断是否声明 low_risk: true(type-only/机械迁移等低逻辑风险)。
133
+ * 声明 low_risk 的 task 缺 review.json 时只 warning 不 error(B2:免逐个评审仪式)。
134
+ */
135
+ function isTaskLowRisk(changeDir, taskId) {
136
+ if (!changeDir) return false
137
+ const taskFile = join(changeDir, 'tasks', `${taskId}.md`)
138
+ if (!existsSync(taskFile)) return false
139
+ try {
140
+ const content = readFileSync(taskFile, 'utf8')
141
+ const fm = content.match(/^---\n([\s\S]*?)\n---/)
142
+ if (!fm) return false
143
+ return /^low_risk:\s*true\s*$/im.test(fm[1])
144
+ } catch {
145
+ return false
146
+ }
147
+ }
148
+
131
149
  export function validateTaskReviews(opts) {
132
- const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true } = opts
150
+ const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null } = opts
133
151
 
134
152
  const taskIds = parseTaskIdsFromPlan(planContent)
135
153
 
@@ -156,8 +174,12 @@ export function validateTaskReviews(opts) {
156
174
  // review.json 存在且 JSON 合法,但 schema 校验失败
157
175
  errors.push(`${taskId}: review.json 校验失败 — ${result.errors.join('; ')}`)
158
176
  } else {
159
- // review.json 不存在
160
- errors.push(`${taskId}: 缺少 review.json — task 未经过评审`)
177
+ // review.json 不存在:声明 low_risk 的 task 豁免(B2:type-only/机械迁移免逐个评审)
178
+ if (isTaskLowRisk(changeDir, taskId)) {
179
+ warnings.push(`${taskId}: 缺少 review.json — task 声明 low_risk: true,已豁免评审(type-only/机械迁移)`)
180
+ } else {
181
+ errors.push(`${taskId}: 缺少 review.json — task 未经过评审`)
182
+ }
161
183
  }
162
184
  continue
163
185
  }
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { execSync } from 'child_process';
16
- import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
16
+ import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
17
17
  import { join, resolve } from 'path';
18
18
  import { tmpdir } from 'os';
19
19
  import { createHash } from 'crypto';
@@ -362,7 +362,6 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
362
362
  const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
363
363
  const allowedPaths = new Set();
364
364
  if (existsSync(tasksDir)) {
365
- const { readdirSync, readFileSync } = require('fs');
366
365
  for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
367
366
  const content = readFileSync(join(tasksDir, tf), 'utf8');
368
367
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
@@ -511,8 +510,7 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
511
510
  // worktree 还在,用 baselineCommit 或 baseHash 做 diff
512
511
  try {
513
512
  const diffBase = meta.baselineCommit || meta.baseHash;
514
- const { execSync: es } = require('child_process');
515
- const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
513
+ const filesRaw = execSync(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
516
514
  const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
517
515
  if (files.length > 0) {
518
516
  lines.push(``);
package/src/worktree.js CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { execSync } from 'child_process';
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
13
13
  import { join, resolve, dirname } from 'path';
14
14
  import { createHash } from 'crypto';
15
15
  import { provisionDeps, lockfileHash } from './worktree-deps.js';
@@ -590,6 +590,29 @@ export class WorktreeManager {
590
590
 
591
591
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
592
592
 
593
+ // Windows 保护:先解链接 worktree/node_modules(junction 指向主 checkout),
594
+ // 否则后续 git worktree remove / rmSync recursive 会跟随 junction 误删主 node_modules 内容。
595
+ if (!isInPlace && existsSync(worktreePath)) {
596
+ const wtNodeModules = join(worktreePath, 'node_modules');
597
+ if (existsSync(wtNodeModules)) {
598
+ let isLink = false;
599
+ try { isLink = lstatSync(wtNodeModules).isSymbolicLink(); } catch {}
600
+ if (isLink) {
601
+ try {
602
+ if (process.platform === 'win32') {
603
+ // Windows rmdir 删 junction(reparse point)不跟随目标,保护主 checkout
604
+ execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
605
+ } else {
606
+ unlinkSync(wtNodeModules);
607
+ }
608
+ details.push('worktree node_modules junction/symlink removed (protect main checkout)');
609
+ } catch (e) {
610
+ details.push(`node_modules link remove failed: ${e.message}`);
611
+ }
612
+ }
613
+ }
614
+ }
615
+
593
616
  // 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
594
617
  let gitRemoveOk = false;
595
618
  if (!isInPlace && existsSync(worktreePath)) {
package/.claude/CLAUDE.md DELETED
@@ -1,23 +0,0 @@
1
- # Claude Code 指引
2
-
3
- # SillySpec工具定位
4
- 1. SillySpec 是给 Agent 调用的 CLI 工具,不是给人类直接使用的产品
5
- 2. SillySpec 是管理 Agent 工作流的 CLI 工具,不是处理业务逻辑的工具
6
- 3. 你是 Agent。SillySpec 是你的流程控制器。你通过 CLI 命令告诉它"我在哪",它告诉你"下一步该做什么"。你执行步骤,它校验产出、推进状态。人类用户只在关键决策点介入审批
7
-
8
- ## 文件生命周期文档同步
9
- 每次修改 `src/stages/` 下的阶段定义(prompt、步骤、输出文件名等)或 `src/run.js`、`src/progress.js` 等影响文件生命周期的代码后,**必须同步更新** `docs/sillyspec/file-lifecycle.md`,确保文档与代码一致。
10
-
11
- ### 触发更新的典型改动
12
- - 新增/删除/重命名阶段步骤
13
- - 修改步骤 prompt 中的输出文件名(如 verify-result.md)
14
- - 修改阶段间的流转逻辑(如 archive 归档方式)
15
- - 新增/删除运行时文件类型(如 gate-status.json)
16
- - 修改 ProgressManager 的数据存储方式(如 SQLite 表结构变更)
17
-
18
- ### 更新检查清单
19
- - [ ] 文件名引用一致(prompt 输出的文件名 == validateFileLocations 期望的文件名)
20
- - [ ] 阶段步骤描述与 `src/stages/*.js` 一致
21
- - [ ] 归档/清理流程描述与实际代码逻辑一致
22
- - [ ] 数据库 Schema 描述与 `src/db.js` 一致
23
- - [ ] 更新文档头部 `updated_at` 时间戳