sillyspec 3.26.11 → 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.
Files changed (62) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +200 -27
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +187 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +88 -46
  31. package/src/run/complete-handlers.js +27 -15
  32. package/src/run/complete.js +1186 -1121
  33. package/src/run/concurrent-detect.js +3 -1
  34. package/src/run/gates.js +172 -25
  35. package/src/run/prompt.js +17 -9
  36. package/src/run/scan-profile.js +35 -23
  37. package/src/run/shared.js +164 -63
  38. package/src/run/stage.js +41 -1
  39. package/src/setup.js +8 -10
  40. package/src/sillyhub-mcp/client.js +74 -6
  41. package/src/spec-dir-typo.js +3 -24
  42. package/src/spec-sync.js +43 -8
  43. package/src/stage-contract.js +12 -38
  44. package/src/stage-review.js +40 -1
  45. package/src/stages/archive.js +4 -3
  46. package/src/stages/brainstorm-auto.js +1 -1
  47. package/src/stages/brainstorm.js +27 -13
  48. package/src/stages/doctor.js +29 -16
  49. package/src/stages/execute.js +170 -154
  50. package/src/stages/knowledge.js +18 -3
  51. package/src/stages/plan-postcheck.js +72 -37
  52. package/src/stages/plan.js +70 -48
  53. package/src/stages/quick.js +3 -1
  54. package/src/stages/verify.js +1 -1
  55. package/src/sync.js +339 -225
  56. package/src/task-review.js +281 -64
  57. package/src/taskcard.js +178 -0
  58. package/src/verify-postcheck.js +48 -35
  59. package/src/worktree-apply.js +58 -34
  60. package/src/worktree-deps.js +120 -20
  61. package/src/worktree.js +55 -9
  62. package/.npmrc-publish +0 -2
@@ -21,7 +21,7 @@
21
21
  * writer 落盘到 <authoritySpecDir>/.runtime/。
22
22
  */
23
23
  import { openDatabase, pluckGet, pluckAll } from './db-engine.js';
24
- import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
24
+ import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync, rmSync } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { CHECK_SEVERITY } from './constants.js';
27
27
  import { checkPlatformManaged } from './run/shared.js';
@@ -101,6 +101,14 @@ function probeDb(dbPath) {
101
101
  schema_version: pick('SELECT schema_version FROM project LIMIT 1'),
102
102
  change_count: pick('SELECT count(*) FROM changes', 0),
103
103
  active_changes: pickCol("SELECT name FROM changes WHERE status='active'"),
104
+ // SS-2b(2026-08-20):active 行 name → last_active 映射,供 D4 空壳判定做时间门槛
105
+ //(刚创建的合法变更目录本来就是空的,见 GHOST_EMPTY_DIR_STALE_MS 注释)。
106
+ active_last_active: (() => {
107
+ try {
108
+ const rows = db.prepare("SELECT name, last_active FROM changes WHERE status='active'").all();
109
+ return Object.fromEntries(rows.map((r) => [r.name, r.last_active]));
110
+ } catch { return {}; }
111
+ })(),
104
112
  last_active: pick('SELECT MAX(last_active) FROM changes'),
105
113
  execute_status_by_change: pickExecuteStatusByChange(),
106
114
  };
@@ -211,6 +219,10 @@ function detectMultiDb(cwd, pointer) {
211
219
  change_count: d.change_count,
212
220
  last_active: d.last_active,
213
221
  active: d.readable ? summarizeActive(d.active_changes) : null,
222
+ // SS-2:全量名单随摘要透传,供 D4 精确集合对账(ghost/orphan)消费。
223
+ active_changes: d.readable ? (d.active_changes || []) : [],
224
+ // SS-2b:name → last_active 映射随摘要透传,供 D4 空壳判定(时间门槛)消费。
225
+ active_last_active: d.readable ? (d.active_last_active || {}) : {},
214
226
  })),
215
227
  };
216
228
  }
