sillyspec 3.25.9 → 3.26.0

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 (76) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +8 -13
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
  3. package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
  4. package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  7. package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
  8. package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
  9. package/.sillyspec-platform-cleaned +1 -0
  10. package/.sillyspec-platform.json +7 -0
  11. package/README.md +9 -0
  12. package/package.json +2 -2
  13. package/src/change-list.js +11 -8
  14. package/src/change-risk-profile.js +22 -0
  15. package/src/db.js +170 -161
  16. package/src/dispatch/backends/local-agent.js +152 -0
  17. package/src/dispatch/backends/sillyhub-mcp.js +242 -0
  18. package/src/dispatch/probe.js +210 -0
  19. package/src/dispatch/strategy.js +121 -0
  20. package/src/doctor-diagnostics.js +19 -25
  21. package/src/fs-atomic.js +69 -69
  22. package/src/git-helper.js +66 -0
  23. package/src/hooks/worktree-guard.js +64 -55
  24. package/src/index.js +326 -36
  25. package/src/init.js +7 -14
  26. package/src/local-detect.js +120 -110
  27. package/src/machine-interface.js +54 -19
  28. package/src/migrate.js +14 -4
  29. package/src/modules.js +16 -5
  30. package/src/progress/change-registry.js +85 -70
  31. package/src/progress/consistency-doctor.js +71 -14
  32. package/src/progress/shared.js +8 -5
  33. package/src/progress/stage-machine.js +67 -65
  34. package/src/progress/step-store.js +72 -67
  35. package/src/progress.js +448 -236
  36. package/src/quicklog.js +59 -12
  37. package/src/run/command.js +78 -39
  38. package/src/run/complete-handlers.js +1028 -889
  39. package/src/run/complete.js +132 -212
  40. package/src/run/concurrent-detect.js +132 -0
  41. package/src/run/gates.js +302 -19
  42. package/src/run/prompt.js +22 -68
  43. package/src/run/quick-audit.js +10 -9
  44. package/src/run/shared.js +722 -600
  45. package/src/run/stage.js +43 -24
  46. package/src/scan-postcheck.js +9 -31
  47. package/src/sillyhub-mcp/client.js +394 -0
  48. package/src/sillyhub-mcp/config.js +62 -0
  49. package/src/spec-dir-typo.js +71 -0
  50. package/src/stage-contract-engine.js +22 -1
  51. package/src/stage-contract-spec.js +11 -4
  52. package/src/stage-contract.js +26 -3
  53. package/src/stage-review.js +118 -10
  54. package/src/stages/archive.js +4 -6
  55. package/src/stages/brainstorm-auto.js +26 -20
  56. package/src/stages/brainstorm.js +29 -5
  57. package/src/stages/cmd-existence.js +145 -0
  58. package/src/stages/doctor.js +10 -7
  59. package/src/stages/execute.js +64 -22
  60. package/src/stages/plan-postcheck.js +152 -0
  61. package/src/stages/plan.js +16 -31
  62. package/src/stages/quick.js +25 -9
  63. package/src/stages/scan.js +53 -8
  64. package/src/stages/status.js +0 -1
  65. package/src/stages/verify.js +14 -9
  66. package/src/sync.js +433 -16
  67. package/src/task-review.js +59 -1
  68. package/src/verify-postcheck.js +938 -862
  69. package/src/version.js +19 -0
  70. package/src/worktree-apply.js +218 -93
  71. package/src/worktree-deps.js +89 -5
  72. package/src/worktree.js +213 -111
  73. package/templates/prompts/taskcard-rules.md +27 -0
  74. package/templates/prompts/verify-probes.md +3 -3
  75. package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
  76. package/src/stages/propose.js +0 -181
@@ -11,8 +11,8 @@
11
11
  * - safe_actions:只描述建议动作与风险等级,绝不自动执行
12
12
  *
13
13
  * 安全约束(硬性):
14
- * - 所有检测只读。DB 以内存副本打开,不调用 export/writeFileSync,不跑建表/迁移,
15
- * close 后丢弃——绝不写回原 db 文件。
14
+ * - 所有检测只读。DB 以 better-sqlite3 只读连接打开(readonly + fileMustExist),不调用
15
+ * export/writeFileSync,不跑建表/迁移,close 后丢弃——绝不写回原 db 文件。
16
16
  * - 不删除/移动任何文件;orphan db 仅报告,处理交给后续 --dump-db / --confirm 流程。
