sillyspec 3.26.10 → 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 (101) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
package/src/fs-atomic.js CHANGED
@@ -1,69 +1,71 @@
1
- /**
2
- * fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
3
- *
4
- * 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
5
- * 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
6
- *
7
- * 注意:DB 持久化由 node:sqlite 引擎承担(提交即落盘 sillyspec.db + WAL 侧车),
8
- * 不经此处的 writeAtomicSync;sillyspec.db 不走原子写改名。
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
- }
1
+ /**
2
+ * fs-atomic.js — 原子文件写 + Windows 友好的 rename 重试
3
+ *
4
+ * 用于 .runtime/*.json、pointer、guard.json 等「会被其他进程 / hook 读取」的配置文件。
5
+ * 保证读者要么看到旧完整内容、要么看到新完整内容,不会读到半截 JSON。
6
+ *
7
+ * 注意:DB 持久化由 node:sqlite 引擎承担(提交即落盘 sillyspec.db + WAL 侧车),
8
+ * 不经此处的 writeAtomicSync;sillyspec.db 不走原子写改名。
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
+ // 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`);
64
+ writeFileSync(tmpPath, content);
65
+ try {
66
+ renameSyncRetry(tmpPath, filePath);
67
+ } catch (err) {
68
+ try { unlinkSync(tmpPath); } catch { /* tmp 可能已被 rename 走,忽略 */ }
69
+ throw err;
70
+ }
71
+ }
package/src/git-helper.js CHANGED
@@ -1,79 +1,82 @@
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
- * safeGit 额外支持 opts.retryOnTimeout:ETIMEDOUT(机器忙瞬时抖动)时用 2× timeout 重试一次。
14
- */
15
- import { execFileSync } from 'node:child_process'
16
-
17
- /**
18
- * 安全执行 git:失败不抛,返回 { value, error } 结构。
19
- * @param {string} cwd
20
- * @param {string[]} args
21
- * @param {{ trim?: boolean, timeout?: number, retryOnTimeout?: boolean }} [opts]
22
- * - trim:false 保留原样输出(git status --porcelain 首行前导空格
23
- * 是状态码的一部分,trim 会削掉致 parsePorcelainPath 丢首字符,坑见 auditQuickCompletion 注释)
24
- * - timeout 默认 5000ms,长操作(大 diff / worktree list)可传更大值
25
- * - retryOnTimeout:true 时,ETIMEDOUT 用 2× timeout 重试一次(默认 false 向后兼容)。
26
- * 机器忙时 git 子进程启动慢易瞬时超时;审计锚点 git 调用失败即 blocked 中断 quick,
27
- * 重试一次能消化绝大多数瞬时抖动,免去用户手工重跑。权限 / 损坏等非超时错误不重试(救不回)。
28
- * @returns {{ value: string|null, error: string|null }}
29
- */
30
- export function safeGit(cwd, args, opts = {}) {
31
- const { trim = true, timeout = 5000, retryOnTimeout = false } = opts
32
- const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
33
- // 单次尝试:返回 { value, errorObj }(errorObj 为原始 Error 或 null),不在内部格式化 message。
34
- // 格式化(取首行)推迟到最终返回,避免重试分支重复格式化,并保留原始 code 供重试判定。
35
- const attempt = (t) => {
36
- try {
37
- let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout: t, stdio: ['ignore', 'pipe', 'pipe'] })
38
- if (trim) value = value.trim()
39
- return { value, errorObj: null }
40
- } catch (e) {
41
- return { value: null, errorObj: e }
42
- }
43
- }
44
- let { value, errorObj } = attempt(timeout)
45
- if (errorObj && retryOnTimeout && errorObj.code === 'ETIMEDOUT') {
46
- ({ value, errorObj } = attempt(timeout * 2))
47
- }
48
- return { value, error: errorObj ? errorObj.message.split('\n')[0] : null }
49
- }
50
-
51
- /**
52
- * 抛错版 git:失败抛异常,成功返回 trim 后 string(对齐 worktree 本地 git() 语义)。
53
- * 调用方自行 catch;不经 shell,参数以数组元素传递。
54
- * @param {string} cwd
55
- * @param {string[]} args
56
- * @param {{ trim?: boolean, timeout?: number }} [opts]
57
- * @returns {string}
58
- */
59
- export function git(cwd, args, opts = {}) {
60
- const { trim = true, timeout = 5000 } = opts
61
- const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
62
- const value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] })
63
- return trim ? value.trim() : value
64
- }
65
-
66
- /**
67
- * 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
68
- * @param {string} cwd
69
- * @param {string[]} args
70
- * @param {{ trim?: boolean, timeout?: number }} [opts]
71
- * @returns {string|null}
72
- */
73
- export function gitQuiet(cwd, args, opts = {}) {
74
- try {
75
- return git(cwd, args, opts)
76
- } catch {
77
- return null
78
- }
79
- }
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
+ * safeGit 额外支持 opts.retryOnTimeout:ETIMEDOUT(机器忙瞬时抖动)时用 2× timeout 重试一次。
14
+ */
15
+ import { execFileSync } from 'node:child_process'
16
+
17
+ /**
18
+ * 安全执行 git:失败不抛,返回 { value, error } 结构。
19
+ * @param {string} cwd
20
+ * @param {string[]} args
21
+ * @param {{ trim?: boolean, timeout?: number, retryOnTimeout?: boolean }} [opts]
22
+ * - trim:false 保留原样输出(git status --porcelain 首行前导空格
23
+ * 是状态码的一部分,trim 会削掉致 parsePorcelainPath 丢首字符,坑见 auditQuickCompletion 注释)
24
+ * - timeout 默认 5000ms,长操作(大 diff / worktree list)可传更大值
25
+ * - retryOnTimeout:true 时,ETIMEDOUT 用 2× timeout 重试一次(默认 false 向后兼容)。
26
+ * 机器忙时 git 子进程启动慢易瞬时超时;审计锚点 git 调用失败即 blocked 中断 quick,
27
+ * 重试一次能消化绝大多数瞬时抖动,免去用户手工重跑。权限 / 损坏等非超时错误不重试(救不回)。
28
+ * @returns {{ value: string|null, error: string|null }}
29
+ */
30
+ export function safeGit(cwd, args, opts = {}) {
31
+ const { trim = true, timeout = 5000, retryOnTimeout = false } = opts
32
+ const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
33
+ // 单次尝试:返回 { value, errorObj }(errorObj 为原始 Error 或 null),不在内部格式化 message。
34
+ // 格式化(取首行)推迟到最终返回,避免重试分支重复格式化,并保留原始 code 供重试判定。
35
+ const attempt = (t) => {
36
+ try {
37
+ let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout: t, stdio: ['ignore', 'pipe', 'pipe'] })
38
+ if (trim) value = value.trim()
39
+ return { value, errorObj: null }
40
+ } catch (e) {
41
+ return { value: null, errorObj: e }
42
+ }
43
+ }
44
+ let { value, errorObj } = attempt(timeout)
45
+ if (errorObj && retryOnTimeout && errorObj.code === 'ETIMEDOUT') {
46
+ ({ value, errorObj } = attempt(timeout * 2))
47
+ }
48
+ return { value, error: errorObj ? errorObj.message.split('\n')[0] : null }
49
+ }
50
+
51
+ /**
52
+ * 抛错版 git:失败抛异常,成功返回 trim 后 string(对齐 worktree 本地 git() 语义)。
53
+ * 调用方自行 catch;不经 shell,参数以数组元素传递。
54
+ * @param {string} cwd
55
+ * @param {string[]} args
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}
60
+ */
61
+ export function git(cwd, args, opts = {}) {
62
+ const { trim = true, timeout = 5000, encoding = 'utf8' } = opts
63
+ const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
64
+ const value = execFileSync('git', fullArgs, { encoding, timeout, stdio: ['ignore', 'pipe', 'pipe'] })
65
+ if (Buffer.isBuffer(value)) return value
66
+ return trim ? value.trim() : value
67
+ }
68
+
69
+ /**
70
+ * 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
71
+ * @param {string} cwd
72
+ * @param {string[]} args
73
+ * @param {{ trim?: boolean, timeout?: number }} [opts]
74
+ * @returns {string|null}
75
+ */
76
+ export function gitQuiet(cwd, args, opts = {}) {
77
+ try {
78
+ return git(cwd, args, opts)
79
+ } catch {
80
+ return null
81
+ }
82
+ }
@@ -1,125 +1,125 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Claude Code PreToolUse hook 入口
4
- *
5
- * 从 stdin 读取 JSON,调用 worktree-guard 判断是否拦截。
6
- *
7
- * Claude Code hooks.json 配置:
8
- * {
9
- * "hooks": {
10
- * "PreToolUse": [
11
- * {
12
- * "matcher": "Edit|Write|MultiEdit|Bash",
13
- * "hooks": [
14
- * {
15
- * "type": "command",
16
- * "command": "node /path/to/sillyspec/src/hooks/claude-pre-tool-use.cjs"
17
- * }
18
- * ]
19
- * }
20
- * ]
21
- * }
22
- * }
23
- */
24
-
25
- 'use strict'
26
-
27
- const path = require('path')
28
- const fs = require('fs')
29
-
30
- // 动态 import ESM 模块
31
- async function main() {
32
- // Claude Code hook 通过 stdin 传入 JSON
33
- let input = ''
34
- try {
35
- input = await readStdin()
36
- } catch {
37
- // 无法读取 stdin → 放行(安全优先于阻断)
38
- process.exit(0)
39
- }
40
-
41
- let parsed
42
- try {
43
- parsed = JSON.parse(input)
44
- } catch {
45
- // 非法 JSON → 放行
46
- process.exit(0)
47
- }
48
-
49
- // 解析 Claude Code hook 输入格式
50
- // 格式:{ tool_name: string, tool_input: { ... } }
51
- const toolName = parsed.tool_name || parsed.tool || ''
52
- const toolInput = parsed.tool_input || {}
53
-
54
- // 转换为 shouldBlock 的输入格式
55
- const toolMap = {
56
- 'Write': 'Write',
57
- 'Edit': 'Edit',
58
- 'MultiEdit': 'MultiEdit',
59
- 'Bash': 'Bash',
60
- }
61
-
62
- const mappedTool = toolMap[toolName]
63
- if (!mappedTool) {
64
- // 不在拦截范围内的工具 → 放行
65
- process.exit(0)
66
- }
67
-
68
- // 构造 opts
69
- const opts = { tool: mappedTool }
70
- if (mappedTool === 'Bash') {
71
- opts.command = toolInput.command || ''
72
- } else {
73
- // Write/Edit/MultiEdit
74
- const fp = toolInput.file_path || toolInput.filePath
75
- if (fp) opts.filePath = fp
76
- // MultiEdit 可能有多个文件
77
- if (mappedTool === 'MultiEdit') {
78
- const fps = toolInput.edits
79
- ? toolInput.edits.map(e => e.file_path || e.filePath).filter(Boolean)
80
- : []
81
- if (fps.length > 0) opts.filePaths = fps
82
- }
83
- }
84
-
85
- // 加载 worktree-guard(ESM)
86
- let shouldBlock
87
- try {
88
- const mod = await import('./worktree-guard.js')
89
- shouldBlock = mod.shouldBlock
90
- } catch (e) {
91
- // 模块加载失败 → 放行(不因为 hook 出错阻断工作流)
92
- // eslint-disable-next-line no-console
93
- console.error(`[sillyspec-hook] 模块加载失败: ${e.message}`)
94
- process.exit(0)
95
- }
96
-
97
- const result = shouldBlock(opts)
98
-
99
- if (result.blocked) {
100
- // 输出错误信息(Claude Code 会显示给 agent)
101
- // eslint-disable-next-line no-console
102
- console.error(`[sillyspec] ❌ ${result.reason || 'blocked by worktree guard'}`)
103
- process.exit(2) // exit code 2 = 阻止工具执行
104
- }
105
-
106
- // 放行
107
- process.exit(0)
108
- }
109
-
110
- function readStdin() {
111
- return new Promise((resolve, reject) => {
112
- const chunks = []
113
- process.stdin.setEncoding('utf8')
114
- process.stdin.on('data', chunk => chunks.push(chunk))
115
- process.stdin.on('end', () => resolve(chunks.join('')))
116
- process.stdin.on('error', reject)
117
- // 超时保护(3秒)
118
- setTimeout(() => {
119
- process.stdin.destroy()
120
- resolve('')
121
- }, 3000)
122
- })
123
- }
124
-
125
- main().catch(() => process.exit(0))
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Claude Code PreToolUse hook 入口
4
+ *
5
+ * 从 stdin 读取 JSON,调用 worktree-guard 判断是否拦截。
6
+ *
7
+ * Claude Code hooks.json 配置:
8
+ * {
9
+ * "hooks": {
10
+ * "PreToolUse": [
11
+ * {
12
+ * "matcher": "Edit|Write|MultiEdit|Bash",
13
+ * "hooks": [
14
+ * {
15
+ * "type": "command",
16
+ * "command": "node /path/to/sillyspec/src/hooks/claude-pre-tool-use.cjs"
17
+ * }
18
+ * ]
19
+ * }
20
+ * ]
21
+ * }
22
+ * }
23
+ */
24
+
25
+ 'use strict'
26
+
27
+ const path = require('path')
28
+ const fs = require('fs')
29
+
30
+ // 动态 import ESM 模块
31
+ async function main() {
32
+ // Claude Code hook 通过 stdin 传入 JSON
33
+ let input = ''
34
+ try {
35
+ input = await readStdin()
36
+ } catch {
37
+ // 无法读取 stdin → 放行(安全优先于阻断)
38
+ process.exit(0)
39
+ }
40
+
41
+ let parsed
42
+ try {
43
+ parsed = JSON.parse(input)
44
+ } catch {
45
+ // 非法 JSON → 放行
46
+ process.exit(0)
47
+ }
48
+
49
+ // 解析 Claude Code hook 输入格式
50
+ // 格式:{ tool_name: string, tool_input: { ... } }
51
+ const toolName = parsed.tool_name || parsed.tool || ''
52
+ const toolInput = parsed.tool_input || {}
53
+
54
+ // 转换为 shouldBlock 的输入格式
55
+ const toolMap = {
56
+ 'Write': 'Write',
57
+ 'Edit': 'Edit',
58
+ 'MultiEdit': 'MultiEdit',
59
+ 'Bash': 'Bash',
60
+ }
61
+
62
+ const mappedTool = toolMap[toolName]
63
+ if (!mappedTool) {
64
+ // 不在拦截范围内的工具 → 放行
65
+ process.exit(0)
66
+ }
67
+
68
+ // 构造 opts
69
+ const opts = { tool: mappedTool }
70
+ if (mappedTool === 'Bash') {
71
+ opts.command = toolInput.command || ''
72
+ } else {
73
+ // Write/Edit/MultiEdit
74
+ const fp = toolInput.file_path || toolInput.filePath
75
+ if (fp) opts.filePath = fp
76
+ // MultiEdit 可能有多个文件
77
+ if (mappedTool === 'MultiEdit') {
78
+ const fps = toolInput.edits
79
+ ? toolInput.edits.map(e => e.file_path || e.filePath).filter(Boolean)
80
+ : []
81
+ if (fps.length > 0) opts.filePaths = fps
82
+ }
83
+ }
84
+
85
+ // 加载 worktree-guard(ESM)
86
+ let shouldBlock
87
+ try {
88
+ const mod = await import('./worktree-guard.js')
89
+ shouldBlock = mod.shouldBlock
90
+ } catch (e) {
91
+ // 模块加载失败 → 放行(不因为 hook 出错阻断工作流)
92
+ // eslint-disable-next-line no-console
93
+ console.error(`[sillyspec-hook] 模块加载失败: ${e.message}`)
94
+ process.exit(0)
95
+ }
96
+
97
+ const result = shouldBlock(opts)
98
+
99
+ if (result.blocked) {
100
+ // 输出错误信息(Claude Code 会显示给 agent)
101
+ // eslint-disable-next-line no-console
102
+ console.error(`[sillyspec] ❌ ${result.reason || 'blocked by worktree guard'}`)
103
+ process.exit(2) // exit code 2 = 阻止工具执行
104
+ }
105
+
106
+ // 放行
107
+ process.exit(0)
108
+ }
109
+
110
+ function readStdin() {
111
+ return new Promise((resolve, reject) => {
112
+ const chunks = []
113
+ process.stdin.setEncoding('utf8')
114
+ process.stdin.on('data', chunk => chunks.push(chunk))
115
+ process.stdin.on('end', () => resolve(chunks.join('')))
116
+ process.stdin.on('error', reject)
117
+ // 超时保护(3秒)
118
+ setTimeout(() => {
119
+ process.stdin.destroy()
120
+ resolve('')
121
+ }, 3000)
122
+ })
123
+ }
124
+
125
+ main().catch(() => process.exit(0))