sillyspec 3.26.11 → 3.26.13

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 (58) 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 +161 -12
  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 +184 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +31 -8
  31. package/src/run/complete-handlers.js +21 -14
  32. package/src/run/complete.js +1181 -1121
  33. package/src/run/gates.js +148 -25
  34. package/src/run/prompt.js +9 -5
  35. package/src/run/shared.js +138 -58
  36. package/src/run/stage.js +41 -1
  37. package/src/setup.js +8 -10
  38. package/src/sillyhub-mcp/client.js +74 -6
  39. package/src/spec-dir-typo.js +3 -24
  40. package/src/spec-sync.js +43 -8
  41. package/src/stage-contract.js +12 -38
  42. package/src/stages/archive.js +4 -3
  43. package/src/stages/brainstorm-auto.js +1 -1
  44. package/src/stages/brainstorm.js +2 -1
  45. package/src/stages/doctor.js +28 -13
  46. package/src/stages/execute.js +164 -153
  47. package/src/stages/plan-postcheck.js +49 -28
  48. package/src/stages/plan.js +57 -40
  49. package/src/stages/quick.js +3 -1
  50. package/src/stages/verify.js +1 -1
  51. package/src/sync.js +323 -221
  52. package/src/task-review.js +246 -61
  53. package/src/taskcard.js +178 -0
  54. package/src/verify-postcheck.js +48 -35
  55. package/src/worktree-apply.js +58 -34
  56. package/src/worktree-deps.js +120 -20
  57. package/src/worktree.js +55 -9
  58. 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
 
package/src/index.js CHANGED
@@ -9,8 +9,7 @@
9
9
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
10
10
  import { writeAtomicSync } from './fs-atomic.js';
11
11
  import { basename, join, resolve } from 'path';
12
- import { execSync } from 'child_process';
13
- import { git } from './git-helper.js';
12
+ import { safeGit, git } from './git-helper.js';
14
13
  import { getVersion } from './version.js';
15
14
 
16
15
  // E22(性能#2):progress.js(拖 db.js→node:sqlite 链 ~48ms)与 run/shared.js(拖 stages 全家
@@ -78,6 +77,7 @@ SillySpec CLI — 规范驱动开发工具包
78
77
  list | meta <change> 列出 / 读取 meta.json
79
78
  cleanup <change> [--force] 清理 worktree
80
79
  doctor [--fix] [--stale-hours N] 健康检查 + 修复
80
+ --cleanup-ghosts [--confirm] 归档幽灵 active 记录(db 有行无目录 / 空壳目录 0 文件超 7 天,SS-2/2b)
81
81
 
82
82
  sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
83
83
  sillyspec config [schema] [--json] 打印 local.yaml 全部已知键 + 生效状态 + 读取点(堵外部 agent 配置发现缺口)
@@ -100,6 +100,9 @@ SillySpec CLI — 规范驱动开发工具包
100
100
  --dump-db --path <db 路径> dump 指定 db 内容到文件(取证用)
101
101
  --json 结构化诊断 + 落盘 .sillyspec/.runtime/doctor-diagnosis.json
102
102
  sillyspec modules <rebuild | status | migrate>
103
+ sillyspec taskcard <change> --task task-01[,task-02...] | --all [--force]
104
+ 生成 Windows 安全 TaskCard 骨架(LF 行尾 + frontmatter 闭合 +
105
+ 硬校验 9 字段齐全,标题自动取自 plan.md,Edit 填占位符即可)
103
106
  sillyspec change-rename <旧变更名> <新变更名>
104
107
  sillyspec knowledge <search --query "..." --limit N
105
108
  | inspect --id "..."
@@ -242,12 +245,9 @@ async function main() {
242
245
  // 导致 sillyspec 命令找不到 .sillyspec。此函数尝试从 git root 解析。
243
246
  function resolveEffectiveDir(baseDir) {
244
247
  if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
245
- try {
246
- const gitRoot = execSync('git rev-parse --show-toplevel', {
247
- cwd: baseDir, encoding: 'utf8', timeout: 5000
248
- }).trim()
249
- if (gitRoot && existsSync(join(gitRoot, '.sillyspec'))) return gitRoot
250
- } catch {}
248
+ // QUAL-01 收口:原 execSync 字符串拼接(经 shell)→ git-helper 数组形式
249
+ const r = safeGit(baseDir, ['rev-parse', '--show-toplevel'])
250
+ if (r.value && existsSync(join(r.value, '.sillyspec'))) return r.value
251
251
  return baseDir
252
252
  }
253
253
 
@@ -373,8 +373,34 @@ async function main() {
373
373
  }
374
374
  break;
375
375
  }
