sillyspec 3.26.10 → 3.26.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +111 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -693
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +1443 -1399
  62. package/src/run/complete.js +225 -24
  63. package/src/run/gates.js +755 -755
  64. package/src/run/prompt.js +761 -761
  65. package/src/run/quick-audit.js +155 -146
  66. package/src/run/shared.js +1268 -1215
  67. package/src/run/stage.js +487 -479
  68. package/src/scan-postcheck.js +371 -371
  69. package/src/setup.js +416 -416
  70. package/src/sillyhub-mcp/client.js +529 -529
  71. package/src/spec-sync.js +323 -319
  72. package/src/stage-contract.js +897 -897
  73. package/src/stage-review.js +549 -549
  74. package/src/stages/archive.js +155 -155
  75. package/src/stages/brainstorm-auto.js +236 -236
  76. package/src/stages/brainstorm.js +596 -597
  77. package/src/stages/cmd-existence.js +145 -145
  78. package/src/stages/doctor.js +371 -371
  79. package/src/stages/execute.js +1003 -1003
  80. package/src/stages/explore.js +34 -34
  81. package/src/stages/index.js +29 -29
  82. package/src/stages/knowledge.js +498 -498
  83. package/src/stages/plan-postcheck.js +1302 -1302
  84. package/src/stages/plan.js +625 -623
  85. package/src/stages/quick.js +151 -151
  86. package/src/stages/scan.js +607 -607
  87. package/src/stages/status.js +64 -64
  88. package/src/stages/verify.js +264 -264
  89. package/src/sync.js +1223 -1135
  90. package/src/task-review.js +1019 -1019
  91. package/src/verify-postcheck.js +1186 -1186
  92. package/src/workflow.js +600 -600
  93. package/src/worktree-apply.js +1213 -1136
  94. package/src/worktree-deps.js +353 -353
  95. package/src/worktree.js +1481 -1481
  96. package/templates/workflows/archive-impact.yaml +79 -79
  97. package/templates/workflows/scan-docs.yaml +132 -132
