sillyspec 3.18.4 → 3.18.6

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.
@@ -68,7 +68,7 @@ $ARGUMENTS
68
68
 
69
69
  ### 关键规则
70
70
  - 不要跳过任何步骤
71
- - 不要手动修改 progress.json
71
+ - 不要手动修改进度数据(SQLite 数据库)
72
72
  - 不要自动 commit,只 git add
73
73
  - 不要使用 npx
74
74
  - 不要编造不存在的 CLI 子命令
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: sillyspec:doctor
3
- description: 用于 SillySpec 自检和状态修复。适合用户说"检查下状态、修复 progress、doctor、状态不对"。全量扫描进度一致性,修复 progress.json 与实际产出不匹配的问题。
3
+ description: 用于 SillySpec 自检和状态修复。适合用户说"检查下状态、修复 progress、doctor、状态不对"。全量扫描进度一致性,修复进度数据与实际产出不匹配的问题。
4
4
  ---
5
5
 
6
6
  ## 前置检查
@@ -23,6 +23,8 @@ description: 用于按 plan 执行代码实现。适合用户说"开始写代码
23
23
  - Worktree 路径在 Step 3(确认 worktree 路径)中输出,后续子代理的 cwd 必须设为该路径
24
24
  - **禁止跳过 worktree 或在主仓库直接写代码**
25
25
  - 如果 worktree 创建失败,CLI 会报错并退出,需要排查后再重试
26
+ - **未提交的文件、dirty 状态等不影响 worktree 创建和进入,直接按 CLI 输出的 worktree 路径操作即可**
27
+ - 不要自行检查 git 状态来判断是否可以进入 worktree,CLI 会自动处理
26
28
 
27
29
  ## 用户指令
28
30
  $ARGUMENTS
@@ -22,15 +22,15 @@ description: 恢复工作 — 从中断处继续
22
22
  sillyspec progress show
23
23
  ```
24
24
 
25
- ### 2. 如果有 progress.json
25
+ ### 2. 如果有活跃变更
26
26
 
27
- progress.json 中提取并展示当前状态,使用 `sillyspec progress show` 查看。
27
+ 从 `sillyspec progress show` 输出中提取并展示当前状态。
28
28
 
29
29
  然后问用户:
30
30
  1. 直接继续执行下一步
31
31
  2. 查看更多细节
32
32
 
33
- ### 3. 如果没有 progress.json 或变更目录
33
+ ### 3. 如果没有活跃变更
34
34
 
35
35
  自动探测项目状态:
36
36
 
@@ -64,5 +64,5 @@ cat .sillyspec/ROADMAP.md 2>/dev/null
64
64
 
65
65
  ### 4. 关键原则
66
66
 
67
- - progress.json 是唯一的恢复数据源(存储在 `.sillyspec/changes/<name>/progress.json` 或旧版 `.sillyspec/.runtime/progress.json`)
68
- - progress.json `sillyspec run <stage> --done` 自动更新,不需要手动保存
67
+ - 进度数据存储在 SQLite 数据库中(`.sillyspec/.runtime/sillyspec.db`),通过 `sillyspec progress show` 命令查看
68
+ - 进度随 `sillyspec run <stage> --done` 自动更新,不需要手动保存
@@ -1,19 +1,19 @@
1
1
  ---
2
2
  name: sillyspec:state
3
- description: 查看当前工作状态 — 显示 progress.json 内容
3
+ description: 查看当前工作状态 — 显示 SillySpec 进度
4
4
  ---
5
5
 
6
6
  你现在是 SillySpec 的状态查看器。
7
7
 
8
8
  ## 流程
9
9
 
10
- ### 1. 读取 progress.json
10
+ ### 1. 读取进度
11
11
 
12
12
  ```bash
