sillyspec 3.20.2 → 3.20.4

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 (133) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
  2. package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
  4. package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
  7. package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
  8. package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
  9. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  10. package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
  11. package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
  12. package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
  13. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  14. package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
  15. package/.claude/skills/sillyspec-state/SKILL.md +54 -54
  16. package/.claude/skills/sillyspec-status/SKILL.md +21 -21
  17. package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
  18. package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
  19. package/.husky/pre-push +13 -13
  20. package/CLAUDE.md +18 -18
  21. package/README.md +198 -188
  22. package/SKILL.md +90 -91
  23. package/bin/sillyspec.js +2 -2
  24. package/docs/brainstorm-plan-contract.md +64 -64
  25. package/docs/plan-execute-contract.md +123 -123
  26. package/docs/platform-scan-protocol.md +298 -298
  27. package/docs/revision-mode.md +115 -115
  28. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
  29. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
  30. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
  31. package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
  32. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
  33. package/docs/sillyspec/file-lifecycle.md +125 -125
  34. package/docs/workflow-contract-regression.md +106 -106
  35. package/docs/worktree-isolation.md +252 -252
  36. package/package.json +40 -40
  37. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  38. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  39. package/packages/dashboard/dist/index.html +16 -16
  40. package/packages/dashboard/index.html +15 -15
  41. package/packages/dashboard/package-lock.json +2384 -2384
  42. package/packages/dashboard/package.json +25 -25
  43. package/packages/dashboard/server/executor.js +86 -86
  44. package/packages/dashboard/server/index.js +588 -588
  45. package/packages/dashboard/server/parser.js +526 -526
  46. package/packages/dashboard/server/watcher.js +344 -344
  47. package/packages/dashboard/src/App.vue +558 -558
  48. package/packages/dashboard/src/components/ActionBar.vue +93 -93
  49. package/packages/dashboard/src/components/CommandPalette.vue +96 -96
  50. package/packages/dashboard/src/components/DetailPanel.vue +137 -137
  51. package/packages/dashboard/src/components/LogStream.vue +65 -65
  52. package/packages/dashboard/src/components/PipelineStage.vue +95 -95
  53. package/packages/dashboard/src/components/PipelineView.vue +156 -156
  54. package/packages/dashboard/src/components/ProjectList.vue +210 -210
  55. package/packages/dashboard/src/components/StageBadge.vue +67 -67
  56. package/packages/dashboard/src/components/StepCard.vue +94 -94
  57. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
  58. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
  59. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
  60. package/packages/dashboard/src/composables/useDashboard.js +170 -170
  61. package/packages/dashboard/src/composables/useKeyboard.js +119 -119
  62. package/packages/dashboard/src/composables/useWebSocket.js +129 -129
  63. package/packages/dashboard/src/main.js +8 -8
  64. package/packages/dashboard/src/style.css +132 -132
  65. package/packages/dashboard/vite.config.js +18 -18
  66. package/src/brainstorm-postcheck.js +158 -158
  67. package/src/change-list.js +52 -52
  68. package/src/change-risk-profile.js +352 -352
  69. package/src/classify-change.js +73 -73
  70. package/src/constants.js +70 -70
  71. package/src/contract-matrix.js +278 -278
  72. package/src/db.js +201 -201
  73. package/src/endpoint-extractor.js +315 -315
  74. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  75. package/src/hooks/worktree-guard.js +653 -653
  76. package/src/index.js +922 -900
  77. package/src/init.js +431 -431
  78. package/src/knowledge-match.js +130 -130
  79. package/src/migrate.js +117 -117
  80. package/src/modules.js +482 -482
  81. package/src/progress.js +1734 -1734
  82. package/src/run.js +3465 -3358
  83. package/src/scan-postcheck.js +387 -383
  84. package/src/setup.js +398 -398
  85. package/src/stage-contract.js +700 -700
  86. package/src/stages/archive.js +160 -160
  87. package/src/stages/brainstorm-auto.js +229 -229
  88. package/src/stages/brainstorm.js +645 -645
  89. package/src/stages/doctor.js +365 -365
  90. package/src/stages/execute.js +625 -625
  91. package/src/stages/explore.js +34 -34
  92. package/src/stages/index.js +29 -29
  93. package/src/stages/knowledge.js +498 -498
  94. package/src/stages/plan-postcheck.js +511 -513
  95. package/src/stages/plan.js +582 -582
  96. package/src/stages/propose.js +174 -174
  97. package/src/stages/quick.js +82 -82
  98. package/src/stages/scan.js +558 -558
  99. package/src/stages/status.js +65 -65
  100. package/src/stages/verify.js +322 -322
  101. package/src/sync.js +497 -497
  102. package/src/task-review.js +346 -346
  103. package/src/workflow.js +785 -785
  104. package/src/worktree-apply.js +549 -549
  105. package/src/worktree-deps.js +185 -0
  106. package/src/worktree.js +982 -932
  107. package/templates/workflows/archive-impact.yaml +79 -79
  108. package/templates/workflows/scan-docs.yaml +132 -132
  109. package/test/brainstorm-plan-contract.test.mjs +273 -273
  110. package/test/check-syntax.mjs +26 -26
  111. package/test/contract-artifacts.test.mjs +323 -323
  112. package/test/decision-supersede.test.mjs +277 -277
  113. package/test/knowledge-match.test.mjs +231 -231
  114. package/test/plan-execute-contract.test.mjs +330 -330
  115. package/test/plan-optimization.test.mjs +572 -572
  116. package/test/platform-artifacts.test.mjs +166 -166
  117. package/test/platform-failure-samples.test.mjs +199 -199
  118. package/test/platform-recovery-chain.test.mjs +167 -167
  119. package/test/platform-recovery.test.mjs +136 -136
  120. package/test/platform-scan-p0.test.mjs +168 -168
  121. package/test/revision-v1.test.mjs +1145 -1145
  122. package/test/run-scan-project-parse.test.mjs +200 -200
  123. package/test/run-tests.mjs +48 -48
  124. package/test/scan-knowledge.test.mjs +175 -175
  125. package/test/scan-paths.test.mjs +68 -68
  126. package/test/scan-postcheck.test.mjs +197 -197
  127. package/test/spec-dir.test.mjs +206 -206
  128. package/test/stage-contract.test.mjs +299 -299
  129. package/test/stage-definitions.test.mjs +39 -39
  130. package/test/wait-gates.test.mjs +496 -496
  131. package/test/worktree-deps-provision.test.mjs +148 -0
  132. package/test/worktree-guard.test.mjs +71 -71
  133. package/test/worktree-native-overlay.test.mjs +188 -188
