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.
- package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
- package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
- package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
- package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
- package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
- package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
- package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
- package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
- package/.claude/skills/sillyspec-quick/SKILL.md +115 -111
- package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
- package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
- package/.claude/skills/sillyspec-state/SKILL.md +64 -64
- package/.claude/skills/sillyspec-status/SKILL.md +54 -54
- package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
- package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
- package/.gitattributes +2 -0
- package/.husky/pre-push +20 -20
- package/README.md +203 -201
- package/SKILL.md +91 -90
- package/package.json +39 -39
- package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
- package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
- package/packages/dashboard/dist/index.html +16 -16
- package/packages/dashboard/package.json +25 -25
- package/packages/dashboard/server/executor.js +95 -86
- package/packages/dashboard/server/index.js +663 -601
- package/packages/dashboard/server/parser.js +526 -526
- package/src/change-risk-profile.js +254 -254
- package/src/classify-change.js +109 -109
- package/src/config-schema.js +313 -310
- package/src/contract-matrix.js +388 -379
- package/src/db.js +370 -350
- package/src/dispatch/backends/local-agent.js +155 -155
- package/src/dispatch/backends/sillyhub-mcp.js +245 -245
- package/src/dispatch/probe.js +246 -210
- package/src/dispatch/strategy.js +121 -121
- package/src/docs-check.js +188 -16
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +898 -711
- package/src/endpoint-extractor.js +401 -401
- package/src/fs-atomic.js +71 -69
- package/src/git-helper.js +82 -79
- package/src/hooks/claude-pre-tool-use.cjs +125 -125
- package/src/hooks/worktree-guard.js +854 -848
- package/src/index.js +251 -20
- package/src/init.js +624 -623
- package/src/knowledge-match.js +130 -130
- package/src/local-detect.js +128 -120
- package/src/machine-interface.js +543 -543
- package/src/migrate.js +127 -127
- package/src/modules.js +7 -5
- package/src/progress/change-registry.js +297 -260
- package/src/progress/consistency-doctor.js +355 -355
- package/src/progress/shared.js +36 -36
- package/src/progress/stage-machine.js +589 -550
- package/src/progress/step-store.js +261 -261
- package/src/progress.js +1285 -1127
- package/src/quicklog.js +724 -693
- package/src/review-tier.js +77 -77
- package/src/run/command.js +1408 -1364
- package/src/run/complete-handlers.js +1450 -1399
- package/src/run/complete.js +1181 -920
- package/src/run/gates.js +878 -755
- package/src/run/prompt.js +765 -761
- package/src/run/quick-audit.js +155 -146
- package/src/run/shared.js +1348 -1215
- package/src/run/stage.js +527 -479
- package/src/scan-postcheck.js +371 -371
- package/src/setup.js +414 -416
- package/src/sillyhub-mcp/client.js +597 -529
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +358 -319
- package/src/stage-contract.js +871 -897
- package/src/stage-review.js +549 -549
- package/src/stages/archive.js +156 -155
- package/src/stages/brainstorm-auto.js +236 -236
- package/src/stages/brainstorm.js +597 -597
- package/src/stages/cmd-existence.js +145 -145
- package/src/stages/doctor.js +386 -371
- package/src/stages/execute.js +1014 -1003
- package/src/stages/explore.js +34 -34
- package/src/stages/index.js +29 -29
- package/src/stages/knowledge.js +498 -498
- package/src/stages/plan-postcheck.js +1323 -1302
- package/src/stages/plan.js +642 -623
- package/src/stages/quick.js +153 -151
- package/src/stages/scan.js +607 -607
- package/src/stages/status.js +64 -64
- package/src/stages/verify.js +264 -264
- package/src/sync.js +1325 -1135
- package/src/task-review.js +1204 -1019
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +1199 -1186
- package/src/workflow.js +600 -600
- package/src/worktree-apply.js +1237 -1136
- package/src/worktree-deps.js +453 -353
- package/src/worktree.js +1527 -1481
- package/templates/workflows/archive-impact.yaml +79 -79
- 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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
*
|
|
71
|
-
* @
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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))
|