@@ -1,848 +1,848 @@
1
- /**
2
- * worktree-guard.js — Hook 拦截判断逻辑
3
- *
4
- * 三重门禁:stageGate × locationGate × fileGate
5
- * 纯判断模块,不做实际的 hook 注入。
6
- *
7
- * P0 优化:
8
- * - 阶段检测:直读 sillyspec.db currentStage(task-10 废 gate-status.json 缓存双源)
9
- * - 拦截提示针对每个阶段给出具体修复建议
10
- */
11
-
12
- import { existsSync, readFileSync, readdirSync } from 'fs'
13
- import path from 'path'
14
- import { execFileSync } from 'child_process'
15
-
16
- // ── 常量 ──
17
-
18
- const WORKTREE_STAGES = ['execute'] // 这些阶段必须在 worktree 里
19
-
20
- const FILE_WHITELIST_EXTS = ['.md']
21
- const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json', 'local.yaml', 'local.yml']
22
-
23
- /** 只读命令(命令名) */
24
- const READONLY_COMMANDS = new Set([
25
- 'grep', 'rg', 'ag', 'find', 'ls', 'cat', 'head', 'tail', 'wc', 'stat',
26
- 'echo', 'pwd', 'basename', 'dirname', 'realpath',
27
- 'node', 'npm', 'npx', // 只允许 --version 等只读子命令,在 matchReadonlyWhitelist 中处理
28
- ])
29
-
30
- /** 只读 git 子命令 */
31
- const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show', 'branch', 'stash'])
32
-
33
- /** 危险 git 子命令 */
34
- const DANGER_GIT_SUBS = new Set([
35
- 'add', 'commit', 'push', 'checkout', 'restore', 'reset', 'clean',
36
- 'mv', 'rm',
37
- ])
38
-
39
- /** 危险 git stash 操作 */
40
- const DANGER_STASH_ACTIONS = new Set(['drop', 'clear', 'pop'])
41
-
42
- /** 危险命令前缀 */
43
- const DANGER_PREFIXES = ['sudo', 'rm -rf', 'rm -r', 'rmdir']
44
-
45
- // ── 阶段 → 拦截提示映射 ──
46
-
47
- const STAGE_HINTS = {
48
- '(none)': [
49
- '没有检测到活跃的 SillySpec 流程。',
50
- '你需要先启动一个任务流程才能修改源码(调用对应的 sillyspec skill):',
51
- '',
52
- ' BUG修复(skill sillyspec-quick):sillyspec run quick "任务描述"',
53
- ' 逻辑变更(skill sillyspec-brainstorm):sillyspec run brainstorm → plan → execute → verify → archive',
54
- ' 全自动模式(skill sillyspec-auto):sillyspec run auto "任务描述"',
55
- ],
56
- 'brainstorm': [
57
- '当前在 brainstorm(需求分析)阶段,这个阶段只写文档,不写代码。',
58
- '完成 brainstorm 后,流程会自动推进到 plan → execute。',
59
- 'execute 阶段才允许写代码。',
60
- ],
61
- 'plan': [
62
- '当前在 plan(计划制定)阶段,这个阶段只写计划文档和任务蓝图,不写代码。',
63
- '完成 plan 后,运行 sillyspec run execute 进入执行阶段。',
64
- ],
65
- 'verify': [
66
- '当前在 verify(验证)阶段,只做代码审查和测试验证,不修改源码。',
67
- '如需修改,请先回到 execute 阶段或使用 quick 模式:',
68
- ' sillyspec run quick "修改描述"',
69
- ],
70
- 'archive': [
71
- '当前在 archive(归档)阶段,不修改源码。',
72
- '如需修改,请开启新变更:sillyspec run quick "修改描述"',
73
- ],
74
- 'explore': [
75
- '当前在 explore(探索)阶段,只读不写。',
76
- '确认方案后使用:sillyspec run brainstorm 或 sillyspec run quick',
77
- ],
78
- }
79
-
80
- // ── 辅助函数 ──
81
-
82
- function resolveWorktreeDir(cwd) {
83
- return path.join(cwd, '.sillyspec', '.runtime', 'worktrees')
84
- }
85
-
86
- function findProjectRoot(cwd) {
87
- let dir = path.resolve(cwd || process.cwd())
88
- while (true) {
89
- if (
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
- function isPathInside(child, parent) {
123
- if (!child || !parent) return false
124
- const absChild = path.resolve(child)
125
- const absParent = path.resolve(parent)
126
- return absChild === absParent || absChild.startsWith(absParent + path.sep)
127
- }
128
-
129
- function toPosixPath(filePath) {
130
- return filePath.replace(/\\/g, '/')
131
- }
132
-
133
- function parseFrontmatter(content) {
134
- if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return {}
135
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
136
- if (!match) return {}
137
- const result = {}
138
- for (const line of match[1].split(/\r?\n/)) {
139
- const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
140
- if (!m) continue
141
- result[m[1]] = m[2].replace(/^['"]|['"]$/g, '').trim()
142
- }
143
- return result
144
- }
145
-
146
- function parseTimestamp(value) {
147
- if (!value) return null
148
- const time = Date.parse(value)
149
- return Number.isNaN(time) ? null : time
150
- }
151
-
152
- function getScanDocInfo(filePath) {
153
- const normalized = toPosixPath(path.resolve(filePath))
154
- const match = normalized.match(/^(.*)\/docs\/([^/]+)\/scan\/([^/]+\.md)$/)
155
- if (!match) return null
156
- return {
157
- specRoot: path.resolve(match[1]),
158
- projectName: match[2],
159
- docName: match[3],
160
- }
161
- }
162
-
163
- function readScanGuard(scanDocInfo, projectRoot) {
164
- const candidates = [
165
- path.join(scanDocInfo.specRoot, '.runtime', 'scan-guard.json'),
166
- path.join(projectRoot, '.sillyspec', '.runtime', 'scan-guard.json'),
167
- ]
168
- for (const p of candidates) {
169
- if (!existsSync(p)) continue
170
- try {
171
- return JSON.parse(readFileSync(p, 'utf8'))
172
- } catch {
173
- return null
174
- }
175
- }
176
- return null
177
- }
178
-
179
- function shouldBlockScanDocOverwrite(filePath, projectRoot) {
180
- const scanDocInfo = getScanDocInfo(filePath)
181
- if (!scanDocInfo || !existsSync(filePath)) return { blocked: false }
182
-
183
- const guard = readScanGuard(scanDocInfo, projectRoot)
184
- if (!guard || guard.forceRescan) return { blocked: false }
185
-
186
- let frontmatter = {}
187
- try {
188
- frontmatter = parseFrontmatter(readFileSync(filePath, 'utf8'))
189
- } catch {
190
- return { blocked: false }
191
- }
192
-
193
- const relPath = toPosixPath(path.relative(projectRoot, filePath))
194
- if (frontmatter.source_commit && guard.sourceCommit && frontmatter.source_commit !== guard.sourceCommit) {
195
- return {
196
- blocked: true,
197
- reason: [
198
- `scan 覆盖保护:${relPath} 的 source_commit=${frontmatter.source_commit} 与当前 scan source_commit=${guard.sourceCommit} 不一致。`,
199
- '如确认要重新生成,请重新运行 scan 并添加 --force-rescan。',
200
- ].join('\n')
201
- }
202
- }
203
-
204
- const existingUpdatedAt = parseTimestamp(frontmatter.updated_at)
205
- const scanStartedAt = parseTimestamp(guard.startedAt)
206
- if (existingUpdatedAt && scanStartedAt && existingUpdatedAt > scanStartedAt) {
207
- return {
208
- blocked: true,
209
- reason: [
210
- `scan 覆盖保护:${relPath} 的 updated_at 晚于本次 scan 开始时间,可能包含手工编辑。`,
211
- '如确认要覆盖,请重新运行 scan 并添加 --force-rescan。',
212
- ].join('\n')
213
- }
214
- }
215
-
216
- return { blocked: false }
217
- }
218
-
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
- * 同步只读查询 sillyspec.db 第一行第一列(不依赖外部 sqlite3 CLI)。
226
- *
227
- * 用子进程跑 node:sqlite 的 DatabaseSync(execFileSync 等待):
228
- * - node:sqlite 是 Node 内置模块,子进程 require('node:sqlite') 直接可用,无需先 resolve 出
229
- * 绝对路径再注入(旧方案需手动解析原生绑定路径,node:sqlite 无此步骤)。
230
- * - readOnly 打开只读连接(替代旧方案 readonly/fileMustExist 语义):绝不写库;WAL 并发读不阻塞主进程写,
231
- * 且只读连接可看到已 commit 但未 checkpoint 的过渡态(修复旧 WASM 内存库裸读 .db
232
- * 看不见 WAL 的过渡态可见性窗口)。db 缺失/损坏由 open 异常自然触发 fail-closed(外层 catch)。
233
- *
234
- * 环境假设(R-09):WAL 模式下只读连接仍需在 .runtime/ 目录建/更新 -shm 索引文件,故 .runtime
235
- * 必须可写(与主进程一致)——“只读”指不改 DB 数据,不等于不对 -shm 加索引。
236
- *
237
- * fail-closed:db 文件不存在 / 子进程打开、查询异常 / 子进程超时或崩溃——一律 console.warn
238
- * (含 e.stderr 详情)并返回 null,调用方(readCurrentStage/isNoWorktreeMode)对 null 走
239
- * fail-closed,禁止 fail-open。
240
- */
241
- function queryDbFirstCell(cwd, sql) {
242
- const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
243
- if (!existsSync(dbPath)) {
244
- // db 文件不存在(findProjectRoot 命中 local.yaml/projects 但无 DB,或 DB 被删):fail-closed。
245
- // warn 让异常(如 DB 误删)可见;正常空态在 findProjectRoot 未命中时根本不会进到这里。
246
- console.warn('⚠️ sillyspec.db 不存在,hook 降级 fail-closed: ' + dbPath)
247
- return null
248
- }
249
- // 子进程同步内置 require('node:sqlite')(DatabaseSync 是同步 API,无 async/import().then 包装)。
250
- // prepare(sql).get() 取第一行整行后取首列值,无行返回 undefined(写空 stdout → 父侧 trim 后 null)。
251
- const script =
252
- "const {DatabaseSync}=require('node:sqlite')," +
253
- "dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ";" +
254
- "let db;" +
255
- "try{db=new DatabaseSync(dbPath,{readOnly:true});" +
256
- "const row=db.prepare(sql).get();" +
257
- "const v=row===undefined?undefined:Object.values(row)[0];" +
258
- "if(v!==undefined)process.stdout.write(String(v??''));db.close()}" +
259
- "catch(e){process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)};"
260
- try {
261
- return execFileSync(process.execPath, ['-e', script], {
262
- encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
263
- }).trim() || null
264
- } catch (e) {
265
- // DB 查询失败(db 损坏 / readOnly 打开失败 / 子进程超时或崩溃):降级返回 null,调用方
266
- //(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因,
267
- // 而非完全静默(子进程把诊断写到了 stderr,execFileSync 抛错时挂在 e.stderr 上)。
268
- const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
269
- console.warn('⚠️ sillyspec.db 查询失败,hook 降级 fail-closed: ' + detail)
270
- return null
271
- }
272
- }
273
-
274
- export { queryDbFirstCell as _queryDbFirstCellForTest };
275
- export {
276
- matchReadonlyWhitelist as _matchReadonlyWhitelistForTest,
277
- matchDangerBlacklist as _matchDangerBlacklistForTest,
278
- isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
279
- };
280
- /**
281
- * 从 sillyspec.db 读取 currentStage(直读 DB,task-10 废 gate-status.json 后为唯一来源)
282
- * @param {string} cwd
283
- * @returns {string|null} 阶段名,null 表示无法确定
284
- */
285
- function readCurrentStage(cwd) {
286
- // 直读 sillyspec.db(node + node:sqlite 只读子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
287
- const v = queryDbFirstCell(cwd, "SELECT current_stage FROM changes WHERE status='active' AND current_stage IN ('execute','quick') ORDER BY last_active DESC LIMIT 1")
288
- return v || null
289
- }
290
-
291
- /**
292
- * 检查当前变更是否处于 noWorktree 模式(直读 sillyspec.db)
293
- * @param {string} cwd
294
- * @returns {boolean}
295
- */
296
- function isNoWorktreeMode(cwd) {
297
- // 直读 sillyspec.db(node + node:sqlite,同 readCurrentStage)
298
- return queryDbFirstCell(cwd, "SELECT no_worktree FROM changes WHERE status='active' AND current_stage IN ('execute','quick') LIMIT 1") === '1'
299
- }
300
-
301
- /**
302
- * 判断路径是否在某已注册 worktree 内
303
- * @param {string} filePath - 绝对路径
304
- * @returns {boolean}
305
- */
306
- function isInsideRegisteredWorktree(filePath, cwd) {
307
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
308
- const effectiveCwd = cwd || process.cwd()
309
-
310
- // 已注册 worktree 名单来源:扫描 .runtime/worktrees/ 子目录(task-10 废 gate-status.json 后的权威来源)。
311
- // queryDbFirstCell 是单行单列,读不了多行 change 名单;目录扫描天然支持多 change 并发,
312
- // 且 worktree 目录存在性 == 注册态,比 DB changes 表更贴合“已注册 worktree”语义
313
- // (changes 表可能含 no_worktree 变更,它们没有 worktree 目录)。
314
- // 跨平台:isPathInside 用 path.sep 判定包含关系,Windows backslash / POSIX slash 均正确。
315
- const worktreeDir = resolveWorktreeDir(effectiveCwd)
316
- let changeNames = []
317
- try {
318
- const entries = readdirSync(worktreeDir, { withFileTypes: true })
319
- changeNames = entries.filter(e => e.isDirectory()).map(e => e.name)
320
- } catch { /* 目录不存在或不可读 → 无注册 worktree */ }
321
-
322
- for (const changeName of changeNames) {
323
- const meta = readWorktreeMeta(effectiveCwd, changeName)
324
- if (meta?.worktreePath && isPathInside(absPath, meta.worktreePath)) return true
325
- }
326
-
327
- return false
328
- }
329
-
330
- /**
331
- * 文件白名单:文档类/配置类始终放行
332
- * @param {string} filePath - 绝对路径
333
- * @returns {boolean}
334
- */
335
- function matchFileWhitelist(filePath) {
336
- // 路径以 .sillyspec/ 开头
337
- const parts = filePath.split(path.sep)
338
- for (let i = 0; i < parts.length; i++) {
339
- if (parts[i] === '.sillyspec') return true
340
- }
341
-
342
- // 路径在 .git/ 下
343
- for (let i = 0; i < parts.length - 1; i++) {
344
- if (parts[i] === '.git') return true
345
- }
346
-
347
- // 扩展名
348
- const ext = path.extname(filePath)
349
- if (FILE_WHITELIST_EXTS.includes(ext)) return true
350
-
351
- // 文件名
352
- const base = path.basename(filePath)
353
- if (FILE_WHITELIST_NAMES.includes(base)) return true
354
-
355
- return false
356
- }
357
-
358
- /**
359
- * 读取并解析 local.yaml(轻量手写解析,返回顶层对象)。
360
- * 消费方读嵌套段 worktree-hook.readonlyCommands(见 isSingleCommandReadonly 的 extraReadonlyCommands)。
361
- * @param {string} cwd
362
- * @returns {Record<string, any>} 解析后的 local.yaml 顶层对象(文件缺/解析失败 → {})
363
- */
364
- function loadLocalConfig(cwd) {
365
- const candidates = [
366
- path.join(cwd, '.sillyspec', 'local.yaml'),
367
- path.join(cwd, '.sillyspec', 'local.yml'),
368
- path.join(cwd, 'local.yaml'),
369
- path.join(cwd, 'local.yml'),
370
- ]
371
- for (const p of candidates) {
372
- if (!existsSync(p)) continue
373
- try {
374
- const content = readFileSync(p, 'utf8')
375
- return parseSimpleYaml(content)
376
- } catch {
377
- return {}
378
- }
379
- }
380
- return {}
381
- }
382
-
383
- function parseSimpleYaml(content) {
384
- const result = {}
385
- let topKey = null
386
- let childKey = null
387
-
388
- function parseValue(value) {
389
- const trimmed = value.trim()
390
- if (!trimmed) return ''
391
- if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
392
- return trimmed.slice(1, -1)
393
- }
394
- if (trimmed === 'true') return true
395
- if (trimmed === 'false') return false
396
- return trimmed
397
- }
398
-
399
- for (const line of content.split('\n')) {
400
- const noComment = line.replace(/\s+#.*$/, '')
401
- const trimmed = noComment.trim()
402
- if (!trimmed || trimmed.startsWith('#')) continue
403
- const indent = noComment.length - noComment.trimStart().length
404
-
405
- if (indent === 0) {
406
- const topLevelMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
407
- if (!topLevelMatch) continue
408
- const key = topLevelMatch[1]
409
- const value = topLevelMatch[2]
410
- topKey = key
411
- childKey = null
412
-
413
- if (value.trim()) {
414
- result[key] = parseValue(value)
415
- } else {
416
- result[key] = {}
417
- }
418
- continue
419
- }
420
-
421
- if (!topKey) continue
422
-
423
- if (indent === 2 && trimmed.startsWith('- ')) {
424
- if (!Array.isArray(result[topKey])) result[topKey] = []
425
- result[topKey].push(parseValue(trimmed.slice(2)))
426
- continue
427
- }
428
-
429
- if (indent === 2) {
430
- const childMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
431
- if (!childMatch) continue
432
- childKey = childMatch[1]
433
- const value = childMatch[2]
434
- if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
435
- result[topKey][childKey] = value.trim() ? parseValue(value) : []
436
- continue
437
- }
438
-
439
- if (indent >= 4 && childKey && trimmed.startsWith('- ')) {
440
- if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
441
- if (!Array.isArray(result[topKey][childKey])) result[topKey][childKey] = []
442
- result[topKey][childKey].push(parseValue(trimmed.slice(2)))
443
- }
444
- }
445
-
446
- for (const key of Object.keys(result)) {
447
- if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
448
- if (Object.keys(result[key]).length === 0) {
449
- result[key] = {}
450
- }
451
- }
452
- }
453
-
454
- return result
455
- }
456
-
457
- /**
458
- * 提取命令中第一个可执行命令名
459
- * @param {string} command
460
- * @returns {string}
461
- */
462
- function extractCommandName(command) {
463
- const trimmed = command.trim()
464
- if (!trimmed) return ''
465
- return trimmed.split(/\s+/)[0]
466
- }
467
-
468
- /**
469
- * 判断单个命令片段是否匹配只读白名单
470
- * @param {string} cmd - 单个命令片段(不含管道/链式操作符)
471
- * @param {string[]} extraReadonlyCommands - local.yaml 扩展的只读命令
472
- * @returns {boolean}
473
- */
474
- function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
475
- const trimmed = cmd.trim()
476
- if (!trimmed) return true // 空片段放行
477
-
478
- const parts = trimmed.split(/\s+/)
479
- const cmdName = parts[0]
480
-
481
- // 纯命令名匹配
482
- if (READONLY_COMMANDS.has(cmdName)) {
483
- // node/npm/npx 需要进一步检查子命令
484
- if (cmdName === 'node' || cmdName === 'npm' || cmdName === 'npx') {
485
- const rest = parts.slice(1).join(' ')
486
- // 仅纯版本查询(`npm --version` / `node -v`,恰好两段)放行。
487
- // 不能用 rest.includes('--version'):`npx any-pkg --version` 会执行 any-pkg,
488
- // 旧实现因 && 优先级高于 || 使 --version 分支无长度约束,可绕过只读白名单。
489
- const isBareVersionQuery = parts.length === 2 && (parts[1] === '--version' || parts[1] === '-v')
490
- return isBareVersionQuery || rest === 'run test' || rest.startsWith('test')
491
- }
492
- return true
493
- }
494
-
495
- // local.yaml 扩展
496
- if (extraReadonlyCommands.includes(cmdName)) return true
497
-
498
- // git 只读子命令
499
- if (cmdName === 'git') {
500
- const sub = parts[1] || ''
501
- if (READONLY_GIT_SUBS.has(sub)) return true
502
- // git stash list
503
- if (sub === 'stash' && (parts[2] === 'list' || parts.length === 2)) return true
504
- // git worktree 管理(list/add/remove)放行
505
- if (sub === 'worktree') return true
506
- return false
507
- }
508
-
509
- // sillyspec 命令全部放行(CLI 工具本身安全)
510
- if (cmdName === 'sillyspec') return true
511
-
512
- return false
513
- }
514
-
515
- /**
516
- * 判断单个命令片段是否匹配危险黑名单
517
- * @param {string} cmd - 单个命令片段
518
- * @returns {boolean}
519
- */
520
- function isSingleCommandDangerous(cmd) {
521
- const trimmed = cmd.trim().toLowerCase()
522
-
523
- for (const prefix of DANGER_PREFIXES) {
524
- if (trimmed.startsWith(prefix)) return true
525
- }
526
-
527
- const parts = trimmed.split(/\s+/)
528
- const cmdName = parts[0]
529
-
530
- if (cmdName === 'git') {
531
- const sub = parts[1] || ''
532
- if (DANGER_GIT_SUBS.has(sub)) return true
533
- // git stash drop/clear/pop
534
- if (sub === 'stash' && DANGER_STASH_ACTIONS.has(parts[2] || '')) return true
535
- }
536
-
537
- // rm(不限于 -rf)
538
- if (cmdName === 'rm') return true
539
-
540
- return false
541
- }
542
-
543
- /**
544
- * 将命令按管道/链式操作符拆分为多个片段
545
- * @param {string} command
546
- * @returns {string[]}
547
- */
548
- function splitCommandParts(command) {
549
- // 按管道、链式操作符、语句分隔、重定向拆分。
550
- // 必须覆盖 `||`/`&&`/`;`/`&`/`|`/`>`/`<`:否则 `echo foo; rm -rf x`、
551
- // `cat f > /etc/passwd` 会被当成单一片段,首命令命中只读白名单 → 整条放行,
552
- // 后半段的危险/写入操作绕过只读白名单与危险黑名单两道门。
553
- // (`$(...)`/反引号命令替换需 shell-token 化,与零依赖风格冲突,暂不覆盖。)
554
- return command.split(/\|\||&&|;|&|\||>|</g).map(s => s.trim()).filter(Boolean)
555
- }
556
-
557
- /**
558
- * 判断命令是否匹配只读白名单(含管道/链式检查)
559
- * @param {string} command
560
- * @param {string[]} extraReadonlyCommands
561
- * @returns {boolean}
562
- */
563
- function matchReadonlyWhitelist(command, extraReadonlyCommands = []) {
564
- const parts = splitCommandParts(command)
565
- return parts.every(p => isSingleCommandReadonly(p, extraReadonlyCommands))
566
- }
567
-
568
- /**
569
- * 判断命令是否匹配危险黑名单(含管道/链式检查)
570
- * @param {string} command
571
- * @returns {boolean}
572
- */
573
- function matchDangerBlacklist(command) {
574
- const parts = splitCommandParts(command)
575
- return parts.some(p => isSingleCommandDangerous(p))
576
- }
577
-
578
- /**
579
- * 构建阶段拦截提示
580
- * @param {string} stage
581
- * @returns {string}
582
- */
583
- function buildStageHint(stage) {
584
- const hint = STAGE_HINTS[stage] || STAGE_HINTS['(none)']
585
- return hint.join('\n')
586
- }
587
-
588
- /**
589
- * 读取并合并所有活跃 quick session 的 guard(D-002@v1)
590
- *
591
- * 为什么合并而不是读单个 session:
592
- * - hook 是独立进程(agent 写文件时触发,非 quick CLI),无法可靠知道当前 agent 属于哪个 quick session。
593
- * - `current-quick-run-id` 是单文件多会话覆盖,读到的可能是他者 session。
594
- * - 对策:读所有活跃 quick-sessions 下各 session 的 guard.json,合并 baselineFiles / allowedFiles 并集。
595
- * 多会话时保护范围过宽(保护所有 session 的 baseline),但不误拦任何 session 的合法写——安全侧倾斜。
596
- *
597
- * 兼容:旧版单文件 `.runtime/quick-guard.json`(task-03 前格式,无 session 目录)也并入合并。
598
- *
599
- * @param {string} projectRoot
600
- * @returns {{ baselineFiles: string[], allowedFiles: string[], hasGuard: boolean }}
601
- * - baselineFiles/allowedFiles 为所有活跃 session 的并集(去重,保持首次出现顺序)
602
- * - hasGuard 表示是否存在任何 guard(用于区分"无 quick 任务"与"有 guard 但列表为空")
603
- */
604
- function readAllQuickGuards(projectRoot) {
605
- const baselineSet = new Set()
606
- const allowedSet = new Set()
607
- let hasGuard = false
608
-
609
- const sessionsDir = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-sessions')
610
- let sessionDirs = []
611
- try {
612
- const entries = readdirSync(sessionsDir, { withFileTypes: true })
613
- sessionDirs = entries.filter(e => e.isDirectory()).map(e => e.name)
614
- } catch { /* 目录不存在或不可读 → 无活跃 session */ }
615
-
616
- for (const sessionName of sessionDirs) {
617
- const guardFile = path.join(sessionsDir, sessionName, 'guard.json')
618
- if (!existsSync(guardFile)) continue
619
- let guard
620
- try {
621
- guard = JSON.parse(readFileSync(guardFile, 'utf8'))
622
- } catch { continue } // 损坏的 guard.json 跳过(不污染并集)
623
- hasGuard = true
624
- for (const f of Array.isArray(guard.baselineFiles) ? guard.baselineFiles : []) {
625
- if (typeof f === 'string' && f) baselineSet.add(f)
626
- }
627
- for (const f of Array.isArray(guard.allowedFiles) ? guard.allowedFiles : []) {
628
- if (typeof f === 'string' && f) allowedSet.add(f)
629
- }
630
- }
631
-
632
- // 兼容:旧版单文件 quick-guard.json(task-03 前格式,迁移期残留)
633
- const legacyGuardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
634
- if (existsSync(legacyGuardFile)) {
635
- try {
636
- const guard = JSON.parse(readFileSync(legacyGuardFile, 'utf8'))
637
- hasGuard = true
638
- for (const f of Array.isArray(guard.baselineFiles) ? guard.baselineFiles : []) {
639
- if (typeof f === 'string' && f) baselineSet.add(f)
640
- }
641
- for (const f of Array.isArray(guard.allowedFiles) ? guard.allowedFiles : []) {
642
- if (typeof f === 'string' && f) allowedSet.add(f)
643
- }
644
- } catch { /* 旧文件损坏,忽略 */ }
645
- }
646
-
647
- return {
648
- baselineFiles: [...baselineSet],
649
- allowedFiles: [...allowedSet],
650
- hasGuard,
651
- }
652
- }
653
-
654
- // ── 公共接口 ──
655
-
656
- /**
657
- * 判断文件写入是否应被拦截
658
- *
659
- * 降级策略(无 worktree = 更严格):
660
- * - noWorktree 模式下,execute/quick 阶段不允许源码写入(没有隔离环境)
661
- * - 除非同时设置 SILLYSPEC_DISABLE_HOOKS=1
662
- *
663
- * P0 优化:
664
- * - 使用 readCurrentStage() 直读 sillyspec.db 获取阶段
665
- * - 拦截提示按阶段给出具体修复建议
666
- *
667
- * @param {string} filePath - 目标文件绝对路径
668
- * @param {string} cwd - 当前工作目录
669
- * @returns {{ blocked: boolean, reason?: string }}
670
- */
671
- export function shouldBlockWrite(filePath, cwd) {
672
- if (!filePath) return { blocked: true, reason: 'no file path' }
673
-
674
- const callerCwd = cwd || process.cwd()
675
- const projectRoot = findProjectRoot(callerCwd)
676
- const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
677
-
678
- // 1. 阶段门禁(直读 sillyspec.db)
679
- const stage = readCurrentStage(projectRoot) || '(none)'
680
-
681
- const scanGuardResult = shouldBlockScanDocOverwrite(absPath, projectRoot)
682
- if (scanGuardResult.blocked) return scanGuardResult
683
-
684
- // 2. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
685
- if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
686
-
687
- if (!['execute', 'quick'].includes(stage)) {
688
- return {
689
- blocked: true,
690
- reason: buildStageHint(stage)
691
- }
692
- }
693
-
694
- // quick 阶段:检查 baselineFiles(合并所有活跃 session guard 并集,D-002@v1)
695
- if (stage === 'quick') {
696
- const { baselineFiles } = readAllQuickGuards(projectRoot)
697
- // 归一为 forward-slash:baselineFiles 取自 guard.json(经 parsePorcelainPath 已是 /),
698
- // Windows 下 path.relative 产 backslash 会与 baseline 不匹配,导致实时防护被静默绕过
699
- const relTarget = path.relative(projectRoot, absPath).split(path.sep).join('/')
700
- // 如果目标是 baseline protected file,阻止写入(并集 = 保护所有 session 的 baseline)
701
- if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + '/'))) {
702
- return {
703
- blocked: true,
704
- reason: [
705
- `⚠️ quick 变更边界保护:${relTarget} 是 baseline 文件,不允许覆盖。`,
706
- `当前 quick 任务不能修改任务开始前已修改的文件。`,
707
- `如确需修改,请在 quick 完成后单独处理此文件。`,
708
- ].join('\n')
709
- }
710
- }
711
- return { blocked: false }
712
- }
713
-
714
- // execute 阶段:位置门禁
715
- if (isInsideRegisteredWorktree(absPath, projectRoot)) return { blocked: false }
716
-
717
- // noWorktree 模式:无隔离环境,禁止源码写入(降级到更严格)
718
- if (isNoWorktreeMode(projectRoot)) {
719
- return {
720
- blocked: true,
721
- reason: [
722
- '当前变更处于无 worktree 隔离模式(changes.no_worktree=1),不允许源码写入。',
723
- '如需修改源码,请在 worktree 隔离环境中工作。',
724
- '若误入此模式,检查 sillyspec.db 该变更的 no_worktree 字段;紧急情况可设置 SILLYSPEC_DISABLE_HOOKS=1 绕过限制。',
725
- ].join('\n')
726
- }
727
- }
728
-
729
- return {
730
- blocked: true,
731
- reason: [
732
- '源码修改只能在 worktree 隔离环境中进行。',
733
- '',
734
- '你可能需要:',
735
- ' 1. 确认 worktree 已创建并登记:sillyspec worktree list',
736
- ' 2. 如未创建,先创建:sillyspec worktree create <变更名>',
737
- ' 3. 在 worktree 目录中工作(子代理的 cwd 设为 worktree 路径)',
738
- '',
739
- '如果你在 execute 阶段,通常会自动创建 worktree。',
740
- '检查是否跳过了 "创建 worktree" 步骤。',
741
- ].join('\n')
742
- }
743
- }
744
-
745
- /**
746
- * 判断 Bash 命令是否应被拦截
747
- * @param {string} command - Bash 命令字符串
748
- * @param {string} cwd - 当前工作目录
749
- * @returns {{ blocked: boolean, reason?: string }}
750
- */
751
- function shouldBlockBash(command, cwd) {
752
- if (!command || !command.trim()) return { blocked: false }
753
-
754
- const callerCwd = cwd || process.cwd()
755
- const projectRoot = findProjectRoot(callerCwd)
756
-
757
- // cwd 在 worktree 内 → 全部放行
758
- if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
759
-
760
- // 阶段门禁(直读 sillyspec.db)
761
- const stage = readCurrentStage(projectRoot) || '(none)'
762
-
763
- if (!['execute', 'quick'].includes(stage)) {
764
- // 非 execute/quick 阶段,只允许只读白名单
765
- const localConfig = loadLocalConfig(projectRoot)
766
- const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
767
- if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
768
- return {
769
- blocked: true,
770
- reason: buildStageHint(stage)
771
- }
772
- }
773
-
774
- // quick 阶段:检查合并后的 baselineFiles(所有活跃 session guard 并集,D-002@v1)
775
- if (stage === 'quick') {
776
- // 危险黑名单仍然拦截
777
- if (matchDangerBlacklist(command)) {
778
- return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
779
- }
780
- // 检查命令是否会覆盖 baseline files(并集 = 保护所有 session 的 baseline)
781
- const { baselineFiles } = readAllQuickGuards(projectRoot)
782
- for (const f of baselineFiles) {
783
- if (command.includes(f) && (command.includes('> ') || command.includes(' tee ') || command.includes('sed ') || command.includes('mv '))) {
784
- return { blocked: true, reason: `quick 变更边界保护:命令可能覆盖 baseline 文件 ${f}` }
785
- }
786
- }
787
- return { blocked: false }
788
- }
789
-
790
- // execute 阶段 + 主工作区
791
- const localConfig = loadLocalConfig(projectRoot)
792
- const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
793
-
794
- // 危险黑名单
795
- if (matchDangerBlacklist(command)) {
796
- return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
797
- }
798
-
799
- // 只读白名单放行
800
- if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
801
-
802
- // 不确定 → 放行
803
- return { blocked: false }
804
- }
805
-
806
- /**
807
- * 判断工具调用是否应被拦截
808
- * @param {{
809
- * tool: 'Write' | 'Edit' | 'MultiEdit' | 'Bash',
810
- * filePath?: string,
811
- * filePaths?: string[],
812
- * command?: string,
813
- * cwd?: string
814
- * }} opts
815
- * @param {{ cwd?: string }} ctx
816
- * @returns {{ blocked: boolean, reason?: string }}
817
- */
818
- export function shouldBlock(opts, ctx = {}) {
819
- // 逃生开关
820
- if (process.env.SILLYSPEC_DISABLE_HOOKS === '1') return { blocked: false }
821
-
822
- const cwd = opts.cwd || ctx.cwd || process.cwd()
823
-
824
- switch (opts.tool) {
825
- case 'Write':
826
- case 'Edit': {
827
- const fp = opts.filePath
828
- if (!fp) return { blocked: true, reason: 'no file path' }
829
- const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
830
- return shouldBlockWrite(absPath, cwd)
831
- }
832
- case 'MultiEdit': {
833
- const filePaths = opts.filePaths
834
- if (!filePaths || filePaths.length === 0) return { blocked: true, reason: 'no file paths' }
835
- for (const fp of filePaths) {
836
- const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
837
- const result = shouldBlockWrite(absPath, cwd)
838
- if (result.blocked) return result
839
- }
840
- return { blocked: false }
841
- }
842
- case 'Bash': {
843
- return shouldBlockBash(opts.command || '', cwd)
844
- }
845
- default:
846
- return { blocked: false }
847
- }
848
- }
1
+ /**
2
+ * worktree-guard.js — Hook 拦截判断逻辑
3
+ *
4
+ * 三重门禁:stageGate × locationGate × fileGate
5
+ * 纯判断模块,不做实际的 hook 注入。
6
+ *
7
+ * P0 优化:
8
+ * - 阶段检测:直读 sillyspec.db currentStage(task-10 废 gate-status.json 缓存双源)
9
+ * - 拦截提示针对每个阶段给出具体修复建议
10
+ */
11
+
12
+ import { existsSync, readFileSync, readdirSync } from 'fs'
13
+ import path from 'path'
14
+ import { execFileSync } from 'child_process'
15
+
16
+ // ── 常量 ──
17
+
18
+ const WORKTREE_STAGES = ['execute'] // 这些阶段必须在 worktree 里
19
+
20
+ const FILE_WHITELIST_EXTS = ['.md']
21
+ const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json', 'local.yaml', 'local.yml']
22
+
23
+ /** 只读命令(命令名) */
24
+ const READONLY_COMMANDS = new Set([
25
+ 'grep', 'rg', 'ag', 'find', 'ls', 'cat', 'head', 'tail', 'wc', 'stat',
26
+ 'echo', 'pwd', 'basename', 'dirname', 'realpath',
27
+ 'node', 'npm', 'npx', // 只允许 --version 等只读子命令,在 matchReadonlyWhitelist 中处理
28
+ ])
29
+
30
+ /** 只读 git 子命令 */
31
+ const READONLY_GIT_SUBS = new Set(['diff', 'status', 'log', 'show', 'branch', 'stash'])
32
+
33
+ /** 危险 git 子命令 */
34
+ const DANGER_GIT_SUBS = new Set([
35
+ 'add', 'commit', 'push', 'checkout', 'restore', 'reset', 'clean',
36
+ 'mv', 'rm',
37
+ ])
38
+
39
+ /** 危险 git stash 操作 */
40
+ const DANGER_STASH_ACTIONS = new Set(['drop', 'clear', 'pop'])
41
+
42
+ /** 危险命令前缀 */
43
+ const DANGER_PREFIXES = ['sudo', 'rm -rf', 'rm -r', 'rmdir']
44
+
45
+ // ── 阶段 → 拦截提示映射 ──
46
+
47
+ const STAGE_HINTS = {
48
+ '(none)': [
49
+ '没有检测到活跃的 SillySpec 流程。',
50
+ '你需要先启动一个任务流程才能修改源码(调用对应的 sillyspec skill):',
51
+ '',
52
+ ' BUG修复(skill sillyspec-quick):sillyspec run quick "任务描述"',
53
+ ' 逻辑变更(skill sillyspec-brainstorm):sillyspec run brainstorm → plan → execute → verify → archive',
54
+ ' 全自动模式(skill sillyspec-auto):sillyspec run auto "任务描述"',
55
+ ],
56
+ 'brainstorm': [
57
+ '当前在 brainstorm(需求分析)阶段,这个阶段只写文档,不写代码。',
58
+ '完成 brainstorm 后,流程会自动推进到 plan → execute。',
59
+ 'execute 阶段才允许写代码。',
60
+ ],
61
+ 'plan': [
62
+ '当前在 plan(计划制定)阶段,这个阶段只写计划文档和任务蓝图,不写代码。',
63
+ '完成 plan 后,运行 sillyspec run execute 进入执行阶段。',
64
+ ],
65
+ 'verify': [
66
+ '当前在 verify(验证)阶段,只做代码审查和测试验证,不修改源码。',
67
+ '如需修改,请先回到 execute 阶段或使用 quick 模式:',
68
+ ' sillyspec run quick "修改描述"',
69
+ ],
70
+ 'archive': [
71
+ '当前在 archive(归档)阶段,不修改源码。',
72
+ '如需修改,请开启新变更:sillyspec run quick "修改描述"',
73
+ ],
74
+ 'explore': [
75
+ '当前在 explore(探索)阶段,只读不写。',
76
+ '确认方案后使用:sillyspec run brainstorm 或 sillyspec run quick',
77
+ ],
78
+ }
79
+
80
+ // ── 辅助函数 ──
81
+
82
+ function resolveWorktreeDir(cwd) {
83
+ return path.join(cwd, '.sillyspec', '.runtime', 'worktrees')
84
+ }
85
+
86
+ function findProjectRoot(cwd) {
87
+ let dir = path.resolve(cwd || process.cwd())
88
+ while (true) {
89
+ if (
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
+ function isPathInside(child, parent) {
123
+ if (!child || !parent) return false
124
+ const absChild = path.resolve(child)
125
+ const absParent = path.resolve(parent)
126
+ return absChild === absParent || absChild.startsWith(absParent + path.sep)
127
+ }
128
+
129
+ function toPosixPath(filePath) {
130
+ return filePath.replace(/\\/g, '/')
131
+ }
132
+
133
+ function parseFrontmatter(content) {
134
+ if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return {}
135
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
136
+ if (!match) return {}
137
+ const result = {}
138
+ for (const line of match[1].split(/\r?\n/)) {
139
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
140
+ if (!m) continue
141
+ result[m[1]] = m[2].replace(/^['"]|['"]$/g, '').trim()
142
+ }
143
+ return result
144
+ }
145
+
146
+ function parseTimestamp(value) {
147
+ if (!value) return null
148
+ const time = Date.parse(value)
149
+ return Number.isNaN(time) ? null : time
150
+ }
151
+
152
+ function getScanDocInfo(filePath) {
153
+ const normalized = toPosixPath(path.resolve(filePath))
154
+ const match = normalized.match(/^(.*)\/docs\/([^/]+)\/scan\/([^/]+\.md)$/)
155
+ if (!match) return null
156
+ return {
157
+ specRoot: path.resolve(match[1]),
158
+ projectName: match[2],
159
+ docName: match[3],
160
+ }
161
+ }
162
+
163
+ function readScanGuard(scanDocInfo, projectRoot) {
164
+ const candidates = [
165
+ path.join(scanDocInfo.specRoot, '.runtime', 'scan-guard.json'),
166
+ path.join(projectRoot, '.sillyspec', '.runtime', 'scan-guard.json'),
167
+ ]
168
+ for (const p of candidates) {
169
+ if (!existsSync(p)) continue
170
+ try {
171
+ return JSON.parse(readFileSync(p, 'utf8'))
172
+ } catch {
173
+ return null
174
+ }
175
+ }
176
+ return null
177
+ }
178
+
179
+ function shouldBlockScanDocOverwrite(filePath, projectRoot) {
180
+ const scanDocInfo = getScanDocInfo(filePath)
181
+ if (!scanDocInfo || !existsSync(filePath)) return { blocked: false }
182
+
183
+ const guard = readScanGuard(scanDocInfo, projectRoot)
184
+ if (!guard || guard.forceRescan) return { blocked: false }
185
+
186
+ let frontmatter = {}
187
+ try {
188
+ frontmatter = parseFrontmatter(readFileSync(filePath, 'utf8'))
189
+ } catch {
190
+ return { blocked: false }
191
+ }
192
+
193
+ const relPath = toPosixPath(path.relative(projectRoot, filePath))
194
+ if (frontmatter.source_commit && guard.sourceCommit && frontmatter.source_commit !== guard.sourceCommit) {
195
+ return {
196
+ blocked: true,
197
+ reason: [
198
+ `scan 覆盖保护:${relPath} 的 source_commit=${frontmatter.source_commit} 与当前 scan source_commit=${guard.sourceCommit} 不一致。`,
199
+ '如确认要重新生成,请重新运行 scan 并添加 --force-rescan。',
200
+ ].join('\n')
201
+ }
202
+ }
203
+
204
+ const existingUpdatedAt = parseTimestamp(frontmatter.updated_at)
205
+ const scanStartedAt = parseTimestamp(guard.startedAt)
206
+ if (existingUpdatedAt && scanStartedAt && existingUpdatedAt > scanStartedAt) {
207
+ return {
208
+ blocked: true,
209
+ reason: [
210
+ `scan 覆盖保护:${relPath} 的 updated_at 晚于本次 scan 开始时间,可能包含手工编辑。`,
211
+ '如确认要覆盖,请重新运行 scan 并添加 --force-rescan。',
212
+ ].join('\n')
213
+ }
214
+ }
215
+
216
+ return { blocked: false }
217
+ }
218
+
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
+ * 同步只读查询 sillyspec.db 第一行第一列(不依赖外部 sqlite3 CLI)。
226
+ *
227
+ * 用子进程跑 node:sqlite 的 DatabaseSync(execFileSync 等待):
228
+ * - node:sqlite 是 Node 内置模块,子进程 require('node:sqlite') 直接可用,无需先 resolve 出
229
+ * 绝对路径再注入(旧方案需手动解析原生绑定路径,node:sqlite 无此步骤)。
230
+ * - readOnly 打开只读连接(替代旧方案 readonly/fileMustExist 语义):绝不写库;WAL 并发读不阻塞主进程写,
231
+ * 且只读连接可看到已 commit 但未 checkpoint 的过渡态(修复旧 WASM 内存库裸读 .db
232
+ * 看不见 WAL 的过渡态可见性窗口)。db 缺失/损坏由 open 异常自然触发 fail-closed(外层 catch)。
233
+ *
234
+ * 环境假设(R-09):WAL 模式下只读连接仍需在 .runtime/ 目录建/更新 -shm 索引文件,故 .runtime
235
+ * 必须可写(与主进程一致)——“只读”指不改 DB 数据,不等于不对 -shm 加索引。
236
+ *
237
+ * fail-closed:db 文件不存在 / 子进程打开、查询异常 / 子进程超时或崩溃——一律 console.warn
238
+ * (含 e.stderr 详情)并返回 null,调用方(readCurrentStage/isNoWorktreeMode)对 null 走
239
+ * fail-closed,禁止 fail-open。
240
+ */
241
+ function queryDbFirstCell(cwd, sql) {
242
+ const dbPath = path.join(cwd, '.sillyspec', '.runtime', 'sillyspec.db')
243
+ if (!existsSync(dbPath)) {
244
+ // db 文件不存在(findProjectRoot 命中 local.yaml/projects 但无 DB,或 DB 被删):fail-closed。
245
+ // warn 让异常(如 DB 误删)可见;正常空态在 findProjectRoot 未命中时根本不会进到这里。
246
+ console.warn('⚠️ sillyspec.db 不存在,hook 降级 fail-closed: ' + dbPath)
247
+ return null
248
+ }
249
+ // 子进程同步内置 require('node:sqlite')(DatabaseSync 是同步 API,无 async/import().then 包装)。
250
+ // prepare(sql).get() 取第一行整行后取首列值,无行返回 undefined(写空 stdout → 父侧 trim 后 null)。
251
+ const script =
252
+ "const {DatabaseSync}=require('node:sqlite')," +
253
+ "dbPath=" + JSON.stringify(dbPath) + ",sql=" + JSON.stringify(sql) + ";" +
254
+ "let db;" +
255
+ "try{db=new DatabaseSync(dbPath,{readOnly:true});" +
256
+ "const row=db.prepare(sql).get();" +
257
+ "const v=row===undefined?undefined:Object.values(row)[0];" +
258
+ "if(v!==undefined)process.stdout.write(String(v??''));db.close()}" +
259
+ "catch(e){process.stderr.write('hook db query failed: '+String(e&&e.message||e));process.exit(1)};"
260
+ try {
261
+ return execFileSync(process.execPath, ['-e', script], {
262
+ encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'pipe'],
263
+ }).trim() || null
264
+ } catch (e) {
265
+ // DB 查询失败(db 损坏 / readOnly 打开失败 / 子进程超时或崩溃):降级返回 null,调用方
266
+ //(readCurrentStage 等)对 null 走 fail-closed。补 warn 让 doctor/agent 能看到根因,
267
+ // 而非完全静默(子进程把诊断写到了 stderr,execFileSync 抛错时挂在 e.stderr 上)。
268
+ const detail = (e && e.stderr ? String(e.stderr).trim() : '') || (e && e.message) || 'timeout/crash'
269
+ console.warn('⚠️ sillyspec.db 查询失败,hook 降级 fail-closed: ' + detail)
270
+ return null
271
+ }
272
+ }
273
+
274
+ export { queryDbFirstCell as _queryDbFirstCellForTest };
275
+ export {
276
+ matchReadonlyWhitelist as _matchReadonlyWhitelistForTest,
277
+ matchDangerBlacklist as _matchDangerBlacklistForTest,
278
+ isSingleCommandReadonly as _isSingleCommandReadonlyForTest,
279
+ };
280
+ /**
281
+ * 从 sillyspec.db 读取 currentStage(直读 DB,task-10 废 gate-status.json 后为唯一来源)
282
+ * @param {string} cwd
283
+ * @returns {string|null} 阶段名,null 表示无法确定
284
+ */
285
+ function readCurrentStage(cwd) {
286
+ // 直读 sillyspec.db(node + node:sqlite 只读子进程,不依赖外部 sqlite3 CLI——Windows 默认没有)
287
+ const v = queryDbFirstCell(cwd, "SELECT current_stage FROM changes WHERE status='active' AND current_stage IN ('execute','quick') ORDER BY last_active DESC LIMIT 1")
288
+ return v || null
289
+ }
290
+
291
+ /**
292
+ * 检查当前变更是否处于 noWorktree 模式(直读 sillyspec.db)
293
+ * @param {string} cwd
294
+ * @returns {boolean}
295
+ */
296
+ function isNoWorktreeMode(cwd) {
297
+ // 直读 sillyspec.db(node + node:sqlite,同 readCurrentStage)
298
+ return queryDbFirstCell(cwd, "SELECT no_worktree FROM changes WHERE status='active' AND current_stage IN ('execute','quick') LIMIT 1") === '1'
299
+ }
300
+
301
+ /**
302
+ * 判断路径是否在某已注册 worktree 内
303
+ * @param {string} filePath - 绝对路径
304
+ * @returns {boolean}
305
+ */
306
+ function isInsideRegisteredWorktree(filePath, cwd) {
307
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
308
+ const effectiveCwd = cwd || process.cwd()
309
+
310
+ // 已注册 worktree 名单来源:扫描 .runtime/worktrees/ 子目录(task-10 废 gate-status.json 后的权威来源)。
311
+ // queryDbFirstCell 是单行单列,读不了多行 change 名单;目录扫描天然支持多 change 并发,
312
+ // 且 worktree 目录存在性 == 注册态,比 DB changes 表更贴合“已注册 worktree”语义
313
+ // (changes 表可能含 no_worktree 变更,它们没有 worktree 目录)。
314
+ // 跨平台:isPathInside 用 path.sep 判定包含关系,Windows backslash / POSIX slash 均正确。
315
+ const worktreeDir = resolveWorktreeDir(effectiveCwd)
316
+ let changeNames = []
317
+ try {
318
+ const entries = readdirSync(worktreeDir, { withFileTypes: true })
319
+ changeNames = entries.filter(e => e.isDirectory()).map(e => e.name)
320
+ } catch { /* 目录不存在或不可读 → 无注册 worktree */ }
321
+
322
+ for (const changeName of changeNames) {
323
+ const meta = readWorktreeMeta(effectiveCwd, changeName)
324
+ if (meta?.worktreePath && isPathInside(absPath, meta.worktreePath)) return true
325
+ }
326
+
327
+ return false
328
+ }
329
+
330
+ /**
331
+ * 文件白名单:文档类/配置类始终放行
332
+ * @param {string} filePath - 绝对路径
333
+ * @returns {boolean}
334
+ */
335
+ function matchFileWhitelist(filePath) {
336
+ // 路径以 .sillyspec/ 开头
337
+ const parts = filePath.split(path.sep)
338
+ for (let i = 0; i < parts.length; i++) {
339
+ if (parts[i] === '.sillyspec') return true
340
+ }
341
+
342
+ // 路径在 .git/ 下
343
+ for (let i = 0; i < parts.length - 1; i++) {
344
+ if (parts[i] === '.git') return true
345
+ }
346
+
347
+ // 扩展名
348
+ const ext = path.extname(filePath)
349
+ if (FILE_WHITELIST_EXTS.includes(ext)) return true
350
+
351
+ // 文件名
352
+ const base = path.basename(filePath)
353
+ if (FILE_WHITELIST_NAMES.includes(base)) return true
354
+
355
+ return false
356
+ }
357
+
358
+ /**
359
+ * 读取并解析 local.yaml(轻量手写解析,返回顶层对象)。
360
+ * 消费方读嵌套段 worktree-hook.readonlyCommands(见 isSingleCommandReadonly 的 extraReadonlyCommands)。
361
+ * @param {string} cwd
362
+ * @returns {Record<string, any>} 解析后的 local.yaml 顶层对象(文件缺/解析失败 → {})
363
+ */
364
+ function loadLocalConfig(cwd) {
365
+ const candidates = [
366
+ path.join(cwd, '.sillyspec', 'local.yaml'),
367
+ path.join(cwd, '.sillyspec', 'local.yml'),
368
+ path.join(cwd, 'local.yaml'),
369
+ path.join(cwd, 'local.yml'),
370
+ ]
371
+ for (const p of candidates) {
372
+ if (!existsSync(p)) continue
373
+ try {
374
+ const content = readFileSync(p, 'utf8')
375
+ return parseSimpleYaml(content)
376
+ } catch {
377
+ return {}
378
+ }
379
+ }
380
+ return {}
381
+ }
382
+
383
+ function parseSimpleYaml(content) {
384
+ const result = {}
385
+ let topKey = null
386
+ let childKey = null
387
+
388
+ function parseValue(value) {
389
+ const trimmed = value.trim()
390
+ if (!trimmed) return ''
391
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
392
+ return trimmed.slice(1, -1)
393
+ }
394
+ if (trimmed === 'true') return true
395
+ if (trimmed === 'false') return false
396
+ return trimmed
397
+ }
398
+
399
+ for (const line of content.split('\n')) {
400
+ const noComment = line.replace(/\s+#.*$/, '')
401
+ const trimmed = noComment.trim()
402
+ if (!trimmed || trimmed.startsWith('#')) continue
403
+ const indent = noComment.length - noComment.trimStart().length
404
+
405
+ if (indent === 0) {
406
+ const topLevelMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
407
+ if (!topLevelMatch) continue
408
+ const key = topLevelMatch[1]
409
+ const value = topLevelMatch[2]
410
+ topKey = key
411
+ childKey = null
412
+
413
+ if (value.trim()) {
414
+ result[key] = parseValue(value)
415
+ } else {
416
+ result[key] = {}
417
+ }
418
+ continue
419
+ }
420
+
421
+ if (!topKey) continue
422
+
423
+ if (indent === 2 && trimmed.startsWith('- ')) {
424
+ if (!Array.isArray(result[topKey])) result[topKey] = []
425
+ result[topKey].push(parseValue(trimmed.slice(2)))
426
+ continue
427
+ }
428
+
429
+ if (indent === 2) {
430
+ const childMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
431
+ if (!childMatch) continue
432
+ childKey = childMatch[1]
433
+ const value = childMatch[2]
434
+ if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
435
+ result[topKey][childKey] = value.trim() ? parseValue(value) : []
436
+ continue
437
+ }
438
+
439
+ if (indent >= 4 && childKey && trimmed.startsWith('- ')) {
440
+ if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
441
+ if (!Array.isArray(result[topKey][childKey])) result[topKey][childKey] = []
442
+ result[topKey][childKey].push(parseValue(trimmed.slice(2)))
443
+ }
444
+ }
445
+
446
+ for (const key of Object.keys(result)) {
447
+ if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
448
+ if (Object.keys(result[key]).length === 0) {
449
+ result[key] = {}
450
+ }
451
+ }
452
+ }
453
+
454
+ return result
455
+ }
456
+
457
+ /**
458
+ * 提取命令中第一个可执行命令名
459
+ * @param {string} command
460
+ * @returns {string}
461
+ */
462
+ function extractCommandName(command) {
463
+ const trimmed = command.trim()
464
+ if (!trimmed) return ''
465
+ return trimmed.split(/\s+/)[0]
466
+ }
467
+
468
+ /**
469
+ * 判断单个命令片段是否匹配只读白名单
470
+ * @param {string} cmd - 单个命令片段(不含管道/链式操作符)
471
+ * @param {string[]} extraReadonlyCommands - local.yaml 扩展的只读命令
472
+ * @returns {boolean}
473
+ */
474
+ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
475
+ const trimmed = cmd.trim()
476
+ if (!trimmed) return true // 空片段放行
477
+
478
+ const parts = trimmed.split(/\s+/)
479
+ const cmdName = parts[0]
480
+
481
+ // 纯命令名匹配
482
+ if (READONLY_COMMANDS.has(cmdName)) {
483
+ // node/npm/npx 需要进一步检查子命令
484
+ if (cmdName === 'node' || cmdName === 'npm' || cmdName === 'npx') {
485
+ const rest = parts.slice(1).join(' ')
486
+ // 仅纯版本查询(`npm --version` / `node -v`,恰好两段)放行。
487
+ // 不能用 rest.includes('--version'):`npx any-pkg --version` 会执行 any-pkg,
488
+ // 旧实现因 && 优先级高于 || 使 --version 分支无长度约束,可绕过只读白名单。
489
+ const isBareVersionQuery = parts.length === 2 && (parts[1] === '--version' || parts[1] === '-v')
490
+ return isBareVersionQuery || rest === 'run test' || rest.startsWith('test')
491
+ }
492
+ return true
493
+ }
494
+
495
+ // local.yaml 扩展
496
+ if (extraReadonlyCommands.includes(cmdName)) return true
497
+
498
+ // git 只读子命令
499
+ if (cmdName === 'git') {
500
+ const sub = parts[1] || ''
501
+ if (READONLY_GIT_SUBS.has(sub)) return true
502
+ // git stash list
503
+ if (sub === 'stash' && (parts[2] === 'list' || parts.length === 2)) return true
504
+ // git worktree 管理(list/add/remove)放行
505
+ if (sub === 'worktree') return true
506
+ return false
507
+ }
508
+
509
+ // sillyspec 命令全部放行(CLI 工具本身安全)
510
+ if (cmdName === 'sillyspec') return true
511
+
512
+ return false
513
+ }
514
+
515
+ /**
516
+ * 判断单个命令片段是否匹配危险黑名单
517
+ * @param {string} cmd - 单个命令片段
518
+ * @returns {boolean}
519
+ */
520
+ function isSingleCommandDangerous(cmd) {
521
+ const trimmed = cmd.trim().toLowerCase()
522
+
523
+ for (const prefix of DANGER_PREFIXES) {
524
+ if (trimmed.startsWith(prefix)) return true
525
+ }
526
+
527
+ const parts = trimmed.split(/\s+/)
528
+ const cmdName = parts[0]
529
+
530
+ if (cmdName === 'git') {
531
+ const sub = parts[1] || ''
532
+ if (DANGER_GIT_SUBS.has(sub)) return true
533
+ // git stash drop/clear/pop
534
+ if (sub === 'stash' && DANGER_STASH_ACTIONS.has(parts[2] || '')) return true
535
+ }
536
+
537
+ // rm(不限于 -rf)
538
+ if (cmdName === 'rm') return true
539
+
540
+ return false
541
+ }
542
+
543
+ /**
544
+ * 将命令按管道/链式操作符拆分为多个片段
545
+ * @param {string} command
546
+ * @returns {string[]}
547
+ */
548
+ function splitCommandParts(command) {
549
+ // 按管道、链式操作符、语句分隔、重定向拆分。
550
+ // 必须覆盖 `||`/`&&`/`;`/`&`/`|`/`>`/`<`:否则 `echo foo; rm -rf x`、
551
+ // `cat f > /etc/passwd` 会被当成单一片段,首命令命中只读白名单 → 整条放行,
552
+ // 后半段的危险/写入操作绕过只读白名单与危险黑名单两道门。
553
+ // (`$(...)`/反引号命令替换需 shell-token 化,与零依赖风格冲突,暂不覆盖。)
554
+ return command.split(/\|\||&&|;|&|\||>|</g).map(s => s.trim()).filter(Boolean)
555
+ }
556
+
557
+ /**
558
+ * 判断命令是否匹配只读白名单(含管道/链式检查)
559
+ * @param {string} command
560
+ * @param {string[]} extraReadonlyCommands
561
+ * @returns {boolean}
562
+ */
563
+ function matchReadonlyWhitelist(command, extraReadonlyCommands = []) {
564
+ const parts = splitCommandParts(command)
565
+ return parts.every(p => isSingleCommandReadonly(p, extraReadonlyCommands))
566
+ }
567
+
568
+ /**
569
+ * 判断命令是否匹配危险黑名单(含管道/链式检查)
570
+ * @param {string} command
571
+ * @returns {boolean}
572
+ */
573
+ function matchDangerBlacklist(command) {
574
+ const parts = splitCommandParts(command)
575
+ return parts.some(p => isSingleCommandDangerous(p))
576
+ }
577
+
578
+ /**
579
+ * 构建阶段拦截提示
580
+ * @param {string} stage
581
+ * @returns {string}
582
+ */
583
+ function buildStageHint(stage) {
584
+ const hint = STAGE_HINTS[stage] || STAGE_HINTS['(none)']
585
+ return hint.join('\n')
586
+ }
587
+
588
+ /**
589
+ * 读取并合并所有活跃 quick session 的 guard(D-002@v1)
590
+ *
591
+ * 为什么合并而不是读单个 session:
592
+ * - hook 是独立进程(agent 写文件时触发,非 quick CLI),无法可靠知道当前 agent 属于哪个 quick session。
593
+ * - `current-quick-run-id` 是单文件多会话覆盖,读到的可能是他者 session。
594
+ * - 对策:读所有活跃 quick-sessions 下各 session 的 guard.json,合并 baselineFiles / allowedFiles 并集。
595
+ * 多会话时保护范围过宽(保护所有 session 的 baseline),但不误拦任何 session 的合法写——安全侧倾斜。
596
+ *
597
+ * 兼容:旧版单文件 `.runtime/quick-guard.json`(task-03 前格式,无 session 目录)也并入合并。
598
+ *
599
+ * @param {string} projectRoot
600
+ * @returns {{ baselineFiles: string[], allowedFiles: string[], hasGuard: boolean }}
601
+ * - baselineFiles/allowedFiles 为所有活跃 session 的并集(去重,保持首次出现顺序)
602
+ * - hasGuard 表示是否存在任何 guard(用于区分"无 quick 任务"与"有 guard 但列表为空")
603
+ */
604
+ function readAllQuickGuards(projectRoot) {
605
+ const baselineSet = new Set()
606
+ const allowedSet = new Set()
607
+ let hasGuard = false
608
+
609
+ const sessionsDir = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-sessions')
610
+ let sessionDirs = []
611
+ try {
612
+ const entries = readdirSync(sessionsDir, { withFileTypes: true })
613
+ sessionDirs = entries.filter(e => e.isDirectory()).map(e => e.name)
614
+ } catch { /* 目录不存在或不可读 → 无活跃 session */ }
615
+
616
+ for (const sessionName of sessionDirs) {
617
+ const guardFile = path.join(sessionsDir, sessionName, 'guard.json')
618
+ if (!existsSync(guardFile)) continue
619
+ let guard
620
+ try {
621
+ guard = JSON.parse(readFileSync(guardFile, 'utf8'))
622
+ } catch { continue } // 损坏的 guard.json 跳过(不污染并集)
623
+ hasGuard = true
624
+ for (const f of Array.isArray(guard.baselineFiles) ? guard.baselineFiles : []) {
625
+ if (typeof f === 'string' && f) baselineSet.add(f)
626
+ }
627
+ for (const f of Array.isArray(guard.allowedFiles) ? guard.allowedFiles : []) {
628
+ if (typeof f === 'string' && f) allowedSet.add(f)
629
+ }
630
+ }
631
+
632
+ // 兼容:旧版单文件 quick-guard.json(task-03 前格式,迁移期残留)
633
+ const legacyGuardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
634
+ if (existsSync(legacyGuardFile)) {
635
+ try {
636
+ const guard = JSON.parse(readFileSync(legacyGuardFile, 'utf8'))
637
+ hasGuard = true
638
+ for (const f of Array.isArray(guard.baselineFiles) ? guard.baselineFiles : []) {
639
+ if (typeof f === 'string' && f) baselineSet.add(f)
640
+ }
641
+ for (const f of Array.isArray(guard.allowedFiles) ? guard.allowedFiles : []) {
642
+ if (typeof f === 'string' && f) allowedSet.add(f)
643
+ }
644
+ } catch { /* 旧文件损坏,忽略 */ }
645
+ }
646
+
647
+ return {
648
+ baselineFiles: [...baselineSet],
649
+ allowedFiles: [...allowedSet],
650
+ hasGuard,
651
+ }
652
+ }
653
+
654
+ // ── 公共接口 ──
655
+
656
+ /**
657
+ * 判断文件写入是否应被拦截
658
+ *
659
+ * 降级策略(无 worktree = 更严格):
660
+ * - noWorktree 模式下,execute/quick 阶段不允许源码写入(没有隔离环境)
661
+ * - 除非同时设置 SILLYSPEC_DISABLE_HOOKS=1
662
+ *
663
+ * P0 优化:
664
+ * - 使用 readCurrentStage() 直读 sillyspec.db 获取阶段
665
+ * - 拦截提示按阶段给出具体修复建议
666
+ *
667
+ * @param {string} filePath - 目标文件绝对路径
668
+ * @param {string} cwd - 当前工作目录
669
+ * @returns {{ blocked: boolean, reason?: string }}
670
+ */
671
+ export function shouldBlockWrite(filePath, cwd) {
672
+ if (!filePath) return { blocked: true, reason: 'no file path' }
673
+
674
+ const callerCwd = cwd || process.cwd()
675
+ const projectRoot = findProjectRoot(callerCwd)
676
+ const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
677
+
678
+ // 1. 阶段门禁(直读 sillyspec.db)
679
+ const stage = readCurrentStage(projectRoot) || '(none)'
680
+
681
+ const scanGuardResult = shouldBlockScanDocOverwrite(absPath, projectRoot)
682
+ if (scanGuardResult.blocked) return scanGuardResult
683
+
684
+ // 2. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
685
+ if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
686
+
687
+ if (!['execute', 'quick'].includes(stage)) {
688
+ return {
689
+ blocked: true,
690
+ reason: buildStageHint(stage)
691
+ }
692
+ }
693
+
694
+ // quick 阶段:检查 baselineFiles(合并所有活跃 session guard 并集,D-002@v1)
695
+ if (stage === 'quick') {
696
+ const { baselineFiles } = readAllQuickGuards(projectRoot)
697
+ // 归一为 forward-slash:baselineFiles 取自 guard.json(经 parsePorcelainPath 已是 /),
698
+ // Windows 下 path.relative 产 backslash 会与 baseline 不匹配,导致实时防护被静默绕过
699
+ const relTarget = path.relative(projectRoot, absPath).split(path.sep).join('/')
700
+ // 如果目标是 baseline protected file,阻止写入(并集 = 保护所有 session 的 baseline)
701
+ if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + '/'))) {
702
+ return {
703
+ blocked: true,
704
+ reason: [
705
+ `⚠️ quick 变更边界保护:${relTarget} 是 baseline 文件,不允许覆盖。`,
706
+ `当前 quick 任务不能修改任务开始前已修改的文件。`,
707
+ `如确需修改,请在 quick 完成后单独处理此文件。`,
708
+ ].join('\n')
709
+ }
710
+ }
711
+ return { blocked: false }
712
+ }
713
+
714
+ // execute 阶段:位置门禁
715
+ if (isInsideRegisteredWorktree(absPath, projectRoot)) return { blocked: false }
716
+
717
+ // noWorktree 模式:无隔离环境,禁止源码写入(降级到更严格)
718
+ if (isNoWorktreeMode(projectRoot)) {
719
+ return {
720
+ blocked: true,
721
+ reason: [
722
+ '当前变更处于无 worktree 隔离模式(changes.no_worktree=1),不允许源码写入。',
723
+ '如需修改源码,请在 worktree 隔离环境中工作。',
724
+ '若误入此模式,检查 sillyspec.db 该变更的 no_worktree 字段;紧急情况可设置 SILLYSPEC_DISABLE_HOOKS=1 绕过限制。',
725
+ ].join('\n')
726
+ }
727
+ }
728
+
729
+ return {
730
+ blocked: true,
731
+ reason: [
732
+ '源码修改只能在 worktree 隔离环境中进行。',
733
+ '',
734
+ '你可能需要:',
735
+ ' 1. 确认 worktree 已创建并登记:sillyspec worktree list',
736
+ ' 2. 如未创建,先创建:sillyspec worktree create <变更名>',
737
+ ' 3. 在 worktree 目录中工作(子代理的 cwd 设为 worktree 路径)',
738
+ '',
739
+ '如果你在 execute 阶段,通常会自动创建 worktree。',
740
+ '检查是否跳过了 "创建 worktree" 步骤。',
741
+ ].join('\n')
742
+ }
743
+ }
744
+
745
+ /**
746
+ * 判断 Bash 命令是否应被拦截
747
+ * @param {string} command - Bash 命令字符串
748
+ * @param {string} cwd - 当前工作目录
749
+ * @returns {{ blocked: boolean, reason?: string }}
750
+ */
751
+ function shouldBlockBash(command, cwd) {
752
+ if (!command || !command.trim()) return { blocked: false }
753
+
754
+ const callerCwd = cwd || process.cwd()
755
+ const projectRoot = findProjectRoot(callerCwd)
756
+
757
+ // cwd 在 worktree 内 → 全部放行
758
+ if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
759
+
760
+ // 阶段门禁(直读 sillyspec.db)
761
+ const stage = readCurrentStage(projectRoot) || '(none)'
762
+
763
+ if (!['execute', 'quick'].includes(stage)) {
764
+ // 非 execute/quick 阶段,只允许只读白名单
765
+ const localConfig = loadLocalConfig(projectRoot)
766
+ const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
767
+ if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
768
+ return {
769
+ blocked: true,
770
+ reason: buildStageHint(stage)
771
+ }
772
+ }
773
+
774
+ // quick 阶段:检查合并后的 baselineFiles(所有活跃 session guard 并集,D-002@v1)
775
+ if (stage === 'quick') {
776
+ // 危险黑名单仍然拦截
777
+ if (matchDangerBlacklist(command)) {
778
+ return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
779
+ }
780
+ // 检查命令是否会覆盖 baseline files(并集 = 保护所有 session 的 baseline)
781
+ const { baselineFiles } = readAllQuickGuards(projectRoot)
782
+ for (const f of baselineFiles) {
783
+ if (command.includes(f) && (command.includes('> ') || command.includes(' tee ') || command.includes('sed ') || command.includes('mv '))) {
784
+ return { blocked: true, reason: `quick 变更边界保护:命令可能覆盖 baseline 文件 ${f}` }
785
+ }
786
+ }
787
+ return { blocked: false }
788
+ }
789
+
790
+ // execute 阶段 + 主工作区
791
+ const localConfig = loadLocalConfig(projectRoot)
792
+ const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
793
+
794
+ // 危险黑名单
795
+ if (matchDangerBlacklist(command)) {
796
+ return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
797
+ }
798
+
799
+ // 只读白名单放行
800
+ if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
801
+
802
+ // 不确定 → 放行
803
+ return { blocked: false }
804
+ }
805
+
806
+ /**
807
+ * 判断工具调用是否应被拦截
808
+ * @param {{
809
+ * tool: 'Write' | 'Edit' | 'MultiEdit' | 'Bash',
810
+ * filePath?: string,
811
+ * filePaths?: string[],
812
+ * command?: string,
813
+ * cwd?: string
814
+ * }} opts
815
+ * @param {{ cwd?: string }} ctx
816
+ * @returns {{ blocked: boolean, reason?: string }}
817
+ */
818
+ export function shouldBlock(opts, ctx = {}) {
819
+ // 逃生开关
820
+ if (process.env.SILLYSPEC_DISABLE_HOOKS === '1') return { blocked: false }
821
+
822
+ const cwd = opts.cwd || ctx.cwd || process.cwd()
823
+
824
+ switch (opts.tool) {
825
+ case 'Write':
826
+ case 'Edit': {
827
+ const fp = opts.filePath
828
+ if (!fp) return { blocked: true, reason: 'no file path' }
829
+ const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
830
+ return shouldBlockWrite(absPath, cwd)
831
+ }
832
+ case 'MultiEdit': {
833
+ const filePaths = opts.filePaths
834
+ if (!filePaths || filePaths.length === 0) return { blocked: true, reason: 'no file paths' }
835
+ for (const fp of filePaths) {
836
+ const absPath = path.isAbsolute(fp) ? fp : path.resolve(cwd, fp)
837
+ const result = shouldBlockWrite(absPath, cwd)
838
+ if (result.blocked) return result
839
+ }
840
+ return { blocked: false }
841
+ }
842
+ case 'Bash': {
843
+ return shouldBlockBash(opts.command || '', cwd)
844
+ }
845
+ default:
846
+ return { blocked: false }
847
+ }
848
+ }