17
17
  * - execute-progress-plan-mismatch 维度同样只读:仅读 plan.md checkbox + 只读查 stages 表,
18
18
  * 绝不调用 ProgressManager 写方法(写操作是 progress.alignExecuteToPlan 的职责,D-001@v2 诊断/写分离)。
@@ -20,7 +20,7 @@
20
20
  * 风格对齐 scan-postcheck.js:checks 用 CHECK_SEVERITY,formatter 产出 schema_version JSON,
21
21
  * writer 落盘到 <authoritySpecDir>/.runtime/。
22
22
  */
23
- import initSqlJs from 'sql.js';
23
+ import Database from 'better-sqlite3';
24
24
  import { existsSync, statSync, readFileSync, readdirSync, mkdirSync, writeFileSync, unlinkSync } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { CHECK_SEVERITY } from './constants.js';
@@ -59,7 +59,7 @@ function resolvePointer(cwd) {
59
59
  * 只读打开一个 db 文件,提取诊断信号。绝不写回。
60
60
  * 返回 null 表示文件不存在。
61
61
  */
62
- async function probeDb(dbPath, SQL) {
62
+ function probeDb(dbPath) {
63
63
  if (!existsSync(dbPath)) return null;
64
64
  const st = statSync(dbPath);
65
65
  const base = { path: dbPath, exists: true, size: st.size, mtime: st.mtime.toISOString() };
@@ -68,18 +68,16 @@ async function probeDb(dbPath, SQL) {
68
68
  }
69
69
  let db = null;
70
70
  try {
71
- const buf = readFileSync(dbPath);
72
- db = new SQL.Database(buf); // 内存副本
71
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
73
72
  const pick = (sql, fallback = null) => {
74
73
  try {
75
- const r = db.exec(sql);
76
- return r.length ? r[0].values[0][0] : fallback;
74
+ const r = db.prepare(sql).pluck().get();
75
+ return r === undefined ? fallback : r;
77
76
  } catch { return fallback; }
78
77
  };
79
78
  const pickCol = (sql) => {
80
79
  try {
81
- const r = db.exec(sql);
82
- return r.length ? r[0].values.map((row) => row[0]) : [];
80
+ return db.prepare(sql).pluck().all();
83
81
  } catch { return []; }
84
82
  };
85
83
  // 每个 active change 的 execute stage status(只读查询;stages 表的 status 列)。
@@ -87,14 +85,12 @@ async function probeDb(dbPath, SQL) {
87
85
  // change_name → execute stage status(无 execute 行则 absent)。
88
86
  const pickExecuteStatusByChange = () => {
89
87
  try {
90
- const r = db.exec(
88
+ const rows = db.prepare(
91
89
  `SELECT c.name, s.status FROM changes c
92
90
  LEFT JOIN stages s ON s.change_id = c.id AND s.stage = 'execute'`
93
- );
91
+ ).all();
94
92
  const out = {};
95
- if (r.length) {
96
- for (const [name, status] of r[0].values) out[name] = status || null;
97
- }
93
+ for (const row of rows) out[row.name] = row.status || null;
98
94
  return out;
99
95
  } catch { return {}; }
100
96
  };
@@ -123,7 +119,7 @@ function summarizeActive(changes) {
123
119
 
124
120
  // ── D1 多 db 探测与权威判定 ────────────────────────────────────────────
125
121
 
126
- async function detectMultiDb(cwd, pointer, SQL) {
122
+ function detectMultiDb(cwd, pointer) {
127
123
  const localSpec = join(cwd, '.sillyspec');
128
124
  const candidates = [
129
125
  { path: join(localSpec, 'sillyspec.db'), kind: 'local_root' },
@@ -136,7 +132,7 @@ async function detectMultiDb(cwd, pointer, SQL) {
136
132
 
137
133
  const dbs = [];
138
134
  for (const c of candidates) {
139
- const probed = await probeDb(c.path, SQL);
135
+ const probed = probeDb(c.path);
140
136
  if (!probed) continue;
141
137
  dbs.push({ kind: c.kind, ...probed });
142
138
  }
@@ -492,8 +488,7 @@ function detectExecuteProgressPlanMismatch(authoritySpecRoot, authDb) {
492
488
 
493
489
  export async function runDoctorDiagnostics({ cwd }) {
494
490
  const pointer = resolvePointer(cwd);
495
- const SQL = await initSqlJs(); // 复用单个 WASM 实例,避免每个 db 重新初始化遗留 handle
496
- const multiDb = await detectMultiDb(cwd, pointer, SQL);
491
+ const multiDb = detectMultiDb(cwd, pointer);
497
492
  const pointerHealth = detectPointerHealth(pointer);
498
493
  const changesSplit = detectChangesSplit(cwd, pointer);
499
494
  const changeDb = detectChangeDbConsistency(cwd, pointer, multiDb);
@@ -662,17 +657,16 @@ export async function dumpDb({ dbPath, cwd }) {
662
657
  }
663
658
  let db = null;
664
659
  try {
665
- const SQL = await initSqlJs();
666
- db = new SQL.Database(readFileSync(dbPath));
660
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
667
661
  const rows = (sql) => {
668
- try { const r = db.exec(sql); return r.length ? r[0].values : []; } catch { return []; }
662
+ try { return db.prepare(sql).all(); } catch { return []; }
669
663
  };
670
664
  const sv = rows('SELECT schema_version FROM project LIMIT 1');
671
- const schemaVersion = sv.length ? sv[0][0] : null;
665
+ const schemaVersion = sv.length ? sv[0].schema_version : null;
672
666
  const changes = rows('SELECT name, current_stage, status, created_at, last_active FROM changes ORDER BY last_active DESC')
673
- .map((r) => ({ name: r[0], current_stage: r[1], status: r[2], created_at: r[3], last_active: r[4] }));
667
+ .map((r) => ({ name: r.name, current_stage: r.current_stage, status: r.status, created_at: r.created_at, last_active: r.last_active }));
674
668
  const stages = rows('SELECT c.name, s.stage, s.status, s.started_at, s.completed_at FROM stages s JOIN changes c ON s.change_id = c.id ORDER BY c.name, s.stage')
675
- .map((r) => ({ change: r[0], stage: r[1], status: r[2], started_at: r[3], completed_at: r[4] }));
669
+ .map((r) => ({ change: r.name, stage: r.stage, status: r.status, started_at: r.started_at, completed_at: r.completed_at }));
676
670
  return writeDump({ ok: true, meta, schema_version: schemaVersion, changes, stages }, authoritySpecDir);
677
671
  } catch (e) {
678
672
  return writeDump({ ok: false, meta, error: `读取失败: ${e.message}` }, authoritySpecDir);
package/src/fs-atomic.js CHANGED
@@ -1,69 +1,69 @@
1
- /**
2
- * fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
3
- *
4
- * 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
5
- * 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
6
- *
7
- * 注意:db.js 有自己的 _atomicWriteSync(含 .bak 备份),那是 DB 专属持久化逻辑,
8
- * 已经过稳定性验证,此处不复用、不替换——保持 db.js 零改动。
9
- */
10
- import { writeFileSync, renameSync, unlinkSync } from 'fs';
11
- import { dirname, basename, join } from 'path';
12
-
13
- // Windows 上杀毒 / 索引 / IDE 占用文件时,rename 偶发这些错误,短退避重试即可
14
- const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
15
-
16
- /** 同步退避:优先 Atomics.wait,不可用时退化忙等(仅在 rename 冲突的极端情况触发)。 */
17
- function sleepSync(ms) {
18
- try {
19
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
20
- } catch {
21
- const end = Date.now() + ms;
22
- while (Date.now() < end) { /* busy wait */ }
23
- }
24
- }
25
-
26
- /**
27
- * rename 带退避重试,覆盖 Windows 偶发的 EPERM/EBUSY/EACCES/ENOTEMPTY。
28
- * 目录 rename 同样适用(归档移动目录时可复用)。
29
- * @param {string} from
30
- * @param {string} to
31
- * @param {number} [retries=5]
32
- */
33
- export function renameSyncRetry(from, to, retries = 5) {
34
- let lastErr;
35
- for (let attempt = 0; attempt <= retries; attempt++) {
36
- try {
37
- renameSync(from, to);
38
- return;
39
- } catch (err) {
40
- lastErr = err;
41
- const code = err && err.code;
42
- // 非可重试错误码立即抛出,不浪费时间
43
- if (!code || !RENAME_RETRY_CODES.includes(code)) throw err;
44
- if (attempt < retries) sleepSync(15 * (attempt + 1));
45
- }
46
- }
47
- throw lastErr;
48
- }
49
-
50
- /**
51
- * 原子写文本文件:同目录 tmp 写入 → rename 覆盖目标(带 Windows 退避重试)。
52
- * - tmp 名含 pid,避免多进程并发写同一目标时 tmp 名碰撞(如多会话写 gate-status.json)。
53
- * - rename 失败时清理 tmp,避免留下孤儿 .tmp 文件。
54
- * - Node 的 rename 在 Windows 上用 MoveFileEx(REPLACE_EXISTING),可覆盖已存在目标;
55
- * EPERM 仅在文件被其他进程锁定时出现,由 renameSyncRetry 退避重试。
56
- * @param {string} filePath - 目标文件绝对路径
57
- * @param {string} content - 文件内容
58
- */
59
- export function writeAtomicSync(filePath, content) {
60
- const dir = dirname(filePath);
61
- const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.tmp`);
62
- writeFileSync(tmpPath, content);
63
- try {
64
- renameSyncRetry(tmpPath, filePath);
65
- } catch (err) {
66
- try { unlinkSync(tmpPath); } catch { /* tmp 可能已被 rename 走,忽略 */ }
67
- throw err;
68
- }
69
- }
1
+ /**
2
+ * fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
3
+ *
4
+ * 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
5
+ * 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
6
+ *
7
+ * 注意:db.js 有自己的 _atomicWriteSync(含 .bak 备份),那是 DB 专属持久化逻辑,
8
+ * 已经过稳定性验证,此处不复用、不替换——保持 db.js 零改动。
9
+ */
10
+ import { writeFileSync, renameSync, unlinkSync } from 'fs';
11
+ import { dirname, basename, join } from 'path';
12
+
13
+ // Windows 上杀毒 / 索引 / IDE 占用文件时,rename 偶发这些错误,短退避重试即可
14
+ const RENAME_RETRY_CODES = ['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'];
15
+
16
+ /** 同步退避:优先 Atomics.wait,不可用时退化忙等(仅在 rename 冲突的极端情况触发)。 */
17
+ function sleepSync(ms) {
18
+ try {
19
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
20
+ } catch {
21
+ const end = Date.now() + ms;
22
+ while (Date.now() < end) { /* busy wait */ }
23
+ }
24
+ }
25
+
26
+ /**
27
+ * rename 带退避重试,覆盖 Windows 偶发的 EPERM/EBUSY/EACCES/ENOTEMPTY。
28
+ * 目录 rename 同样适用(归档移动目录时可复用)。
29
+ * @param {string} from
30
+ * @param {string} to
31
+ * @param {number} [retries=5]
32
+ */
33
+ export function renameSyncRetry(from, to, retries = 5) {
34
+ let lastErr;
35
+ for (let attempt = 0; attempt <= retries; attempt++) {
36
+ try {
37
+ renameSync(from, to);
38
+ return;
39
+ } catch (err) {
40
+ lastErr = err;
41
+ const code = err && err.code;
42
+ // 非可重试错误码立即抛出,不浪费时间
43
+ if (!code || !RENAME_RETRY_CODES.includes(code)) throw err;
44
+ if (attempt < retries) sleepSync(15 * (attempt + 1));
45
+ }
46
+ }
47
+ throw lastErr;
48
+ }
49
+
50
+ /**
51
+ * 原子写文本文件:同目录 tmp 写入 → rename 覆盖目标(带 Windows 退避重试)。
52
+ * - tmp 名含 pid,避免多进程并发写同一目标时 tmp 名碰撞(如多会话写 local.yaml)。
53
+ * - rename 失败时清理 tmp,避免留下孤儿 .tmp 文件。
54
+ * - Node 的 rename 在 Windows 上用 MoveFileEx(REPLACE_EXISTING),可覆盖已存在目标;
55
+ * EPERM 仅在文件被其他进程锁定时出现,由 renameSyncRetry 退避重试。
56
+ * @param {string} filePath - 目标文件绝对路径
57
+ * @param {string} content - 文件内容
58
+ */
59
+ export function writeAtomicSync(filePath, content) {
60
+ const dir = dirname(filePath);
61
+ const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.tmp`);
62
+ writeFileSync(tmpPath, content);
63
+ try {
64
+ renameSyncRetry(tmpPath, filePath);
65
+ } catch (err) {
66
+ try { unlinkSync(tmpPath); } catch { /* tmp 可能已被 rename 走,忽略 */ }
67
+ throw err;
68
+ }
69
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * 统一公共 git 调用入口(数组形式 execFileSync,不经 shell)。
3
+ *
4
+ * 背景:worktree 链路原用 `execSync(\`git ${args}\`)` 字符串拼接(经 shell),
5
+ * 文件名/路径/用户输入插值进 shell 存在空格拆词 + 命令注入面;
6
+ * run/shared.js 的 safeGit 已用 execFileSync 数组形式(注释明示避免拆词)。
7
+ * 本模块把 safeGit 收口为单一实现,并补抛错版 git / 静默版 gitQuiet,
8
+ * 供 worktree 链与 run 层共用,消除口径分裂。
9
+ *
10
+ * 三者统一带 `-c safe.directory=<cwd>` per-command(不污染全局 config)+ `-C cwd` 前缀;
11
+ * 默认 timeout 5000ms(opts.timeout 可覆盖),默认 trim(opts.trim:false 保留原样输出)。
12
+ * 数组形式天然规避 shell 拆词,路径含空格 / 元字符安全。
13
+ */
14
+ import { execFileSync } from 'node:child_process'
15
+
16
+ /**
17
+ * 安全执行 git:失败不抛,返回 { value, error } 结构。
18
+ * @param {string} cwd
19
+ * @param {string[]} args
20
+ * @param {{ trim?: boolean, timeout?: number }} [opts]
21
+ * - trim:false 保留原样输出(git status --porcelain 首行前导空格
22
+ * 是状态码的一部分,trim 会削掉致 parsePorcelainPath 丢首字符,坑见 auditQuickCompletion 注释)
23
+ * - timeout 默认 5000ms,长操作(大 diff / worktree list)可传更大值
24
+ * @returns {{ value: string|null, error: string|null }}
25
+ */
26
+ export function safeGit(cwd, args, opts = {}) {
27
+ const { trim = true, timeout = 5000 } = opts
28
+ const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
29
+ try {
30
+ let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout })
31
+ if (trim) value = value.trim()
32
+ return { value, error: null }
33
+ } catch (e) {
34
+ return { value: null, error: e.message.split('\n')[0] }
35
+ }
36
+ }
37
+
38
+ /**
39
+ * 抛错版 git:失败抛异常,成功返回 trim 后 string(对齐 worktree 本地 git() 语义)。
40
+ * 调用方自行 catch;不经 shell,参数以数组元素传递。
41
+ * @param {string} cwd
42
+ * @param {string[]} args
43
+ * @param {{ trim?: boolean, timeout?: number }} [opts]
44
+ * @returns {string}
45
+ */
46
+ export function git(cwd, args, opts = {}) {
47
+ const { trim = true, timeout = 5000 } = opts
48
+ const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
49
+ const value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout })
50
+ return trim ? value.trim() : value
51
+ }
52
+
53
+ /**
54
+ * 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
55
+ * @param {string} cwd
56
+ * @param {string[]} args
57
+ * @param {{ trim?: boolean, timeout?: number }} [opts]
58
+ * @returns {string|null}
59
+ */
60
+ export function gitQuiet(cwd, args, opts = {}) {
61
+ try {
62
+ return git(cwd, args, opts)
63
+ } catch {
64
+ return null
65
+ }
66
+ }
@@ -5,7 +5,7 @@
5
5
  * 纯判断模块,不做实际的 hook 注入。
6
6
  *
7
7
  * P0 优化:
8
- * - 阶段检测 fallback:gate-status.json → sillyspec.db currentStage
8
+ * - 阶段检测:直读 sillyspec.db currentStage(task-10 废 gate-status.json 缓存双源)
9
9
  * - 拦截提示针对每个阶段给出具体修复建议
10
10
  */
11
11
 
@@ -13,7 +13,6 @@ import { existsSync, readFileSync, readdirSync } from 'fs'
13
13
  import path from 'path'
14
14
  import { execFileSync } from 'child_process'
15
15
  import { createRequire } from 'module'
16
- import { pathToFileURL } from 'url'
17
16
 
18
17
  // ── 常量 ──
19
18
 
@@ -89,7 +88,6 @@ function findProjectRoot(cwd) {
89
88
  let dir = path.resolve(cwd || process.cwd())
90
89
  while (true) {
91
90
  if (
92
- existsSync(path.join(dir, '.sillyspec', '.runtime', 'gate-status.json')) ||
93
91
  existsSync(path.join(dir, '.sillyspec', '.runtime', 'sillyspec.db')) ||
94
92
  existsSync(path.join(dir, '.sillyspec', 'local.yaml')) ||
95
93
  existsSync(path.join(dir, '.sillyspec', 'local.yml')) ||
@@ -224,47 +222,55 @@ function isInsideWorktreeStorage(filePath, cwd) {
224
222
  return isPathInside(absPath, resolveWorktreeDir(cwd || process.cwd()))
225
223
  }
226
224
 
227
- /**
228
- * 读取 gate-status.json
229
- * @param {string} cwd
230
- * @returns {{ stage: string, changes?: string[], updatedAt?: string } | null}
231
- */
232
- function readGateStatus(cwd) {
233
- const p = path.join(cwd, '.sillyspec', '.runtime', 'gate-status.json')
234
- if (!existsSync(p)) return null
235
- try {
236
- return JSON.parse(readFileSync(p, 'utf8'))
237
- } catch {
238
- return null
239
- }
240
- }
241
-
242
225
  /**
243
226
  * 同步只读查询 sillyspec.db 第一行第一列(不依赖外部 sqlite3 CLI)。
244
- * sql.js 的 init 是 async,故用子进程跑(execFileSync 等待);sql.js 模块经 hook 进程的
245
- * require.resolve 解析出绝对路径传入,避免子进程 cwd(项目目录)找不到 sql.js。
246
- * 全平台一致(node 是 SillySpec 自身运行时)。返回字符串值或 null。
227
+ *
228
+ * 用子进程跑 better-sqlite3 同步原生绑定(execFileSync 等待):
229
+ * - 原生绑定经 hook 进程的 createRequire(import.meta.url).resolve('better-sqlite3') 解析出绝对路径,
230
+ * JSON.stringify 嵌入子进程脚本——子进程 cwd 是项目目录,裸 require('better-sqlite3') 找不到包
231
+ * (用户项目未必直接装了它);绝对路径 Windows/Linux/macOS 均 require 直接可用,无需 file:// URL。
232
+ * - readonly + fileMustExist 双 true 打开只读连接:绝不写库;WAL 并发读不阻塞主进程写,
233
+ * 且原生绑定可看到已 commit 但未 checkpoint 的过渡态(修复旧 WASM 内存库裸读 .db
234
+ * 看不见 WAL 的过渡态可见性窗口)。
235
+ *
236
+ * 环境假设(R-09):WAL 模式下只读连接仍需在 .runtime/ 目录建/更新 -shm 索引文件,故 .runtime
237
+ * 必须可写(与主进程一致)——“只读”指不改 DB 数据,不等于不对 -shm 加索引。
238
+ *
239
+ * fail-closed:better-sqlite3 resolve 失败 / db 文件不存在 / fileMustExist 打开失败 / 查询异常 /
240
+ * 子进程超时或崩溃——一律 console.warn(含 e.stderr 详情)并返回 null,调用方
241
+ *(readCurrentStage/isNoWorktreeMode)对 null 走 fail-closed,禁止 fail-open。
247
242
  */
248
243
  function queryDbFirstCell(cwd, sql) {
249
244
  const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
250
- if (!existsSync(dbPath)) return null
251
- let sqlJsPath
252
- try { sqlJsPath = createRequire(import.meta.url).resolve('sql.js') } catch { return null }
253
- // Windows 上 dynamic import 不接受裸 "C:\..." 路径,必须转 file:// URL(全平台合法)
254
- const libUrl = pathToFileURL(sqlJsPath).href
245
+ if (!existsSync(dbPath)) {
246
+ // db 文件不存在(findProjectRoot 命中 local.yaml/projects 但无 DB,或 DB 被删):fail-closed。
247
+ // warn 让异常(如 DB 误删)可见;正常空态在 findProjectRoot 未命中时根本不会进到这里。
248
+ console.warn('⚠️ sillyspec.db 不存在,hook 降级 fail-closed: ' + dbPath)
249
+ return null
250
+ }
251
+ let libPath
252
+ try {
253
+ libPath = createRequire(import.meta.url).resolve('better-sqlite3')
254
+ } catch (e) {
255
+ console.warn('⚠️ sillyspec.db 查询失败(better-sqlite3 未解析),hook 降级 fail-closed: ' + ((e && e.message) || e))
256
+ return null
257
+ }
258
+ // 子进程同步 require 绝对路径(better-sqlite3 是同步原生 API,无 async/import().then 包装)。
259
+ // pluck().get() 取第一行第一列原值,无行返回 undefined(写空 stdout → 父侧 trim 后 null)。
255
260
  const script =
256
- "const fs=require('fs');" +
257
- "const dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ",lib=" + JSON.stringify(libUrl) + ";" +
258
- "if(!fs.existsSync(dbPath))process.exit(0);" +
259
- "import(lib).then(async m=>{const SQL=await m.default();" +
260
- "const db=new SQL.Database(fs.readFileSync(dbPath));const r=db.exec(sql);" +
261
- "if(r.length&&r[0].values.length)process.stdout.write(String(r[0].values[0][0]??''));db.close()}).catch(e=>{process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)});"
261
+ "const D=require(" + JSON.stringify(libPath) + ")," +
262
+ "dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ";" +
263
+ "let db;" +
264
+ "try{db=new D(dbPath,{readonly:true,fileMustExist:true});" +
265
+ "const v=db.prepare(sql).pluck().get();" +
266
+ "if(v!==undefined)process.stdout.write(String(v??''));db.close()}" +
267
+ "catch(e){process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)};"
262
268
  try {
263
269
  return execFileSync(process.execPath, ['-e', script], {
264
270
  encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
265
271
  }).trim() || null
266
272
  } catch (e) {
267
- // DB 查询失败(db 损坏 / sql.js resolve 失败 / 子进程超时):降级返回 null,调用方
273
+ // DB 查询失败(db 损坏 / fileMustExist 打开失败 / 子进程超时或崩溃):降级返回 null,调用方
268
274
  //(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因,
269
275
  // 而非完全静默(子进程把诊断写到了 stderr,execFileSync 抛错时挂在 e.stderr 上)。
270
276
  const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
@@ -280,47 +286,48 @@ export {
280
286
  isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
281
287
  };
282
288
  /**
283
- * 从 sillyspec.db 读取 currentStage
284
- * 优先级:gate-status.json > sillyspec.db
289
+ * 从 sillyspec.db 读取 currentStage(直读 DB,task-10 废 gate-status.json 后为唯一来源)
285
290
  * @param {string} cwd
286
291
  * @returns {string|null} 阶段名,null 表示无法确定
287
292
  */
288
293
  function readCurrentStage(cwd) {
289
- // 1. gate-status.json(高速缓存,权威来源)
290
- const gateStatus = readGateStatus(cwd)
291
- if (gateStatus && gateStatus.stage) return gateStatus.stage
292
-
293
- // 2. 从 sillyspec.db 读取(node + sql.js 子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
294
+ // 直读 sillyspec.db(node + better-sqlite3 只读子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
294
295
  const v = queryDbFirstCell(cwd, "SELECT current_stage FROM changes WHERE status='active' AND current_stage IN ('execute','quick') ORDER BY last_active DESC LIMIT 1")
295
296
  return v || null
296
297
  }
297
298
 
298
299
  /**
299
- * 检查当前变更是否处于 noWorktree 模式
300
+ * 检查当前变更是否处于 noWorktree 模式(直读 sillyspec.db)
300
301
  * @param {string} cwd
301
302
  * @returns {boolean}
302
303
  */
303
304
  function isNoWorktreeMode(cwd) {
304
- // 1. 检查 gate-status.json
305
- const gateStatus = readGateStatus(cwd)
306
- if (gateStatus && gateStatus.noWorktree) return true
307
-
308
- // 2. 从 sillyspec.db 读取(node + sql.js,同 readCurrentStage)
305
+ // 直读 sillyspec.db(node + better-sqlite3,同 readCurrentStage)
309
306
  return queryDbFirstCell(cwd, "SELECT no_worktree FROM changes WHERE status='active' AND current_stage IN ('execute','quick') LIMIT 1") === '1'
310
307
  }
311
308
 
312
309
  /**
313
- * 判断路径是否在 worktree 内
310
+ * 判断路径是否在某已注册 worktree 内
314
311
  * @param {string} filePath - 绝对路径
315
312
  * @returns {boolean}
316
313
  */
317
314
  function isInsideRegisteredWorktree(filePath, cwd) {
318
315
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
319
316
  const effectiveCwd = cwd || process.cwd()
320
- const gateStatus = readGateStatus(effectiveCwd)
321
- const changes = Array.isArray(gateStatus?.changes) ? gateStatus.changes : []
322
317
 
323
- for (const changeName of changes) {
318
+ // 已注册 worktree 名单来源:扫描 .runtime/worktrees/ 子目录(task-10 废 gate-status.json 后的权威来源)。
319
+ // queryDbFirstCell 是单行单列,读不了多行 change 名单;目录扫描天然支持多 change 并发,
320
+ // 且 worktree 目录存在性 == 注册态,比 DB changes 表更贴合“已注册 worktree”语义
321
+ // (changes 表可能含 no_worktree 变更,它们没有 worktree 目录)。
322
+ // 跨平台:isPathInside 用 path.sep 判定包含关系,Windows backslash / POSIX slash 均正确。
323
+ const worktreeDir = resolveWorktreeDir(effectiveCwd)
324
+ let changeNames = []
325
+ try {
326
+ const entries = readdirSync(worktreeDir, { withFileTypes: true })
327
+ changeNames = entries.filter(e => e.isDirectory()).map(e => e.name)
328
+ } catch { /* 目录不存在或不可读 → 无注册 worktree */ }
329
+
330
+ for (const changeName of changeNames) {
324
331
  const meta = readWorktreeMeta(effectiveCwd, changeName)
325
332
  if (meta?.worktreePath && isPathInside(absPath, meta.worktreePath)) return true
326
333
  }
@@ -661,7 +668,7 @@ function readAllQuickGuards(projectRoot) {
661
668
  * - 除非同时设置 SILLYSPEC_DISABLE_HOOKS=1
662
669
  *
663
670
  * P0 优化:
664
- * - 使用 readCurrentStage() fallback 读取阶段(gate-status → progress)
671
+ * - 使用 readCurrentStage() 直读 sillyspec.db 获取阶段
665
672
  * - 拦截提示按阶段给出具体修复建议
666
673
  *
667
674
  * @param {string} filePath - 目标文件绝对路径
@@ -675,7 +682,7 @@ export function shouldBlockWrite(filePath, cwd) {
675
682
  const projectRoot = findProjectRoot(callerCwd)
676
683
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
677
684
 
678
- // 1. 阶段门禁(使用 fallback 读取)
685
+ // 1. 阶段门禁(直读 sillyspec.db)
679
686
  const stage = readCurrentStage(projectRoot) || '(none)'
680
687
 
681
688
  const scanGuardResult = shouldBlockScanDocOverwrite(absPath, projectRoot)
@@ -694,9 +701,11 @@ export function shouldBlockWrite(filePath, cwd) {
694
701
  // quick 阶段:检查 baselineFiles(合并所有活跃 session guard 并集,D-002@v1)
695
702
  if (stage === 'quick') {
696
703
  const { baselineFiles } = readAllQuickGuards(projectRoot)
697
- const relTarget = path.relative(projectRoot, absPath)
704
+ // 归一为 forward-slash:baselineFiles 取自 guard.json(经 parsePorcelainPath 已是 /),
705
+ // Windows 下 path.relative 产 backslash 会与 baseline 不匹配,导致实时防护被静默绕过
706
+ const relTarget = path.relative(projectRoot, absPath).split(path.sep).join('/')
698
707
  // 如果目标是 baseline protected file,阻止写入(并集 = 保护所有 session 的 baseline)
699
- if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + path.sep))) {
708
+ if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + '/'))) {
700
709
  return {
701
710
  blocked: true,
702
711
  reason: [
@@ -755,7 +764,7 @@ export function shouldBlockBash(command, cwd) {
755
764
  // cwd 在 worktree 内 → 全部放行
756
765
  if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
757
766
 
758
- // 阶段门禁(使用 fallback 读取)
767
+ // 阶段门禁(直读 sillyspec.db)
759
768
  const stage = readCurrentStage(projectRoot) || '(none)'
760
769
 
761
770
  if (!['execute', 'quick'].includes(stage)) {