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.
@@ -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.15.2",
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 isInsideWorktree(filePath) {
159
- return filePath.includes(WORKTREE_SEGMENT)
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 currentKey = null
218
- let inArray = false
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 trimmed = line.trim()
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
- const topLevelMatch = trimmed.match(/^(\S[\S]*)\s*:\s*(.*)$/)
226
- if (topLevelMatch && !trimmed.startsWith(' ')) {
293
+ if (indent === 0) {
294
+ const topLevelMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
295
+ if (!topLevelMatch) continue
227
296
  const key = topLevelMatch[1]
228
- const val = topLevelMatch[2].trim()
229
- currentKey = key
297
+ const value = topLevelMatch[2]
298
+ topKey = key
299
+ childKey = null
230
300
 
231
- if (val) {
232
- // key: value (单行)
233
- result[key] = val
234
- inArray = false
301
+ if (value.trim()) {
302
+ result[key] = parseValue(value)
235
303
  } else {
236
- // key: (多行值开始)
237
- result[key] = []
238
- inArray = true
304
+ result[key] = {}
239
305
  }
240
306
  continue
241
307
  }
242
308
 
243
- // 数组项
244
- if (inArray && currentKey && trimmed.startsWith('- ')) {
245
- const item = trimmed.slice(2).trim()
246
- if (Array.isArray(result[currentKey])) {
247
- result[currentKey].push(item)
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
- if (inArray && !trimmed.startsWith('- ') && !trimmed.startsWith('#')) {
254
- inArray = false
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 absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
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 effectiveCwd = cwd || process.cwd()
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 阶段直接放行(不要求 worktree)
421
- if (stage === 'quick') return { blocked: false }
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 (isInsideWorktree(absPath)) return { blocked: false }
530
+ if (isInsideRegisteredWorktree(absPath, projectRoot)) return { blocked: false }
425
531
 
426
532
  // noWorktree 模式:无隔离环境,禁止源码写入(降级到更严格)
427
- if (isNoWorktreeMode(effectiveCwd)) {
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 已创建:sillyspec worktree list',
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 effectiveCwd = cwd || process.cwd()
569
+ const callerCwd = cwd || process.cwd()
570
+ const projectRoot = findProjectRoot(callerCwd)
464
571
 
465
572
  // cwd 在 worktree 内 → 全部放行
466
- if (isInsideWorktree(effectiveCwd)) return { blocked: false }
573
+ if (isInsideRegisteredWorktree(callerCwd, projectRoot)) return { blocked: false }
467
574
 
468
575
  // 阶段门禁(使用 fallback 读取)
469
- const stage = readCurrentStage(effectiveCwd) || '(none)'
576
+ const stage = readCurrentStage(projectRoot) || '(none)'
470
577
 
471
578
  if (!['execute', 'quick'].includes(stage)) {
472
579
  // 非 execute/quick 阶段,只允许只读白名单
473
- const localConfig = loadLocalConfig(effectiveCwd)
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 阶段直接放行(不要求 worktree)
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(effectiveCwd)
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
- console.log(`✅ worktree 已清理: ${wtName}`);
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 - 项目根目录