sillyspec 3.22.2 → 3.22.4

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
 
@@ -3304,7 +3321,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
3304
3321
  return { stageCompleted: false, currentIdx, nextPendingIdx }
3305
3322
  }
3306
3323
 
3307
- async function skipStep(pm, progress, stageName, cwd, changeName) {
3324
+ async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
3308
3325
  const stageData = progress.stages[stageName]
3309
3326
  if (!stageData || !stageData.steps) {
3310
3327
  console.error(`❌ 阶段 ${stageName} 未初始化`)
@@ -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';
@@ -244,9 +244,20 @@ export class WorktreeManager {
244
244
 
245
245
  // 2. 检查 worktree 是否已存在
246
246
  if (existsSync(worktreePath)) {
247
- // 目录在但 meta.json 不存在(幽灵状态),自动清理
247
+ // 目录在但 meta.json 不存在(幽灵状态)—— 删之前必须确认无未提交改动,
248
+ // 否则会丢失 execute 期间未 commit 的代码(不可恢复,3.22.4 修复)。
248
249
  if (!this.getMeta(name)) {
249
- console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json),自动清理...`);
250
+ let uncommitted = '';
251
+ try { uncommitted = git(worktreePath, 'status --porcelain') } catch {}
252
+ if (uncommitted.trim()) {
253
+ throw new Error(
254
+ `检测到幽灵 worktree(无 meta.json)但含未提交改动,拒绝自动清理(防丢代码)。\n` +
255
+ ` 目录:${worktreePath}\n` +
256
+ ` 请先检查/commit/备份该目录,再手动清理:sillyspec worktree cleanup ${name} --force\n` +
257
+ ` (未提交文件数:${uncommitted.trim().split('\n').length})`
258
+ );
259
+ }
260
+ console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json,无未提交改动),自动清理...`);
250
261
  try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
251
262
  // 同步清理 git worktree 注册 + 残留分支,否则目录虽删但 git 内部状态未清,
252
263
  // 后续 git worktree add 会因「worktree 已注册」或「分支已存在」失败
@@ -590,6 +601,29 @@ export class WorktreeManager {
590
601
 
591
602
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
592
603
 
604
+ // Windows 保护:先解链接 worktree/node_modules(junction 指向主 checkout),
605
+ // 否则后续 git worktree remove / rmSync recursive 会跟随 junction 误删主 node_modules 内容。
606
+ if (!isInPlace && existsSync(worktreePath)) {
607
+ const wtNodeModules = join(worktreePath, 'node_modules');
608
+ if (existsSync(wtNodeModules)) {
609
+ let isLink = false;
610
+ try { isLink = lstatSync(wtNodeModules).isSymbolicLink(); } catch {}
611
+ if (isLink) {
612
+ try {
613
+ if (process.platform === 'win32') {
614
+ // Windows rmdir 删 junction(reparse point)不跟随目标,保护主 checkout
615
+ execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
616
+ } else {
617
+ unlinkSync(wtNodeModules);
618
+ }
619
+ details.push('worktree node_modules junction/symlink removed (protect main checkout)');
620
+ } catch (e) {
621
+ details.push(`node_modules link remove failed: ${e.message}`);
622
+ }
623
+ }
624
+ }
625
+ }
626
+
593
627
  // 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
594
628
  let gitRemoveOk = false;
595
629
  if (!isInPlace && existsSync(worktreePath)) {