376
+ case 'dump': {
377
+ // 只读进度导出(design §6.2):daemon 消费的机器接口 JSON。
378
+ // --json 模式下劫持 console.log 到 stderr,stdout 留给纯 JSON(与 gate/derive 同纪律)。
379
+ const dumpData = pm.dump(progDir);
380
+ if (json) {
381
+ const { buildEnvelope } = await import('./machine-interface.js');
382
+ const envelope = buildEnvelope({
383
+ command: 'progress dump',
384
+ ok: dumpData !== null,
385
+ data: dumpData,
386
+ errors: dumpData === null ? ['无活跃变更或进度数据不存在'] : [],
387
+ });
388
+ console.log(JSON.stringify(envelope));
389
+ } else {
390
+ if (!dumpData) {
391
+ console.log('📭 无活跃变更或进度数据不存在');
392
+ } else {
393
+ console.log(`项目: ${dumpData.project}`);
394
+ console.log(`当前变更: ${dumpData.current_change || '(无)'}`);
395
+ console.log(`当前阶段: ${dumpData.current_stage || '(无)'}`);
396
+ console.log(`最后活跃: ${dumpData.last_active || '(无)'}`);
397
+ console.log(`产物数: ${dumpData.artifacts?.length || 0}`);
398
+ }
399
+ }
400
+ break;
401
+ }
376
402
  default:
377
- console.log('用法: sillyspec progress <init|show|validate|reset|set-stage|add-step|update-step|complete-stage>');
403
+ console.log('用法: sillyspec progress <init|show|validate|reset|set-stage|add-step|update-step|complete-stage|dump>');
378
404
  process.exit(2);
379
405
  }
380
406
  break;
@@ -627,6 +653,7 @@ async function main() {
627
653
  paths: cliPaths || cfg.paths,
628
654
  skip: cfg.skip,
629
655
  keywordAssert: cfg.keywordAssert,
656
+ crossRepoRoots: cfg.crossRepoRoots,
630
657
  });
631
658
  // task-03 修复链路(FR-04,platform-map-auto-anchors):fixActive 才构造 fixes——
632
659
  // 仅 fix.fixable===true 且 newLine 为整数的条目可重锚;newRef = ref 的行号部分替换为
