sillyspec 3.15.2 → 3.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-push +13 -0
- package/README.md +1 -1
- package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -0
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -0
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -0
- package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -0
- package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +193 -0
- package/docs/sillyspec/file-lifecycle.md +106 -1167
- package/docs/worktree-isolation.md +57 -2
- package/package.json +5 -1
- package/src/db.js +17 -0
- package/src/hooks/worktree-guard.js +166 -47
- package/src/index.js +44 -3
- package/src/progress.js +79 -0
- package/src/run.js +447 -55
- package/src/stage-contract.js +347 -0
- package/src/stages/archive.js +6 -10
- package/src/stages/brainstorm.js +4 -1
- package/src/stages/doctor.js +43 -2
- package/src/stages/execute.js +32 -5
- package/src/stages/propose.js +4 -1
- package/src/stages/quick.js +3 -3
- package/src/stages/scan.js +18 -18
- package/src/workflow.js +6 -2
- package/src/worktree-apply.js +14 -0
- package/src/worktree.js +201 -11
- package/test/check-syntax.mjs +26 -0
- package/test/run-tests.mjs +20 -0
- package/test/scan-paths.test.mjs +68 -0
- package/test/stage-contract.test.mjs +128 -0
- package/test/stage-definitions.test.mjs +43 -0
- package/test/worktree-guard.test.mjs +78 -0
|
@@ -117,9 +117,11 @@ allowWrite = stageGate && locationGate && fileGate
|
|
|
117
117
|
| **`--no-worktree` 标志** | 跳过隔离创建,但 hook 仍然拦截源码写入 |
|
|
118
118
|
| **`SILLYSPEC_DISABLE_HOOKS=1`** | 紧急禁用所有 hook,全部放行 |
|
|
119
119
|
| **无 gate-status.json** | stageGate=false,默认禁止源码写入 |
|
|
120
|
-
| **worktree 创建失败** |
|
|
120
|
+
| **worktree 创建失败** | 自动降级为 in-place + baseline protection(mode: in-place-fallback) |
|
|
121
|
+
| **已在 linked worktree** | 复用当前目录(mode: native-worktree) |
|
|
122
|
+
| **worktree 目录未 ignore** | 阻断创建,提示修复 |
|
|
121
123
|
|
|
122
|
-
> ⚠️
|
|
124
|
+
> ⚠️ in-place 降级模式仍会记录 baseline(baselineFiles + baselineHash + baselineCommit),hook 拦截继续生效,不会无保护地直接写源码。
|
|
123
125
|
|
|
124
126
|
## 多 Agent 并行使用
|
|
125
127
|
|
|
@@ -146,6 +148,59 @@ sillyspec worktree create feature-ui
|
|
|
146
148
|
| `SILLYSPEC_DISABLE_HOOKS` | 设为 `1` 时禁用所有 hook(紧急逃生) |
|
|
147
149
|
| `SILLYSPEC_WORKTREE_DIR` | 自定义 worktree 存储目录(默认 `.sillyspec/.runtime/worktrees/`) |
|
|
148
150
|
|
|
151
|
+
## 环境隔离检测
|
|
152
|
+
|
|
153
|
+
SillySpec 在创建 worktree 前会自动检测当前环境的隔离状态:
|
|
154
|
+
|
|
155
|
+
### submodule 防护
|
|
156
|
+
|
|
157
|
+
使用 `git rev-parse --git-dir` 和 `--git-common-dir` 判断是否在 linked worktree 中。
|
|
158
|
+
同时用 `--show-superproject-working-tree` 排除 git submodule 的误判:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
if GIT_DIR != GIT_COMMON && 无 superproject:
|
|
162
|
+
→ 已在 linked worktree,复用当前隔离环境
|
|
163
|
+
if 无 superproject 为空:
|
|
164
|
+
→ 在 git submodule 内,阻断创建并提示
|
|
165
|
+
else:
|
|
166
|
+
→ 在主仓库中,正常创建 worktree
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### .gitignore 强制校验
|
|
170
|
+
|
|
171
|
+
worktree 存储目录 `.sillyspec/.runtime/worktrees/` 必须被 `.gitignore` 忽略:
|
|
172
|
+
|
|
173
|
+
- **init / doctor 阶段:** 预检查并提示修复
|
|
174
|
+
- **execute 阶段:** 未 ignore 则直接阻断 worktree 创建,抛出明确错误
|
|
175
|
+
- **不会自动修改 .gitignore:** 避免污染 baseline
|
|
176
|
+
|
|
177
|
+
修复方式:在项目 `.gitignore` 中添加:
|
|
178
|
+
```
|
|
179
|
+
.sillyspec/.runtime/worktrees/
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### isolation 状态
|
|
183
|
+
|
|
184
|
+
worktree create 后,isolation 状态写入 `sillyspec.db` 的 `changes` 表(权威状态源):
|
|
185
|
+
|
|
186
|
+
| 字段 | 说明 |
|
|
187
|
+
|------|------|
|
|
188
|
+
| `isolation_status` | pending / verified / degraded / blocked |
|
|
189
|
+
| `isolation_mode` | worktree / native-worktree / in-place-fallback / null |
|
|
190
|
+
| `isolation_reason` | blocked 或 degraded 时的原因说明 |
|
|
191
|
+
|
|
192
|
+
状态映射:
|
|
193
|
+
|
|
194
|
+
| status | mode | 触发条件 |
|
|
195
|
+
|--------|------|----------|
|
|
196
|
+
| verified | worktree | `git worktree add` 成功 |
|
|
197
|
+
| verified | native-worktree | 已在 linked worktree,复用 |
|
|
198
|
+
| degraded | in-place-fallback | `git worktree add` 失败,降级 in-place |
|
|
199
|
+
| blocked | null | submodule 内 / gitignore 未配置 |
|
|
200
|
+
| null | null | 尚未执行隔离检查 |
|
|
201
|
+
|
|
202
|
+
> ⚠️ isolation 的权威来源是 sillyspec.db。meta.json 中的 mode 仅作为 worktree 目录的运行时元信息。gate-status.json 不存储 isolation。
|
|
203
|
+
|
|
149
204
|
## 常见问题和故障排除
|
|
150
205
|
|
|
151
206
|
### worktree 残留无法清理
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sillyspec",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.16.1",
|
|
4
4
|
"description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
|
|
5
5
|
"icon": "logo.jpg",
|
|
6
6
|
"homepage": "https://sillyspec.ppdmq.top/",
|
|
@@ -23,6 +23,10 @@
|
|
|
23
23
|
"spec-driven"
|
|
24
24
|
],
|
|
25
25
|
"license": "MIT",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node test/run-tests.mjs",
|
|
28
|
+
"lint": "node test/check-syntax.mjs"
|
|
29
|
+
},
|
|
26
30
|
"dependencies": {
|
|
27
31
|
"@inquirer/prompts": "^7.10.1",
|
|
28
32
|
"chalk": "^5.6.2",
|
package/src/db.js
CHANGED
|
@@ -164,5 +164,22 @@ export class DB {
|
|
|
164
164
|
this.db.run('CREATE INDEX IF NOT EXISTS idx_changes_status ON changes(status)');
|
|
165
165
|
this.db.run('CREATE INDEX IF NOT EXISTS idx_stages_change ON stages(change_id)');
|
|
166
166
|
this.db.run('CREATE INDEX IF NOT EXISTS idx_steps_stage ON steps(stage_id)');
|
|
167
|
+
|
|
168
|
+
// Migration: add isolation columns to changes table (idempotent)
|
|
169
|
+
this._migrateAddColumn('changes', 'isolation_status', 'TEXT');
|
|
170
|
+
this._migrateAddColumn('changes', 'isolation_mode', 'TEXT');
|
|
171
|
+
this._migrateAddColumn('changes', 'isolation_reason', 'TEXT');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 幂等地给表添加列(列已存在则跳过)
|
|
176
|
+
* @private
|
|
177
|
+
*/
|
|
178
|
+
_migrateAddColumn(table, column, type) {
|
|
179
|
+
try {
|
|
180
|
+
this.db.run(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
181
|
+
} catch {
|
|
182
|
+
// 列已存在,静默跳过
|
|
183
|
+
}
|
|
167
184
|
}
|
|
168
185
|
}
|
|
@@ -16,8 +16,6 @@ import path from 'path'
|
|
|
16
16
|
|
|
17
17
|
const WORKTREE_STAGES = ['execute'] // 这些阶段必须在 worktree 里
|
|
18
18
|
|
|
19
|
-
const WORKTREE_SEGMENT = '.sillyspec/.runtime/worktrees/'
|
|
20
|
-
|
|
21
19
|
const FILE_WHITELIST_EXTS = ['.md']
|
|
22
20
|
const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json', 'local.yaml', 'local.yml']
|
|
23
21
|
|
|
@@ -84,6 +82,55 @@ function resolveWorktreeDir(cwd) {
|
|
|
84
82
|
return path.join(cwd, '.sillyspec', '.runtime', 'worktrees')
|
|
85
83
|
}
|
|
86
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
|
+
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 isInsideWorktreeStorage(filePath, cwd) {
|
|
130
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
|
|
131
|
+
return isPathInside(absPath, resolveWorktreeDir(cwd || process.cwd()))
|
|
132
|
+
}
|
|
133
|
+
|
|
87
134
|
/**
|
|
88
135
|
* 读取 gate-status.json
|
|
89
136
|
* @param {string} cwd
|
|
@@ -155,8 +202,18 @@ function isNoWorktreeMode(cwd) {
|
|
|
155
202
|
* @param {string} filePath - 绝对路径
|
|
156
203
|
* @returns {boolean}
|
|
157
204
|
*/
|
|
158
|
-
function
|
|
159
|
-
|
|
205
|
+
function isInsideRegisteredWorktree(filePath, cwd) {
|
|
206
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
|
|
207
|
+
const effectiveCwd = cwd || process.cwd()
|
|
208
|
+
const gateStatus = readGateStatus(effectiveCwd)
|
|
209
|
+
const changes = Array.isArray(gateStatus?.changes) ? gateStatus.changes : []
|
|
210
|
+
|
|
211
|
+
for (const changeName of changes) {
|
|
212
|
+
const meta = readWorktreeMeta(effectiveCwd, changeName)
|
|
213
|
+
if (meta?.worktreePath && isPathInside(absPath, meta.worktreePath)) return true
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return false
|
|
160
217
|
}
|
|
161
218
|
|
|
162
219
|
/**
|
|
@@ -194,6 +251,8 @@ function matchFileWhitelist(filePath) {
|
|
|
194
251
|
*/
|
|
195
252
|
function loadLocalConfig(cwd) {
|
|
196
253
|
const candidates = [
|
|
254
|
+
path.join(cwd, '.sillyspec', 'local.yaml'),
|
|
255
|
+
path.join(cwd, '.sillyspec', 'local.yml'),
|
|
197
256
|
path.join(cwd, 'local.yaml'),
|
|
198
257
|
path.join(cwd, 'local.yml'),
|
|
199
258
|
]
|
|
@@ -209,49 +268,74 @@ function loadLocalConfig(cwd) {
|
|
|
209
268
|
return {}
|
|
210
269
|
}
|
|
211
270
|
|
|
212
|
-
/**
|
|
213
|
-
* 简易 YAML 解析,只处理顶层简单结构
|
|
214
|
-
*/
|
|
215
271
|
function parseSimpleYaml(content) {
|
|
216
272
|
const result = {}
|
|
217
|
-
let
|
|
218
|
-
let
|
|
273
|
+
let topKey = null
|
|
274
|
+
let childKey = null
|
|
275
|
+
|
|
276
|
+
function parseValue(value) {
|
|
277
|
+
const trimmed = value.trim()
|
|
278
|
+
if (!trimmed) return ''
|
|
279
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
280
|
+
return trimmed.slice(1, -1)
|
|
281
|
+
}
|
|
282
|
+
if (trimmed === 'true') return true
|
|
283
|
+
if (trimmed === 'false') return false
|
|
284
|
+
return trimmed
|
|
285
|
+
}
|
|
219
286
|
|
|
220
287
|
for (const line of content.split('\n')) {
|
|
221
|
-
const
|
|
288
|
+
const noComment = line.replace(/\s+#.*$/, '')
|
|
289
|
+
const trimmed = noComment.trim()
|
|
222
290
|
if (!trimmed || trimmed.startsWith('#')) continue
|
|
291
|
+
const indent = noComment.length - noComment.trimStart().length
|
|
223
292
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
293
|
+
if (indent === 0) {
|
|
294
|
+
const topLevelMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
|
|
295
|
+
if (!topLevelMatch) continue
|
|
227
296
|
const key = topLevelMatch[1]
|
|
228
|
-
const
|
|
229
|
-
|
|
297
|
+
const value = topLevelMatch[2]
|
|
298
|
+
topKey = key
|
|
299
|
+
childKey = null
|
|
230
300
|
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
result[key] = val
|
|
234
|
-
inArray = false
|
|
301
|
+
if (value.trim()) {
|
|
302
|
+
result[key] = parseValue(value)
|
|
235
303
|
} else {
|
|
236
|
-
|
|
237
|
-
result[key] = []
|
|
238
|
-
inArray = true
|
|
304
|
+
result[key] = {}
|
|
239
305
|
}
|
|
240
306
|
continue
|
|
241
307
|
}
|
|
242
308
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (Array.isArray(result[
|
|
247
|
-
|
|
248
|
-
|
|
309
|
+
if (!topKey) continue
|
|
310
|
+
|
|
311
|
+
if (indent === 2 && trimmed.startsWith('- ')) {
|
|
312
|
+
if (!Array.isArray(result[topKey])) result[topKey] = []
|
|
313
|
+
result[topKey].push(parseValue(trimmed.slice(2)))
|
|
314
|
+
continue
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (indent === 2) {
|
|
318
|
+
const childMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
|
|
319
|
+
if (!childMatch) continue
|
|
320
|
+
childKey = childMatch[1]
|
|
321
|
+
const value = childMatch[2]
|
|
322
|
+
if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
|
|
323
|
+
result[topKey][childKey] = value.trim() ? parseValue(value) : []
|
|
249
324
|
continue
|
|
250
325
|
}
|
|
251
326
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
327
|
+
if (indent >= 4 && childKey && trimmed.startsWith('- ')) {
|
|
328
|
+
if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
|
|
329
|
+
if (!Array.isArray(result[topKey][childKey])) result[topKey][childKey] = []
|
|
330
|
+
result[topKey][childKey].push(parseValue(trimmed.slice(2)))
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
for (const key of Object.keys(result)) {
|
|
335
|
+
if (result[key] && typeof result[key] === 'object' && !Array.isArray(result[key])) {
|
|
336
|
+
if (Object.keys(result[key]).length === 0) {
|
|
337
|
+
result[key] = {}
|
|
338
|
+
}
|
|
255
339
|
}
|
|
256
340
|
}
|
|
257
341
|
|
|
@@ -401,14 +485,15 @@ function buildStageHint(stage) {
|
|
|
401
485
|
export function shouldBlockWrite(filePath, cwd) {
|
|
402
486
|
if (!filePath) return { blocked: true, reason: 'no file path' }
|
|
403
487
|
|
|
404
|
-
const
|
|
488
|
+
const callerCwd = cwd || process.cwd()
|
|
489
|
+
const projectRoot = findProjectRoot(callerCwd)
|
|
490
|
+
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
|
|
405
491
|
|
|
406
|
-
// 1.
|
|
407
|
-
if (matchFileWhitelist(absPath)) return { blocked: false }
|
|
492
|
+
// 1. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
|
|
493
|
+
if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
|
|
408
494
|
|
|
409
495
|
// 2. 阶段门禁(使用 fallback 读取)
|
|
410
|
-
const
|
|
411
|
-
const stage = readCurrentStage(effectiveCwd) || '(none)'
|
|
496
|
+
const stage = readCurrentStage(projectRoot) || '(none)'
|
|
412
497
|
|
|
413
498
|
if (!['execute', 'quick'].includes(stage)) {
|
|
414
499
|
return {
|
|
@@ -417,14 +502,35 @@ export function shouldBlockWrite(filePath, cwd) {
|
|
|
417
502
|
}
|
|
418
503
|
}
|
|
419
504
|
|
|
420
|
-
// quick
|
|
421
|
-
if (stage === 'quick')
|
|
505
|
+
// quick 阶段:检查 quick-guard.json 的 baselineFiles
|
|
506
|
+
if (stage === 'quick') {
|
|
507
|
+
try {
|
|
508
|
+
const guardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
|
|
509
|
+
const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
|
|
510
|
+
const baselineFiles = guard.baselineFiles || []
|
|
511
|
+
const relTarget = path.relative(projectRoot, absPath)
|
|
512
|
+
// 如果目标是 baseline protected file,阻止写入
|
|
513
|
+
if (baselineFiles.some(f => relTarget === f || relTarget.startsWith(f + path.sep))) {
|
|
514
|
+
return {
|
|
515
|
+
blocked: true,
|
|
516
|
+
reason: [
|
|
517
|
+
`⚠️ quick 变更边界保护:${relTarget} 是 baseline 文件,不允许覆盖。`,
|
|
518
|
+
`当前 quick 任务不能修改任务开始前已修改的文件。`,
|
|
519
|
+
`如确需修改,请在 quick 完成后单独处理此文件。`,
|
|
520
|
+
].join('\n')
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
} catch {
|
|
524
|
+
// quick-guard.json 不存在(非 quick 任务或未记录),放行
|
|
525
|
+
}
|
|
526
|
+
return { blocked: false }
|
|
527
|
+
}
|
|
422
528
|
|
|
423
529
|
// execute 阶段:位置门禁
|
|
424
|
-
if (
|
|
530
|
+
if (isInsideRegisteredWorktree(absPath, projectRoot)) return { blocked: false }
|
|
425
531
|
|
|
426
532
|
// noWorktree 模式:无隔离环境,禁止源码写入(降级到更严格)
|
|
427
|
-
if (isNoWorktreeMode(
|
|
533
|
+
if (isNoWorktreeMode(projectRoot)) {
|
|
428
534
|
return {
|
|
429
535
|
blocked: true,
|
|
430
536
|
reason: [
|
|
@@ -441,7 +547,7 @@ export function shouldBlockWrite(filePath, cwd) {
|
|
|
441
547
|
'源码修改只能在 worktree 隔离环境中进行。',
|
|
442
548
|
'',
|
|
443
549
|
'你可能需要:',
|
|
444
|
-
' 1. 确认 worktree
|
|
550
|
+
' 1. 确认 worktree 已创建并登记:sillyspec worktree list',
|
|
445
551
|
' 2. 如未创建,先创建:sillyspec worktree create <变更名>',
|
|
446
552
|
' 3. 在 worktree 目录中工作(子代理的 cwd 设为 worktree 路径)',
|
|
447
553
|
'',
|
|
@@ -460,17 +566,18 @@ export function shouldBlockWrite(filePath, cwd) {
|
|
|
460
566
|
export function shouldBlockBash(command, cwd) {
|
|
461
567
|
if (!command || !command.trim()) return { blocked: false }
|
|
462
568
|
|
|
463
|
-
const
|
|
569
|
+
const callerCwd = cwd || process.cwd()
|
|
570
|
+
const projectRoot = findProjectRoot(callerCwd)
|
|
464
571
|
|
|
465
572
|
// cwd 在 worktree 内 → 全部放行
|
|
466
|
-
if (
|
|
573
|
+
if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
|
|
467
574
|
|
|
468
575
|
// 阶段门禁(使用 fallback 读取)
|
|
469
|
-
const stage = readCurrentStage(
|
|
576
|
+
const stage = readCurrentStage(projectRoot) || '(none)'
|
|
470
577
|
|
|
471
578
|
if (!['execute', 'quick'].includes(stage)) {
|
|
472
579
|
// 非 execute/quick 阶段,只允许只读白名单
|
|
473
|
-
const localConfig = loadLocalConfig(
|
|
580
|
+
const localConfig = loadLocalConfig(projectRoot)
|
|
474
581
|
const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
|
|
475
582
|
if (matchReadonlyWhitelist(command, extraReadonly)) return { blocked: false }
|
|
476
583
|
return {
|
|
@@ -479,17 +586,29 @@ export function shouldBlockBash(command, cwd) {
|
|
|
479
586
|
}
|
|
480
587
|
}
|
|
481
588
|
|
|
482
|
-
// quick
|
|
589
|
+
// quick 阶段:检查 quick-guard.json
|
|
483
590
|
if (stage === 'quick') {
|
|
484
591
|
// 危险黑名单仍然拦截
|
|
485
592
|
if (matchDangerBlacklist(command)) {
|
|
486
593
|
return { blocked: true, reason: `dangerous command blocked: ${command.trim()}` }
|
|
487
594
|
}
|
|
595
|
+
// 检查命令是否会覆盖 baseline files
|
|
596
|
+
try {
|
|
597
|
+
const guardFile = path.join(projectRoot, '.sillyspec', '.runtime', 'quick-guard.json')
|
|
598
|
+
const guard = JSON.parse(readFileSync(guardFile, 'utf8'))
|
|
599
|
+
const baselineFiles = guard.baselineFiles || []
|
|
600
|
+
// 检查命令中是否引用了 baseline file
|
|
601
|
+
for (const f of baselineFiles) {
|
|
602
|
+
if (command.includes(f) && (command.includes('> ') || command.includes(' tee ') || command.includes('sed ') || command.includes('mv '))) {
|
|
603
|
+
return { blocked: true, reason: `quick 变更边界保护:命令可能覆盖 baseline 文件 ${f}` }
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
} catch {}
|
|
488
607
|
return { blocked: false }
|
|
489
608
|
}
|
|
490
609
|
|
|
491
610
|
// execute 阶段 + 主工作区
|
|
492
|
-
const localConfig = loadLocalConfig(
|
|
611
|
+
const localConfig = loadLocalConfig(projectRoot)
|
|
493
612
|
const extraReadonly = localConfig.worktreeHook?.readonlyCommands || localConfig['worktree-hook']?.readonlyCommands || []
|
|
494
613
|
|
|
495
614
|
// 危险黑名单
|
package/src/index.js
CHANGED
|
@@ -32,6 +32,10 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
32
32
|
--status 查看阶段进度
|
|
33
33
|
--reset 重置阶段
|
|
34
34
|
--change <name> 设置当前变更名
|
|
35
|
+
--spec-root <path> 平台模式:SillySpec storage root 路径
|
|
36
|
+
--runtime-root <path> 平台模式:运行时产物根路径
|
|
37
|
+
--workspace-id <id> 平台模式:workspace ID
|
|
38
|
+
--scan-run-id <id> 平台模式:scan run ID
|
|
35
39
|
auto 连续推进 brainstorm→plan→execute→verify
|
|
36
40
|
|
|
37
41
|
可选阶段:
|
|
@@ -270,10 +274,29 @@ async function main() {
|
|
|
270
274
|
}
|
|
271
275
|
case 'worktree': {
|
|
272
276
|
const { WorktreeManager } = await import('./worktree.js');
|
|
277
|
+
const { ProgressManager } = await import('./progress.js');
|
|
273
278
|
const wtSubCmd = filteredArgs[1];
|
|
274
|
-
// 提取第一个非 -- 开头的位置参数作为 wtName
|
|
275
279
|
const wtName = filteredArgs.slice(2).find(a => !a.startsWith('-'));
|
|
276
280
|
const wm = new WorktreeManager({ cwd: dir });
|
|
281
|
+
const pm = new ProgressManager();
|
|
282
|
+
|
|
283
|
+
// isolation 写入 DB 的辅助函数
|
|
284
|
+
async function _writeIsolationToDB(cwd, changeName, info) {
|
|
285
|
+
if (info.blocked) {
|
|
286
|
+
await pm.updateChangeIsolation(cwd, changeName, {
|
|
287
|
+
status: 'blocked',
|
|
288
|
+
mode: null,
|
|
289
|
+
reason: info.reason,
|
|
290
|
+
});
|
|
291
|
+
} else {
|
|
292
|
+
const mode = info.mode || 'worktree';
|
|
293
|
+
const statusMap = { 'worktree': 'verified', 'native-worktree': 'verified', 'in-place-fallback': 'degraded' };
|
|
294
|
+
await pm.updateChangeIsolation(cwd, changeName, {
|
|
295
|
+
status: statusMap[mode] || 'verified',
|
|
296
|
+
mode,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
277
300
|
|
|
278
301
|
if (!wtSubCmd || wtSubCmd === 'help' || wtSubCmd === '--help' || wtSubCmd === '-h') {
|
|
279
302
|
console.log(`
|
|
@@ -306,8 +329,15 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
306
329
|
console.log(` 分支: ${info.branch}`);
|
|
307
330
|
console.log(` 路径: ${info.worktreePath}`);
|
|
308
331
|
console.log(` 基准: ${info.baseHash.slice(0, 8)}`);
|
|
332
|
+
if (info.mode) {
|
|
333
|
+
console.log(` 模式: ${info.mode}`);
|
|
334
|
+
}
|
|
335
|
+
// 写入 isolation 信息到 gate-status.json
|
|
336
|
+
await _writeIsolationToDB(dir, wtName, info);
|
|
309
337
|
} catch (e) {
|
|
310
338
|
console.error(`❌ ${e.message}`);
|
|
339
|
+
// 写入 blocked 状态到 gate-status.json
|
|
340
|
+
await _writeIsolationToDB(dir, wtName, { blocked: true, reason: e.message });
|
|
311
341
|
process.exit(1);
|
|
312
342
|
}
|
|
313
343
|
break;
|
|
@@ -373,9 +403,20 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
373
403
|
console.error('❌ 用法: sillyspec worktree cleanup <change-name>');
|
|
374
404
|
process.exit(1);
|
|
375
405
|
}
|
|
406
|
+
const forceFlag = args.includes('--force');
|
|
376
407
|
try {
|
|
377
|
-
wm.cleanup(wtName);
|
|
378
|
-
|
|
408
|
+
const result = wm.cleanup(wtName, { force: forceFlag });
|
|
409
|
+
if (result.result === 'cleaned') {
|
|
410
|
+
console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
|
|
411
|
+
} else if (result.result === 'force-cleaned') {
|
|
412
|
+
console.log(`⚠️ worktree 已强制清理: ${wtName} (mode: ${result.mode})`);
|
|
413
|
+
console.log(` 原因: git worktree remove 失败,通过直接删除目录完成`);
|
|
414
|
+
} else if (result.result === 'skipped') {
|
|
415
|
+
console.log(`⏭️ worktree 跳过清理: ${wtName} (mode: ${result.mode})`);
|
|
416
|
+
console.log(` 原因: in-place 模式没有隔离目录需要清理`);
|
|
417
|
+
} else {
|
|
418
|
+
console.log(`ℹ️ worktree 未找到: ${wtName}`);
|
|
419
|
+
}
|
|
379
420
|
} catch (e) {
|
|
380
421
|
console.error(`❌ ${e.message}`);
|
|
381
422
|
process.exit(1);
|
package/src/progress.js
CHANGED
|
@@ -393,6 +393,85 @@ export class ProgressManager {
|
|
|
393
393
|
});
|
|
394
394
|
}
|
|
395
395
|
|
|
396
|
+
/**
|
|
397
|
+
* 更新变更的隔离状态
|
|
398
|
+
* @param {string} cwd - 项目根目录
|
|
399
|
+
* @param {string} changeName - 变更名
|
|
400
|
+
* @param {{ status: string, mode?: string, reason?: string }} isolation
|
|
401
|
+
*/
|
|
402
|
+
async updateChangeIsolation(cwd, changeName, isolation) {
|
|
403
|
+
const db = await this._ensureDB(cwd);
|
|
404
|
+
const sqlDb = db.getDb();
|
|
405
|
+
try {
|
|
406
|
+
sqlDb.run(
|
|
407
|
+
`UPDATE changes SET isolation_status = ?, isolation_mode = ?, isolation_reason = ?, last_active = ? WHERE name = ?`,
|
|
408
|
+
[isolation.status, isolation.mode || null, isolation.reason || null, new Date().toISOString(), changeName]
|
|
409
|
+
);
|
|
410
|
+
db._save();
|
|
411
|
+
} catch (err) {
|
|
412
|
+
console.warn('⚠️ 更新 isolation 状态失败:', err.message);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 读取变更的隔离状态
|
|
418
|
+
* @param {string} cwd - 项目根目录
|
|
419
|
+
* @param {string} changeName - 变更名
|
|
420
|
+
* @returns {{ status: string|null, mode: string|null, reason: string|null }|null}
|
|
421
|
+
*/
|
|
422
|
+
async readChangeIsolation(cwd, changeName) {
|
|
423
|
+
const db = await this._ensureDB(cwd);
|
|
424
|
+
const sqlDb = db.getDb();
|
|
425
|
+
try {
|
|
426
|
+
const rows = sqlDb.exec(
|
|
427
|
+
`SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name = ?`,
|
|
428
|
+
[changeName]
|
|
429
|
+
);
|
|
430
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
|
|
431
|
+
const [status, mode, reason] = rows[0].values[0];
|
|
432
|
+
return { status: status || null, mode: mode || null, reason: reason || null };
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async _updatePlatformLastSync(cwd, changeName) {
|
|
439
|
+
if (!changeName) return;
|
|
440
|
+
const db = await this._ensureDB(cwd);
|
|
441
|
+
db.transaction((sqlDb) => {
|
|
442
|
+
sqlDb.run(
|
|
443
|
+
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1 WHERE name = ?',
|
|
444
|
+
[new Date().toISOString(), changeName]
|
|
445
|
+
);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async _updateApprovalStatus(cwd, changeName, status, reason = null) {
|
|
450
|
+
if (!changeName || !status) return;
|
|
451
|
+
const db = await this._ensureDB(cwd);
|
|
452
|
+
db.transaction((sqlDb) => {
|
|
453
|
+
const rows = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
454
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) return;
|
|
455
|
+
const changeId = rows[0].values[0][0];
|
|
456
|
+
const now = new Date().toISOString();
|
|
457
|
+
sqlDb.run(
|
|
458
|
+
`INSERT INTO approvals (change_id, status, requested_at, approved_at, rejection_reason)
|
|
459
|
+
VALUES (?, ?, ?, ?, ?)
|
|
460
|
+
ON CONFLICT(change_id) DO UPDATE SET
|
|
461
|
+
status = excluded.status,
|
|
462
|
+
approved_at = excluded.approved_at,
|
|
463
|
+
rejection_reason = excluded.rejection_reason`,
|
|
464
|
+
[
|
|
465
|
+
changeId,
|
|
466
|
+
status,
|
|
467
|
+
now,
|
|
468
|
+
status === 'approved' ? now : null,
|
|
469
|
+
status === 'rejected' ? reason : null,
|
|
470
|
+
]
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
396
475
|
/**
|
|
397
476
|
* 重命名变更:同步更新 DB + 目录
|
|
398
477
|
* @param {string} cwd - 项目根目录
|