sillyspec 3.11.8 → 3.11.10
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/.npmrc.tmp +1 -0
- package/docs/worktree-isolation.md +197 -0
- package/package.json +1 -1
- package/src/change-list.js +52 -0
- package/src/hooks/claude-pre-tool-use.cjs +125 -0
- package/src/hooks/worktree-guard.js +488 -0
- package/src/index.js +115 -0
- package/src/progress.js +57 -1
- package/src/stages/execute.js +57 -11
- package/src/stages/quick.js +42 -3
- package/src/worktree-apply.js +266 -0
- package/src/worktree.js +226 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-guard.js — Hook 拦截判断逻辑
|
|
3
|
+
*
|
|
4
|
+
* 三重门禁:stageGate × locationGate × fileGate
|
|
5
|
+
* 纯判断模块,不做实际的 hook 注入。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from 'fs'
|
|
9
|
+
import path from 'path'
|
|
10
|
+
|
|
11
|
+
// ── 常量 ──
|
|
12
|
+
|
|
13
|
+
const ALLOWED_STAGES = ['execute', 'quick']
|
|
14
|
+
|
|
15
|
+
const WORKTREE_SEGMENT = '.sillyspec/.runtime/worktrees/'
|
|
16
|
+
|
|
17
|
+
const FILE_WHITELIST_EXTS = ['.md']
|
|
18
|
+
const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json', 'local.yaml', 'local.yml']
|
|
19
|
+
|
|
20
|
+
/** 只读命令(命令名) */
|
|
21
|
+
const READONLY_COMMANDS = new Set([
|
|
22
|
+
'grep', 'rg', 'ag', 'find', 'ls', 'cat', 'head', 'tail', 'wc', 'stat',
|
|
23
|
+
'echo', 'pwd', 'basename', 'dirname', 'realpath',
|
|
24
|
+
'node', 'npm', 'npx', // 只允许 --version 等只读子命令,在 matchReadonlyWhitelist 中处理
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
/** 只读 git 子命令 */
|
|
28
|
+
const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show', 'branch', 'stash'])
|
|
29
|
+
|
|
30
|
+
/** 危险 git 子命令 */
|
|
31
|
+
const DANGER_GIT_SUBS = new Set([
|
|
32
|
+
'add', 'commit', 'push', 'checkout', 'restore', 'reset', 'clean',
|
|
33
|
+
'mv', 'rm',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/** 危险 git stash 操作 */
|
|
37
|
+
const DANGER_STASH_ACTIONS = new Set(['drop', 'clear', 'pop'])
|
|
38
|
+
|
|
39
|
+
/** 危险命令前缀 */
|
|
40
|
+
const DANGER_PREFIXES = ['sudo', 'rm -rf', 'rm -r', 'rmdir']
|
|
41
|
+
|
|
42
|
+
// ── 辅助函数 ──
|
|
43
|
+
|
|
44
|
+
function resolveWorktreeDir(cwd) {
|
|
45
|
+
return path.join(cwd, '.sillyspec', '.runtime', 'worktrees')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 读取 gate-status.json
|
|
50
|
+
* @param {string} cwd
|
|
51
|
+
* @returns {{ stage: string, changes?: string[], updatedAt?: string } | null}
|
|
52
|
+
*/
|
|
53
|
+
function readGateStatus(cwd) {
|
|
54
|
+
const p = path.join(cwd, '.sillyspec', '.runtime', 'gate-status.json')
|
|
55
|
+
if (!existsSync(p)) return null
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(p, 'utf8'))
|
|
58
|
+
} catch {
|
|
59
|
+
return null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 检查当前变更是否处于 noWorktree 模式
|
|
65
|
+
* @param {string} cwd
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function isNoWorktreeMode(cwd) {
|
|
69
|
+
// 1. 检查 gate-status.json
|
|
70
|
+
const gateStatus = readGateStatus(cwd)
|
|
71
|
+
if (gateStatus && gateStatus.noWorktree) return true
|
|
72
|
+
|
|
73
|
+
// 2. 检查 progress.json
|
|
74
|
+
const progressFiles = [
|
|
75
|
+
path.join(cwd, '.sillyspec', '.runtime', 'progress.json'),
|
|
76
|
+
]
|
|
77
|
+
for (const p of progressFiles) {
|
|
78
|
+
if (!existsSync(p)) continue
|
|
79
|
+
try {
|
|
80
|
+
const data = JSON.parse(readFileSync(p, 'utf8'))
|
|
81
|
+
if (data.noWorktree) return true
|
|
82
|
+
} catch { /* skip */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. 检查变更级 progress.json
|
|
86
|
+
const runtimeDir = path.join(cwd, '.sillyspec', '.runtime')
|
|
87
|
+
const globalFile = path.join(runtimeDir, 'global.json')
|
|
88
|
+
if (existsSync(globalFile)) {
|
|
89
|
+
try {
|
|
90
|
+
const global = JSON.parse(readFileSync(globalFile, 'utf8'))
|
|
91
|
+
const changesDir = path.join(cwd, '.sillyspec', 'changes')
|
|
92
|
+
for (const cn of (global.activeChanges || [])) {
|
|
93
|
+
const pp = path.join(changesDir, cn, 'progress.json')
|
|
94
|
+
if (!existsSync(pp)) continue
|
|
95
|
+
try {
|
|
96
|
+
const data = JSON.parse(readFileSync(pp, 'utf8'))
|
|
97
|
+
if (data.noWorktree) return true
|
|
98
|
+
} catch { /* skip */ }
|
|
99
|
+
}
|
|
100
|
+
} catch { /* skip */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 判断路径是否在 worktree 内
|
|
108
|
+
* @param {string} filePath - 绝对路径
|
|
109
|
+
* @returns {boolean}
|
|
110
|
+
*/
|
|
111
|
+
function isInsideWorktree(filePath) {
|
|
112
|
+
return filePath.includes(WORKTREE_SEGMENT)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 文件白名单:文档类/配置类始终放行
|
|
117
|
+
* @param {string} filePath - 绝对路径
|
|
118
|
+
* @returns {boolean}
|
|
119
|
+
*/
|
|
120
|
+
function matchFileWhitelist(filePath) {
|
|
121
|
+
// 路径以 .sillyspec/ 开头
|
|
122
|
+
const parts = filePath.split(path.sep)
|
|
123
|
+
for (let i = 0; i < parts.length; i++) {
|
|
124
|
+
if (parts[i] === '.sillyspec') return true
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 路径在 .git/ 下
|
|
128
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
129
|
+
if (parts[i] === '.git') return true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 扩展名
|
|
133
|
+
const ext = path.extname(filePath)
|
|
134
|
+
if (FILE_WHITELIST_EXTS.includes(ext)) return true
|
|
135
|
+
|
|
136
|
+
// 文件名
|
|
137
|
+
const base = path.basename(filePath)
|
|
138
|
+
if (FILE_WHITELIST_NAMES.includes(base)) return true
|
|
139
|
+
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 读取 local.yaml 中的扩展白名单配置(如果存在)
|
|
145
|
+
* @param {string} cwd
|
|
146
|
+
* @returns {{ fileWhitelist?: string[], readonlyCommands?: string[] }}
|
|
147
|
+
*/
|
|
148
|
+
function loadLocalConfig(cwd) {
|
|
149
|
+
const candidates = [
|
|
150
|
+
path.join(cwd, 'local.yaml'),
|
|
151
|
+
path.join(cwd, 'local.yml'),
|
|
152
|
+
]
|
|
153
|
+
for (const p of candidates) {
|
|
154
|
+
if (!existsSync(p)) continue
|
|
155
|
+
try {
|
|
156
|
+
// 简单 YAML 解析(只处理顶层键值对和简单数组)
|
|
157
|
+
const content = readFileSync(p, 'utf8')
|
|
158
|
+
return parseSimpleYaml(content)
|
|
159
|
+
} catch {
|
|
160
|
+
return {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return {}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 简易 YAML 解析,只处理顶层简单结构
|
|
168
|
+
*/
|
|
169
|
+
function parseSimpleYaml(content) {
|
|
170
|
+
const result = {}
|
|
171
|
+
let currentKey = null
|
|
172
|
+
let inArray = false
|
|
173
|
+
|
|
174
|
+
for (const line of content.split('\n')) {
|
|
175
|
+
const trimmed = line.trim()
|
|
176
|
+
if (!trimmed || trimmed.startsWith('#')) continue
|
|
177
|
+
|
|
178
|
+
// 顶层键
|
|
179
|
+
const topLevelMatch = trimmed.match(/^(\S[\S]*)\s*:\s*(.*)$/)
|
|
180
|
+
if (topLevelMatch && !trimmed.startsWith(' ')) {
|
|
181
|
+
const key = topLevelMatch[1]
|
|
182
|
+
const val = topLevelMatch[2].trim()
|
|
183
|
+
currentKey = key
|
|
184
|
+
|
|
185
|
+
if (val) {
|
|
186
|
+
// key: value (单行)
|
|
187
|
+
result[key] = val
|
|
188
|
+
inArray = false
|
|
189
|
+
} else {
|
|
190
|
+
// key: (多行值开始)
|
|
191
|
+
result[key] = []
|
|
192
|
+
inArray = true
|
|
193
|
+
}
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 数组项
|
|
198
|
+
if (inArray && currentKey && trimmed.startsWith('- ')) {
|
|
199
|
+
const item = trimmed.slice(2).trim()
|
|
200
|
+
if (Array.isArray(result[currentKey])) {
|
|
201
|
+
result[currentKey].push(item)
|
|
202
|
+
}
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 非数组行结束数组模式
|
|
207
|
+
if (inArray && !trimmed.startsWith('- ') && !trimmed.startsWith('#')) {
|
|
208
|
+
inArray = false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return result
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 提取命令中第一个可执行命令名
|
|
217
|
+
* @param {string} command
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
function extractCommandName(command) {
|
|
221
|
+
const trimmed = command.trim()
|
|
222
|
+
if (!trimmed) return ''
|
|
223
|
+
return trimmed.split(/\s+/)[0]
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 判断单个命令片段是否匹配只读白名单
|
|
228
|
+
* @param {string} cmd - 单个命令片段(不含管道/链式操作符)
|
|
229
|
+
* @param {string[]} extraReadonlyCommands - local.yaml 扩展的只读命令
|
|
230
|
+
* @returns {boolean}
|
|
231
|
+
*/
|
|
232
|
+
function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
|
|
233
|
+
const trimmed = cmd.trim()
|
|
234
|
+
if (!trimmed) return true // 空片段放行
|
|
235
|
+
|
|
236
|
+
const parts = trimmed.split(/\s+/)
|
|
237
|
+
const cmdName = parts[0]
|
|
238
|
+
|
|
239
|
+
// 纯命令名匹配
|
|
240
|
+
if (READONLY_COMMANDS.has(cmdName)) {
|
|
241
|
+
// node/npm/npx 需要进一步检查子命令
|
|
242
|
+
if (cmdName === 'node' || cmdName === 'npm' || cmdName === 'npx') {
|
|
243
|
+
const rest = parts.slice(1).join(' ')
|
|
244
|
+
return rest.includes('--version') || rest.includes('-v') && parts.length <= 3 || rest === 'run test' || rest.startsWith('test')
|
|
245
|
+
}
|
|
246
|
+
return true
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// local.yaml 扩展
|
|
250
|
+
if (extraReadonlyCommands.includes(cmdName)) return true
|
|
251
|
+
|
|
252
|
+
// git 只读子命令
|
|
253
|
+
if (cmdName === 'git') {
|
|
254
|
+
const sub = parts[1] || ''
|
|
255
|
+
if (READONLY_GIT_SUBS.has(sub)) return true
|
|
256
|
+
// git stash list
|
|
257
|
+
if (sub === 'stash' && (parts[2] === 'list' || parts.length === 2)) return true
|
|
258
|
+
return false
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// sillyspec worktree 命令
|
|
262
|
+
if (cmdName === 'sillyspec') {
|
|
263
|
+
const sub = parts[1] || ''
|
|
264
|
+
if (sub === 'worktree') return true
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 判断单个命令片段是否匹配危险黑名单
|
|
273
|
+
* @param {string} cmd - 单个命令片段
|
|
274
|
+
* @returns {boolean}
|
|
275
|
+
*/
|
|
276
|
+
function isSingleCommandDangerous(cmd) {
|
|
277
|
+
const trimmed = cmd.trim().toLowerCase()
|
|
278
|
+
|
|
279
|
+
for (const prefix of DANGER_PREFIXES) {
|
|
280
|
+
if (trimmed.startsWith(prefix)) return true
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const parts = trimmed.split(/\s+/)
|
|
284
|
+
const cmdName = parts[0]
|
|
285
|
+
|
|
286
|
+
if (cmdName === 'git') {
|
|
287
|
+
const sub = parts[1] || ''
|
|
288
|
+
if (DANGER_GIT_SUBS.has(sub)) return true
|
|
289
|
+
// git stash drop/clear/pop
|
|
290
|
+
if (sub === 'stash' && DANGER_STASH_ACTIONS.has(parts[2] || '')) return true
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// rm(不限于 -rf)
|
|
294
|
+
if (cmdName === 'rm') return true
|
|
295
|
+
|
|
296
|
+
return false
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 将命令按管道/链式操作符拆分为多个片段
|
|
301
|
+
* @param {string} command
|
|
302
|
+
* @returns {string[]}
|
|
303
|
+
*/
|
|
304
|
+
function splitCommandParts(command) {
|
|
305
|
+
// 按管道和链式操作符拆分
|
|
306
|
+
return command.split(/(?:\|\|&&|&&|\|)/g).map(s => s.trim()).filter(Boolean)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 判断命令是否匹配只读白名单(含管道/链式检查)
|
|
311
|
+
* @param {string} command
|
|
312
|
+
* @param {string[]} extraReadonlyCommands
|
|
313
|
+
* @returns {boolean}
|
|
314
|
+
*/
|
|
315
|
+
function matchReadonlyWhitelist(command, extraReadonlyCommands = []) {
|
|
316
|
+
const parts = splitCommandParts(command)
|
|
317
|
+
return parts.every(p => isSingleCommandReadonly(p, extraReadonlyCommands))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 判断命令是否匹配危险黑名单(含管道/链式检查)
|
|
322
|
+
* @param {string} command
|
|
323
|
+
* @returns {boolean}
|
|
324
|
+
*/
|
|
325
|
+
function matchDangerBlacklist(command) {
|
|
326
|
+
const parts = splitCommandParts(command)
|
|
327
|
+
return parts.some(p => isSingleCommandDangerous(p))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── 公共接口 ──
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 判断文件写入是否应被拦截
|
|
334
|
+
*
|
|
335
|
+
* 降级策略(无 worktree = 更严格):
|
|
336
|
+
* - noWorktree 模式下,execute/quick 阶段不允许源码写入(没有隔离环境)
|
|
337
|
+
* - 除非同时设置 SILLYSPEC_DISABLE_HOOKS=1
|
|
338
|
+
*
|
|
339
|
+
* @param {string} filePath - 目标文件绝对路径
|
|
340
|
+
* @param {string} cwd - 当前工作目录
|
|
341
|
+
* @returns {{ blocked: boolean, reason?: string }}
|
|
342
|
+
*/
|
|
343
|
+
export function shouldBlockWrite(filePath, cwd) {
|
|
344
|
+
if (!filePath) return { blocked: true, reason: 'no file path' }
|
|
345
|
+
|
|
346
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
|
|
347
|
+
|
|
348
|
+
// 1. 文件门禁:文档类/配置类始终放行
|
|
349
|
+
if (matchFileWhitelist(absPath)) return { blocked: false }
|
|
350
|
+
|
|
351
|
+
// 2. 阶段门禁
|
|
352
|
+
const effectiveCwd = cwd || process.cwd()
|
|
353
|
+
const gateStatus = readGateStatus(effectiveCwd)
|
|
354
|
+
if (!gateStatus || !ALLOWED_STAGES.includes(gateStatus.stage)) {
|
|
355
|
+
const stage = gateStatus?.stage || '(none)'
|
|
356
|
+
return {
|
|
357
|
+
blocked: true,
|
|
358
|
+
reason: [
|
|
359
|
+
`当前阶段 "${stage}" 不允许修改源码。`,
|
|
360
|
+
`源码修改只能在 execute 或 quick 阶段进行。`,
|
|
361
|
+
'请先完成文档规划流程:',
|
|
362
|
+
' - 小改动:运行 sillyspec run quick',
|
|
363
|
+
' - 大改动:运行 sillyspec run brainstorm → plan → execute',
|
|
364
|
+
' - 或使用 sillyspec run auto 连续推进全流程',
|
|
365
|
+
].join('\n')
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 3. 位置门禁
|
|
370
|
+
if (isInsideWorktree(absPath)) return { blocked: false }
|
|
371
|
+
|
|
372
|
+
// noWorktree 模式:无隔离环境,禁止源码写入(降级到更严格)
|
|
373
|
+
if (isNoWorktreeMode(effectiveCwd)) {
|
|
374
|
+
return {
|
|
375
|
+
blocked: true,
|
|
376
|
+
reason: [
|
|
377
|
+
'当前处于 --no-worktree 降级模式,不允许源码写入。',
|
|
378
|
+
'如需修改源码,请移除 --no-worktree 标志重新执行。',
|
|
379
|
+
'紧急情况可设置 SILLYSPEC_DISABLE_HOOKS=1 绕过限制。',
|
|
380
|
+
].join('\n')
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
blocked: true,
|
|
386
|
+
reason: [
|
|
387
|
+
'源码修改只能在 worktree 隔离环境中进行。',
|
|
388
|
+
'execute/quick 阶段会自动创建 worktree。',
|
|
389
|
+
'如果你正在 execute 阶段,请确认 worktree 已创建:sillyspec worktree list',
|
|
390
|
+
].join('\n')
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 判断 Bash 命令是否应被拦截
|
|
396
|
+
* @param {string} command - Bash 命令字符串
|
|
397
|
+
* @param {string} cwd - 当前工作目录
|
|
398
|
+
* @returns {{ blocked: boolean, reason?: string }}
|
|
399
|
+
*/
|
|
400
|
+
export function shouldBlockBash(command, cwd) {
|
|
401
|
+
if (!command || !command.trim()) return { blocked: false }
|
|
402
|
+
|
|
403
|
+
const effectiveCwd = cwd || process.cwd()
|
|
404
|
+
|
|
405
|
+
// cwd 在 worktree 内 → 全部放行
|
|
406
|
+
if (isInsideWorktree(effectiveCwd)) return { blocked: false }
|
|
407
|
+
|
|
408
|
+
// 阶段门禁
|
|
409
|
+
const gateStatus = readGateStatus(effectiveCwd)
|
|
410
|
+
const stageOk = gateStatus && ALLOWED_STAGES.includes(gateStatus.stage)
|
|
411
|
+
|
|
412
|
+
if (!stageOk) {
|
|
413
|
+
// 非 execute/quick 阶段,只允许只读白名单
|
|
414
|
+
const localConfig = loadLocalConfig(effectiveCwd)
|
|
415
|
+
const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
|
|
416
|
+
if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
|
|
417
|
+
const stage = gateStatus?.stage || '(none)'
|
|
418
|
+
return {
|
|
419
|
+
blocked: true,
|
|
420
|
+
reason: [
|
|
421
|
+
`当前阶段 "${stage}" 不允许执行此命令。`,
|
|
422
|
+
'源码修改只能在 execute 或 quick 阶段进行。',
|
|
423
|
+
'请先完成文档规划流程:',
|
|
424
|
+
' - 小改动:运行 sillyspec run quick',
|
|
425
|
+
' - 大改动:运行 sillyspec run brainstorm → plan → execute',
|
|
426
|
+
].join('\n')
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// execute/quick 阶段 + 主工作区
|
|
431
|
+
const localConfig = loadLocalConfig(effectiveCwd)
|
|
432
|
+
const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
|
|
433
|
+
|
|
434
|
+
// 危险黑名单
|
|
435
|
+
if (matchDangerBlacklist(command)) {
|
|
436
|
+
return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 只读白名单放行
|
|
440
|
+
if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
|
|
441
|
+
|
|
442
|
+
// 不确定 → 放行
|
|
443
|
+
return { blocked: false }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* 判断工具调用是否应被拦截
|
|
448
|
+
* @param {{
|
|
449
|
+
* tool: 'Write' | 'Edit' | 'MultiEdit' | 'Bash',
|
|
450
|
+
* filePath?: string,
|
|
451
|
+
* filePaths?: string[],
|
|
452
|
+
* command?: string,
|
|
453
|
+
* cwd?: string
|
|
454
|
+
* }} opts
|
|
455
|
+
* @param {{ cwd?: string }} ctx
|
|
456
|
+
* @returns {{ blocked: boolean, reason?: string }}
|
|
457
|
+
*/
|
|
458
|
+
export function shouldBlock(opts, ctx = {}) {
|
|
459
|
+
// 逃生开关
|
|
460
|
+
if (process.env.SILLYSPEC_DISABLE_HOOKS === '1') return { blocked: false }
|
|
461
|
+
|
|
462
|
+
const cwd = opts.cwd || ctx.cwd || process.cwd()
|
|
463
|
+
|
|
464
|
+
switch (opts.tool) {
|
|
465
|
+
case 'Write':
|
|
466
|
+
case 'Edit': {
|
|
467
|
+
const fp = opts.filePath
|
|
468
|
+
if (!fp) return { blocked: true, reason: 'no file path' }
|
|
469
|
+
const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
|
|
470
|
+
return shouldBlockWrite(absPath, cwd)
|
|
471
|
+
}
|
|
472
|
+
case 'MultiEdit': {
|
|
473
|
+
const filePaths = opts.filePaths
|
|
474
|
+
if (!filePaths || filePaths.length === 0) return { blocked: true, reason: 'no file paths' }
|
|
475
|
+
for (const fp of filePaths) {
|
|
476
|
+
const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
|
|
477
|
+
const result = shouldBlockWrite(absPath, cwd)
|
|
478
|
+
if (result.blocked) return result
|
|
479
|
+
}
|
|
480
|
+
return { blocked: false }
|
|
481
|
+
}
|
|
482
|
+
case 'Bash': {
|
|
483
|
+
return shouldBlockBash(opts.command || '', cwd)
|
|
484
|
+
}
|
|
485
|
+
default:
|
|
486
|
+
return { blocked: false }
|
|
487
|
+
}
|
|
488
|
+
}
|
package/src/index.js
CHANGED
|
@@ -257,6 +257,121 @@ async function main() {
|
|
|
257
257
|
console.log('按 Ctrl+C 停止服务器');
|
|
258
258
|
break;
|
|
259
259
|
}
|
|
260
|
+
case 'worktree': {
|
|
261
|
+
const { WorktreeManager } = await import('./worktree.js');
|
|
262
|
+
const wtSubCmd = filteredArgs[1];
|
|
263
|
+
const wtName = filteredArgs[2];
|
|
264
|
+
const wm = new WorktreeManager({ cwd: dir });
|
|
265
|
+
|
|
266
|
+
if (!wtSubCmd || wtSubCmd === 'help' || wtSubCmd === '--help' || wtSubCmd === '-h') {
|
|
267
|
+
console.log(`
|
|
268
|
+
SillySpec worktree — git worktree 隔离管理
|
|
269
|
+
|
|
270
|
+
用法:
|
|
271
|
+
sillyspec worktree create <change-name> [--base <branch>] 创建隔离 worktree
|
|
272
|
+
sillyspec worktree apply <change-name> [--check-only] 校验并应用变更到主工作区
|
|
273
|
+
sillyspec worktree list 列出所有活跃 worktree
|
|
274
|
+
sillyspec worktree cleanup <change-name> 强制清理 worktree
|
|
275
|
+
|
|
276
|
+
选项:
|
|
277
|
+
--base <branch> create: 指定基础分支(默认当前 HEAD)
|
|
278
|
+
--check-only apply: 只输出检查结果,不实际 apply
|
|
279
|
+
`);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
switch (wtSubCmd) {
|
|
284
|
+
case 'create': {
|
|
285
|
+
if (!wtName) {
|
|
286
|
+
console.error('❌ 用法: sillyspec worktree create <change-name> [--base <branch>]');
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
const baseIdx = args.indexOf('--base');
|
|
290
|
+
const base = baseIdx >= 0 && args[baseIdx + 1] ? args[baseIdx + 1] : undefined;
|
|
291
|
+
try {
|
|
292
|
+
const info = wm.create(wtName, { base });
|
|
293
|
+
console.log(`✅ worktree 已创建`);
|
|
294
|
+
console.log(` 分支: ${info.branch}`);
|
|
295
|
+
console.log(` 路径: ${info.worktreePath}`);
|
|
296
|
+
console.log(` 基准: ${info.baseHash.slice(0, 8)}`);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
console.error(`❌ ${e.message}`);
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
case 'apply': {
|
|
304
|
+
if (!wtName) {
|
|
305
|
+
console.error('❌ 用法: sillyspec worktree apply <change-name> [--check-only]');
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
const checkOnly = args.includes('--check-only');
|
|
309
|
+
const { applyWorktree } = await import('./worktree-apply.js');
|
|
310
|
+
const result = applyWorktree(wtName, { cwd: dir, checkOnly });
|
|
311
|
+
|
|
312
|
+
if (result.errors.length > 0) {
|
|
313
|
+
console.error(`❌ 校验失败:`);
|
|
314
|
+
for (const err of result.errors) {
|
|
315
|
+
console.error(` ${err}`);
|
|
316
|
+
}
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (result.changedFiles.length === 0) {
|
|
321
|
+
console.log('📭 无变更需要应用');
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (checkOnly) {
|
|
326
|
+
console.log(`✅ 检查通过 (${result.changedFiles.length} 个文件):`);
|
|
327
|
+
for (const f of result.changedFiles) {
|
|
328
|
+
console.log(` ${f}`);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
console.log(`✅ 已应用 ${result.changedFiles.length} 个文件变更`);
|
|
332
|
+
}
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
case 'list': {
|
|
336
|
+
const items = wm.list();
|
|
337
|
+
if (items.length === 0) {
|
|
338
|
+
console.log('📭 无活跃 worktree');
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
// 计算列宽
|
|
342
|
+
const maxName = Math.max('Change Name'.length, ...items.map(i => i.changeName.length));
|
|
343
|
+
const maxBranch = Math.max('Branch'.length, ...items.map(i => i.branch.length));
|
|
344
|
+
const header = ` ${'Change Name'.padEnd(maxName)} ${'Branch'.padEnd(maxBranch)} Created`;
|
|
345
|
+
const sep = ` ${'─'.repeat(maxName)} ${'─'.repeat(maxBranch)} ${'─'.repeat(19)}`;
|
|
346
|
+
console.log(header);
|
|
347
|
+
console.log(sep);
|
|
348
|
+
for (const item of items) {
|
|
349
|
+
const created = item.createdAt ? item.createdAt.replace('T', ' ').replace('Z', '').slice(0, 19) : '-';
|
|
350
|
+
console.log(` ${item.changeName.padEnd(maxName)} ${item.branch.padEnd(maxBranch)} ${created}`);
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case 'cleanup': {
|
|
355
|
+
if (!wtName) {
|
|
356
|
+
console.error('❌ 用法: sillyspec worktree cleanup <change-name>');
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
wm.cleanup(wtName);
|
|
361
|
+
console.log(`✅ worktree 已清理: ${wtName}`);
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.error(`❌ ${e.message}`);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
default:
|
|
369
|
+
console.error(`❌ 未知子命令: worktree ${wtSubCmd}`);
|
|
370
|
+
console.log(' 运行 sillyspec worktree --help 查看帮助');
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
260
375
|
default:
|
|
261
376
|
console.error(`❌ 未知命令: ${command}`);
|
|
262
377
|
printUsage();
|
package/src/progress.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, copyFileSync, unlinkSync, readdirSync } from 'fs';
|
|
14
|
-
import { join, basename } from 'path';
|
|
14
|
+
import { join, basename, resolve } from 'path';
|
|
15
15
|
|
|
16
16
|
const RUNTIME_DIR = '.sillyspec/.runtime';
|
|
17
17
|
const CHANGES_DIR = '.sillyspec/changes';
|
|
@@ -199,6 +199,7 @@ export class ProgressManager {
|
|
|
199
199
|
const tmpPath = progressPath + '.tmp';
|
|
200
200
|
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n');
|
|
201
201
|
renameSync(tmpPath, progressPath);
|
|
202
|
+
this._updateGateStatus(cwd);
|
|
202
203
|
return;
|
|
203
204
|
}
|
|
204
205
|
|
|
@@ -207,6 +208,7 @@ export class ProgressManager {
|
|
|
207
208
|
const tmpPath = progressPath + '.tmp';
|
|
208
209
|
writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n');
|
|
209
210
|
renameSync(tmpPath, progressPath);
|
|
211
|
+
this._updateGateStatus(cwd);
|
|
210
212
|
}
|
|
211
213
|
|
|
212
214
|
_backup(cwd, data) {
|
|
@@ -693,6 +695,60 @@ export class ProgressManager {
|
|
|
693
695
|
return `📊 批量进度: ${bar} ${completed}/${total}${suffix}`;
|
|
694
696
|
}
|
|
695
697
|
|
|
698
|
+
/**
|
|
699
|
+
* 更新 gate-status.json,供 worktree-guard hook 读取
|
|
700
|
+
* 扫描所有活跃变更的 currentStage,任一为 execute/quick 则 stage 设为该值
|
|
701
|
+
*/
|
|
702
|
+
_updateGateStatus(cwd) {
|
|
703
|
+
const changes = this.listChanges(cwd);
|
|
704
|
+
if (changes.length === 0) {
|
|
705
|
+
// 无活跃变更,删除 gate-status(如果存在)
|
|
706
|
+
const gatePath = this._runtimePath(cwd, 'gate-status.json');
|
|
707
|
+
if (existsSync(gatePath)) {
|
|
708
|
+
try { unlinkSync(gatePath); } catch {}
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let gateStage = null;
|
|
714
|
+
let hasNoWorktree = false;
|
|
715
|
+
const activeChanges = [];
|
|
716
|
+
|
|
717
|
+
for (const cn of changes) {
|
|
718
|
+
const data = this.read(cwd, cn);
|
|
719
|
+
if (!data || !data.currentStage) continue;
|
|
720
|
+
const stage = data.currentStage;
|
|
721
|
+
if (['execute', 'quick'].includes(stage)) {
|
|
722
|
+
// 优先取 execute,其次 quick
|
|
723
|
+
if (gateStage !== 'execute' || stage === 'execute') {
|
|
724
|
+
gateStage = stage;
|
|
725
|
+
}
|
|
726
|
+
activeChanges.push(cn);
|
|
727
|
+
if (data.noWorktree) hasNoWorktree = true;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const gatePath = this._runtimePath(cwd, 'gate-status.json');
|
|
732
|
+
|
|
733
|
+
if (gateStage) {
|
|
734
|
+
this._ensureRuntimeDir(cwd);
|
|
735
|
+
const gateData = {
|
|
736
|
+
stage: gateStage,
|
|
737
|
+
changes: activeChanges,
|
|
738
|
+
updatedAt: new Date().toISOString(),
|
|
739
|
+
...(hasNoWorktree ? { noWorktree: true } : {}),
|
|
740
|
+
};
|
|
741
|
+
const tmpPath = gatePath + '.tmp';
|
|
742
|
+
writeFileSync(tmpPath, JSON.stringify(gateData, null, 2) + '\n');
|
|
743
|
+
renameSync(tmpPath, gatePath);
|
|
744
|
+
} else {
|
|
745
|
+
// 无 execute/quick 阶段,删除 gate-status
|
|
746
|
+
if (existsSync(gatePath)) {
|
|
747
|
+
try { unlinkSync(gatePath); } catch {}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
696
752
|
_ensureGitignore(cwd) {
|
|
697
753
|
const gitignorePath = join(cwd, '.gitignore');
|
|
698
754
|
const rule = '.sillyspec/.runtime/';
|