@@ -654,6 +681,9 @@ async function main() {
654
681
  console.log(JSON.stringify(result, null, 2));
655
682
  } else {
656
683
  for (const w of result.warnings) console.warn(`⚠️ ${w}`);
684
+ if (result.crossRepoSkipped > 0) {
685
+ console.log(`ℹ️ 跨仓 repo:// 引用跳过 ${result.crossRepoSkipped} 处(未配 docs-check.cross_repo_roots 映射;配置后走同款行号+关键词校验)`);
686
+ }
657
687
  if (result.ok) {
658
688
  console.log(`✅ docs check: ${result.total} 处引用全通过(其中 ${result.kwChecked} 处带关键词断言)`);
659
689
  } else {
@@ -793,8 +823,9 @@ async function main() {
793
823
  // slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
794
824
  case 'doctor': {
795
825
  const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir);
796
- // 执行流:--cleanup-remnant / --dump-db(结构化诊断之外的修复/取证动作)
826
+ // 执行流:--cleanup-remnant / --cleanup-ghosts / --dump-db(结构化诊断之外的修复/取证动作)
797
827
  const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
828
+ const cleanupGhosts = filteredArgs.includes('--cleanup-ghosts');
798
829
  const dumpDbFlag = filteredArgs.includes('--dump-db');
799
830
  const doctorConfirm = filteredArgs.includes('--confirm');
800
831
  const pathIdx = filteredArgs.indexOf('--path');
@@ -825,6 +856,20 @@ async function main() {
825
856
  // 复用顶层静态 import(line 12),不动态 await import
826
857
  const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(doctorEffectiveDir, specDir) });
827
858
  const specBase = resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec');
859
+ // 坑 doctor-align-bypass-review-gate(2026-08-20 实证):align 直写 completed 绕过
860
+ // completeStep,此前 execute 的 Stage/Task Review Gate 被整体跳过——worktree 清理后的
861
+ // 恢复场景恰是最需要审计的时刻。confirm 落盘前跑同源只读校验(dry-run 不拦,保持只读语义)。
862
+ if (doctorConfirm) {
863
+ const { enforceAlignExecuteReviewGate } = await import('./run/gates.js');
864
+ const gateBlocked = await enforceAlignExecuteReviewGate({
865
+ cwd: doctorEffectiveDir, changeName: alignChange, specBase,
866
+ });
867
+ if (gateBlocked) {
868
+ console.error(' 对齐未执行(review 校验未过)。补齐 review 后重跑本命令。');
869
+ process.exitCode = 1;
870
+ break;
871
+ }
872
+ }
828
873
  let r;
829
874
  try {
830
875
  r = pm.alignExecuteToPlan(doctorEffectiveDir, alignChange, specBase, { confirm: doctorConfirm });
@@ -866,6 +911,26 @@ async function main() {
866
911
  process.exitCode = r.errors.length > 0 ? 1 : 0;
867
912
  break;
868
913
  }
914
+ if (cleanupGhosts) {
915
+ // SS-2(2026-08-20):归档幽灵行(db active 无目录)。默认 dry-run,--confirm 才写。
916
+ // SS-2b:扩空壳目录(active + 目录 0 文件 + last_active 超 7 天),归档行并移除空目录。
917
+ const { cleanupGhostChanges } = await import('./doctor-diagnostics.js');
918
+ const r = await cleanupGhostChanges({ cwd: doctorEffectiveDir, specDir, confirm: doctorConfirm });
919
+ if (json) {
920
+ console.log(JSON.stringify(r, null, 2));
921
+ } else {
922
+ console.log(`👻 幽灵 active 记录(db active 无目录 / 空壳目录超 7 天)${doctorConfirm ? '已归档' : '待清理(dry-run)'}:${r.count} 个 [db: ${r.db_path || '(未找到)'}]`);
923
+ for (const n of r.ghosts) console.log(` ${doctorConfirm ? '✅' : '-'} ${n}`);
924
+ for (const n of (r.empty_shells || [])) console.log(` ${doctorConfirm ? '✅' : '🫙'} ${n}(空壳目录${doctorConfirm ? ',空目录已移除' : ''})`);
925
+ for (const n of (r.skipped_nonempty || [])) console.log(` ⏭️ ${n}(复查时目录已非空,已放过——可能是活跃变更)`);
926
+ for (const e of r.errors) console.log(` ❌ ${e.name || ''}: ${e.error}`);
927
+ if (!doctorConfirm && r.count > 0) console.log(`
928
+ 加 --confirm 执行归档(仅改 status=archived,可逆;空壳同时移除 0 文件空目录,不删任何有内容的目录)。`);
929
+ if (r.reason) console.log(`ℹ️ ${r.reason}`);
930
+ }
931
+ process.exitCode = r.errors.length > 0 ? 1 : 0;
932
+ break;
933
+ }
869
934
  if (dumpDbFlag) {
870
935
  if (!dbPath) { console.error('❌ --dump-db 需要 --path <db 路径>'); process.exitCode = 2; break; }
871
936
  const { dumpDb } = await import('./doctor-diagnostics.js');
@@ -950,6 +1015,10 @@ async function main() {
950
1015
  for (let i = 1; i < args.length; i++) {
951
1016
  if (args[i] === '--port' && args[i + 1]) {
952
1017
  port = parseInt(args[i + 1], 10);
1018
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1019
+ console.error(`❌ 无效端口: ${args[i + 1]}(应为 1-65535 整数)`);
1020
+ process.exit(2);
1021
+ }
953
1022
  i++;
954
1023
  } else if (args[i] === '--no-open') {
955
1024
  openBrowser = false;
@@ -1298,7 +1367,7 @@ SillySpec worktree — git worktree 隔离管理
1298
1367
  const staleHours = staleIdx !== -1 && args[staleIdx + 1] ? parseInt(args[staleIdx + 1], 10) : 24;
1299
1368
  const changeIdx = args.indexOf('--change');
1300
1369
  const changeName = changeIdx !== -1 && args[changeIdx + 1] ? args[changeIdx + 1] : null;
1301
- const diag = wm.doctor({ fix: fixFlag, staleHours, changeName });
1370
+ const diag = await wm.doctor({ fix: fixFlag, staleHours, changeName });
1302
1371
  if (diag.issues.length === 0) {
1303
1372
  console.log('✅ worktree 健康检查通过,无异常');
1304
1373
  } else {
@@ -1437,6 +1506,15 @@ SillySpec platform — SillyHub 平台同步
1437
1506
  process.exit(1);
1438
1507
  }
1439
1508
 
1509
+ // 体检 SEC-05:platform 子命令族统一校验 --change 值——此前只有 run 命令族走
1510
+ // assertSafeChangeName,platform sync/pull/resolve 直接透传,冲突文件名
1511
+ // join(runtimeDir, `sync-conflict-${changeName}.json`) 可被 ../ 穿越出 .runtime
1512
+ {
1513
+ const pChangeIdx = args.indexOf('--change');
1514
+ const pChangeVal = pChangeIdx >= 0 && args[pChangeIdx + 1] ? args[pChangeIdx + 1] : null;
1515
+ if (pChangeVal) assertSafeChangeName(pChangeVal, 'platform --change 变更名');
1516
+ }
1517
+
1440
1518
  switch (platformSub) {
1441
1519
  case 'pointer': {
1442
1520
  // 指针状态检查(不依赖 sync 模块)
@@ -1503,7 +1581,11 @@ SillySpec platform — SillyHub 平台同步
1503
1581
  const tokenIdx = args.indexOf('--token');
1504
1582
  const token = tokenIdx >= 0 && args[tokenIdx + 1] ? args[tokenIdx + 1] : undefined;
1505
1583
  if (!token) {
1506
- console.error('⚠️ 未提供 --token,将使用交互式输入(TODO: task-11)');
1584
+ // 缺 token 直接终止(体检 HUB-02):交互式输入尚未实现(task-11),此前
1585
+ // 继续执行会把 undefined 序列化成字符串 "undefined" 写入 local.yaml,
1586
+ // 报"连接成功"后所有同步持续 401(Bearer undefined)
1587
+ console.error('❌ 缺少 --token <token>(交互式输入暂未实现);请在 SillyHub 平台获取 token 后重试');
1588
+ process.exit(1);
1507
1589
  }
1508
1590
  await syncModule.connect(url, token, dir);
1509
1591
  break;
@@ -1626,6 +1708,8 @@ SillySpec platform — SillyHub 平台同步
1626
1708
  console.error('❌ 必须恰好指定 --keep-local / --take-platform / --abort 之一');
1627
1709
  process.exit(1);
1628
1710
  }
1711
+ // 位置参数变更名与 --change 同规格校验(SEC-05:resolve 会按该名重推/删冲突文件)
1712
+ assertSafeChangeName(resolveName, 'platform resolve 变更名');
1629
1713
  const modeMap = { '--keep-local': 'keep-local', '--take-platform': 'take-platform', '--abort': 'abort' };
1630
1714
  const resolveMode = modeMap[resolveFlags[0]];
1631
1715
  const r = await syncModule.resolve(resolveName, resolveMode, dir);
@@ -1673,6 +1757,71 @@ SillySpec platform — SillyHub 平台同步
1673
1757
  }
1674
1758
  break;
1675
1759
  }
1760
+ case 'taskcard': {
1761
+ const tcName = filteredArgs.slice(1).find(a => !a.startsWith('-'));
1762
+ if (!tcName || tcName === 'help' || tcName === '--help' || tcName === '-h') {
1763
+ console.log(`
1764
+ SillySpec taskcard — 生成 Windows 安全的 TaskCard 骨架
1765
+
1766
+ 用法:
1767
+ sillyspec taskcard <change-name> --task task-01[,task-02...] [--title <t>] [--title-zh <t>] [--force]
1768
+ sillyspec taskcard <change-name> --all [--force]
1769
+
1770
+ 骨架由 CLI 直写(LF 行尾 + frontmatter 闭合 + 硬校验 9 字段齐全),标题自动取自 plan.md
1771
+ checkbox 行;之后用 Edit 填充占位符即可,勿手写整卡(CRLF/漏闭合 ---/漏字段的源头治理)。
1772
+ `);
1773
+ break;
1774
+ }
1775
+ try { assertSafeChangeName(tcName, '变更名'); }
1776
+ catch (e) { console.error(`❌ ${e.message}`); process.exit(2); }
1777
+
1778
+ const force = filteredArgs.includes('--force');
1779
+ const all = filteredArgs.includes('--all');
1780
+ const getVal = (flag) => {
1781
+ const idx = filteredArgs.indexOf(flag);
1782
+ return idx !== -1 && filteredArgs[idx + 1] && !filteredArgs[idx + 1].startsWith('-')
1783
+ ? filteredArgs[idx + 1] : null;
1784
+ };
1785
+ const taskVal = getVal('--task');
1786
+ const titleVal = getVal('--title');
1787
+ const titleZhVal = getVal('--title-zh');
1788
+
1789
+ if (all && taskVal) {
1790
+ console.error('❌ --all 与 --task 互斥(--all 取 plan.md 全部任务,--task 显式指定)');
1791
+ process.exit(1);
1792
+ }
1793
+ if (!all && !taskVal) {
1794
+ console.error('❌ 用法: sillyspec taskcard <change-name> --task task-01[,task-02...] | --all [--force]');
1795
+ process.exit(1);
1796
+ }
1797
+
1798
+ let taskIds;
1799
+ if (all) {
1800
+ taskIds = 'all';
1801
+ } else {
1802
+ const { normalizeTaskId } = await import('./taskcard.js');
1803
+ try {
1804
+ taskIds = taskVal.split(',').map(s => s.trim()).filter(Boolean).map(normalizeTaskId);
1805
+ } catch (e) {
1806
+ console.error(`❌ ${e.message}`);
1807
+ process.exit(1);
1808
+ }
1809
+ }
1810
+
1811
+ const { cmdTaskcard } = await import('./taskcard.js');
1812
+ try {
1813
+ const result = cmdTaskcard(tcName, { cwd: dir, specDir, taskIds, title: titleVal, titleZh: titleZhVal, force });
1814
+ for (const f of result.created) console.log(`✅ 已生成: ${f}`);
1815
+ for (const f of result.skipped) console.log(`⏭️ 已存在,跳过(--force 覆盖): ${f}`);
1816
+ if (result.created.length > 0) {
1817
+ console.log(` 下一步:用 Edit tool 填充占位符(allowed_paths/goal/implementation/acceptance/verify/constraints),勿用 Write 整文件重写。`);
1818
+ }
1819
+ } catch (e) {
1820
+ console.error(`❌ ${e.message}`);
1821
+ process.exit(1);
1822
+ }
1823
+ break;
1824
+ }
1676
1825
  case 'change-rename': {
1677
1826
  const oldName = filteredArgs[1];
1678
1827
  const newName = filteredArgs[2];
package/src/init.js CHANGED
@@ -5,6 +5,7 @@ import { checkbox, confirm, input } from '@inquirer/prompts';
5
5
  import { ProgressManager } from './progress.js';
6
6
  import chalk from 'chalk';
7
7
  import { getVersion } from './version.js';
8
+ import { gitQuiet } from './git-helper.js';
8
9
  import { renderExample } from './config-schema.js';
9
10
  // 向后兼容:getVersion 已抽到轻量 version.js(避免 index.js 为 --version 静态加载 init.js 的 inquirer 税),
10
11
  // 此处 re-export 保持 init.js 既有导出 API 不破坏(如 test/init-claude-injection.test.mjs)。
@@ -557,9 +558,9 @@ export async function cmdInit(projectDir, options = {}) {
557
558
 
558
559
  let repo = '';
559
560
  try {
560
- const { execSync } = await import('child_process');
561
+ // QUAL-01 收口:裸 execFileSync → git-helper(safe.directory + 数组形式)
561
562
  const absPath = resolve(projectDir, subPath.trim() || `./${name.trim()}`);
562
- repo = execSync('git remote get-url origin', { cwd: absPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
563
+ repo = gitQuiet(absPath, ['remote', 'get-url', 'origin']) || '';
563
564
  } catch {}
564
565
 
565
566
  subprojects.push({ name: name.trim(), path: subPath.trim() || `./${name.trim()}`, role: role.trim(), repo });