sillyspec 3.26.9 → 3.26.11

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 (98) 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 +111 -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 -81
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -675
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +198 -9
  62. package/src/run/complete.js +225 -24
  63. package/src/run/concurrent-detect.js +22 -0
  64. package/src/run/gates.js +755 -755
  65. package/src/run/prompt.js +761 -721
  66. package/src/run/quick-audit.js +155 -146
  67. package/src/run/shared.js +1268 -1197
  68. package/src/run/stage.js +487 -479
  69. package/src/scan-postcheck.js +371 -371
  70. package/src/setup.js +416 -416
  71. package/src/sillyhub-mcp/client.js +529 -529
  72. package/src/spec-sync.js +323 -0
  73. package/src/stage-contract.js +897 -897
  74. package/src/stage-review.js +549 -549
  75. package/src/stages/archive.js +155 -154
  76. package/src/stages/brainstorm-auto.js +236 -236
  77. package/src/stages/brainstorm.js +596 -597
  78. package/src/stages/cmd-existence.js +145 -145
  79. package/src/stages/doctor.js +371 -371
  80. package/src/stages/execute.js +1003 -991
  81. package/src/stages/explore.js +34 -34
  82. package/src/stages/index.js +29 -29
  83. package/src/stages/knowledge.js +498 -498
  84. package/src/stages/plan-postcheck.js +1302 -1281
  85. package/src/stages/plan.js +625 -604
  86. package/src/stages/quick.js +151 -143
  87. package/src/stages/scan.js +606 -605
  88. package/src/stages/status.js +64 -64
  89. package/src/stages/verify.js +264 -261
  90. package/src/sync.js +1223 -1125
  91. package/src/task-review.js +1019 -1019
  92. package/src/verify-postcheck.js +1186 -1186
  93. package/src/workflow.js +600 -600
  94. package/src/worktree-apply.js +1213 -1136
  95. package/src/worktree-deps.js +353 -353
  96. package/src/worktree.js +1481 -1437
  97. package/templates/workflows/archive-impact.yaml +79 -79
  98. package/templates/workflows/scan-docs.yaml +132 -132
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 持久化由 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
+ 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
+ }
package/src/git-helper.js CHANGED
@@ -1,79 +1,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 }} [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 }} [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,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))