sillyspec 3.16.2 → 3.17.0
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 +1 -1
- package/src/change-list.js +1 -1
- package/src/index.js +12 -5
- package/src/init.js +47 -36
- package/src/progress.js +21 -4
- package/src/run.js +139 -74
- package/src/scan-postcheck.js +179 -0
- package/src/stage-contract.js +7 -1
- package/src/stages/execute.js +11 -5
- package/src/stages/scan.js +17 -1
- package/src/worktree-apply.js +5 -3
- package/src/worktree.js +5 -3
- package/test/scan-postcheck.test.mjs +179 -0
- package/test/spec-dir.test.mjs +200 -0
package/src/run.js
CHANGED
|
@@ -4,9 +4,21 @@
|
|
|
4
4
|
* CLI 成为流程引擎,AI 变成步骤执行器。
|
|
5
5
|
* 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
|
|
6
6
|
*/
|
|
7
|
-
import { basename, join } from 'path'
|
|
7
|
+
import { basename, join, resolve } from 'path'
|
|
8
8
|
import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, statSync } from 'fs'
|
|
9
9
|
import { ProgressManager } from './progress.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 解析规范目录路径
|
|
13
|
+
* @param {string} cwd - 项目根目录
|
|
14
|
+
* @param {object} [opts]
|
|
15
|
+
* @param {string} [opts.specDir] - 用户指定的 specDir(通过 --spec-dir 或 --spec-root)
|
|
16
|
+
* @returns {string} 规范目录的绝对路径
|
|
17
|
+
*/
|
|
18
|
+
function resolveSpecDir(cwd, opts = {}) {
|
|
19
|
+
if (opts.specDir) return resolve(opts.specDir)
|
|
20
|
+
return join(cwd, '.sillyspec')
|
|
21
|
+
}
|
|
10
22
|
import { stageRegistry, auxiliaryStages } from './stages/index.js'
|
|
11
23
|
import { checkTransition, runValidators } from './stage-contract.js'
|
|
12
24
|
import { buildExecuteSteps } from './stages/execute.js'
|
|
@@ -155,8 +167,8 @@ async function checkApproval(cwd, changeName) {
|
|
|
155
167
|
/**
|
|
156
168
|
* 统一查找变更目录(与 progress.js 的变更检测逻辑一致)
|
|
157
169
|
*/
|
|
158
|
-
function resolveChangeDir(cwd, progress) {
|
|
159
|
-
const changesDir = join(cwd, '
|
|
170
|
+
function resolveChangeDir(cwd, progress, specDir = null) {
|
|
171
|
+
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
160
172
|
if (!existsSync(changesDir)) return null
|
|
161
173
|
|
|
162
174
|
// 1. 优先用 currentChange
|
|
@@ -177,9 +189,9 @@ function resolveChangeDir(cwd, progress) {
|
|
|
177
189
|
* 自动探测并设置 currentChange(唯一变更目录时)
|
|
178
190
|
* @returns {boolean} 是否设置了 currentChange
|
|
179
191
|
*/
|
|
180
|
-
function autoDetectChange(progress, cwd) {
|
|
192
|
+
function autoDetectChange(progress, cwd, specDir = null) {
|
|
181
193
|
if (progress.currentChange) return false
|
|
182
|
-
const changesDir = join(cwd, '
|
|
194
|
+
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
183
195
|
if (!existsSync(changesDir)) return false
|
|
184
196
|
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
185
197
|
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
@@ -193,9 +205,9 @@ function autoDetectChange(progress, cwd) {
|
|
|
193
205
|
/**
|
|
194
206
|
* 从 progress 或变更目录推导变更名
|
|
195
207
|
*/
|
|
196
|
-
function resolveChangeName(cwd, progress) {
|
|
208
|
+
function resolveChangeName(cwd, progress, specDir = null) {
|
|
197
209
|
if (progress.currentChange) return progress.currentChange
|
|
198
|
-
const changesDir = join(cwd, '
|
|
210
|
+
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
199
211
|
if (!existsSync(changesDir)) return null
|
|
200
212
|
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
201
213
|
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
@@ -206,9 +218,9 @@ function resolveChangeName(cwd, progress) {
|
|
|
206
218
|
/**
|
|
207
219
|
* 获取阶段的步骤定义(execute 需要动态构建)
|
|
208
220
|
*/
|
|
209
|
-
async function getStageSteps(stageName, cwd, progress) {
|
|
221
|
+
async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
210
222
|
if (stageName === 'execute') {
|
|
211
|
-
const changeDir = resolveChangeDir(cwd, progress)
|
|
223
|
+
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
212
224
|
let planFile = null
|
|
213
225
|
if (changeDir) {
|
|
214
226
|
const p = join(changeDir, 'plan.md')
|
|
@@ -217,7 +229,7 @@ async function getStageSteps(stageName, cwd, progress) {
|
|
|
217
229
|
return buildExecuteSteps(planFile)
|
|
218
230
|
}
|
|
219
231
|
if (stageName === 'plan') {
|
|
220
|
-
const changeDir = resolveChangeDir(cwd, progress)
|
|
232
|
+
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
221
233
|
return buildPlanSteps(changeDir)
|
|
222
234
|
}
|
|
223
235
|
const def = stageRegistry[stageName]
|
|
@@ -227,10 +239,10 @@ async function getStageSteps(stageName, cwd, progress) {
|
|
|
227
239
|
/**
|
|
228
240
|
* 确保阶段的 steps 已初始化到 progress
|
|
229
241
|
*/
|
|
230
|
-
async function ensureStageSteps(progress, stageName, cwd) {
|
|
242
|
+
async function ensureStageSteps(progress, stageName, cwd, specDir = null) {
|
|
231
243
|
if (!progress.stages) progress.stages = {}
|
|
232
244
|
|
|
233
|
-
const steps = await getStageSteps(stageName, cwd, progress)
|
|
245
|
+
const steps = await getStageSteps(stageName, cwd, progress, specDir)
|
|
234
246
|
if (!steps) return false
|
|
235
247
|
|
|
236
248
|
if (!progress.stages[stageName] || !progress.stages[stageName].steps || progress.stages[stageName].steps.length === 0) {
|
|
@@ -331,40 +343,59 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
331
343
|
if (changeName && promptText.includes('<change-name>')) {
|
|
332
344
|
promptText = promptText.replace(/<change-name>/g, changeName)
|
|
333
345
|
}
|
|
334
|
-
//
|
|
335
|
-
if (
|
|
346
|
+
// 平台模式:注入路径覆盖指令
|
|
347
|
+
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
|
|
336
348
|
const projectName = dbProjectName || basename(cwd)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
: join(cwd, '.sillyspec')
|
|
349
|
+
// platformOpts.specRoot 现在指向 specDir 本身(可能是 cwd/.sillyspec 或外部路径)
|
|
350
|
+
const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
340
351
|
const docsRoot = join(specSillyspec, 'docs', projectName)
|
|
341
352
|
const projectsRoot = join(specSillyspec, 'projects')
|
|
353
|
+
const changesRoot = join(specSillyspec, 'changes')
|
|
342
354
|
|
|
343
355
|
promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
|
|
344
356
|
promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
|
|
345
357
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
358
|
+
const platformDirectives = []
|
|
359
|
+
platformDirectives.push(
|
|
360
|
+
`## ⚠️ 平台模式 — 写入路径约束(必须严格遵守)\n` +
|
|
361
|
+
`\n` +
|
|
362
|
+
`规范目录(specDir): \`${specSillyspec}\`\n` +
|
|
363
|
+
`- 文档根目录: \`${docsRoot}/\`\n` +
|
|
364
|
+
`- 项目注册表: \`${projectsRoot}/\`\n` +
|
|
365
|
+
`- 变更目录: \`${changesRoot}/\`\n` +
|
|
366
|
+
`\n` +
|
|
367
|
+
`### ⛔ 写入规则\n` +
|
|
368
|
+
`1. **所有文档、配置、产物只能写入上述路径**。严禁写入源码目录或相对路径 \`.sillyspec/\`。\n` +
|
|
369
|
+
`2. **不允许**从 cwd 推导文档路径,必须使用上面列出的绝对路径。\n` +
|
|
370
|
+
`3. **源码扫描范围**必须排除:.sillyspec/、.claude/、.git/、node_modules/、dist/、build/、__pycache__/\n` +
|
|
371
|
+
`4. **local.yaml 校验**:commands 中引用的命令必须在 package.json 的 scripts 中存在,不存在的标记为 unavailable,不能写 "配置良好"\n` +
|
|
372
|
+
`\n` +
|
|
373
|
+
`### ⛔ Write 工具规则\n` +
|
|
374
|
+
`1. 如果 Write 返回 \"File has not been read yet\",正确动作是:先 Read 目标文件 → 再 Write 覆盖。\n` +
|
|
375
|
+
`2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
|
|
376
|
+
`3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
|
|
377
|
+
`\n` +
|
|
378
|
+
`创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
|
|
379
|
+
)
|
|
380
|
+
if (platformOpts.runtimeRoot) {
|
|
381
|
+
const scanRunId = platformOpts.scanRunId || 'scan-' + new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
|
382
|
+
platformDirectives.push(
|
|
383
|
+
`运行时产物写入: \`${platformOpts.runtimeRoot}/scan-runs/${scanRunId}/\`\n`
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
if (platformOpts.workspaceId) {
|
|
387
|
+
platformDirectives.push(`workspace_id: ${platformOpts.workspaceId}`)
|
|
388
|
+
}
|
|
389
|
+
promptText = platformDirectives.join('\n') + '\n\n' + promptText
|
|
390
|
+
} else {
|
|
391
|
+
// 非 platform 模式也要替换占位符
|
|
392
|
+
if (stageName === 'scan') {
|
|
393
|
+
const projectName = dbProjectName || basename(cwd)
|
|
394
|
+
const specSillyspec = join(cwd, '.sillyspec')
|
|
395
|
+
const docsRoot = join(specSillyspec, 'docs', projectName)
|
|
396
|
+
const projectsRoot = join(specSillyspec, 'projects')
|
|
397
|
+
promptText = promptText.replace(/\{DOCS_ROOT\}/g, docsRoot)
|
|
398
|
+
promptText = promptText.replace(/\{PROJECTS_ROOT\}/g, projectsRoot)
|
|
368
399
|
}
|
|
369
400
|
}
|
|
370
401
|
|
|
@@ -378,6 +409,13 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
378
409
|
console.log('- 不要用 mv/rename 重命名变更目录,必须用 `sillyspec change-rename <旧名> <新名>`')
|
|
379
410
|
console.log('- 文档类型文件(.md/.yaml/.json 等)头部必须包含 author(git 用户名)和 created_at(精确到秒)')
|
|
380
411
|
console.log('- 执行构建/测试前必须先读 local.yaml,优先使用其中配置的命令、路径和环境变量;未配置时才使用默认值')
|
|
412
|
+
// 平台模式额外铁律
|
|
413
|
+
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) {
|
|
414
|
+
const specSillyspec = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
415
|
+
console.log(`- **平台模式:所有文件只能写入 \`${specSillyspec}/\` 下的对应子目录,严禁写入源码目录。**`)
|
|
416
|
+
console.log('- **平台模式:Write 工具失败时,不允许用 cat > / tee / heredoc 等方式绕过。先 Read 再 Write,仍失败则记录并停止。**')
|
|
417
|
+
console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
|
|
418
|
+
}
|
|
381
419
|
// 路径安全规则:防止 AI 拼错变更目录
|
|
382
420
|
if (changeName) {
|
|
383
421
|
const changeDir = join('.sillyspec', 'changes', changeName)
|
|
@@ -391,7 +429,7 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
|
|
|
391
429
|
/**
|
|
392
430
|
* sillyspec run <stage> 主命令
|
|
393
431
|
*/
|
|
394
|
-
export async function runCommand(args, cwd) {
|
|
432
|
+
export async function runCommand(args, cwd, specDir = null) {
|
|
395
433
|
// 解析参数
|
|
396
434
|
const stageName = args[0]
|
|
397
435
|
const flags = args.slice(1)
|
|
@@ -416,20 +454,24 @@ export async function runCommand(args, cwd) {
|
|
|
416
454
|
const isSkipApproval = flags.includes('--skip-approval')
|
|
417
455
|
|
|
418
456
|
// 平台模式参数(供 SillyHub 等平台调用)
|
|
457
|
+
// --spec-dir 是统一参数名,--spec-root 保留为向后兼容别名
|
|
419
458
|
const getFlagValue = (name) => {
|
|
420
459
|
const idx = flags.indexOf(name)
|
|
421
460
|
return idx !== -1 && flags[idx + 1] ? flags[idx + 1] : null
|
|
422
461
|
}
|
|
462
|
+
const resolvedSpecDir = specDir || getFlagValue('--spec-dir') || getFlagValue('--spec-root');
|
|
423
463
|
const platformOpts = {
|
|
424
|
-
specRoot:
|
|
425
|
-
runtimeRoot: getFlagValue('--runtime-root'),
|
|
464
|
+
specRoot: resolvedSpecDir ? resolve(resolvedSpecDir) : null,
|
|
465
|
+
runtimeRoot: getFlagValue('--runtime-root') ? resolve(getFlagValue('--runtime-root')) : null,
|
|
426
466
|
workspaceId: getFlagValue('--workspace-id'),
|
|
427
467
|
scanRunId: getFlagValue('--scan-run-id'),
|
|
428
468
|
}
|
|
429
469
|
|
|
430
470
|
// 跨 --done 生命周期:从 metadata 文件恢复 platformOpts
|
|
431
471
|
// 首次 scan 时写入,所有后续调用(包括 run、--done、--skip)都读取
|
|
432
|
-
|
|
472
|
+
// 优先在 specDir 下查找,否则回退到 cwd/.sillyspec/.runtime/
|
|
473
|
+
const specRoot = platformOpts.specRoot || resolveSpecDir(cwd)
|
|
474
|
+
const platformOptsFile = join(specRoot, '.runtime', 'platform-scan.json')
|
|
433
475
|
let platformFileExists = existsSync(platformOptsFile)
|
|
434
476
|
// 如果命令行没传 spec-root,尝试从持久化文件恢复
|
|
435
477
|
if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
|
|
@@ -461,7 +503,7 @@ export async function runCommand(args, cwd) {
|
|
|
461
503
|
if (platformOpts.specRoot || platformOpts.runtimeRoot) {
|
|
462
504
|
try {
|
|
463
505
|
const { mkdirSync, writeFileSync } = await import('fs')
|
|
464
|
-
mkdirSync(join(
|
|
506
|
+
mkdirSync(join(specRoot, '.runtime'), { recursive: true })
|
|
465
507
|
writeFileSync(platformOptsFile, JSON.stringify({
|
|
466
508
|
specRoot: platformOpts.specRoot,
|
|
467
509
|
runtimeRoot: platformOpts.runtimeRoot,
|
|
@@ -509,7 +551,7 @@ export async function runCommand(args, cwd) {
|
|
|
509
551
|
const knownFlags = new Set([
|
|
510
552
|
'--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
|
|
511
553
|
'--output', '--input', '--change',
|
|
512
|
-
'--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
554
|
+
'--spec-dir', '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
|
|
513
555
|
'--files', '--allow-new', '--force-baseline',
|
|
514
556
|
'--json', '--dir', '--help',
|
|
515
557
|
])
|
|
@@ -528,17 +570,17 @@ export async function runCommand(args, cwd) {
|
|
|
528
570
|
|
|
529
571
|
const isAuxiliary = auxiliaryStages.includes(stageName)
|
|
530
572
|
|
|
531
|
-
const pm = new ProgressManager()
|
|
573
|
+
const pm = new ProgressManager({ specDir: specRoot })
|
|
532
574
|
let progress = await pm.read(cwd, changeName)
|
|
533
575
|
|
|
534
576
|
if (!progress) {
|
|
535
577
|
// 如果指定了变更名或有变更目录,自动初始化变更的 progress
|
|
536
|
-
const autoChange = changeName || resolveChangeNameAuto(cwd)
|
|
578
|
+
const autoChange = changeName || resolveChangeNameAuto(cwd, specRoot)
|
|
537
579
|
if (autoChange) {
|
|
538
580
|
progress = await pm.initChange(cwd, autoChange)
|
|
539
581
|
} else if (isAuxiliary) {
|
|
540
582
|
// 辅助阶段(scan/explore/quick/doctor/status)自动使用默认变更名
|
|
541
|
-
const autoName = changeName || resolveChangeNameAuto(cwd) || 'default'
|
|
583
|
+
const autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
|
|
542
584
|
changeName = autoName
|
|
543
585
|
progress = await pm.initChange(cwd, autoName)
|
|
544
586
|
// initChange 可能因 project 表为空返回 null
|
|
@@ -563,7 +605,7 @@ export async function runCommand(args, cwd) {
|
|
|
563
605
|
}
|
|
564
606
|
|
|
565
607
|
// 确保 progress 有 currentChange
|
|
566
|
-
const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress)
|
|
608
|
+
const effectiveChange = changeName || progress.currentChange || resolveChangeName(cwd, progress, specRoot)
|
|
567
609
|
|
|
568
610
|
// -- auto 模式:自动推进所有流程阶段
|
|
569
611
|
if (stageName === 'auto') {
|
|
@@ -582,7 +624,7 @@ export async function runCommand(args, cwd) {
|
|
|
582
624
|
}
|
|
583
625
|
|
|
584
626
|
// 确保步骤已初始化
|
|
585
|
-
const changed = await ensureStageSteps(progress, stageName, cwd)
|
|
627
|
+
const changed = await ensureStageSteps(progress, stageName, cwd, specRoot)
|
|
586
628
|
if (changed && effectiveChange) {
|
|
587
629
|
await pm._write(cwd, progress, effectiveChange)
|
|
588
630
|
triggerSync(cwd, effectiveChange)
|
|
@@ -605,14 +647,14 @@ export async function runCommand(args, cwd) {
|
|
|
605
647
|
}
|
|
606
648
|
|
|
607
649
|
// 默认:输出当前步骤
|
|
608
|
-
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts)
|
|
650
|
+
return await runStage(pm, progress, stageName, cwd, effectiveChange, isSkipApproval, platformOpts, { quickFiles, isAllowNew, isForceBaseline })
|
|
609
651
|
}
|
|
610
652
|
|
|
611
653
|
/**
|
|
612
654
|
* 自动推导变更名(不依赖 progress)
|
|
613
655
|
*/
|
|
614
|
-
function resolveChangeNameAuto(cwd) {
|
|
615
|
-
const changesDir = join(cwd, '
|
|
656
|
+
function resolveChangeNameAuto(cwd, specDir = null) {
|
|
657
|
+
const changesDir = join(specDir || resolveSpecDir(cwd), 'changes')
|
|
616
658
|
if (!existsSync(changesDir)) return null
|
|
617
659
|
const entries = readdirSync(changesDir, { withFileTypes: true })
|
|
618
660
|
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
@@ -620,7 +662,7 @@ function resolveChangeNameAuto(cwd) {
|
|
|
620
662
|
return null
|
|
621
663
|
}
|
|
622
664
|
|
|
623
|
-
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}) {
|
|
665
|
+
async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
624
666
|
// 状态转换校验
|
|
625
667
|
const prevStage = progress.currentStage || ''
|
|
626
668
|
const transition = checkTransition(prevStage, stageName)
|
|
@@ -696,9 +738,9 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
|
|
|
696
738
|
.trim().split('\n').filter(Boolean)
|
|
697
739
|
.map(line => line.slice(3).trim())
|
|
698
740
|
.filter(f => !f.startsWith('.sillyspec/'))
|
|
699
|
-
const allowedFiles = quickFiles || []
|
|
700
|
-
const allowNew = isAllowNew || false
|
|
701
|
-
const forceBaseline = isForceBaseline || false
|
|
741
|
+
const allowedFiles = quickOpts?.quickFiles || []
|
|
742
|
+
const allowNew = quickOpts?.isAllowNew || false
|
|
743
|
+
const forceBaseline = quickOpts?.isForceBaseline || false
|
|
702
744
|
progress.quickGuard = {
|
|
703
745
|
baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
|
|
704
746
|
baselineFiles,
|
|
@@ -1034,8 +1076,8 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1034
1076
|
appendFileSync(inputsPath, entry)
|
|
1035
1077
|
}
|
|
1036
1078
|
|
|
1037
|
-
// 平台模式:scan 完成后生成 manifest.json
|
|
1038
|
-
if (stageName === 'scan' && platformOpts.specRoot) {
|
|
1079
|
+
// 平台模式:scan 完成后生成 manifest.json + post-check
|
|
1080
|
+
if (stageName === 'scan' && (platformOpts.specRoot || platformOpts.runtimeRoot)) {
|
|
1039
1081
|
try {
|
|
1040
1082
|
const { mkdirSync, writeFileSync } = await import('fs')
|
|
1041
1083
|
const { join } = await import('path')
|
|
@@ -1061,29 +1103,52 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
1061
1103
|
}
|
|
1062
1104
|
// 清理平台参数临时文件
|
|
1063
1105
|
const { unlinkSync } = await import('fs')
|
|
1064
|
-
const platformOptsFile = join(
|
|
1106
|
+
const platformOptsFile = join(specRoot, '.runtime', 'platform-scan.json')
|
|
1065
1107
|
try { unlinkSync(platformOptsFile) } catch {}
|
|
1066
1108
|
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1109
|
+
// CLI 层 post-check(替代旧的简单检查)
|
|
1110
|
+
const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
|
|
1111
|
+
const postResult = runScanPostCheck({
|
|
1112
|
+
cwd,
|
|
1113
|
+
specDir: platformOpts.specRoot,
|
|
1114
|
+
outputText,
|
|
1115
|
+
})
|
|
1116
|
+
printScanPostCheckResult(postResult)
|
|
1117
|
+
|
|
1118
|
+
// 将 post-check 结果写入 manifest
|
|
1119
|
+
manifest.scan_post_check = {
|
|
1120
|
+
status: postResult.status,
|
|
1121
|
+
checks: postResult.checks,
|
|
1122
|
+
}
|
|
1123
|
+
// 更新 manifest
|
|
1124
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n')
|
|
1125
|
+
console.log(`📄 manifest.json 已更新(含 post-check 结果)`)
|
|
1126
|
+
|
|
1127
|
+
// failed_post_check 时强制阻止 clean success
|
|
1128
|
+
if (postResult.status === 'failed_post_check') {
|
|
1129
|
+
stageData.status = 'failed_post_check'
|
|
1130
|
+
stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
1131
|
+
await pm._write(cwd, progress, changeName)
|
|
1132
|
+
console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
|
|
1133
|
+
console.error(` 请检查上方错误信息并修复后重新 scan。`)
|
|
1134
|
+
} else if (postResult.status === 'completed_with_warnings') {
|
|
1135
|
+
// 警告不阻止完成,但记录
|
|
1136
|
+
stageData.status = 'completed'
|
|
1137
|
+
stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
1138
|
+
await pm._write(cwd, progress, changeName)
|
|
1081
1139
|
}
|
|
1082
1140
|
} catch (e) {
|
|
1083
1141
|
console.warn(`⚠️ manifest.json 写入失败: ${e.message}`)
|
|
1084
1142
|
}
|
|
1085
1143
|
}
|
|
1086
1144
|
|
|
1145
|
+
// 非 platform 模式 scan 也做轻量 post-check
|
|
1146
|
+
if (stageName === 'scan' && !platformOpts.specRoot && !platformOpts.runtimeRoot) {
|
|
1147
|
+
const { runScanPostCheck, printScanPostCheckResult } = await import('./scan-postcheck.js')
|
|
1148
|
+
const postResult = runScanPostCheck({ cwd, specDir: null, outputText })
|
|
1149
|
+
printScanPostCheckResult(postResult)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1087
1152
|
validateMetadata(cwd, stageName)
|
|
1088
1153
|
|
|
1089
1154
|
// 验证关键文件是否在正确的变更目录下
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scan-postcheck.js — CLI 层 scan 完成后强制校验
|
|
3
|
+
*
|
|
4
|
+
* 不依赖 AI agent 的自检报告,由 CLI 代码直接检查文件系统。
|
|
5
|
+
* 平台模式下必须通过所有 check 才能 success,否则降级。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
9
|
+
import { join, basename } from 'path'
|
|
10
|
+
|
|
11
|
+
const REQUIRED_SCAN_DOCS = [
|
|
12
|
+
'ARCHITECTURE.md',
|
|
13
|
+
'CONVENTIONS.md',
|
|
14
|
+
'STRUCTURE.md',
|
|
15
|
+
'INTEGRATIONS.md',
|
|
16
|
+
'TESTING.md',
|
|
17
|
+
'CONCERNS.md',
|
|
18
|
+
'PROJECT.md',
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} opts
|
|
23
|
+
* @param {string} opts.cwd - 源码项目根目录 (source_root)
|
|
24
|
+
* @param {string} opts.specDir - 规范目录 (spec-root),null 时为非平台模式
|
|
25
|
+
* @param {string} [opts.outputText] - 最后一步(自检)的 AI 输出文本
|
|
26
|
+
* @returns {{ status: 'success'|'completed_with_warnings'|'failed_post_check', checks: Array<{name, severity, detail}> }}
|
|
27
|
+
*/
|
|
28
|
+
export function runScanPostCheck({ cwd, specDir, outputText = '' }) {
|
|
29
|
+
const isPlatform = !!specDir
|
|
30
|
+
const checks = []
|
|
31
|
+
|
|
32
|
+
if (!isPlatform) {
|
|
33
|
+
// 非平台模式:只做轻量检查
|
|
34
|
+
const localSpec = join(cwd, '.sillyspec')
|
|
35
|
+
const scanDir = join(localSpec, 'docs', basename(cwd), 'scan')
|
|
36
|
+
|
|
37
|
+
// 检查 7 份文档是否存在
|
|
38
|
+
const missing = REQUIRED_SCAN_DOCS.filter(f => !existsSync(join(scanDir, f)))
|
|
39
|
+
if (missing.length > 0) {
|
|
40
|
+
checks.push({ name: 'missing_docs', severity: 'warning', detail: `缺少 ${missing.length} 份 scan 文档: ${missing.join(', ')}` })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const hasWarning = checks.some(c => c.severity === 'warning')
|
|
44
|
+
return { status: hasWarning ? 'completed_with_warnings' : 'success', checks }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── 平台模式:严格检查 ──
|
|
48
|
+
|
|
49
|
+
const projectName = basename(cwd)
|
|
50
|
+
|
|
51
|
+
// 1. source_root 污染检查
|
|
52
|
+
const localDocsDir = join(cwd, '.sillyspec', 'docs')
|
|
53
|
+
if (existsSync(localDocsDir)) {
|
|
54
|
+
try {
|
|
55
|
+
const leaked = readdirSync(localDocsDir, { recursive: true }).filter(e => String(e).endsWith('.md'))
|
|
56
|
+
if (leaked.length > 0) {
|
|
57
|
+
checks.push({
|
|
58
|
+
name: 'source_root_docs_leak',
|
|
59
|
+
severity: 'failed',
|
|
60
|
+
detail: `source_root 下存在 ${leaked.length} 个文档文件(${localDocsDir}/),agent 可能写入到了错误路径`
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. spec_root 检查 7 份必需文档
|
|
67
|
+
const specScanDir = join(specDir, 'docs', projectName, 'scan')
|
|
68
|
+
const missingDocs = REQUIRED_SCAN_DOCS.filter(f => !existsSync(join(specScanDir, f)))
|
|
69
|
+
if (missingDocs.length > 0) {
|
|
70
|
+
checks.push({
|
|
71
|
+
name: missingDocs.length === REQUIRED_SCAN_DOCS.length ? 'all_docs_missing' : 'partial_docs_missing',
|
|
72
|
+
severity: 'failed',
|
|
73
|
+
detail: missingDocs.length === REQUIRED_SCAN_DOCS.length
|
|
74
|
+
? `spec_root 下无任何 scan 文档(${specScanDir}/),扫描可能未执行`
|
|
75
|
+
: `spec_root 缺少必需文档: ${missingDocs.join(', ')}(7 份 scan 文档均为 required)`
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3. 检查文档 header(author / created_at)
|
|
80
|
+
const existingDocs = REQUIRED_SCAN_DOCS.filter(f => existsSync(join(specScanDir, f)))
|
|
81
|
+
const docsMissingHeader = []
|
|
82
|
+
for (const doc of existingDocs) {
|
|
83
|
+
const content = readFileSync(join(specScanDir, doc), 'utf8')
|
|
84
|
+
if (!content.includes('author') || !content.includes('created_at')) {
|
|
85
|
+
docsMissingHeader.push(doc)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (docsMissingHeader.length > 0) {
|
|
89
|
+
checks.push({
|
|
90
|
+
name: 'docs_missing_header',
|
|
91
|
+
severity: 'warning',
|
|
92
|
+
detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}`
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 4. local.yaml 校验
|
|
97
|
+
const localYamlPath = join(specDir, 'local.yaml')
|
|
98
|
+
if (existsSync(localYamlPath)) {
|
|
99
|
+
const yamlContent = readFileSync(localYamlPath, 'utf8')
|
|
100
|
+
const packageJsonPath = join(cwd, 'package.json')
|
|
101
|
+
const invalidCommands = []
|
|
102
|
+
|
|
103
|
+
// 简单提取 local.yaml 中的 commands
|
|
104
|
+
const commandMatch = yamlContent.match(/build:\s*"([^"]+)"/) ||
|
|
105
|
+
yamlContent.match(/test:\s*"([^"]+)"/) ||
|
|
106
|
+
yamlContent.match(/lint:\s*"([^"]+)"/)
|
|
107
|
+
|
|
108
|
+
if (commandMatch) {
|
|
109
|
+
// 提取所有 npm run <script> 形式的命令
|
|
110
|
+
const npmRunCommands = yamlContent.match(/npm run (\S+)/g) || []
|
|
111
|
+
if (npmRunCommands.length > 0 && existsSync(packageJsonPath)) {
|
|
112
|
+
try {
|
|
113
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
|
|
114
|
+
const scripts = pkg.scripts || {}
|
|
115
|
+
for (const cmd of npmRunCommands) {
|
|
116
|
+
const scriptName = cmd.replace('npm run ', '')
|
|
117
|
+
if (!scripts[scriptName]) {
|
|
118
|
+
invalidCommands.push(`${cmd} (package.json 无 ${scriptName} script)`)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (invalidCommands.length > 0) {
|
|
126
|
+
checks.push({
|
|
127
|
+
name: 'local_config_invalid',
|
|
128
|
+
severity: 'warning',
|
|
129
|
+
detail: `local.yaml 引用不存在的命令: ${invalidCommands.join('; ')}`
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 5. 检查 AI 输出中的错误标记
|
|
135
|
+
if (outputText) {
|
|
136
|
+
const errorPatterns = [
|
|
137
|
+
{ pattern: /tool_use_error/i, name: 'tool_use_error', detail: 'AI 输出中包含 tool_use_error' },
|
|
138
|
+
{ pattern: /API Error.*529/i, name: 'api_error_529', detail: 'AI 输出中包含 API Error 529' },
|
|
139
|
+
{ pattern: /rate.?limit.*exhausted/i, name: 'rate_limit_exhausted', detail: 'AI 输出中包含 rate_limit exhausted' },
|
|
140
|
+
{ pattern: /fallback|retry.*failed|skipped.*validat/i, name: 'fallback_or_skip', detail: 'AI 输出中出现 fallback/retry failed/skipped validation' },
|
|
141
|
+
]
|
|
142
|
+
for (const ep of errorPatterns) {
|
|
143
|
+
if (ep.pattern.test(outputText)) {
|
|
144
|
+
checks.push({ name: ep.name, severity: 'warning', detail: ep.detail })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 6. 计算 finalStatus
|
|
150
|
+
const hasFailed = checks.some(c => c.severity === 'failed')
|
|
151
|
+
const hasWarning = checks.some(c => c.severity === 'warning')
|
|
152
|
+
|
|
153
|
+
let status
|
|
154
|
+
if (hasFailed) {
|
|
155
|
+
status = 'failed_post_check'
|
|
156
|
+
} else if (hasWarning) {
|
|
157
|
+
status = 'completed_with_warnings'
|
|
158
|
+
} else {
|
|
159
|
+
status = 'success'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { status, checks }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 打印 post-check 结果到 stdout
|
|
167
|
+
*/
|
|
168
|
+
export function printScanPostCheckResult(result) {
|
|
169
|
+
if (result.checks.length === 0) {
|
|
170
|
+
console.log(' ✅ CLI post-check: 全部通过')
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const check of result.checks) {
|
|
175
|
+
const icon = check.severity === 'failed' ? '❌' : '⚠️'
|
|
176
|
+
console.log(` ${icon} CLI post-check [${check.name}]: ${check.detail}`)
|
|
177
|
+
}
|
|
178
|
+
console.log(` 📋 最终状态: ${result.status}`)
|
|
179
|
+
}
|
package/src/stage-contract.js
CHANGED
|
@@ -212,7 +212,13 @@ const contracts = {
|
|
|
212
212
|
description: '归档与收口',
|
|
213
213
|
allowedFrom: ['verify'],
|
|
214
214
|
allowedTo: [],
|
|
215
|
-
|
|
215
|
+
// 阶段级 validator 全部移除,改为 run.js 中 step 4 完成后的硬编码校验。
|
|
216
|
+
// 理由:两个 validator 的生效窗口互斥 ——
|
|
217
|
+
// validateChangeClosed 要求变更目录存在(step 4 --confirm 后已被移到 archive 目录)
|
|
218
|
+
// validateArchiveOutputs 要求 archive 目录存在(step 4 前还不存在)
|
|
219
|
+
// 注册为阶段级 validator 会导致每步都误报错误。
|
|
220
|
+
// run.js:893-909 已在正确的时机(step 4 完成后)执行相同检查。
|
|
221
|
+
validators: [],
|
|
216
222
|
},
|
|
217
223
|
|
|
218
224
|
// === 辅助阶段 ===
|
package/src/stages/execute.js
CHANGED
|
@@ -322,15 +322,21 @@ function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
|
|
|
322
322
|
|
|
323
323
|
const worktreeSection = (worktreePath)
|
|
324
324
|
? `
|
|
325
|
-
###
|
|
326
|
-
你必须在以下 worktree 中工作(子代理的 cwd 设为此路径):
|
|
327
|
-
\`${worktreePath}\`
|
|
325
|
+
### 工作目录(必须严格遵守)
|
|
328
326
|
|
|
329
|
-
|
|
327
|
+
调用 Task 工具启动子代理时,**workdir 参数是强制必传的**。
|
|
328
|
+
不传 workdir 会导致子代理把文件写到主工作区而非 worktree,破坏隔离。
|
|
329
|
+
|
|
330
|
+
\`\`\`json
|
|
331
|
+
{
|
|
332
|
+
"subagent_type": "general",
|
|
333
|
+
"workdir": "${worktreePath}",
|
|
334
|
+
"prompt": "在此编写任务描述..."
|
|
335
|
+
}
|
|
336
|
+
\`\`\`
|
|
330
337
|
|
|
331
338
|
### 注意
|
|
332
339
|
蓝图文件(tasks.md / design.md / proposal.md / requirements.md)在主工作区 .sillyspec/changes/<change>/ 下,它们可能不在 worktree 中。读取蓝图时使用主工作区路径,不要拼接到 worktree 路径下。
|
|
333
|
-
子代理的 cwd 参数设为 \`${worktreePath}\`。
|
|
334
340
|
`
|
|
335
341
|
: ''
|
|
336
342
|
|
package/src/stages/scan.js
CHANGED
|
@@ -453,8 +453,24 @@ step1 → step2 → step3
|
|
|
453
453
|
5. 清理:\`rm -f {DOCS_ROOT}/scan/_env-detect.md\`
|
|
454
454
|
6. \`git add .sillyspec/\` — 暂存扫描结果(不要 commit,由用户通过统一提交工具处理)
|
|
455
455
|
|
|
456
|
+
### ⛔ 路径合规检查(平台模式下必须执行)
|
|
457
|
+
7. 确认所有文档都写入 \`{DOCS_ROOT}/\`(spec-root 下),**而非源码目录下的 .sillyspec/**
|
|
458
|
+
8. 检查是否出现 tool_use_error 或 API Error 未恢复
|
|
459
|
+
9. 检查 7 份文档 header 是否包含 author 和 created_at
|
|
460
|
+
10. 检查 local.yaml 中 commands 是否在 package.json scripts 中真实存在,不存在的必须标记 unavailable
|
|
461
|
+
|
|
462
|
+
### ⛔ 最终状态判定
|
|
463
|
+
如果出现以下**任意**情况,最终状态**不能**写"全部通过",只能写 \`completed_with_warnings\` 或 \`failed_post_check\`:
|
|
464
|
+
- 源码目录下存在 docs(路径合规检查失败)
|
|
465
|
+
- source_commit 为 null
|
|
466
|
+
- Write 工具出现过失败
|
|
467
|
+
- API Error 529 或 rate_limit
|
|
468
|
+
- fallback / retry / skipped validation
|
|
469
|
+
- 文档引用不存在的文件或模块
|
|
470
|
+
- 文档内容包含 .sillyspec/ 等工具目录的扫描结果
|
|
471
|
+
|
|
456
472
|
### 输出
|
|
457
|
-
|
|
473
|
+
每个项目的扫描完整性报告(必须包含路径合规检查结果和最终状态)
|
|
458
474
|
|
|
459
475
|
### 注意
|
|
460
476
|
- ❌ 修改代码 / 编造路径 / 读源码全文`,
|