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.
@@ -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/';