13
- sillyspec run state --status 2>/dev/null
13
+ sillyspec progress show
14
14
  ```
15
15
 
16
- ### 2. 如果有 progress.json
16
+ ### 2. 如果有活跃变更
17
17
 
18
18
  格式化展示当前状态:
19
19
 
@@ -33,9 +33,9 @@ sillyspec run state --status 2>/dev/null
33
33
  > **阻塞项**:
34
34
  > - xxx(如无则省略)
35
35
 
36
- ### 3. 如果没有 progress.json
36
+ ### 3. 如果没有活跃变更
37
37
 
38
- 提示用户项目还没有开始,或 progress.json 尚未生成:
38
+ 提示用户项目还没有开始:
39
39
 
40
40
  > 📊 还没有工作记录。
41
41
  >
@@ -44,11 +44,11 @@ sillyspec run state --status 2>/dev/null
44
44
  > - 已有项目:`/sillyspec:scan`
45
45
  > - 恢复中断的工作:`/sillyspec:resume`
46
46
  >
47
- > progress.json 会在 `sillyspec init` 时自动创建。
47
+ > 进度数据会在 `sillyspec init` 时自动创建到 SQLite 数据库中。
48
48
 
49
49
  ### 注意
50
50
 
51
51
  - 这是只读命令,**不修改任何文件**
52
52
  - `/sillyspec:status` 查看项目整体进度(change 文件级别)
53
- - `/sillyspec:state` 查看当前工作状态(progress.json 级别)
53
+ - `/sillyspec:state` 查看当前工作状态(阶段/步骤级别)
54
54
  - 两者互补:status 看"有什么",state 看"在做什么"
@@ -43,7 +43,7 @@ created_at: 2026-06-04 16:25:42
43
43
  | `batch_progress` | 批量任务统计 |
44
44
  | `approvals` | 平台审批状态 |
45
45
 
46
- `progress.js` 通过 SQL 读写这些表,并组装成兼容旧 progress JSON JS 对象。当前没有看到 `progress.json` 被作为权威状态写入。
46
+ `progress.js` 通过 SQL 读写这些表,并组装成兼容旧 progress 格式的 JS 对象。进度数据仅存储在 SQLite 数据库中,不再使用 progress.json 文件。
47
47
 
48
48
  注意:`db.js` 的 `project.schema_version` DDL 默认值是 `4`,但 `progress.js` 的 `CURRENT_VERSION` 是 `3`,并在初始化/写入时使用 `3`。文档不要把这里写成稳定的 v4 schema 事实。
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.18.4",
3
+ "version": "3.18.6",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -48,12 +48,13 @@ function startProgressWatch(projectPath) {
48
48
  progressWatchers.get(projectPath).refCount++
49
49
  return
50
50
  }
51
- const progressFile = join(projectPath, '.sillyspec', '.runtime', 'progress.json')
52
- if (!existsSync(progressFile)) return
51
+ // Watch the SQLite database file for changes (replaces old progress.json watch)
52
+ const dbFile = join(projectPath, '.sillyspec', '.runtime', 'sillyspec.db')
53
+ if (!existsSync(dbFile)) return
53
54
 
54
55
  let timer = null
55
56
  try {
56
- const watcher = watch(progressFile, (eventType) => {
57
+ const watcher = watch(dbFile, (eventType) => {
57
58
  if (timer) clearTimeout(timer)
58
59
  timer = setTimeout(() => {
59
60
  timer = null
@@ -77,18 +77,13 @@ export function parseProjectOverview(projectPath) {
77
77
 
78
78
  // --- Last active ---
79
79
  const sillyspecDir = join(projectPath, '.sillyspec')
80
- const progressPath = join(sillyspecDir, '.runtime', 'progress.json')
81
- if (existsSync(progressPath)) {
80
+ // Progress is stored in SQLite (.sillyspec/.runtime/sillyspec.db), not progress.json
81
+ // Use mtime of the DB file as a fallback for lastActive
82
+ const dbPath = join(sillyspecDir, '.runtime', 'sillyspec.db')
83
+ if (existsSync(dbPath)) {
82
84
  try {
83
- const progress = JSON.parse(readFileSync(progressPath, 'utf-8'))
84
- if (progress.stages) {
85
- for (const stageData of Object.values(progress.stages)) {
86
- if (stageData.lastActive && (!result.lastActive || new Date(stageData.lastActive) > new Date(result.lastActive))) {
87
- result.lastActive = stageData.lastActive
88
- }
89
- }
90
- }
91
- if (progress.lastActive) result.lastActive = progress.lastActive
85
+ const s = statSync(dbPath)
86
+ result.lastActive = s.mtime.toISOString()
92
87
  } catch {}
93
88
  }
94
89
  if (!result.lastActive) {
@@ -432,81 +427,46 @@ export function parseSillyspecDocsTree(projectPath) {
432
427
  }
433
428
 
434
429
  /**
435
- * Parse project state from .sillyspec directory
430
+ * Parse project state from .sillyspec SQLite database via CLI.
431
+ * Progress data is stored in SQLite (.sillyspec/.runtime/sillyspec.db),
432
+ * accessed through `sillyspec progress show`.
436
433
  * @param {string} projectPath - Path to the project directory
437
- * @returns {object} Project state with currentStage, nextStep, progress, stages, specs, lastActive
434
+ * @returns {object|null} Project state with currentStage, stages, lastActive
438
435
  */
439
436
  export function parseProjectState(projectPath) {
440
437
  const sillyspecDir = join(projectPath, '.sillyspec')
438
+ const dbPath = join(sillyspecDir, '.runtime', 'sillyspec.db')
441
439
 
442
- if (!existsSync(sillyspecDir)) {
440
+ if (!existsSync(sillyspecDir) || !existsSync(dbPath)) {
443
441
  return null
444
442
  }
445
443
 
446
444
  let currentStage = ''
447
- let nextStep = null
448
- let progress = { stages: {} }
449
- let stages = []
450
- let specs = []
451
445
  let lastActive = null
452
446
 
453
- // Read progress.json for current stage
454
- const progressPath = join(sillyspecDir, '.runtime', 'progress.json')
455
- if (existsSync(progressPath)) {
456
- try {
457
- const progressData = JSON.parse(readFileSync(progressPath, 'utf-8'))
458
- progress = progressData
459
- currentStage = progressData.currentStage || ''
460
- stages = Object.keys(progressData.stages || {})
461
-
462
- // Find last active
463
- if (progressData.lastActive) lastActive = progressData.lastActive
464
- if (progressData.stages) {
465
- for (const [stageName, stageData] of Object.entries(progressData.stages)) {
466
- if (stageData.lastActive || stageData.startedAt) {
467
- const t = stageData.lastActive || stageData.startedAt
468
- if (!lastActive || new Date(t) > new Date(lastActive)) lastActive = t
469
- }
470
- }
471
- }
472
- } catch (err) {
473
- // Progress file exists but couldn't be parsed
474
- }
475
- }
447
+ // Use DB file mtime as lastActive indicator
448
+ try {
449
+ const s = statSync(dbPath)
450
+ lastActive = s.mtime.toISOString()
451
+ } catch {}
476
452
 
477
- // List all spec files
478
- const specsDir = join(sillyspecDir, 'specs')
479
- if (existsSync(specsDir)) {
480
- try {
481
- const specFiles = readdirSync(specsDir)
482
- .filter(f => f.endsWith('.md'))
483
- .sort()
484
-
485
- specs = specFiles.map(f => {
486
- const specPath = join(specsDir, f)
487
- try {
488
- const content = readFileSync(specPath, 'utf-8')
489
- const titleMatch = content.match(/^#\s+(.+)$/m)
490
- return {
491
- name: f,
492
- title: titleMatch ? titleMatch[1] : f,
493
- path: specPath
494
- }
495
- } catch {
496
- return { name: f, title: f, path: specPath }
497
- }
498
- })
499
- } catch (err) {
500
- // Specs directory couldn't be read
501
- }
453
+ // Use CLI to read current stage from SQLite
454
+ try {
455
+ const output = execSync('sillyspec progress show 2>/dev/null', {
456
+ cwd: projectPath, encoding: 'utf-8', timeout: 5000
457
+ })
458
+ const stageMatch = output.match(/当前阶段:\s*(\S+)/)
459
+ if (stageMatch) currentStage = stageMatch[1]
460
+ } catch {
461
+ // CLI unavailable or no active change
502
462
  }
503
463
 
504
464
  return {
505
465
  currentStage,
506
- nextStep,
507
- progress,
508
- stages,
509
- specs,
466
+ nextStep: null,
467
+ progress: { stages: {} },
468
+ stages: [],
469
+ specs: [],
510
470
  lastActive
511
471
  }
512
472
  }
package/src/index.js CHANGED
@@ -145,6 +145,21 @@ async function main() {
145
145
  targetDir = resolve(filteredArgs[1]);
146
146
  filteredArgs.splice(1, 1);
147
147
  }
148
+ // ── 自动纠正 cwd ──
149
+ // 当 agent 在 worktree 内跑 pnpm 等工具后 shell cwd 可能被改变,
150
+ // 导致 sillyspec 命令找不到 .sillyspec。此函数尝试从 git root 解析。
151
+ function resolveEffectiveDir(baseDir) {
152
+ if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
153
+ try {
154
+ const { execSync } = require('child_process')
155
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
156
+ cwd: baseDir, encoding: 'utf8', timeout: 5000
157
+ }).trim()
158
+ if (gitRoot && existsSync(join(gitRoot, '.sillyspec'))) return gitRoot
159
+ } catch {}
160
+ return baseDir
161
+ }
162
+
148
163
  const dir = targetDir;
149
164
 
150
165
  if (command === 'init' && !existsSync(dir)) {
@@ -167,6 +182,7 @@ async function main() {
167
182
  break;
168
183
  case 'progress': {
169
184
  const pm = new ProgressManager();
185
+ const progDir = resolveEffectiveDir(dir);
170
186
  const subCommand = filteredArgs[1];
171
187
  const stageIdx = filteredArgs.indexOf('--stage');
172
188
  const stage = stageIdx >= 0 && filteredArgs[stageIdx + 1] ? filteredArgs[stageIdx + 1] : null;
@@ -176,18 +192,18 @@ async function main() {
176
192
 
177
193
  switch (subCommand) {
178
194
  case 'init':
179
- pm.init(dir);
195
+ pm.init(progDir);
180
196
  break;
181
197
  case 'status':
182
198
  case 'show':
183
- pm.show(dir, progChangeName);
199
+ pm.show(progDir, progChangeName);
184
200
  break;
185
201
  case 'check':
186
- await pm.checkConsistency(dir, progChangeName);
202
+ await pm.checkConsistency(progDir, progChangeName);
187
203
  break;
188
204
  case 'repair': {
189
205
  const repairApply = filteredArgs.includes('--apply');
190
- await pm.repairConsistency(dir, { apply: repairApply, changeName: progChangeName });
206
+ await pm.repairConsistency(progDir, { apply: repairApply, changeName: progChangeName });
191
207
  break;
192
208
  }
193
209
  case 'validate':
@@ -270,7 +286,22 @@ async function main() {
270
286
  }
271
287
  case 'run': {
272
288
  const { runCommand } = await import('./run.js')
273
- await runCommand(filteredArgs.slice(1), dir, specDir)
289
+ await runCommand(filteredArgs.slice(1), resolveEffectiveDir(dir), specDir)
290
+ break
291
+ }
292
+ // task-10: 顶层命令别名,转发 runCommand,与 case 'run': 路径行为一致
293
+ // help 文本(:44-46)已宣称这些 stage 可直接使用,这里补齐路由避免落 default 分支。
294
+ // 注意:filteredArgs[0] === command,直接透传 filteredArgs 即可让 runCommand
295
+ // 从 args[0] 取到 stage 名(run.js:1036)。与 case 'run': 的 filteredArgs.slice(1)
296
+ // 区别只在于 slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
297
+ case 'doctor':
298
+ case 'scan':
299
+ case 'status':
300
+ case 'quick':
301
+ case 'explore': {
302
+ const { runCommand } = await import('./run.js')
303
+ const stageArgs = [command, ...filteredArgs.slice(1)]
304
+ await runCommand(stageArgs, resolveEffectiveDir(dir), specDir)
274
305
  break
275
306
  }
276
307
  case 'dashboard': {
package/src/run.js CHANGED
@@ -11,6 +11,22 @@ const require = createRequire(import.meta.url)
11
11
  import { ProgressManager } from './progress.js'
12
12
  import { SCAN_STATUS, POINTER_STATUS, isPointerCorrupted } from './constants.js'
13
13
 
14
+ /**
15
+ * 清洗项目名:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符。
16
+ * - 必须含至少一个字母(拒绝纯数字 "0"/"7"/"07",避免 scan-projects.json 脏数据)
17
+ * - 长度必须 ≥ 2(拒绝单字符 "a"/"0")
18
+ * @param {string} name - 原始项目名候选
19
+ * @returns {string | null} 合法项目名或 null(拒绝)
20
+ */
21
+ export function sanitizeProjectName(name) {
22
+ if (!name) return null
23
+ const clean = String(name).replace(/[^a-zA-Z0-9_\-.]/g, '').trim()
24
+ if (!clean) return null
25
+ if (!/[a-zA-Z]/.test(clean)) return null // 纯数字/符号拒绝("0"/"7"/"07")
26
+ if (clean.length < 2) return null // 单字符拒绝("a"/"0")
27
+ return clean
28
+ }
29
+
14
30
  /**
15
31
  * 在容器/Docker 环境下,git 可能因目录所有权不匹配报 dubious ownership。
16
32
  * 使用 -c safe.directory= 临时参数,不污染全局 git config。
@@ -666,6 +682,12 @@ async function outputStep(stageName, stepIndex, steps, cwd, changeName, dbProjec
666
682
  `2. **不允许**用 cat >、tee、heredoc 等 Bash 方式绕过 Write 工具。\n` +
667
683
  `3. 如果 Write 和 Read 均失败,记录失败并停止当前 step。\n` +
668
684
  `\n` +
685
+ `### 📍 Workflow YAML 占位符映射(task-05)\n` +
686
+ `读取 \`{WORKFLOWS_ROOT}/scan-docs.yaml\` 时,yaml 内的占位符按以下映射替换为绝对路径:\n` +
687
+ `- \`{SPEC_ROOT}\` → \`${specSillyspec}\`(规范目录根)\n` +
688
+ `- \`<project>\` → 当前项目名(见下方 step 提示,等于 \`${projectName}\`)\n` +
689
+ `- 例:\`{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md\` → \`${docsRoot}/scan/ARCHITECTURE.md\`\n` +
690
+ `\n` +
669
691
  `创建目录: \`mkdir -p ${docsRoot}/{scan,modules,flows} ${projectsRoot} ${changesRoot}\`\n`