@@ -359,6 +371,35 @@ function detectChangesSplit(cwd, pointer) {
359
371
 
360
372
  // ── D4 change↔db 一致性(针对权威 db + 权威 changes 目录)──────────────
361
373
 
374
+ // SS-2b(2026-08-20):空壳目录判定的 last_active 时间门槛(7 天)。
375
+ // 背景:brainstorm 刚注册的合法变更目录本来就是 0 文件(首份产物 proposal.md
376
+ // 落盘前有空窗,2026-08-20 task-truth-unify 即空目录数小时)。「active 行 + 目录
377
+ // 0 文件」不能直接判幽灵,必须叠加 last_active 陈旧度;门槛取 7 天(quick 会话
378
+ // 生命周期通常当天结束,正规变更 brainstorm 首产物也远早于 7 天)。
379
+ const GHOST_EMPTY_DIR_STALE_MS = 7 * 24 * 60 * 60 * 1000;
380
+
381
+ /** 目录内是否 0 个常规文件(递归;空目录/仅空子目录都算 0 文件)。读失败按非空处理(宁漏勿杀)。 */
382
+ function dirHasNoFiles(dirPath) {
383
+ const stack = [dirPath];
384
+ while (stack.length > 0) {
385
+ const cur = stack.pop();
386
+ let entries;
387
+ try { entries = readdirSync(cur, { withFileTypes: true }); } catch { return false; }
388
+ for (const e of entries) {
389
+ if (e.isFile()) return false;
390
+ if (e.isDirectory()) stack.push(join(cur, e.name));
391
+ }
392
+ }
393
+ return true;
394
+ }
395
+
396
+ /** last_active 是否陈旧超过 GHOST_EMPTY_DIR_STALE_MS。无值/不可解析按非陈旧处理(宁漏勿杀)。 */
397
+ function isStaleLastActive(lastActive, now = Date.now()) {
398
+ if (!lastActive) return false;
399
+ const ts = Date.parse(lastActive);
400
+ return Number.isFinite(ts) && (now - ts) > GHOST_EMPTY_DIR_STALE_MS;
401
+ }
402
+
362
403
  function detectChangeDbConsistency(cwd, pointer, multiDb) {
363
404
  const auth = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
364
405
  if (!auth) {
@@ -373,25 +414,52 @@ function detectChangeDbConsistency(cwd, pointer, multiDb) {
373
414
  }
374
415
  const authoritySpecRoot = pointer.present && pointer.specRoot ? pointer.specRoot : join(cwd, '.sillyspec');
375
416
  const dirChanges = listChanges(authoritySpecRoot);
376
- const dbActive = auth.active || { count: 0, sample: [] };
377
- // active.count 是权威 db 的 active change 数;但 listChanges 给的是目录数,两者口径要对齐
378
- // 这里用 active_changes 全量需从 probe 取——summary 只给了 count/sample,
379
- // 所以用 count 与目录数做粗对齐,细对齐留给后续 dump。
380
- const dirCount = dirChanges.length;
381
- const dbCount = dbActive.count;
417
+ // SS-2(2026-08-20):从 count 粗对齐升级为精确集合对账——ghost_rows(db active
418
+ // 无目录)与 orphan_dirs(目录无 db active 行)逐名列出,供 doctor --cleanup-ghosts
419
+ // 消费,不再让 52 条残留只停留在「需 dump 后细对齐」。
420
+ const activeNames = (auth.active_changes && Array.isArray(auth.active_changes))
421
+ ? auth.active_changes
422
+ : [];
423
+ const dirSet = new Set(dirChanges);
424
+ const activeSet = new Set(activeNames);
425
+ const ghostRows = activeNames.filter((n) => !dirSet.has(n));
426
+ const orphanDirs = dirChanges.filter((n) => !activeSet.has(n));
427
+ // SS-2b:空壳——db active + 目录存在但 0 文件 + last_active 超过 7 天。有目录所以
428
+ // 逃过 ghostRows 的「无目录」判定(2026-08-15 清理时 6 个空壳即此漏网形态)。
429
+ const activeLastActive = (auth.active_last_active && typeof auth.active_last_active === 'object')
430
+ ? auth.active_last_active
431
+ : {};
432
+ const emptyShells = activeNames.filter((n) =>
433
+ dirSet.has(n) && isStaleLastActive(activeLastActive[n]) && dirHasNoFiles(join(authoritySpecRoot, 'changes', n)));
382
434
  const findings = [];
383
435
  let pass = true;
384
436
  let severity = null;
385
- // 仅当差异显著才告警(口径不完全可比,避免误报)
386
- if (Math.abs(dirCount - dbCount) > Math.max(2, dirCount * 0.2)) {
437
+ if (ghostRows.length > 0) {
387
438
  pass = false;
388
439
  severity = CHECK_SEVERITY.WARNING;
389
440
  findings.push(
390
- `权威 db active changes (${dbCount}) 与 changes/ 目录数 (${dirCount}) 差异显著——` +
391
- `可能存在孤儿目录(有目录无 db 记录)或幽灵记录(db 有记录无目录),需 dump 后细对齐`
441
+ `幽灵记录 ${ghostRows.length} 条(db active 但无目录):${ghostRows.slice(0, 10).join(', ')}${ghostRows.length > 10 ? ' …' : ''}——` +
442
+ `可用 sillyspec doctor --cleanup-ghosts --confirm 归档清理`
443
+ );
444
+ }
445
+ if (emptyShells.length > 0) {
446
+ pass = false;
447
+ severity = severity || CHECK_SEVERITY.WARNING;
448
+ findings.push(
449
+ `空壳目录 ${emptyShells.length} 个(db active + 目录 0 文件 + last_active 超 7 天):${emptyShells.slice(0, 10).join(', ')}${emptyShells.length > 10 ? ' …' : ''}——` +
450
+ `可用 sillyspec doctor --cleanup-ghosts --confirm 归档行并移除空目录`
451
+ );
452
+ }
453
+ if (orphanDirs.length > 0) {
454
+ pass = false;
455
+ severity = severity || CHECK_SEVERITY.WARNING;
456
+ findings.push(
457
+ `孤儿目录 ${orphanDirs.length} 个(目录存在但无 db active 行):${orphanDirs.slice(0, 10).join(', ')}${orphanDirs.length > 10 ? ' …' : ''}——` +
458
+ `先 dump 确认再手工归位,doctor 不自动删目录`
392
459
  );
393
- } else {
394
- findings.push(`权威 db active changes (${dbCount}) 与 changes/ 目录数 (${dirCount}) 基本一致`);
460
+ }
461
+ if (pass) {
462
+ findings.push(`权威 db active changes (${activeNames.length}) 与 changes/ 目录数 (${dirChanges.length}) 完全一致`);
395
463
  }
396
464
  return {
397
465
  name: 'change_db_consistency',
@@ -399,10 +467,15 @@ function detectChangeDbConsistency(cwd, pointer, multiDb) {
399
467
  pass,
400
468
  severity,
401
469
  findings,
402
- safe_actions: [],
470
+ safe_actions: ghostRows.length > 0 || emptyShells.length > 0
471
+ ? ['sillyspec doctor --cleanup-ghosts(dry-run)→ 加 --confirm 归档幽灵行/空壳目录']
472
+ : [],
403
473
  authority_spec_root: authoritySpecRoot,
404
- dir_count: dirCount,
405
- db_active_count: dbCount,
474
+ dir_count: dirChanges.length,
475
+ db_active_count: activeNames.length,
476
+ ghost_rows: ghostRows,
477
+ orphan_dirs: orphanDirs,
478
+ empty_shells: emptyShells,
406
479
  };
407
480
  }
408
481
 
@@ -416,15 +489,17 @@ function detectChangeDbConsistency(cwd, pointer, multiDb) {
416
489
  * @returns {{total:number, checked:number} | null} null = plan/tasks.md 都不存在或无 task 行
417
490
  */
418
491
  function readPlanCheckboxStatus(changeDir) {
419
- const planPath = join(changeDir, 'plan.md');
492
+ // 2026-08-20-task-truth-unify:tasks.md(任务注册表)优先;plan.md 纯 ID 引用行无 checkbox,
493
+ // 旧「plan 优先」顺序对新契约变更恒 null(D5 诊断静默致盲)。tasks.md 缺失回退 plan.md(旧变更兼容)。
420
494
  const tasksPath = join(changeDir, 'tasks.md');
495
+ const planPath = join(changeDir, 'plan.md');
421
496
  let content = null;
422
- if (existsSync(planPath)) {
423
- try { content = readFileSync(planPath, 'utf8'); } catch { content = null; }
424
- }
425
- if (content == null && existsSync(tasksPath)) {
497
+ if (existsSync(tasksPath)) {
426
498
  try { content = readFileSync(tasksPath, 'utf8'); } catch { content = null; }
427
499
  }
500
+ if (content == null && existsSync(planPath)) {
501
+ try { content = readFileSync(planPath, 'utf8'); } catch { content = null; }
502
+ }
428
503
  if (content == null) return null;
429
504
  // match both "- [ ] task-01: title" and "- [x] task-01: title"
430
505
  const taskLine = /(?:^- \[[ x]\] )task-\d+[^:]*:?\s*.+$/gm;
@@ -709,3 +784,115 @@ function writeDump(result, authoritySpecDir) {
709
784
  return { ...result, dump_write_error: e.message };
710
785
  }
711
786
  }
787
+
788
+
789
+ /**
790
+ * SS-2(2026-08-20):归档幽灵行——db active 但 changes/ 无同名目录的记录。
791
+ * SS-2b(2026-08-20):扩空壳目录——db active + 目录存在但 0 文件 + last_active 超
792
+ * 7 天(GHOST_EMPTY_DIR_STALE_MS)。两者都是 quick 收尾注销缺陷(工具侧已修)在
793
+ * 历史会话中累积的僵尸 active 行,污染 listChanges/progress show 的「活跃变更」
794
+ * 列表;旧 doctor 只 WARNING 不给动作(stage-machine 提示「可用 doctor 清理」却清不掉)。
795
+ *
796
+ * 安全设计:
797
+ * - 仅把 changes.status 从 active 改为 archived(对齐 change-registry 归档语义,
798
+ * 状态可逆:手工改回 status='active' 即恢复,不删任何行)。
799
+ * - 默认 dry-run 只列名单;加 confirm 才写。
800
+ * - 空壳目录归档时同时移除空目录(删前复查仍为 0 文件,复查非空整条放过);
801
+ * 有内容的目录一律不动。孤儿目录(有目录无行)不自动删,留给人工归位。
802
+ */
803
+ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false }) {
804
+ const pointer = resolvePointer(cwd);
805
+ const localSpec = join(cwd, '.sillyspec');
806
+ const candidates = [
807
+ join(localSpec, '.runtime', 'sillyspec.db'),
808
+ join(localSpec, 'sillyspec.db'),
809
+ ];
810
+ if (pointer.specRoot) {
811
+ candidates.unshift(
812
+ join(pointer.specRoot, '.runtime', 'sillyspec.db'),
813
+ join(pointer.specRoot, 'sillyspec.db'),
814
+ );
815
+ }
816
+ let dbPath = null;
817
+ for (const p of candidates) {
818
+ try {
819
+ if (existsSync(p) && statSync(p).size > 0) { dbPath = p; break; }
820
+ } catch { /* stat 失败跳过 */ }
821
+ }
822
+ if (!dbPath) {
823
+ return { action: 'skipped', reason: '未找到非空 sillyspec.db', ghosts: [], archived: [], errors: [], count: 0 };
824
+ }
825
+ const authoritySpecRoot = (pointer.present && pointer.specRoot && existsSync(pointer.specRoot))
826
+ ? pointer.specRoot
827
+ : localSpec;
828
+ const changesDir = join(authoritySpecRoot, 'changes');
829
+ let dirNames = [];
830
+ try {
831
+ dirNames = readdirSync(changesDir, { withFileTypes: true })
832
+ .filter((e) => e.isDirectory() && e.name !== 'archive')
833
+ .map((e) => e.name);
834
+ } catch { /* 目录缺失 → 全部 active 都是幽灵 */ }
835
+ const dirSet = new Set(dirNames);
836
+
837
+ let db = null;
838
+ const archived = [];
839
+ const errors = [];
840
+ const skippedNonEmpty = [];
841
+ const removedDirs = [];
842
+ try {
843
+ db = openDatabase(dbPath, { readOnly: !confirm });
844
+ // last_active 与 name 一起取:空壳判定需要时间门槛(见 isStaleLastActive)。
845
+ const activeRows = db.prepare("SELECT name, last_active FROM changes WHERE status='active'").all();
846
+ const ghosts = activeRows.filter((r) => !dirSet.has(r.name)).map((r) => r.name);
847
+ const emptyShells = activeRows
848
+ .filter((r) => dirSet.has(r.name) && isStaleLastActive(r.last_active) && dirHasNoFiles(join(changesDir, r.name)))
849
+ .map((r) => r.name);
850
+ if (confirm) {
851
+ const now = new Date().toISOString();
852
+ const stmt = db.prepare("UPDATE changes SET status = 'archived', last_active = ? WHERE name = ? AND status = 'active'");
853
+ for (const n of ghosts) {
854
+ try {
855
+ stmt.run(now, n);
856
+ archived.push(n);
857
+ } catch (e) {
858
+ errors.push({ name: n, error: e.message });
859
+ }
860
+ }
861
+ for (const n of emptyShells) {
862
+ // 删目录前复查非空:dry-run 与 --confirm 之间并发会话可能已往空目录写入首份产物
863
+ //(如 brainstorm 落 proposal.md)。复查非空 → 整条放过(行也不归档),宁漏勿杀。
864
+ const dirPath = join(changesDir, n);
865
+ if (!dirHasNoFiles(dirPath)) {
866
+ skippedNonEmpty.push(n);
867
+ continue;
868
+ }
869
+ try {
870
+ stmt.run(now, n);
871
+ rmSync(dirPath, { recursive: true, force: true });
872
+ removedDirs.push(n);
873
+ archived.push(n);
874
+ } catch (e) {
875
+ errors.push({ name: n, error: e.message });
876
+ }
877
+ }
878
+ }
879
+ const activeAfter = confirm ? pluckAll(db, "SELECT name FROM changes WHERE status='active'") : null;
880
+ return {
881
+ action: confirm ? 'archived' : 'dry_run',
882
+ db_path: dbPath,
883
+ changes_dir: changesDir,
884
+ ghosts: confirm ? archived : ghosts,
885
+ empty_shells: confirm ? removedDirs : emptyShells,
886
+ skipped_nonempty: skippedNonEmpty,
887
+ removed_dirs: removedDirs,
888
+ archived,
889
+ errors,
890
+ count: ghosts.length + emptyShells.length,
891
+ active_after: activeAfter,
892
+ };
893
+ } catch (e) {
894
+ return { action: 'error', reason: e.message, ghosts: [], archived: [], errors: [{ error: e.message }], count: 0 };
895
+ } finally {
896
+ if (db) { try { db.close(); } catch { /* 忽略关闭失败 */ } }
897
+ }
898
+ }
package/src/fs-atomic.js CHANGED
@@ -58,7 +58,9 @@ export function renameSyncRetry(from, to, retries = 5) {
58
58
  */
59
59
  export function writeAtomicSync(filePath, content) {
60
60
  const dir = dirname(filePath);
61
- const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.tmp`);
61
+ // pid + 随机段双因子:Windows PID 重用激进,两进程可能撞同 pid,tmp 名碰撞 rename 互相覆盖
62
+ const rnd = Math.random().toString(36).slice(2, 10);
63
+ const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.${rnd}.tmp`);
62
64
  writeFileSync(tmpPath, content);
63
65
  try {
64
66
  renameSyncRetry(tmpPath, filePath);
package/src/git-helper.js CHANGED
@@ -53,13 +53,16 @@ export function safeGit(cwd, args, opts = {}) {
53
53
  * 调用方自行 catch;不经 shell,参数以数组元素传递。
54
54
  * @param {string} cwd
55
55
  * @param {string[]} args
56
- * @param {{ trim?: boolean, timeout?: number }} [opts]
57
- * @returns {string}
56
+ * @param {{ trim?: boolean, timeout?: number, encoding?: string }} [opts]
57
+ * - encoding:'buffer' 返回原始 Buffer(git diff --binary 等**二进制输出**专用——
58
+ * utf8 解码会破坏 NUL 字节使补丁不可应用;Buffer 输出跳过 trim)
59
+ * @returns {string|Buffer}
58
60
  */
59
61
  export function git(cwd, args, opts = {}) {
60
- const { trim = true, timeout = 5000 } = opts
62
+ const { trim = true, timeout = 5000, encoding = 'utf8' } = opts
61
63
  const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
62
- const value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] })
64
+ const value = execFileSync('git', fullArgs, { encoding, timeout, stdio: ['ignore', 'pipe', 'pipe'] })
65
+ if (Buffer.isBuffer(value)) return value
63
66
  return trim ? value.trim() : value
64
67
  }
65
68
 
@@ -121,8 +121,14 @@ function readWorktreeMeta(cwd, changeName) {
121
121
 
122
122
  function isPathInside(child, parent) {
123
123
  if (!child || !parent) return false
124
- const absChild = path.resolve(child)
125
- const absParent = path.resolve(parent)
124
+ let absChild = path.resolve(child)
125
+ let absParent = path.resolve(parent)
126
+ // Windows 文件系统大小写不敏感:git worktree list / 编辑器给出的路径大小写可能与
127
+ // 注册的 meta.worktreePath 不一致,敏感比较会把 worktree 内合法写入误判为越权(体检 BUG-09)
128
+ if (process.platform === 'win32') {
129
+ absChild = absChild.toLowerCase()
130
+ absParent = absParent.toLowerCase()
131
+ }
126
132
  return absChild === absParent || absChild.startsWith(absParent + path.sep)
127
133
  }
128
134