sillyspec 3.16.0 → 3.16.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.16.0",
3
+ "version": "3.16.2",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -24,8 +24,8 @@
24
24
  ],
25
25
  "license": "MIT",
26
26
  "scripts": {
27
- "test": "node test/*.test.mjs",
28
- "lint": "node --check src/index.js && node --check src/run.js && node --check src/progress.js && node --check src/db.js && node --check src/worktree.js && node --check src/workflow.js && node --check src/worktree-apply.js"
27
+ "test": "node test/run-tests.mjs",
28
+ "lint": "node test/check-syntax.mjs"
29
29
  },
30
30
  "dependencies": {
31
31
  "@inquirer/prompts": "^7.10.1",
@@ -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/progress.js CHANGED
@@ -435,6 +435,43 @@ export class ProgressManager {
435
435
  }
436
436
  }
437
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
+
438
475
  /**
439
476
  * 重命名变更:同步更新 DB + 目录
440
477
  * @param {string} cwd - 项目根目录