670
692
  )
671
693
  if (platformOpts.runtimeRoot) {
@@ -1416,7 +1438,9 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
1416
1438
  const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
1417
1439
  // 状态转换校验
1418
1440
  const prevStage = progress.currentStage || ''
1419
- const transition = checkTransition(prevStage, stageName)
1441
+ // task-07: 提取 prevStage 的 stageData,传给 checkTransition 检测 failed_post_check 门控
1442
+ const fromStageData = (progress.stages && prevStage && progress.stages[prevStage]) || undefined
1443
+ const transition = checkTransition(prevStage, stageName, fromStageData ? { fromStageData } : {})
1420
1444
  if (!transition.allowed) {
1421
1445
  console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
1422
1446
  console.error(` 原因: ${transition.reason}`)
@@ -2150,16 +2174,15 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2150
2174
  if (stageName === 'scan' && steps[currentIdx]?.name === '构建扫描项目列表') {
2151
2175
  // 解析项目列表:从 step 2 输出提取,或回退读取 projects/*.yaml
2152
2176
  let projectNames = []
2153
- // 项目名清洗:只保留 ASCII 字母/数字/横线/下划线/点,过滤中文和特殊字符
2154
- const sanitizeProjectName = (name) => {
2155
- const clean = name.replace(/[^a-zA-Z0-9_\-.]/g, '').trim()
2156
- return clean || null
2157
- }
2177
+ // sanitizeProjectName 已提取到模块顶层(含字母校验 + 长度≥2)
2158
2178
  if (outputText) {
2159
2179
  // 匹配方式 1: "1. project-name" 编号列表
2160
- const numbered = outputText.match(/^\s*\d+\.\s+(\S+)/gm)
2180
+ // 正则收紧:token 必须以字母开头,避免误捕获纯数字 "0"/"7" 和步骤说明中英文行
2181
+ const numbered = outputText.match(/^\s*\d+\.\s+([a-zA-Z][\w\-.]*)/gm)
2161
2182
  if (numbered) {
2162
- const raw = numbered.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/[—\-:].*$/, '').trim())
2183
+ // task-05 B2 延伸修正:原 /[—\-:].*$/ 会把 ASCII 连字符当后缀分隔符,
2184
+ // 把 order-service 切成 order。现只针对中文长破折号 `—`(LLM 输出列表时常作分隔符)。
2185
+ const raw = numbered.map(m => m.replace(/^\s*\d+\.\s+/, '').replace(/—.*$/, '').trim())
2163
2186
  projectNames = raw.map(sanitizeProjectName).filter(Boolean)
2164
2187
  if (projectNames.length > 0) { stageData.scanMeta = stageData.scanMeta || {}; stageData.scanMeta.projectListParsed = true; }
2165
2188
  }
@@ -2434,8 +2457,18 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2434
2457
  stageData.status = SCAN_STATUS.FAILED_POST_CHECK
2435
2458
  stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
2436
2459
  await pm._write(cwd, progress, changeName)
2460
+ triggerSync(cwd, changeName, platformOpts)
2437
2461
  console.error(`\n❌ scan post-check 失败,状态设为 failed_post_check。不允许 clean success。`)
2438
2462
  console.error(` 请检查上方错误信息并修复后重新 scan。`)
2463
+ // 平台模式:exit(1) 让 daemon/SillyHub 感知非 0 退出码(manifest.json 已落盘,不会被撤销)
2464
+ if (platformOpts.specRoot || platformOpts.runtimeRoot) {
2465
+ console.error(' 平台模式:CLI 将以 exit code 1 退出,通知 SillyHub scan 失败。')
2466
+ process.exit(1)
2467
+ }
2468
+ // 接口与 plan contract (run.js:2551 附近 plan 失败分支) 对齐:
2469
+ // 返回 { stageCompleted:false, currentIdx, nextPendingIdx: currentIdx }
2470
+ // 让上层 runStage 走"完成但不推进"分支,--done 被拒
2471
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2439
2472
  } else if (postResult.status === 'completed_with_warnings') {
2440
2473
  // 警告不阻止完成,但记录
2441
2474
  stageData.status = 'completed'
@@ -2623,8 +2656,17 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2623
2656
  const { loadWorkflow, runPostCheck, formatCheckReport, saveWorkflowRun } = await import('./workflow.js')
2624
2657
  const wf = loadWorkflow(cwd, 'scan-docs')
2625
2658
  if (wf) {
2626
- // 确定当前项目:优先从 step metadata 读取,回退从 display name 提取
2627
- const currentProjectName = steps[currentIdx].project
2659
+ // 确定当前项目(优先级链):
2660
+ // progress.project (dbProjectName,平台模式真实项目名,与 outputStep 占位符渲染对齐)
2661
+ // > change?.project (变更对象的项目字段,平台模式 change 创建时传入)
2662
+ // > steps[idx].project (perProject 展开标记,兼容旧模式)
2663
+ // > steps[idx].name 正则提取 [xxx] 后缀
2664
+ // > null(回退检查所有项目)
2665
+ // task-05 修复:日志显示项目名变 frontend 是 perProject 误展开 bug,
2666
+ // 用 progress.project(与 outputStep 占位符渲染路径一致)修正 myaaa/frontend 分裂。
2667
+ const currentProjectName = progress.project
2668
+ || (typeof change !== 'undefined' && change ? change.project : null)
2669
+ || steps[currentIdx].project
2628
2670
  || (steps[currentIdx].name.match(/\[([^\]]+)\]\s*$/) || [])[1]
2629
2671
  || null
2630
2672
 
@@ -2644,7 +2686,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2644
2686
 
2645
2687
  let anyFailed = false
2646
2688
  for (const pName of projectsToCheck) {
2647
- const result = runPostCheck(wf, cwd, pName)
2689
+ const result = runPostCheck(wf, cwd, pName, {}, specBase)
2648
2690
  const report = formatCheckReport(result)
2649
2691
  console.log(report)
2650
2692
  if (result.status === 'fail') {
@@ -2667,6 +2709,10 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2667
2709
  }
2668
2710
  if (anyFailed) {
2669
2711
  console.log(`\n⚠️ 存在检查失败项,请按上面的重试提示修复后再继续。`)
2712
+ // task-07: 阻断推进(与 task-06 平台模式 scan-postcheck 失败分支 return 结构对齐)
2713
+ // scan 深度扫描产物校验未通过时,不允许 clean success / 进入下一 step,
2714
+ // 让上层走"完成但不推进"分支,--done 被拒。
2715
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
2670
2716
  }
2671
2717
  }
2672
2718
  } catch (e) {
@@ -2682,7 +2728,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
2682
2728
  if (wf && changeName) {
2683
2729
  const raw = JSON.stringify(wf)
2684
2730
  const resolved = JSON.parse(raw.replace(/<change-name>/g, changeName))
2685
- const result = runPostCheck(resolved, cwd, 'sillyspec')
2731
+ const result = runPostCheck(resolved, cwd, 'sillyspec', {}, specBase)
2686
2732
  // 只报告 impact-analyzer 的结果(doc-syncer 是后续步骤)
2687
2733
  const impactResult = (result.roles || []).find(r => r.id === 'impact-analyzer')
2688
2734
  if (impactResult) {
@@ -587,9 +587,11 @@ export function getContract(stageName) {
587
587
  * 校验状态转换是否允许
588
588
  * @param {string} fromStage - 当前阶段(空字符串表示变更起始)
589
589
  * @param {string} toStage - 目标阶段
590
+ * @param {{ fromStageData?: { status?: string } | undefined }} [options] - 可选,从 progress.stages[prevStage] 提取
590
591
  * @returns {{ allowed: boolean, reason?: string }}
591
592
  */
592
- export function checkTransition(fromStage, toStage) {
593
+ export function checkTransition(fromStage, toStage, options = {}) {
594
+ const { fromStageData } = options // { status?: string } | undefined
593
595
  const contract = contracts[toStage]
594
596
  if (!contract) {
595
597
  return { allowed: false, reason: `未知阶段: ${toStage}` }
@@ -605,6 +607,17 @@ export function checkTransition(fromStage, toStage) {
605
607
  return { allowed: true }
606
608
  }
607
609
 
610
+ // task-07: failed_post_check 门控
611
+ // scan post-check 未通过时,禁止进入主流程的下游阶段(brainstorm/plan/execute/verify/archive)
612
+ // 必须先重跑 scan 修复。toStage === 'scan' 的重跑路径已被上方 fromStage === toStage 放行。
613
+ // fromStageData.status 缺失(旧数据)时门控不触发(向后兼容)。
614
+ if (fromStage === 'scan' && fromStageData?.status === 'failed_post_check' && toStage !== 'scan') {
615
+ return {
616
+ allowed: false,
617
+ reason: 'scan post-check 未通过(failed_post_check),需修复后重跑 scan 再进入 ' + toStage,
618
+ }
619
+ }
620
+
608
621
  // archive 特殊处理:从 verify 来的允许,从其他主流程阶段来的需要校验
609
622
  if (toStage === 'archive') {
610
623
  if (fromStage === 'verify') {
@@ -165,6 +165,7 @@ const fixedPrefix = [
165
165
  - **worktree 已由 CLI 在 execute 阶段启动时自动创建,不要自行创建或跳过**
166
166
  - **后续所有子代理的 cwd 必须设为该 worktree 路径**
167
167
  - 如果 meta.json 不存在(说明创建失败),停止并报错
168
+ - **不要自行检查 git dirty/uncommitted 状态来判断是否可以进入 worktree,CLI 已自动处理**
168
169
 
169
170
  ### 输出
170
171
  worktree 路径 + 分支名 + 模式
package/src/workflow.js CHANGED
@@ -149,10 +149,13 @@ function replaceProjectPlaceholder(wf, projectName) {
149
149
  * @param {string} cwd - 项目根目录
150
150
  * @returns {CheckResult}
151
151
  */
152
- function checkOutput(outputDef, projectName, cwd) {
152
+ function checkOutput(outputDef, projectName, cwd, specBase) {
153
+ // specBase 优先(平台模式 platformOpts.specRoot,已含或不含 .sillyspec 语义);
154
+ // 未传时回退 join(cwd, '.sillyspec'),等价于旧行为 resolve(cwd, '.sillyspec/...')
155
+ const effectiveBase = specBase || join(cwd, '.sillyspec')
153
156
  // 将 <project> 替换为实际项目名
154
157
  const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
155
- const fullPath = resolve(cwd, rawPath)
158
+ const fullPath = resolve(effectiveBase, rawPath)
156
159
  const checks = outputDef.checks || []
157
160
  const results = []
158
161
 
@@ -241,7 +244,7 @@ function checkOutput(outputDef, projectName, cwd) {
241
244
  * retry_prompts: [{ role_id, role_name, prompt }]
242
245
  * }
243
246
  */
244
- export function runPostCheck(wf, cwd, projectName, placeholders = {}) {
247
+ export function runPostCheck(wf, cwd, projectName, placeholders = {}, specBase) {
245
248
  let resolved = replaceProjectPlaceholder(wf, projectName)
246
249
  if (Object.keys(placeholders).length > 0) {
247
250
  let json = JSON.stringify(resolved)
@@ -250,10 +253,11 @@ export function runPostCheck(wf, cwd, projectName, placeholders = {}) {
250
253
  }
251
254
  resolved = JSON.parse(json)
252
255
  }
253
- return _checkWorkflow(resolved, cwd, projectName)
256
+ return _checkWorkflow(resolved, cwd, projectName, specBase)
254
257
  }
255
258
 
256
- function _checkWorkflow(wf, cwd, projectName) {
259
+ function _checkWorkflow(wf, cwd, projectName, specBase) {
260
+ const effectiveBase = specBase || join(cwd, '.sillyspec')
257
261
  const workflowName = wf.name || 'unknown'
258
262
  const specVersion = wf.spec_version || wf.version || 0
259
263
  const workflowChecks = wf.checks?.workflow_level || []
@@ -270,7 +274,7 @@ function _checkWorkflow(wf, cwd, projectName) {
270
274
 
271
275
  for (const outputDef of outputDefs) {
272
276
  const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
273
- const checkResults = checkOutput(outputDef, projectName, cwd)
277
+ const checkResults = checkOutput(outputDef, projectName, cwd, effectiveBase)
274
278
  const outputPassed = checkResults.every(c => c.passed)
275
279
 
276
280
  outputs.push({
@@ -309,7 +313,7 @@ function _checkWorkflow(wf, cwd, projectName) {
309
313
  for (const check of workflowChecks) {
310
314
  switch (check.type) {
311
315
  case 'file_count': {
312
- const scanDir = join(cwd, '.sillyspec', 'docs', projectName, check.path || 'scan/')
316
+ const scanDir = join(effectiveBase, 'docs', projectName, check.path || 'scan/')
313
317
  if (existsSync(scanDir)) {
314
318
  const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
315
319
  const min = check.min || 0
@@ -328,7 +332,7 @@ function _checkWorkflow(wf, cwd, projectName) {
328
332
  break
329
333
  }
330
334
  case 'no_empty_files': {
331
- const scanDir = join(cwd, '.sillyspec', 'docs', projectName, check.path || 'scan/')
335
+ const scanDir = join(effectiveBase, 'docs', projectName, check.path || 'scan/')
332
336
  if (existsSync(scanDir)) {
333
337
  const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
334
338
  let anyEmpty = false
@@ -214,7 +214,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
214
214
  const patchFiles = hasAllowList
215
215
  ? [...allowSet].filter(f => changedFiles.includes(f))
216
216
  : changedFiles;
217
- const fileArgs = patchFiles.map(f => `-- ${f}`).join(' ');
217
+ const fileArgs = patchFiles.length > 0 ? `-- ${patchFiles.join(' ')}` : '';
218
218
 
219
219
  // 创建临时文件
220
220
  const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
@@ -236,7 +236,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
236
236
 
237
237
  // tracked 文件:git diff baseHash
238
238
  if (trackedFiles.length > 0) {
239
- const trackedArgs = trackedFiles.map(f => `-- ${f}`).join(' ');
239
+ const trackedArgs = trackedFiles.length > 0 ? `-- ${trackedFiles.join(' ')}` : '';
240
240
  patchContent += execSync(
241
241
  `git diff --binary ${diffBase} ${trackedArgs}`,
242
242
  { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
@@ -245,11 +245,12 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
245
245
 
246
246
  // untracked 新文件:git add 到 index,git diff --cached,然后 reset
247
247
  if (untrackedPatchFiles.length > 0) {
248
- const addArgs = untrackedPatchFiles.map(f => `-- ${f}`).join(' ');
248
+ const addArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
249
249
  git(worktreePath, `add ${addArgs}`);
250
250
  try {
251
+ const diffCachedArgs = untrackedPatchFiles.length > 0 ? `-- ${untrackedPatchFiles.join(' ')}` : '';
251
252
  patchContent += execSync(
252
- `git diff --binary --cached ${untrackedPatchFiles.map(f => `-- ${f}`).join(' ')}`,
253
+ `git diff --binary --cached ${diffCachedArgs}`,
253
254
  { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
254
255
  );
255
256
  } finally {