sillyspec 3.16.0 → 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/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 -1297
- package/package.json +3 -3
- package/src/hooks/worktree-guard.js +166 -47
- package/src/progress.js +37 -0
- package/src/run.js +279 -41
- 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 +1 -2
- package/src/stages/propose.js +4 -1
- package/src/stages/scan.js +3 -3
- package/test/check-syntax.mjs +26 -0
- package/test/run-tests.mjs +20 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sillyspec",
|
|
3
|
-
"version": "3.16.
|
|
3
|
+
"version": "3.16.1",
|
|
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
|
|
28
|
-
"lint": "node
|
|
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
|
|
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/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 - 项目根目录
|