@@ -1,124 +1,124 @@
1
- /**
2
- * worktree-guard.js — Hook 拦截判断逻辑
3
- *
4
- * 三重门禁:stageGate × locationGate × fileGate
5
- * 纯判断模块,不做实际的 hook 注入。
6
- *
7
- * P0 优化:
8
- * - 阶段检测 fallback:gate-status.json → sillyspec.db currentStage
9
- * - 拦截提示针对每个阶段给出具体修复建议
10
- */
11
-
12
- import { existsSync, readFileSync } from 'fs'
13
- import path from 'path'
14
-
15
- // ── 常量 ──
16
-
17
- const WORKTREE_STAGES = ['execute'] // 这些阶段必须在 worktree 里
18
-
19
- const FILE_WHITELIST_EXTS = ['.md']
20
- const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json', 'local.yaml', 'local.yml']
21
-
22
- /** 只读命令(命令名) */
23
- const READONLY_COMMANDS = new Set([
24
- 'grep', 'rg', 'ag', 'find', 'ls', 'cat', 'head', 'tail', 'wc', 'stat',
25
- 'echo', 'pwd', 'basename', 'dirname', 'realpath',
26
- 'node', 'npm', 'npx', // 只允许 --version 等只读子命令,在 matchReadonlyWhitelist 中处理
27
- ])
28
-
29
- /** 只读 git 子命令 */
30
- const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show', 'branch', 'stash'])
31
-
32
- /** 危险 git 子命令 */
33
- const DANGER_GIT_SUBS = new Set([
34
- 'add', 'commit', 'push', 'checkout', 'restore', 'reset', 'clean',
35
- 'mv', 'rm',
36
- ])
37
-
38
- /** 危险 git stash 操作 */
39
- const DANGER_STASH_ACTIONS = new Set(['drop', 'clear', 'pop'])
40
-
41
- /** 危险命令前缀 */
42
- const DANGER_PREFIXES = ['sudo', 'rm -rf', 'rm -r', 'rmdir']
43
-
44
- // ── 阶段 → 拦截提示映射 ──
45
-
46
- const STAGE_HINTS = {
47
- '(none)': [
48
- '没有检测到活跃的 SillySpec 流程。',
49
- '你需要先启动一个任务流程才能修改源码(调用对应的 sillyspec skill):',
50
- '',
51
- ' BUG修复(skill sillyspec-quick):sillyspec run quick "任务描述"',
52
- ' 逻辑变更(skill sillyspec-brainstorm):sillyspec run brainstorm → plan → execute → verify → archive',
53
- ' 全自动模式(skill sillyspec-auto):sillyspec run auto "任务描述"',
54
- ],
55
- 'brainstorm': [
56
- '当前在 brainstorm(需求分析)阶段,这个阶段只写文档,不写代码。',
57
- '完成 brainstorm 后,流程会自动推进到 plan → execute。',
58
- 'execute 阶段才允许写代码。',
59
- ],
60
- 'plan': [
61
- '当前在 plan(计划制定)阶段,这个阶段只写计划文档和任务蓝图,不写代码。',
62
- '完成 plan 后,运行 sillyspec run execute 进入执行阶段。',
63
- ],
64
- 'verify': [
65
- '当前在 verify(验证)阶段,只做代码审查和测试验证,不修改源码。',
66
- '如需修改,请先回到 execute 阶段或使用 quick 模式:',
67
- ' sillyspec run quick "修改描述"',
68
- ],
69
- 'archive': [
70
- '当前在 archive(归档)阶段,不修改源码。',
71
- '如需修改,请开启新变更:sillyspec run quick "修改描述"',
72
- ],
73
- 'explore': [
74
- '当前在 explore(探索)阶段,只读不写。',
75
- '确认方案后使用:sillyspec run brainstorm 或 sillyspec run quick',
76
- ],
77
- }
78
-
79
- // ── 辅助函数 ──
80
-
81
- function resolveWorktreeDir(cwd) {
82
- return path.join(cwd, '.sillyspec', '.runtime', 'worktrees')
83
- }
84
-
85
- function findProjectRoot(cwd) {
86
- let dir = path.resolve(cwd || process.cwd())
87
- while (true) {
88
- if (
89
- existsSync(path.join(dir, '.sillyspec', '.runtime', 'gate-status.json')) ||
90
- existsSync(path.join(dir, '.sillyspec', '.runtime', 'sillyspec.db')) ||
91
- existsSync(path.join(dir, '.sillyspec', 'local.yaml')) ||
92
- existsSync(path.join(dir, '.sillyspec', 'local.yml')) ||
93
- existsSync(path.join(dir, '.sillyspec', 'projects'))
94
- ) {
95
- return dir
96
- }
97
- const parent = path.dirname(dir)
98
- if (parent === dir) return path.resolve(cwd || process.cwd())
99
- dir = parent
100
- }
101
- }
102
-
103
- function safeChangeName(changeName) {
104
- return typeof changeName === 'string'
105
- && changeName
106
- && !changeName.includes('..')
107
- && !changeName.includes('/')
108
- && !changeName.includes('\\')
109
- }
110
-
111
- function readWorktreeMeta(cwd, changeName) {
112
- if (!safeChangeName(changeName)) return null
113
- const metaPath = path.join(resolveWorktreeDir(cwd), changeName, 'meta.json')
114
- if (!existsSync(metaPath)) return null
115
- try {
116
- return JSON.parse(readFileSync(metaPath, 'utf8'))
117
- } catch {
118
- return null
119
- }
120
- }
121
-
1
+ /**
2
+ * worktree-guard.js — Hook 拦截判断逻辑
3
+ *
4
+ * 三重门禁:stageGate × locationGate × fileGate
5
+ * 纯判断模块,不做实际的 hook 注入。
6
+ *
7
+ * P0 优化:
8
+ * - 阶段检测 fallback:gate-status.json → sillyspec.db currentStage
9
+ * - 拦截提示针对每个阶段给出具体修复建议
10
+ */
11
+
12
+ import { existsSync, readFileSync } from 'fs'
13
+ import path from 'path'
14
+
15
+ // ── 常量 ──
16
+
17
+ const WORKTREE_STAGES = ['execute'] // 这些阶段必须在 worktree 里
18
+
19
+ const FILE_WHITELIST_EXTS = ['.md']
20
+ const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json', 'local.yaml', 'local.yml']
21
+
22
+ /** 只读命令(命令名) */
23
+ const READONLY_COMMANDS = new Set([
24
+ 'grep', 'rg', 'ag', 'find', 'ls', 'cat', 'head', 'tail', 'wc', 'stat',
25
+ 'echo', 'pwd', 'basename', 'dirname', 'realpath',
26
+ 'node', 'npm', 'npx', // 只允许 --version 等只读子命令,在 matchReadonlyWhitelist 中处理
27
+ ])
28
+
29
+ /** 只读 git 子命令 */
30
+ const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show', 'branch', 'stash'])
31
+
32
+ /** 危险 git 子命令 */
33
+ const DANGER_GIT_SUBS = new Set([
34
+ 'add', 'commit', 'push', 'checkout', 'restore', 'reset', 'clean',
35
+ 'mv', 'rm',
36
+ ])
37
+
38
+ /** 危险 git stash 操作 */
39
+ const DANGER_STASH_ACTIONS = new Set(['drop', 'clear', 'pop'])
40
+
41
+ /** 危险命令前缀 */
42
+ const DANGER_PREFIXES = ['sudo', 'rm -rf', 'rm -r', 'rmdir']
43
+
44
+ // ── 阶段 → 拦截提示映射 ──
45
+
46
+ const STAGE_HINTS = {
47
+ '(none)': [
48
+ '没有检测到活跃的 SillySpec 流程。',
49
+ '你需要先启动一个任务流程才能修改源码(调用对应的 sillyspec skill):',
50
+ '',
51
+ ' BUG修复(skill sillyspec-quick):sillyspec run quick "任务描述"',
52
+ ' 逻辑变更(skill sillyspec-brainstorm):sillyspec run brainstorm → plan → execute → verify → archive',
53
+ ' 全自动模式(skill sillyspec-auto):sillyspec run auto "任务描述"',
54
+ ],
55
+ 'brainstorm': [
56
+ '当前在 brainstorm(需求分析)阶段,这个阶段只写文档,不写代码。',
57
+ '完成 brainstorm 后,流程会自动推进到 plan → execute。',
58
+ 'execute 阶段才允许写代码。',
59
+ ],
60
+ 'plan': [
61
+ '当前在 plan(计划制定)阶段,这个阶段只写计划文档和任务蓝图,不写代码。',
62
+ '完成 plan 后,运行 sillyspec run execute 进入执行阶段。',
63
+ ],
64
+ 'verify': [
65
+ '当前在 verify(验证)阶段,只做代码审查和测试验证,不修改源码。',
66
+ '如需修改,请先回到 execute 阶段或使用 quick 模式:',
67
+ ' sillyspec run quick "修改描述"',
68
+ ],
69
+ 'archive': [
70
+ '当前在 archive(归档)阶段,不修改源码。',
71
+ '如需修改,请开启新变更:sillyspec run quick "修改描述"',
72
+ ],
73
+ 'explore': [
74
+ '当前在 explore(探索)阶段,只读不写。',
75
+ '确认方案后使用:sillyspec run brainstorm 或 sillyspec run quick',
76
+ ],
77
+ }
78
+
79
+ // ── 辅助函数 ──
80
+
81
+ function resolveWorktreeDir(cwd) {
82
+ return path.join(cwd, '.sillyspec', '.runtime', 'worktrees')
83
+ }
84
+
85
+ function findProjectRoot(cwd) {
86
+ let dir = path.resolve(cwd || process.cwd())
87
+ while (true) {
88
+ if (
89
+ existsSync(path.join(dir, '.sillyspec', '.runtime', 'gate-status.json')) ||
90
+ existsSync(path.join(dir, '.sillyspec', '.runtime', 'sillyspec.db')) ||
91
+ existsSync(path.join(dir, '.sillyspec', 'local.yaml')) ||
92
+ existsSync(path.join(dir, '.sillyspec', 'local.yml')) ||
93
+ existsSync(path.join(dir, '.sillyspec', 'projects'))
94
+ ) {
95
+ return dir
96
+ }
97
+ const parent = path.dirname(dir)
98
+ if (parent === dir) return path.resolve(cwd || process.cwd())
99
+ dir = parent
100
+ }
101
+ }
102
+
103
+ function safeChangeName(changeName) {
104
+ return typeof changeName === 'string'
105
+ && changeName
106
+ && !changeName.includes('..')
107
+ && !changeName.includes('/')
108
+ && !changeName.includes('\\')
109
+ }
110
+
111
+ function readWorktreeMeta(cwd, changeName) {
112
+ if (!safeChangeName(changeName)) return null
113
+ const metaPath = path.join(resolveWorktreeDir(cwd), changeName, 'meta.json')
114
+ if (!existsSync(metaPath)) return null
115
+ try {
116
+ return JSON.parse(readFileSync(metaPath, 'utf8'))
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
122
  function isPathInside(child, parent) {
123
123
  if (!child || !parent) return false
124
124
  const absChild = path.resolve(child)
@@ -217,368 +217,368 @@ function shouldBlockScanDocOverwrite(filePath, projectRoot) {
217
217
  }
218
218
 
219
219
  function isInsideWorktreeStorage(filePath, cwd) {
220
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
221
- return isPathInside(absPath, resolveWorktreeDir(cwd || process.cwd()))
222
- }
223
-
224
- /**
225
- * 读取 gate-status.json
226
- * @param {string} cwd
227
- * @returns {{ stage: string, changes?: string[], updatedAt?: string } | null}
228
- */
229
- function readGateStatus(cwd) {
230
- const p = path.join(cwd, '.sillyspec', '.runtime', 'gate-status.json')
231
- if (!existsSync(p)) return null
232
- try {
233
- return JSON.parse(readFileSync(p, 'utf8'))
234
- } catch {
235
- return null
236
- }
237
- }
238
-
239
- /**
240
- * 从 sillyspec.db 读取 currentStage
241
- * 优先级:gate-status.json > sillyspec.db
242
- * @param {string} cwd
243
- * @returns {string|null} 阶段名,null 表示无法确定
244
- */
245
- function readCurrentStage(cwd) {
246
- // 1. gate-status.json(高速缓存,权威来源)
247
- const gateStatus = readGateStatus(cwd)
248
- if (gateStatus && gateStatus.stage) return gateStatus.stage
249
-
250
- // 2. 从 sillyspec.db 读取(通过 sqlite3 CLI 同步调用)
251
- const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
252
- if (!existsSync(dbPath)) return null
253
- try {
254
- const { execSync } = require('child_process')
255
- const result = execSync(
256
- `sqlite3 "${dbPath}" "SELECT current_stage FROM changes WHERE status='active' AND current_stage IN ('execute','quick') ORDER BY last_active DESC LIMIT 1"`,
257
- { encoding: 'utf8', timeout: 2000 }
258
- ).trim()
259
- return result || null
260
- } catch { /* sqlite3 CLI 不可用或查询失败 */ }
261
-
262
- return null
263
- }
264
-
265
- /**
266
- * 检查当前变更是否处于 noWorktree 模式
267
- * @param {string} cwd
268
- * @returns {boolean}
269
- */
270
- function isNoWorktreeMode(cwd) {
271
- // 1. 检查 gate-status.json
272
- const gateStatus = readGateStatus(cwd)
273
- if (gateStatus && gateStatus.noWorktree) return true
274
-
275
- // 2. 从 sillyspec.db 读取
276
- const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
277
- if (!existsSync(dbPath)) return false
278
- try {
279
- const { execSync } = require('child_process')
280
- const result = execSync(
281
- `sqlite3 "${dbPath}" "SELECT no_worktree FROM changes WHERE status='active' AND current_stage IN ('execute','quick') LIMIT 1"`,
282
- { encoding: 'utf8', timeout: 2000 }
283
- ).trim()
284
- return result === '1'
285
- } catch { /* sqlite3 CLI 不可用或查询失败 */ }
286
-
287
- return false
288
- }
289
-
290
- /**
291
- * 判断路径是否在 worktree 内
292
- * @param {string} filePath - 绝对路径
293
- * @returns {boolean}
294
- */
295
- function isInsideRegisteredWorktree(filePath, cwd) {
296
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
297
- const effectiveCwd = cwd || process.cwd()
298
- const gateStatus = readGateStatus(effectiveCwd)
299
- const changes = Array.isArray(gateStatus?.changes) ? gateStatus.changes : []
300
-
301
- for (const changeName of changes) {
302
- const meta = readWorktreeMeta(effectiveCwd, changeName)
303
- if (meta?.worktreePath && isPathInside(absPath, meta.worktreePath)) return true
304
- }
305
-
306
- return false
307
- }
308
-
309
- /**
310
- * 文件白名单:文档类/配置类始终放行
311
- * @param {string} filePath - 绝对路径
312
- * @returns {boolean}
313
- */
314
- function matchFileWhitelist(filePath) {
315
- // 路径以 .sillyspec/ 开头
316
- const parts = filePath.split(path.sep)
317
- for (let i = 0; i < parts.length; i++) {
318
- if (parts[i] === '.sillyspec') return true
319
- }
320
-
321
- // 路径在 .git/ 下
322
- for (let i = 0; i < parts.length - 1; i++) {
323
- if (parts[i] === '.git') return true
324
- }
325
-
326
- // 扩展名
327
- const ext = path.extname(filePath)
328
- if (FILE_WHITELIST_EXTS.includes(ext)) return true
329
-
330
- // 文件名
331
- const base = path.basename(filePath)
332
- if (FILE_WHITELIST_NAMES.includes(base)) return true
333
-
334
- return false
335
- }
336
-
337
- /**
338
- * 读取 local.yaml 中的扩展白名单配置(如果存在)
339
- * @param {string} cwd
340
- * @returns {{ fileWhitelist?: string[], readonlyCommands?: string[] }}
341
- */
342
- function loadLocalConfig(cwd) {
343
- const candidates = [
344
- path.join(cwd, '.sillyspec', 'local.yaml'),
345
- path.join(cwd, '.sillyspec', 'local.yml'),
346
- path.join(cwd, 'local.yaml'),
347
- path.join(cwd, 'local.yml'),
348
- ]
349
- for (const p of candidates) {
350
- if (!existsSync(p)) continue
351
- try {
352
- const content = readFileSync(p, 'utf8')
353
- return parseSimpleYaml(content)
354
- } catch {
355
- return {}
356
- }
357
- }
358
- return {}
359
- }
360
-
361
- function parseSimpleYaml(content) {
362
- const result = {}
363
- let topKey = null
364
- let childKey = null
365
-
366
- function parseValue(value) {
367
- const trimmed = value.trim()
368
- if (!trimmed) return ''
369
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
370
- return trimmed.slice(1, -1)
371
- }
372
- if (trimmed === 'true') return true
373
- if (trimmed === 'false') return false
374
- return trimmed
375
- }
376
-
377
- for (const line of content.split('\n')) {
378
- const noComment = line.replace(/\s+#.*$/, '')
379
- const trimmed = noComment.trim()
380
- if (!trimmed || trimmed.startsWith('#')) continue
381
- const indent = noComment.length - noComment.trimStart().length
382
-
383
- if (indent === 0) {
384
- const topLevelMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
385
- if (!topLevelMatch) continue
386
- const key = topLevelMatch[1]
387
- const value = topLevelMatch[2]
388
- topKey = key
389
- childKey = null
390
-
391
- if (value.trim()) {
392
- result[key] = parseValue(value)
393
- } else {
394
- result[key] = {}
395
- }
396
- continue
397
- }
398
-
399
- if (!topKey) continue
400
-
401
- if (indent === 2 && trimmed.startsWith('- ')) {
402
- if (!Array.isArray(result[topKey])) result[topKey] = []
403
- result[topKey].push(parseValue(trimmed.slice(2)))
404
- continue
405
- }
406
-
407
- if (indent === 2) {
408
- const childMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
409
- if (!childMatch) continue
410
- childKey = childMatch[1]
411
- const value = childMatch[2]
412
- if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
413
- result[topKey][childKey] = value.trim() ? parseValue(value) : []
414
- continue
415
- }
416
-
417
- if (indent >= 4 && childKey && trimmed.startsWith('- ')) {
418
- if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
419
- if (!Array.isArray(result[topKey][childKey])) result[topKey][childKey] = []
420
- result[topKey][childKey].push(parseValue(trimmed.slice(2)))
421
- }
422
- }
423
-
424
- for (const key of Object.keys(result)) {
425
- if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
426
- if (Object.keys(result[key]).length === 0) {
427
- result[key] = {}
428
- }
429
- }
430
- }
431
-
432
- return result
433
- }
434
-
435
- /**
436
- * 提取命令中第一个可执行命令名
437
- * @param {string} command
438
- * @returns {string}
439
- */
440
- function extractCommandName(command) {
441
- const trimmed = command.trim()
442
- if (!trimmed) return ''
443
- return trimmed.split(/\s+/)[0]
444
- }
445
-
446
- /**
447
- * 判断单个命令片段是否匹配只读白名单
448
- * @param {string} cmd - 单个命令片段(不含管道/链式操作符)
449
- * @param {string[]} extraReadonlyCommands - local.yaml 扩展的只读命令
450
- * @returns {boolean}
451
- */
452
- function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
453
- const trimmed = cmd.trim()
454
- if (!trimmed) return true // 空片段放行
455
-
456
- const parts = trimmed.split(/\s+/)
457
- const cmdName = parts[0]
458
-
459
- // 纯命令名匹配
460
- if (READONLY_COMMANDS.has(cmdName)) {
461
- // node/npm/npx 需要进一步检查子命令
462
- if (cmdName === 'node' || cmdName === 'npm' || cmdName === 'npx') {
463
- const rest = parts.slice(1).join(' ')
464
- return rest.includes('--version') || rest.includes('-v') && parts.length <= 3 || rest === 'run test' || rest.startsWith('test')
465
- }
466
- return true
467
- }
468
-
469
- // local.yaml 扩展
470
- if (extraReadonlyCommands.includes(cmdName)) return true
471
-
472
- // git 只读子命令
473
- if (cmdName === 'git') {
474
- const sub = parts[1] || ''
475
- if (READONLY_GIT_SUBS.has(sub)) return true
476
- // git stash list
477
- if (sub === 'stash' && (parts[2] === 'list' || parts.length === 2)) return true
478
- // git worktree 管理(list/add/remove)放行
479
- if (sub === 'worktree') return true
480
- return false
481
- }
482
-
483
- // sillyspec 命令全部放行(CLI 工具本身安全)
484
- if (cmdName === 'sillyspec') return true
485
-
486
- return false
487
- }
488
-
489
- /**
490
- * 判断单个命令片段是否匹配危险黑名单
491
- * @param {string} cmd - 单个命令片段
492
- * @returns {boolean}
493
- */
494
- function isSingleCommandDangerous(cmd) {
495
- const trimmed = cmd.trim().toLowerCase()
496
-
497
- for (const prefix of DANGER_PREFIXES) {
498
- if (trimmed.startsWith(prefix)) return true
499
- }
500
-
501
- const parts = trimmed.split(/\s+/)
502
- const cmdName = parts[0]
503
-
504
- if (cmdName === 'git') {
505
- const sub = parts[1] || ''
506
- if (DANGER_GIT_SUBS.has(sub)) return true
507
- // git stash drop/clear/pop
508
- if (sub === 'stash' && DANGER_STASH_ACTIONS.has(parts[2] || '')) return true
509
- }
510
-
511
- // rm(不限于 -rf)
512
- if (cmdName === 'rm') return true
513
-
514
- return false
515
- }
516
-
517
- /**
518
- * 将命令按管道/链式操作符拆分为多个片段
519
- * @param {string} command
520
- * @returns {string[]}
521
- */
522
- function splitCommandParts(command) {
523
- // 按管道和链式操作符拆分
524
- return command.split(/(?:\|\|&&|&&|\|)/g).map(s => s.trim()).filter(Boolean)
525
- }
526
-
527
- /**
528
- * 判断命令是否匹配只读白名单(含管道/链式检查)
529
- * @param {string} command
530
- * @param {string[]} extraReadonlyCommands
531
- * @returns {boolean}
532
- */
533
- function matchReadonlyWhitelist(command, extraReadonlyCommands = []) {
534
- const parts = splitCommandParts(command)
535
- return parts.every(p => isSingleCommandReadonly(p, extraReadonlyCommands))
536
- }
537
-
538
- /**
539
- * 判断命令是否匹配危险黑名单(含管道/链式检查)
540
- * @param {string} command
541
- * @returns {boolean}
542
- */
543
- function matchDangerBlacklist(command) {
544
- const parts = splitCommandParts(command)
545
- return parts.some(p => isSingleCommandDangerous(p))
546
- }
547
-
548
- /**
549
- * 构建阶段拦截提示
550
- * @param {string} stage
551
- * @returns {string}
552
- */
553
- function buildStageHint(stage) {
554
- const hint = STAGE_HINTS[stage] || STAGE_HINTS['(none)']
555
- return hint.join('\n')
556
- }
557
-
558
- // ── 公共接口 ──
559
-
560
- /**
561
- * 判断文件写入是否应被拦截
562
- *
563
- * 降级策略(无 worktree = 更严格):
564
- * - noWorktree 模式下,execute/quick 阶段不允许源码写入(没有隔离环境)
565
- * - 除非同时设置 SILLYSPEC_DISABLE_HOOKS=1
566
- *
567
- * P0 优化:
568
- * - 使用 readCurrentStage() fallback 读取阶段(gate-status → progress)
569
- * - 拦截提示按阶段给出具体修复建议
570
- *
571
- * @param {string} filePath - 目标文件绝对路径
572
- * @param {string} cwd - 当前工作目录
573
- * @returns {{ blocked: boolean, reason?: string }}
574
- */
575
- export function shouldBlockWrite(filePath, cwd) {
576
- if (!filePath) return { blocked: true, reason: 'no file path' }
577
-
578
- const callerCwd = cwd || process.cwd()
579
- const projectRoot = findProjectRoot(callerCwd)
580
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
581
-
220
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
221
+ return isPathInside(absPath, resolveWorktreeDir(cwd || process.cwd()))
222
+ }
223
+
224
+ /**
225
+ * 读取 gate-status.json
226
+ * @param {string} cwd
227
+ * @returns {{ stage: string, changes?: string[], updatedAt?: string } | null}
228
+ */
229
+ function readGateStatus(cwd) {
230
+ const p = path.join(cwd, '.sillyspec', '.runtime', 'gate-status.json')
231
+ if (!existsSync(p)) return null
232
+ try {
233
+ return JSON.parse(readFileSync(p, 'utf8'))
234
+ } catch {
235
+ return null
236
+ }
237
+ }
238
+
239
+ /**
240
+ * 从 sillyspec.db 读取 currentStage
241
+ * 优先级:gate-status.json > sillyspec.db
242
+ * @param {string} cwd
243
+ * @returns {string|null} 阶段名,null 表示无法确定
244
+ */
245
+ function readCurrentStage(cwd) {
246
+ // 1. gate-status.json(高速缓存,权威来源)
247
+ const gateStatus = readGateStatus(cwd)
248
+ if (gateStatus && gateStatus.stage) return gateStatus.stage
249
+
250
+ // 2. 从 sillyspec.db 读取(通过 sqlite3 CLI 同步调用)
251
+ const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
252
+ if (!existsSync(dbPath)) return null
253
+ try {
254
+ const { execSync } = require('child_process')
255
+ const result = execSync(
256
+ `sqlite3 "${dbPath}" "SELECT current_stage FROM changes WHERE status='active' AND current_stage IN ('execute','quick') ORDER BY last_active DESC LIMIT 1"`,
257
+ { encoding: 'utf8', timeout: 2000 }
258
+ ).trim()
259
+ return result || null
260
+ } catch { /* sqlite3 CLI 不可用或查询失败 */ }
261
+
262
+ return null
263
+ }
264
+
265
+ /**
266
+ * 检查当前变更是否处于 noWorktree 模式
267
+ * @param {string} cwd
268
+ * @returns {boolean}
269
+ */
270
+ function isNoWorktreeMode(cwd) {
271
+ // 1. 检查 gate-status.json
272
+ const gateStatus = readGateStatus(cwd)
273
+ if (gateStatus && gateStatus.noWorktree) return true
274
+
275
+ // 2. 从 sillyspec.db 读取
276
+ const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
277
+ if (!existsSync(dbPath)) return false
278
+ try {
279
+ const { execSync } = require('child_process')
280
+ const result = execSync(
281
+ `sqlite3 "${dbPath}" "SELECT no_worktree FROM changes WHERE status='active' AND current_stage IN ('execute','quick') LIMIT 1"`,
282
+ { encoding: 'utf8', timeout: 2000 }
283
+ ).trim()
284
+ return result === '1'
285
+ } catch { /* sqlite3 CLI 不可用或查询失败 */ }
286
+
287
+ return false
288
+ }
289
+
290
+ /**
291
+ * 判断路径是否在 worktree 内
292
+ * @param {string} filePath - 绝对路径
293
+ * @returns {boolean}
294
+ */
295
+ function isInsideRegisteredWorktree(filePath, cwd) {
296
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
297
+ const effectiveCwd = cwd || process.cwd()
298
+ const gateStatus = readGateStatus(effectiveCwd)
299
+ const changes = Array.isArray(gateStatus?.changes) ? gateStatus.changes : []
300
+
301
+ for (const changeName of changes) {
302
+ const meta = readWorktreeMeta(effectiveCwd, changeName)
303
+ if (meta?.worktreePath && isPathInside(absPath, meta.worktreePath)) return true
304
+ }
305
+
306
+ return false
307
+ }
308
+
309
+ /**
310
+ * 文件白名单:文档类/配置类始终放行
311
+ * @param {string} filePath - 绝对路径
312
+ * @returns {boolean}
313
+ */
314
+ function matchFileWhitelist(filePath) {
315
+ // 路径以 .sillyspec/ 开头
316
+ const parts = filePath.split(path.sep)
317
+ for (let i = 0; i < parts.length; i++) {
318
+ if (parts[i] === '.sillyspec') return true
319
+ }
320
+
321
+ // 路径在 .git/ 下
322
+ for (let i = 0; i < parts.length - 1; i++) {
323
+ if (parts[i] === '.git') return true
324
+ }
325
+
326
+ // 扩展名
327
+ const ext = path.extname(filePath)
328
+ if (FILE_WHITELIST_EXTS.includes(ext)) return true
329
+
330
+ // 文件名
331
+ const base = path.basename(filePath)
332
+ if (FILE_WHITELIST_NAMES.includes(base)) return true
333
+
334
+ return false
335
+ }
336
+
337
+ /**
338
+ * 读取 local.yaml 中的扩展白名单配置(如果存在)
339
+ * @param {string} cwd
340
+ * @returns {{ fileWhitelist?: string[], readonlyCommands?: string[] }}
341
+ */
342
+ function loadLocalConfig(cwd) {
343
+ const candidates = [
344
+ path.join(cwd, '.sillyspec', 'local.yaml'),
345
+ path.join(cwd, '.sillyspec', 'local.yml'),
346
+ path.join(cwd, 'local.yaml'),
347
+ path.join(cwd, 'local.yml'),
348
+ ]
349
+ for (const p of candidates) {
350
+ if (!existsSync(p)) continue
351
+ try {
352
+ const content = readFileSync(p, 'utf8')
353
+ return parseSimpleYaml(content)
354
+ } catch {
355
+ return {}
356
+ }
357
+ }
358
+ return {}
359
+ }
360
+
361
+ function parseSimpleYaml(content) {
362
+ const result = {}
363
+ let topKey = null
364
+ let childKey = null
365
+
366
+ function parseValue(value) {
367
+ const trimmed = value.trim()
368
+ if (!trimmed) return ''
369
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
370
+ return trimmed.slice(1, -1)
371
+ }
372
+ if (trimmed === 'true') return true
373
+ if (trimmed === 'false') return false
374
+ return trimmed
375
+ }
376
+
377
+ for (const line of content.split('\n')) {
378
+ const noComment = line.replace(/\s+#.*$/, '')
379
+ const trimmed = noComment.trim()
380
+ if (!trimmed || trimmed.startsWith('#')) continue
381
+ const indent = noComment.length - noComment.trimStart().length
382
+
383
+ if (indent === 0) {
384
+ const topLevelMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
385
+ if (!topLevelMatch) continue
386
+ const key = topLevelMatch[1]
387
+ const value = topLevelMatch[2]
388
+ topKey = key
389
+ childKey = null
390
+
391
+ if (value.trim()) {
392
+ result[key] = parseValue(value)
393
+ } else {
394
+ result[key] = {}
395
+ }
396
+ continue
397
+ }
398
+
399
+ if (!topKey) continue
400
+
401
+ if (indent === 2 && trimmed.startsWith('- ')) {
402
+ if (!Array.isArray(result[topKey])) result[topKey] = []
403
+ result[topKey].push(parseValue(trimmed.slice(2)))
404
+ continue
405
+ }
406
+
407
+ if (indent === 2) {
408
+ const childMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
409
+ if (!childMatch) continue
410
+ childKey = childMatch[1]
411
+ const value = childMatch[2]
412
+ if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
413
+ result[topKey][childKey] = value.trim() ? parseValue(value) : []
414
+ continue
415
+ }
416
+
417
+ if (indent >= 4 && childKey && trimmed.startsWith('- ')) {
418
+ if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
419
+ if (!Array.isArray(result[topKey][childKey])) result[topKey][childKey] = []
420
+ result[topKey][childKey].push(parseValue(trimmed.slice(2)))
421
+ }
422
+ }
423
+
424
+ for (const key of Object.keys(result)) {
425
+ if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
426
+ if (Object.keys(result[key]).length === 0) {
427
+ result[key] = {}
428
+ }
429
+ }
430
+ }
431
+
432
+ return result
433
+ }
434
+
435
+ /**
436
+ * 提取命令中第一个可执行命令名
437
+ * @param {string} command
438
+ * @returns {string}
439
+ */
440
+ function extractCommandName(command) {
441
+ const trimmed = command.trim()
442
+ if (!trimmed) return ''
443
+ return trimmed.split(/\s+/)[0]
444
+ }
445
+
446
+ /**
447
+ * 判断单个命令片段是否匹配只读白名单
448
+ * @param {string} cmd - 单个命令片段(不含管道/链式操作符)
449
+ * @param {string[]} extraReadonlyCommands - local.yaml 扩展的只读命令
450
+ * @returns {boolean}
451
+ */
452
+ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
453
+ const trimmed = cmd.trim()
454
+ if (!trimmed) return true // 空片段放行
455
+
456
+ const parts = trimmed.split(/\s+/)
457
+ const cmdName = parts[0]
458
+
459
+ // 纯命令名匹配
460
+ if (READONLY_COMMANDS.has(cmdName)) {
461
+ // node/npm/npx 需要进一步检查子命令
462
+ if (cmdName === 'node' || cmdName === 'npm' || cmdName === 'npx') {
463
+ const rest = parts.slice(1).join(' ')
464
+ return rest.includes('--version') || rest.includes('-v') && parts.length <= 3 || rest === 'run test' || rest.startsWith('test')
465
+ }
466
+ return true
467
+ }
468
+
469
+ // local.yaml 扩展
470
+ if (extraReadonlyCommands.includes(cmdName)) return true
471
+
472
+ // git 只读子命令
473
+ if (cmdName === 'git') {
474
+ const sub = parts[1] || ''
475
+ if (READONLY_GIT_SUBS.has(sub)) return true
476
+ // git stash list
477
+ if (sub === 'stash' && (parts[2] === 'list' || parts.length === 2)) return true
478
+ // git worktree 管理(list/add/remove)放行
479
+ if (sub === 'worktree') return true
480
+ return false
481
+ }
482
+
483
+ // sillyspec 命令全部放行(CLI 工具本身安全)
484
+ if (cmdName === 'sillyspec') return true
485
+
486
+ return false
487
+ }
488
+
489
+ /**
490
+ * 判断单个命令片段是否匹配危险黑名单
491
+ * @param {string} cmd - 单个命令片段
492
+ * @returns {boolean}
493
+ */
494
+ function isSingleCommandDangerous(cmd) {
495
+ const trimmed = cmd.trim().toLowerCase()
496
+
497
+ for (const prefix of DANGER_PREFIXES) {
498
+ if (trimmed.startsWith(prefix)) return true
499
+ }
500
+
501
+ const parts = trimmed.split(/\s+/)
502
+ const cmdName = parts[0]
503
+
504
+ if (cmdName === 'git') {
505
+ const sub = parts[1] || ''
506
+ if (DANGER_GIT_SUBS.has(sub)) return true
507
+ // git stash drop/clear/pop
508
+ if (sub === 'stash' && DANGER_STASH_ACTIONS.has(parts[2] || '')) return true
509
+ }
510
+
511
+ // rm(不限于 -rf)
512
+ if (cmdName === 'rm') return true
513
+
514
+ return false
515
+ }
516
+
517
+ /**
518
+ * 将命令按管道/链式操作符拆分为多个片段
519
+ * @param {string} command
520
+ * @returns {string[]}
521
+ */
522
+ function splitCommandParts(command) {
523
+ // 按管道和链式操作符拆分
524
+ return command.split(/(?:\|\|&&|&&|\|)/g).map(s => s.trim()).filter(Boolean)
525
+ }
526
+
527
+ /**
528
+ * 判断命令是否匹配只读白名单(含管道/链式检查)
529
+ * @param {string} command
530
+ * @param {string[]} extraReadonlyCommands
531
+ * @returns {boolean}
532
+ */
533
+ function matchReadonlyWhitelist(command, extraReadonlyCommands = []) {
534
+ const parts = splitCommandParts(command)
535
+ return parts.every(p => isSingleCommandReadonly(p, extraReadonlyCommands))
536
+ }
537
+
538
+ /**
539
+ * 判断命令是否匹配危险黑名单(含管道/链式检查)
540
+ * @param {string} command
541
+ * @returns {boolean}
542
+ */
543
+ function matchDangerBlacklist(command) {
544
+ const parts = splitCommandParts(command)
545
+ return parts.some(p => isSingleCommandDangerous(p))
546
+ }
547
+
548
+ /**
549
+ * 构建阶段拦截提示
550
+ * @param {string} stage
551
+ * @returns {string}
552
+ */
553
+ function buildStageHint(stage) {
554
+ const hint = STAGE_HINTS[stage] || STAGE_HINTS['(none)']
555
+ return hint.join('\n')
556
+ }
557
+
558
+ // ── 公共接口 ──
559
+
560
+ /**
561
+ * 判断文件写入是否应被拦截
562
+ *
563
+ * 降级策略(无 worktree = 更严格):
564
+ * - noWorktree 模式下,execute/quick 阶段不允许源码写入(没有隔离环境)
565
+ * - 除非同时设置 SILLYSPEC_DISABLE_HOOKS=1
566
+ *
567
+ * P0 优化:
568
+ * - 使用 readCurrentStage() fallback 读取阶段(gate-status → progress)
569
+ * - 拦截提示按阶段给出具体修复建议
570
+ *
571
+ * @param {string} filePath - 目标文件绝对路径
572
+ * @param {string} cwd - 当前工作目录
573
+ * @returns {{ blocked: boolean, reason?: string }}
574
+ */
575
+ export function shouldBlockWrite(filePath, cwd) {
576
+ if (!filePath) return { blocked: true, reason: 'no file path' }
577
+
578
+ const callerCwd = cwd || process.cwd()
579
+ const projectRoot = findProjectRoot(callerCwd)
580
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
581
+
582
582
  // 1. 阶段门禁(使用 fallback 读取)
583
583
  const stage = readCurrentStage(projectRoot) || '(none)'
584
584
 
@@ -589,173 +589,173 @@ export function shouldBlockWrite(filePath, cwd) {
589
589
  if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
590
590
 
591
591
  if (!['execute', 'quick'].includes(stage)) {
592
- return {
593
- blocked: true,
594
- reason: buildStageHint(stage)
595
- }
596
- }
597
-
598
- // quick 阶段:检查 quick-guard.json 的 baselineFiles
599
- if (stage === 'quick') {
600
- try {
601
- const guardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
602
- const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
603
- const baselineFiles = guard.baselineFiles || []
604
- const relTarget = path.relative(projectRoot, absPath)
605
- // 如果目标是 baseline protected file,阻止写入
606
- if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + path.sep))) {
607
- return {
608
- blocked: true,
609
- reason: [
610
- `⚠️ quick 变更边界保护:${relTarget} 是 baseline 文件,不允许覆盖。`,
611
- `当前 quick 任务不能修改任务开始前已修改的文件。`,
612
- `如确需修改,请在 quick 完成后单独处理此文件。`,
613
- ].join('\n')
614
- }
615
- }
616
- } catch {
617
- // quick-guard.json 不存在(非 quick 任务或未记录),放行
618
- }
619
- return { blocked: false }
620
- }
621
-
622
- // execute 阶段:位置门禁
623
- if (isInsideRegisteredWorktree(absPath, projectRoot)) return { blocked: false }
624
-
625
- // noWorktree 模式:无隔离环境,禁止源码写入(降级到更严格)
626
- if (isNoWorktreeMode(projectRoot)) {
627
- return {
628
- blocked: true,
629
- reason: [
630
- '当前处于 --no-worktree 降级模式,不允许源码写入。',
631
- '如需修改源码,请移除 --no-worktree 标志重新执行。',
632
- '紧急情况可设置 SILLYSPEC_DISABLE_HOOKS=1 绕过限制。',
633
- ].join('\n')
634
- }
635
- }
636
-
637
- return {
638
- blocked: true,
639
- reason: [
640
- '源码修改只能在 worktree 隔离环境中进行。',
641
- '',
642
- '你可能需要:',
643
- ' 1. 确认 worktree 已创建并登记:sillyspec worktree list',
644
- ' 2. 如未创建,先创建:sillyspec worktree create <变更名>',
645
- ' 3. 在 worktree 目录中工作(子代理的 cwd 设为 worktree 路径)',
646
- '',
647
- '如果你在 execute 阶段,通常会自动创建 worktree。',
648
- '检查是否跳过了 "创建 worktree" 步骤。',
649
- ].join('\n')
650
- }
651
- }
652
-
653
- /**
654
- * 判断 Bash 命令是否应被拦截
655
- * @param {string} command - Bash 命令字符串
656
- * @param {string} cwd - 当前工作目录
657
- * @returns {{ blocked: boolean, reason?: string }}
658
- */
659
- export function shouldBlockBash(command, cwd) {
660
- if (!command || !command.trim()) return { blocked: false }
661
-
662
- const callerCwd = cwd || process.cwd()
663
- const projectRoot = findProjectRoot(callerCwd)
664
-
665
- // cwd 在 worktree 内 → 全部放行
666
- if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
667
-
668
- // 阶段门禁(使用 fallback 读取)
669
- const stage = readCurrentStage(projectRoot) || '(none)'
670
-
671
- if (!['execute', 'quick'].includes(stage)) {
672
- // 非 execute/quick 阶段,只允许只读白名单
673
- const localConfig = loadLocalConfig(projectRoot)
674
- const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
675
- if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
676
- return {
677
- blocked: true,
678
- reason: buildStageHint(stage)
679
- }
680
- }
681
-
682
- // quick 阶段:检查 quick-guard.json
683
- if (stage === 'quick') {
684
- // 危险黑名单仍然拦截
685
- if (matchDangerBlacklist(command)) {
686
- return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
687
- }
688
- // 检查命令是否会覆盖 baseline files
689
- try {
690
- const guardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
691
- const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
692
- const baselineFiles = guard.baselineFiles || []
693
- // 检查命令中是否引用了 baseline file
694
- for (const f of baselineFiles) {
695
- if (command.includes(f) && (command.includes('> ') || command.includes(' tee ') || command.includes('sed ') || command.includes('mv '))) {
696
- return { blocked: true, reason: `quick 变更边界保护:命令可能覆盖 baseline 文件 ${f}` }
697
- }
698
- }
699
- } catch {}
700
- return { blocked: false }
701
- }
702
-
703
- // execute 阶段 + 主工作区
704
- const localConfig = loadLocalConfig(projectRoot)
705
- const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
706
-
707
- // 危险黑名单
708
- if (matchDangerBlacklist(command)) {
709
- return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
710
- }
711
-
712
- // 只读白名单放行
713
- if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
714
-
715
- // 不确定 → 放行
716
- return { blocked: false }
717
- }
718
-
719
- /**
720
- * 判断工具调用是否应被拦截
721
- * @param {{
722
- * tool: 'Write' | 'Edit' | 'MultiEdit' | 'Bash',
723
- * filePath?: string,
724
- * filePaths?: string[],
725
- * command?: string,
726
- * cwd?: string
727
- * }} opts
728
- * @param {{ cwd?: string }} ctx
729
- * @returns {{ blocked: boolean, reason?: string }}
730
- */
731
- export function shouldBlock(opts, ctx = {}) {
732
- // 逃生开关
733
- if (process.env.SILLYSPEC_DISABLE_HOOKS === '1') return { blocked: false }
734
-
735
- const cwd = opts.cwd || ctx.cwd || process.cwd()
736
-
737
- switch (opts.tool) {
738
- case 'Write':
739
- case 'Edit': {
740
- const fp = opts.filePath
741
- if (!fp) return { blocked: true, reason: 'no file path' }
742
- const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
743
- return shouldBlockWrite(absPath, cwd)
744
- }
745
- case 'MultiEdit': {
746
- const filePaths = opts.filePaths
747
- if (!filePaths || filePaths.length === 0) return { blocked: true, reason: 'no file paths' }
748
- for (const fp of filePaths) {
749
- const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
750
- const result = shouldBlockWrite(absPath, cwd)
751
- if (result.blocked) return result
752
- }
753
- return { blocked: false }
754
- }
755
- case 'Bash': {
756
- return shouldBlockBash(opts.command || '', cwd)
757
- }
758
- default:
759
- return { blocked: false }
760
- }
761
- }
592
+ return {
593
+ blocked: true,
594
+ reason: buildStageHint(stage)
595
+ }
596
+ }
597
+
598
+ // quick 阶段:检查 quick-guard.json 的 baselineFiles
599
+ if (stage === 'quick') {
600
+ try {
601
+ const guardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
602
+ const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
603
+ const baselineFiles = guard.baselineFiles || []
604
+ const relTarget = path.relative(projectRoot, absPath)
605
+ // 如果目标是 baseline protected file,阻止写入
606
+ if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + path.sep))) {
607
+ return {
608
+ blocked: true,
609
+ reason: [
610
+ `⚠️ quick 变更边界保护:${relTarget} 是 baseline 文件,不允许覆盖。`,
611
+ `当前 quick 任务不能修改任务开始前已修改的文件。`,
612
+ `如确需修改,请在 quick 完成后单独处理此文件。`,
613
+ ].join('\n')
614
+ }
615
+ }
616
+ } catch {
617
+ // quick-guard.json 不存在(非 quick 任务或未记录),放行
618
+ }
619
+ return { blocked: false }
620
+ }
621
+
622
+ // execute 阶段:位置门禁
623
+ if (isInsideRegisteredWorktree(absPath, projectRoot)) return { blocked: false }
624
+
625
+ // noWorktree 模式:无隔离环境,禁止源码写入(降级到更严格)
626
+ if (isNoWorktreeMode(projectRoot)) {
627
+ return {
628
+ blocked: true,
629
+ reason: [
630
+ '当前处于 --no-worktree 降级模式,不允许源码写入。',
631
+ '如需修改源码,请移除 --no-worktree 标志重新执行。',
632
+ '紧急情况可设置 SILLYSPEC_DISABLE_HOOKS=1 绕过限制。',
633
+ ].join('\n')
634
+ }
635
+ }
636
+
637
+ return {
638
+ blocked: true,
639
+ reason: [
640
+ '源码修改只能在 worktree 隔离环境中进行。',
641
+ '',
642
+ '你可能需要:',
643
+ ' 1. 确认 worktree 已创建并登记:sillyspec worktree list',
644
+ ' 2. 如未创建,先创建:sillyspec worktree create <变更名>',
645
+ ' 3. 在 worktree 目录中工作(子代理的 cwd 设为 worktree 路径)',
646
+ '',
647
+ '如果你在 execute 阶段,通常会自动创建 worktree。',
648
+ '检查是否跳过了 "创建 worktree" 步骤。',
649
+ ].join('\n')
650
+ }
651
+ }
652
+
653
+ /**
654
+ * 判断 Bash 命令是否应被拦截
655
+ * @param {string} command - Bash 命令字符串
656
+ * @param {string} cwd - 当前工作目录
657
+ * @returns {{ blocked: boolean, reason?: string }}
658
+ */
659
+ export function shouldBlockBash(command, cwd) {
660
+ if (!command || !command.trim()) return { blocked: false }
661
+
662
+ const callerCwd = cwd || process.cwd()
663
+ const projectRoot = findProjectRoot(callerCwd)
664
+
665
+ // cwd 在 worktree 内 → 全部放行
666
+ if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
667
+
668
+ // 阶段门禁(使用 fallback 读取)
669
+ const stage = readCurrentStage(projectRoot) || '(none)'
670
+
671
+ if (!['execute', 'quick'].includes(stage)) {
672
+ // 非 execute/quick 阶段,只允许只读白名单
673
+ const localConfig = loadLocalConfig(projectRoot)
674
+ const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
675
+ if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
676
+ return {
677
+ blocked: true,
678
+ reason: buildStageHint(stage)
679
+ }
680
+ }
681
+
682
+ // quick 阶段:检查 quick-guard.json
683
+ if (stage === 'quick') {
684
+ // 危险黑名单仍然拦截
685
+ if (matchDangerBlacklist(command)) {
686
+ return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
687
+ }
688
+ // 检查命令是否会覆盖 baseline files
689
+ try {
690
+ const guardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
691
+ const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
692
+ const baselineFiles = guard.baselineFiles || []
693
+ // 检查命令中是否引用了 baseline file
694
+ for (const f of baselineFiles) {
695
+ if (command.includes(f) && (command.includes('> ') || command.includes(' tee ') || command.includes('sed ') || command.includes('mv '))) {
696
+ return { blocked: true, reason: `quick 变更边界保护:命令可能覆盖 baseline 文件 ${f}` }
697
+ }
698
+ }
699
+ } catch {}
700
+ return { blocked: false }
701
+ }
702
+
703
+ // execute 阶段 + 主工作区
704
+ const localConfig = loadLocalConfig(projectRoot)
705
+ const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
706
+
707
+ // 危险黑名单
708
+ if (matchDangerBlacklist(command)) {
709
+ return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
710
+ }
711
+
712
+ // 只读白名单放行
713
+ if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
714
+
715
+ // 不确定 → 放行
716
+ return { blocked: false }
717
+ }
718
+
719
+ /**
720
+ * 判断工具调用是否应被拦截
721
+ * @param {{
722
+ * tool: 'Write' | 'Edit' | 'MultiEdit' | 'Bash',
723
+ * filePath?: string,
724
+ * filePaths?: string[],
725
+ * command?: string,
726
+ * cwd?: string
727
+ * }} opts
728
+ * @param {{ cwd?: string }} ctx
729
+ * @returns {{ blocked: boolean, reason?: string }}
730
+ */
731
+ export function shouldBlock(opts, ctx = {}) {
732
+ // 逃生开关
733
+ if (process.env.SILLYSPEC_DISABLE_HOOKS === '1') return { blocked: false }
734
+
735
+ const cwd = opts.cwd || ctx.cwd || process.cwd()
736
+
737
+ switch (opts.tool) {
738
+ case 'Write':
739
+ case 'Edit': {
740
+ const fp = opts.filePath
741
+ if (!fp) return { blocked: true, reason: 'no file path' }
742
+ const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
743
+ return shouldBlockWrite(absPath, cwd)
744
+ }
745
+ case 'MultiEdit': {
746
+ const filePaths = opts.filePaths
747
+ if (!filePaths || filePaths.length === 0) return { blocked: true, reason: 'no file paths' }
748
+ for (const fp of filePaths) {
749
+ const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
750
+ const result = shouldBlockWrite(absPath, cwd)
751
+ if (result.blocked) return result
752
+ }
753
+ return { blocked: false }
754
+ }
755
+ case 'Bash': {
756
+ return shouldBlockBash(opts.command || '', cwd)
757
+ }
758
+ default:
759
+ return { blocked: false }
760
+ }
761
+ }