sillyspec 3.20.5 → 3.20.7

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.
Files changed (37) hide show
  1. package/.claude/CLAUDE.md +23 -0
  2. package/.claude/skills/sillyspec-archive/SKILL.md +86 -21
  3. package/.claude/skills/sillyspec-auto/SKILL.md +98 -83
  4. package/.claude/skills/sillyspec-brainstorm/SKILL.md +78 -44
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +61 -31
  6. package/.claude/skills/sillyspec-execute/SKILL.md +90 -30
  7. package/.claude/skills/sillyspec-explore/SKILL.md +96 -109
  8. package/.claude/skills/sillyspec-export/SKILL.md +4 -0
  9. package/.claude/skills/sillyspec-init/SKILL.md +7 -0
  10. package/.claude/skills/sillyspec-plan/SKILL.md +74 -21
  11. package/.claude/skills/sillyspec-propose/SKILL.md +61 -21
  12. package/.claude/skills/sillyspec-quick/SKILL.md +84 -21
  13. package/.claude/skills/sillyspec-scan/SKILL.md +74 -21
  14. package/.claude/skills/sillyspec-state/SKILL.md +11 -1
  15. package/.claude/skills/sillyspec-status/SKILL.md +55 -21
  16. package/.claude/skills/sillyspec-verify/SKILL.md +82 -21
  17. package/.claude/skills/sillyspec-workspace/SKILL.md +12 -0
  18. package/CLAUDE.md +4 -0
  19. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +5 -0
  20. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +3 -3
  21. package/docs/sillyspec/file-lifecycle.md +17 -5
  22. package/docs/worktree-isolation.md +1 -1
  23. package/package.json +2 -2
  24. package/src/doctor-diagnostics.js +575 -0
  25. package/src/hooks/worktree-guard.js +111 -111
  26. package/src/index.js +158 -86
  27. package/src/progress.js +68 -0
  28. package/src/quick-recommend.js +115 -0
  29. package/src/run.js +272 -51
  30. package/src/stage-contract.js +23 -5
  31. package/src/stages/quick.js +36 -26
  32. package/src/sync.js +14 -3
  33. package/src/worktree.js +69 -28
  34. package/test/decision-ref-version.mjs +85 -0
  35. package/test/platform-scan-p0.test.mjs +18 -7
  36. package/test/quick-recommend.test.mjs +146 -0
  37. package/test/stage-contract.test.mjs +5 -3
@@ -119,104 +119,104 @@ function readWorktreeMeta(cwd, changeName) {
119
119
  }
120
120
  }
121
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 toPosixPath(filePath) {
130
- return filePath.replace(/\\/g, '/')
131
- }
132
-
133
- function parseFrontmatter(content) {
134
- if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return {}
135
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
136
- if (!match) return {}
137
- const result = {}
138
- for (const line of match[1].split(/\r?\n/)) {
139
- const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
140
- if (!m) continue
141
- result[m[1]] = m[2].replace(/^['"]|['"]$/g, '').trim()
142
- }
143
- return result
144
- }
145
-
146
- function parseTimestamp(value) {
147
- if (!value) return null
148
- const time = Date.parse(value)
149
- return Number.isNaN(time) ? null : time
150
- }
151
-
152
- function getScanDocInfo(filePath) {
153
- const normalized = toPosixPath(path.resolve(filePath))
154
- const match = normalized.match(/^(.*)\/docs\/([^/]+)\/scan\/([^/]+\.md)$/)
155
- if (!match) return null
156
- return {
157
- specRoot: path.resolve(match[1]),
158
- projectName: match[2],
159
- docName: match[3],
160
- }
161
- }
162
-
163
- function readScanGuard(scanDocInfo, projectRoot) {
164
- const candidates = [
165
- path.join(scanDocInfo.specRoot, '.runtime', 'scan-guard.json'),
166
- path.join(projectRoot, '.sillyspec', '.runtime', 'scan-guard.json'),
167
- ]
168
- for (const p of candidates) {
169
- if (!existsSync(p)) continue
170
- try {
171
- return JSON.parse(readFileSync(p, 'utf8'))
172
- } catch {
173
- return null
174
- }
175
- }
176
- return null
177
- }
178
-
179
- function shouldBlockScanDocOverwrite(filePath, projectRoot) {
180
- const scanDocInfo = getScanDocInfo(filePath)
181
- if (!scanDocInfo || !existsSync(filePath)) return { blocked: false }
182
-
183
- const guard = readScanGuard(scanDocInfo, projectRoot)
184
- if (!guard || guard.forceRescan) return { blocked: false }
185
-
186
- let frontmatter = {}
187
- try {
188
- frontmatter = parseFrontmatter(readFileSync(filePath, 'utf8'))
189
- } catch {
190
- return { blocked: false }
191
- }
192
-
193
- const relPath = toPosixPath(path.relative(projectRoot, filePath))
194
- if (frontmatter.source_commit && guard.sourceCommit && frontmatter.source_commit !== guard.sourceCommit) {
195
- return {
196
- blocked: true,
197
- reason: [
198
- `scan 覆盖保护:${relPath} 的 source_commit=${frontmatter.source_commit} 与当前 scan source_commit=${guard.sourceCommit} 不一致。`,
199
- '如确认要重新生成,请重新运行 scan 并添加 --force-rescan。',
200
- ].join('\n')
201
- }
202
- }
203
-
204
- const existingUpdatedAt = parseTimestamp(frontmatter.updated_at)
205
- const scanStartedAt = parseTimestamp(guard.startedAt)
206
- if (existingUpdatedAt && scanStartedAt && existingUpdatedAt > scanStartedAt) {
207
- return {
208
- blocked: true,
209
- reason: [
210
- `scan 覆盖保护:${relPath} 的 updated_at 晚于本次 scan 开始时间,可能包含手工编辑。`,
211
- '如确认要覆盖,请重新运行 scan 并添加 --force-rescan。',
212
- ].join('\n')
213
- }
214
- }
215
-
216
- return { blocked: false }
217
- }
218
-
219
- function isInsideWorktreeStorage(filePath, cwd) {
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 toPosixPath(filePath) {
130
+ return filePath.replace(/\\/g, '/')
131
+ }
132
+
133
+ function parseFrontmatter(content) {
134
+ if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) return {}
135
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/)
136
+ if (!match) return {}
137
+ const result = {}
138
+ for (const line of match[1].split(/\r?\n/)) {
139
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
140
+ if (!m) continue
141
+ result[m[1]] = m[2].replace(/^['"]|['"]$/g, '').trim()
142
+ }
143
+ return result
144
+ }
145
+
146
+ function parseTimestamp(value) {
147
+ if (!value) return null
148
+ const time = Date.parse(value)
149
+ return Number.isNaN(time) ? null : time
150
+ }
151
+
152
+ function getScanDocInfo(filePath) {
153
+ const normalized = toPosixPath(path.resolve(filePath))
154
+ const match = normalized.match(/^(.*)\/docs\/([^/]+)\/scan\/([^/]+\.md)$/)
155
+ if (!match) return null
156
+ return {
157
+ specRoot: path.resolve(match[1]),
158
+ projectName: match[2],
159
+ docName: match[3],
160
+ }
161
+ }
162
+
163
+ function readScanGuard(scanDocInfo, projectRoot) {
164
+ const candidates = [
165
+ path.join(scanDocInfo.specRoot, '.runtime', 'scan-guard.json'),
166
+ path.join(projectRoot, '.sillyspec', '.runtime', 'scan-guard.json'),
167
+ ]
168
+ for (const p of candidates) {
169
+ if (!existsSync(p)) continue
170
+ try {
171
+ return JSON.parse(readFileSync(p, 'utf8'))
172
+ } catch {
173
+ return null
174
+ }
175
+ }
176
+ return null
177
+ }
178
+
179
+ function shouldBlockScanDocOverwrite(filePath, projectRoot) {
180
+ const scanDocInfo = getScanDocInfo(filePath)
181
+ if (!scanDocInfo || !existsSync(filePath)) return { blocked: false }
182
+
183
+ const guard = readScanGuard(scanDocInfo, projectRoot)
184
+ if (!guard || guard.forceRescan) return { blocked: false }
185
+
186
+ let frontmatter = {}
187
+ try {
188
+ frontmatter = parseFrontmatter(readFileSync(filePath, 'utf8'))
189
+ } catch {
190
+ return { blocked: false }
191
+ }
192
+
193
+ const relPath = toPosixPath(path.relative(projectRoot, filePath))
194
+ if (frontmatter.source_commit && guard.sourceCommit && frontmatter.source_commit !== guard.sourceCommit) {
195
+ return {
196
+ blocked: true,
197
+ reason: [
198
+ `scan 覆盖保护:${relPath} 的 source_commit=${frontmatter.source_commit} 与当前 scan source_commit=${guard.sourceCommit} 不一致。`,
199
+ '如确认要重新生成,请重新运行 scan 并添加 --force-rescan。',
200
+ ].join('\n')
201
+ }
202
+ }
203
+
204
+ const existingUpdatedAt = parseTimestamp(frontmatter.updated_at)
205
+ const scanStartedAt = parseTimestamp(guard.startedAt)
206
+ if (existingUpdatedAt && scanStartedAt && existingUpdatedAt > scanStartedAt) {
207
+ return {
208
+ blocked: true,
209
+ reason: [
210
+ `scan 覆盖保护:${relPath} 的 updated_at 晚于本次 scan 开始时间,可能包含手工编辑。`,
211
+ '如确认要覆盖,请重新运行 scan 并添加 --force-rescan。',
212
+ ].join('\n')
213
+ }
214
+ }
215
+
216
+ return { blocked: false }
217
+ }
218
+
219
+ function isInsideWorktreeStorage(filePath, cwd) {
220
220
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd || process.cwd(), filePath)
221
221
  return isPathInside(absPath, resolveWorktreeDir(cwd || process.cwd()))
222
222
  }
@@ -579,16 +579,16 @@ export function shouldBlockWrite(filePath, cwd) {
579
579
  const projectRoot = findProjectRoot(callerCwd)
580
580
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
581
581
 
582
- // 1. 阶段门禁(使用 fallback 读取)
583
- const stage = readCurrentStage(projectRoot) || '(none)'
584
-
585
- const scanGuardResult = shouldBlockScanDocOverwrite(absPath, projectRoot)
586
- if (scanGuardResult.blocked) return scanGuardResult
587
-
588
- // 2. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
589
- if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
590
-
591
- if (!['execute', 'quick'].includes(stage)) {
582
+ // 1. 阶段门禁(使用 fallback 读取)
583
+ const stage = readCurrentStage(projectRoot) || '(none)'
584
+
585
+ const scanGuardResult = shouldBlockScanDocOverwrite(absPath, projectRoot)
586
+ if (scanGuardResult.blocked) return scanGuardResult
587
+
588
+ // 2. 文件门禁:文档类/配置类始终放行,但 worktree 存储区内的源码必须继续走登记校验。
589
+ if (!isInsideWorktreeStorage(absPath, projectRoot) && matchFileWhitelist(absPath)) return { blocked: false }
590
+
591
+ if (!['execute', 'quick'].includes(stage)) {
592
592
  return {
593
593
  blocked: true,
594
594
  reason: buildStageHint(stage)
@@ -627,9 +627,9 @@ export function shouldBlockWrite(filePath, cwd) {
627
627
  return {
628
628
  blocked: true,
629
629
  reason: [
630
- '当前处于 --no-worktree 降级模式,不允许源码写入。',
631
- '如需修改源码,请移除 --no-worktree 标志重新执行。',
632
- '紧急情况可设置 SILLYSPEC_DISABLE_HOOKS=1 绕过限制。',
630
+ '当前变更处于无 worktree 隔离模式(changes.no_worktree=1),不允许源码写入。',
631
+ '如需修改源码,请在 worktree 隔离环境中工作。',
632
+ '若误入此模式,检查 sillyspec.db 该变更的 no_worktree 字段;紧急情况可设置 SILLYSPEC_DISABLE_HOOKS=1 绕过限制。',
633
633
  ].join('\n')
634
634
  }
635
635
  }
package/src/index.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
10
10
  import { join, resolve } from 'path';
11
11
  import { cmdInit, getVersion } from './init.js';
12
- import { ProgressManager } from './progress.js';
12
+ import { ProgressManager, resolvePlatformSpecDir } from './progress.js';
13
13
 
14
14
  // ── CLI 入口 ──
15
15
 
@@ -18,86 +18,91 @@ function printUsage() {
18
18
  SillySpec CLI — 规范驱动开发工具包
19
19
 
20
20
  用法:
21
- sillyspec init 初始化(零交互,自动检测工具)
22
- [--tool <name>] 只安装指定工具
23
- [--interactive] 交互式引导
24
- [--dir <path>] 指定目录
25
- [--spec-dir <path>] 指定规范目录(默认 <项目>/.sillyspec)
26
-
27
- sillyspec setup [--list] 安装推荐 MCP 工具
28
- [--list] 查看已安装状态
29
-
30
- sillyspec run <stage> 执行阶段步骤(核心命令)
31
- --done --output "..." 完成当前步骤
32
- --skip 跳过可选步骤
33
- --status 查看阶段进度
34
- --reset 重置阶段(从头开始)
35
- --reopen 重新打开已完成阶段进入修订模式
36
- --from-step <index|name> 配合 --reopen:从指定步骤开始修订
37
- --change <name> 设置当前变更名
38
- --spec-dir <path> 指定规范目录(默认 <项目>/.sillyspec)
39
- --runtime-root <path> 平台模式:运行时产物根路径
40
- --workspace-id <id> 平台模式:workspace ID
41
- --scan-run-id <id> 平台模式:scan run ID
42
- auto 连续推进 brainstorm→plan→execute→verify
43
-
44
- 可选阶段:
45
- scan, brainstorm, plan, execute, verify, archive
46
- quick, explore, status, doctor
47
-
48
- Revision mode:
49
- 已完成阶段不能直接重跑。使用 --reopen --from-step 进入受控修订。
50
- 重开会使下游阶段自动标记为 stale,但不修改已有产物文件。
21
+ sillyspec init [--tool <name>] [--interactive] [--dir <path>] [--spec-dir <path>]
22
+ 初始化(零交互,自动检测工具并安装命令模板)
23
+ sillyspec setup [--list] 安装推荐 MCP 工具(--list 查看已安装状态)
24
+
25
+ sillyspec run <stage> [options] 执行阶段步骤(核心命令)
26
+ sillyspec <stage> [options] 顶层别名,等同 run <stage>
27
+ stage: scan | brainstorm | plan | execute | verify | archive |
28
+ quick | explore | status | doctor | auto
29
+
30
+ run 通用参数(所有 stage 适用):
31
+ --done --output "摘要" [--input "用户原话"] 完成当前步骤
32
+ --status 查看阶段进度
33
+ --skip 跳过可选步骤
34
+ --reset 重置阶段(从头开始)
35
+ --reopen --from-step <序号|名称> 重新打开已完成阶段进入修订
36
+ --wait --reason "..." --options "A,B" 暂停等用户决策
37
+ --continue --answer "..." 恢复等待中的步骤
38
+ --done --answer "..." --output "..." 一步完成 wait+done
39
+ --change <名> 指定变更名(多活跃变更必填,单变更可省)
40
+ --spec-dir <path> 指定规范目录(默认 <项目>/.sillyspec)
41
+ --non-interactive CI/脚本:禁用交互式 prompt
42
+ --interactive 强制交互(即便 stdin 非 TTY)
43
+ --skip-approval 跳过审批/校验门控(需明确意图)
44
+ --json 输出 JSON(程序化读取)
45
+
46
+ 阶段特有参数:
47
+ quick: --linked-changes none|a,b 显式关联变更(取代 --change,推荐)
48
+ --files a.js,b.js 显式声明允许修改的文件
49
+ --allow-new 允许新增文件(默认禁止)
50
+ --force-baseline 允许覆盖 baseline 受保护文件
51
+ --confirm 完成时确认接受变更审计
52
+ scan: --deep 强制 deep 扫描 profile
53
+ --force-rescan 覆盖已有 scan 文档保护
54
+ archive: --confirm 归档确认(必须)
55
+ auto: --mode <模式> 显式指定流程模式
56
+ 平台: --runtime-root <path> / --workspace-id <id> / --scan-run-id <id>
51
57
 
52
58
  sillyspec progress <cmd> 进度记录(轻量,不强制顺序)
53
- init 初始化项目数据库
54
- show 查看当前进度
55
- set-stage <stage> 设置当前阶段
56
- add-step <stage> <name> 添加步骤
57
- update-step <s> <n> --status <st> [--output <t>]
58
- complete-stage <stage> 标记阶段完成
59
- check 状态一致性检查(只报告,不修复)
60
- repair [--apply] 修复状态元数据(默认 dry-run,--apply 才修改)
61
- validate 校验并修复
62
- reset [--stage X] 重置进度
63
-
64
- sillyspec docs migrate 迁移旧文档到统一结构
65
-
66
- sillyspec knowledge <cmd> 知识库管理(agent-safe,输出 JSON)
67
- search --query "<text>" --limit N 搜索知识库
68
- inspect --id "<id>" 读取知识条目详情
69
- validate 校验知识库健康度
70
- refresh 从 scan 文档刷新自动知识(仅写 generated/)
71
- propose --title "<title>" --category <name> 提议新知识(写入 proposed/)
72
-
73
- sillyspec platform <cmd> SillyHub 平台同步
74
- connect <url> [--token <t>] 连接平台
75
- disconnect 断开连接
76
- sync [--change <name>] 同步变更状态
77
- sync-docs [--change <name>] 同步四件套文档
78
- status 查看同步状态
79
- approve <change-name> 审批变更
80
- reject <change-name> [--reason <r>] 拒绝变更
81
-
82
- sillyspec dashboard 启动 Dashboard Web UI
83
- [--port <number>] 指定端口(默认 3456)
84
- [--no-open] 不自动打开浏览器
85
-
86
- 选项:
87
- --json 输出 JSON(给 AI 程序化读取)
88
- --dir <path> 指定项目目录(默认当前目录)
89
- --spec-dir <path> 指定规范目录(默认 <项目目录>/.sillyspec)
59
+ init | show | set-stage <stage> | add-step <stage> <name> |
60
+ update-step <stage> <name> --status <st> [--output <t>] | complete-stage <stage> |
61
+ check | repair [--apply] | validate | reset [--stage X] |
62
+ batch --total N --completed M [--failed F] [--skipped K] | batch --status
63
+
64
+ sillyspec worktree <cmd> git worktree 隔离管理(execute 阶段相关)
65
+ create <change> [--base <branch>] 创建隔离 worktree
66
+ apply <change> [--check-only] 校验并应用变更到主工作区
67
+ assess <change> 风险审计 + 自动 apply
68
+ list | meta <change> 列出 / 读取 meta.json
69
+ cleanup <change> [--force] 清理 worktree
70
+ doctor [--fix] [--stale-hours N] 健康检查 + 修复
71
+
72
+ sillyspec workflow check <name> [--project <p>] [--change <c>] [--json]
73
+ sillyspec workflow list
74
+ sillyspec modules <rebuild | status | migrate>
75
+ sillyspec change-rename <旧变更名> <新变更名>
76
+ sillyspec knowledge <search --query "..." --limit N
77
+ | inspect --id "..."
78
+ | validate | refresh
79
+ | propose --title "..." --category <name>>
80
+ sillyspec platform <connect <url> [--token <t>]
81
+ | disconnect
82
+ | sync [--change <name>] | sync-docs [--change <name>]
83
+ | status | pointer [--cleanup]
84
+ | approve <change> | reject <change> [--reason <r>]>
85
+ sillyspec docs migrate
86
+ sillyspec dashboard [--port <N>] [--no-open]
87
+
88
+ 全局选项:
89
+ --json 输出 JSON
90
+ --dir <path> 指定项目目录(默认当前目录)
91
+ --spec-dir <path> 指定规范目录(默认 <项目目录>/.sillyspec)
92
+ --tool <name> 指定工具(init 用)
93
+ --interactive, -i 交互式引导
94
+ --version, -v 查看版本
90
95
 
91
96
  示例:
92
97
  sillyspec init
93
- sillyspec init --spec-dir /data/specs/my-project
94
- sillyspec run scan
95
- sillyspec run brainstorm
96
- sillyspec run quick
97
- sillyspec run explore
98
- sillyspec run brainstorm --done --output "需求已澄清"
99
- sillyspec setup --list
100
- sillyspec dashboard --port 8080 --no-open
98
+ sillyspec run brainstorm --change 2026-07-03-add-login
99
+ sillyspec run quick --linked-changes none --done --output "修复手机号校验"
100
+ sillyspec run verify --done --output "验证通过,测试全绿"
101
+ sillyspec run archive --done --confirm --output "归档完成"
102
+ sillyspec run plan --reopen --from-step 2 # 修订 plan,从第 2 步重做
103
+ sillyspec run quick --non-interactive --done --output "CI 内的快修" # 脚本/CI
104
+ sillyspec progress show
105
+ sillyspec worktree apply 2026-07-03-add-login
101
106
  `);
102
107
  }
103
108
 
@@ -188,7 +193,7 @@ async function main() {
188
193
  await (await import('./setup.js')).cmdSetup(dir, { json, list: setupList });
189
194
  break;
190
195
  case 'progress': {
191
- const pm = new ProgressManager();
196
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
192
197
  const progDir = specDir ? dir : resolveEffectiveDir(dir);
193
198
  const subCommand = filteredArgs[1];
194
199
  const stageIdx = filteredArgs.indexOf('--stage');
@@ -298,16 +303,75 @@ async function main() {
298
303
  await runCommand(filteredArgs.slice(1), effectiveDir, specDir)
299
304
  break
300
305
  }
301
- // task-10: 顶层命令别名,转发 runCommand,与 case 'run': 路径行为一致
302
- // help 文本(:44-46)已宣称这些 stage 可直接使用,这里补齐路由避免落 default 分支。
306
+ // 顶层命令别名:转发 runCommand,与 case 'run': 路径行为一致。
307
+ // printUsage 宣称所有 stage 都可直接使用,这里补齐全部路由避免落 default 分支。
303
308
  // 注意:filteredArgs[0] === command,直接透传 filteredArgs 即可让 runCommand
304
- // 从 args[0] 取到 stage 名(run.js:1036)。与 case 'run': 的 filteredArgs.slice(1)
305
- // 区别只在于 slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
306
- case 'doctor':
309
+ // 从 args[0] 取到 stage 名。与 case 'run': 的 filteredArgs.slice(1) 区别只在于
310
+ // slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
311
+ case 'doctor': {
312
+ const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir);
313
+ // 执行流:--cleanup-remnant / --dump-db(结构化诊断之外的修复/取证动作)
314
+ const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
315
+ const dumpDbFlag = filteredArgs.includes('--dump-db');
316
+ const doctorConfirm = filteredArgs.includes('--confirm');
317
+ const pathIdx = filteredArgs.indexOf('--path');
318
+ const dbPath = pathIdx >= 0 && filteredArgs[pathIdx + 1] ? filteredArgs[pathIdx + 1] : null;
319
+
320
+ if (cleanupRemnant) {
321
+ const { cleanupRemnantDbs } = await import('./doctor-diagnostics.js');
322
+ const r = await cleanupRemnantDbs({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
323
+ if (json) {
324
+ console.log(JSON.stringify(r, null, 2));
325
+ } else {
326
+ const list = doctorConfirm ? r.deleted : r.would_delete.map((x) => x.path);
327
+ console.log(`🗑️ 空占位 db ${doctorConfirm ? '已删除' : '待清理(dry-run)'}:${r.count} 个`);
328
+ for (const p of list) console.log(` ${doctorConfirm ? '✅' : '-'} ${p}`);
329
+ for (const e of r.errors) console.log(` ❌ ${e.path}: ${e.error}`);
330
+ if (!doctorConfirm && r.count > 0) console.log(`\n加 --confirm 执行删除(仅删 0 字节占位,不动有内容的 db)。`);
331
+ }
332
+ process.exitCode = r.errors.length > 0 ? 1 : 0;
333
+ break;
334
+ }
335
+ if (dumpDbFlag) {
336
+ if (!dbPath) { console.error('❌ --dump-db 需要 --path <db 路径>'); process.exitCode = 2; break; }
337
+ const { dumpDb } = await import('./doctor-diagnostics.js');
338
+ const r = await dumpDb({ dbPath, cwd: doctorEffectiveDir });
339
+ if (json) {
340
+ console.log(JSON.stringify(r, null, 2));
341
+ } else if (r.ok === false && r.error) {
342
+ console.error(`❌ ${r.error}`);
343
+ } else {
344
+ console.log(`📦 dump ${dbPath} (${r.meta?.size}B):${r.changes?.length || 0} changes, ${r.stages?.length || 0} stages`);
345
+ if (r.written_to) console.log(` 写入:${r.written_to}`);
346
+ }
347
+ process.exitCode = r.ok === false ? 1 : 0;
348
+ break;
349
+ }
350
+ // --json:走结构化诊断(doctor-diagnostics.js),输出 JSON + 落盘 doctor-diagnosis.json
351
+ if (json) {
352
+ const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js');
353
+ const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir });
354
+ const output = formatDoctorJson(result, { source_root: dir });
355
+ const written = writeDoctorDiagnosis(output, result.authoritySpecDir);
356
+ if (written) console.error(`📁 诊断结果已写入: ${written}`);
357
+ console.log(JSON.stringify(output, null, 2));
358
+ process.exitCode = output.overall_status === 'pass' ? 0 : 1;
359
+ break;
360
+ }
361
+ // 否则:保持原有 prompt 驱动的 bash 自检流程
362
+ const { runCommand } = await import('./run.js');
363
+ await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir);
364
+ break;
365
+ }
307
366
  case 'scan':
308
367
  case 'status':
309
368
  case 'quick':
310
- case 'explore': {
369
+ case 'explore':
370
+ case 'brainstorm':
371
+ case 'plan':
372
+ case 'execute':
373
+ case 'verify':
374
+ case 'archive': {
311
375
  const { runCommand } = await import('./run.js')
312
376
  const stageArgs = [command, ...filteredArgs.slice(1)]
313
377
  const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
@@ -347,7 +411,7 @@ async function main() {
347
411
  const wtSubCmd = filteredArgs[1];
348
412
  const wtName = filteredArgs.slice(2).find(a => !a.startsWith('-'));
349
413
  const wm = new WorktreeManager({ cwd: dir });
350
- const pm = new ProgressManager({ specDir });
414
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
351
415
 
352
416
  // isolation 写入 DB 的辅助函数
353
417
  async function _writeIsolationToDB(cwd, changeName, info) {
@@ -749,7 +813,7 @@ SillySpec platform — SillyHub 平台同步
749
813
  console.error('❌ 用法: sillyspec change-rename <旧变更名> <新变更名>');
750
814
  process.exit(1);
751
815
  }
752
- const pm = new ProgressManager({ specDir });
816
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
753
817
  await pm.renameChange(dir, oldName, newName);
754
818
  break;
755
819
  }
@@ -919,4 +983,12 @@ SillySpec modules — 模块文档管理
919
983
  }
920
984
  }
921
985
 
922
- main();
986
+ main().catch((err) => {
987
+ // 平台指针失效:fail-closed,打印修复引导而非静默回退/stack trace
988
+ if (err?.name === 'PointerUnreachableError') {
989
+ console.error(`❌ ${err.message}`);
990
+ process.exit(1);
991
+ }
992
+ console.error(err);
993
+ process.exit(1);
994
+ });
package/src/progress.js CHANGED
@@ -34,6 +34,74 @@ export function resolveSpecDir(startDir) {
34
34
  }
35
35
  return join(resolve(startDir), SPEC_DIR_NAME);
36
36
  }
37
+
38
+ /**
39
+ * 平台指针不可达错误。pointer 存在但失效时抛出,阻止静默回退到本地孤儿 db。
40
+ * 逃生口:显式 --spec-dir 绕过 pointer 校验。
41
+ */
42
+ export class PointerUnreachableError extends Error {
43
+ constructor({ pointerPath, specRoot, reason, hint }) {
44
+ super(
45
+ `平台指针不可用:${reason}\n` +
46
+ ` pointer: ${pointerPath}\n` +
47
+ ` specRoot: ${specRoot || '(缺失)'}\n` +
48
+ `修复:${hint}`
49
+ );
50
+ this.name = 'PointerUnreachableError';
51
+ this.pointerPath = pointerPath;
52
+ this.specRoot = specRoot || null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 平台感知的 specDir 解析(fail-closed,所有 CLI 子命令统一入口)。
58
+ *
59
+ * 优先级:显式 --spec-dir > pointer.specRoot(可达)> resolveSpecDir(cwd)。
60
+ *
61
+ * fail-closed 语义(修复"状态穿越"定时炸弹):
62
+ * 一旦项目进入过平台模式(pointer 文件存在),pointer 失效(specRoot 不可达 /
63
+ * 损坏 / 缺字段)不再静默回退到本地——否则会读到切平台前的过期本地 db。
64
+ * 此时抛 PointerUnreachableError,由 CLI 顶层捕获并打印修复引导。
65
+ *
66
+ * 无 pointer = 纯本地项目(从未平台 scan),正常走本地解析,不受影响。
67
+ */
68
+ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
69
+ if (explicitSpecDir) return resolve(explicitSpecDir);
70
+ const pointerPath = join(resolve(cwd), '.sillyspec-platform.json');
71
+ if (!existsSync(pointerPath)) {
72
+ return resolveSpecDir(cwd);
73
+ }
74
+ // pointer 存在 = 进过平台模式,严格校验,不静默回退
75
+ let ptr;
76
+ try {
77
+ ptr = JSON.parse(readFileSync(pointerPath, 'utf8'));
78
+ } catch (e) {
79
+ throw new PointerUnreachableError({
80
+ pointerPath,
81
+ specRoot: null,
82
+ reason: `pointer 文件损坏(${e.message})`,
83
+ hint: `sillyspec platform pointer --cleanup 删除后重跑平台 scan`,
84
+ });
85
+ }
86
+ if (!ptr.specRoot) {
87
+ throw new PointerUnreachableError({
88
+ pointerPath,
89
+ specRoot: null,
90
+ reason: 'pointer 缺少 specRoot 字段',
91
+ hint: `sillyspec platform pointer --cleanup 删除后重跑平台 scan`,
92
+ });
93
+ }
94
+ if (!existsSync(ptr.specRoot)) {
95
+ throw new PointerUnreachableError({
96
+ pointerPath,
97
+ specRoot: ptr.specRoot,
98
+ reason: 'pointer.specRoot 路径不存在(daemon 未起?已迁移?)',
99
+ hint: `启动 SillyHub daemon;或 sillyspec doctor --json 诊断;或显式 --spec-dir <本地路径> 临时走本地`,
100
+ });
101
+ }
102
+ return ptr.specRoot;
103
+ }
104
+
37
105
  const CHANGES_SUBDIR = 'changes';
38
106
  const GLOBAL_FILE = 'global.json';
39
107
  const CURRENT_VERSION = 3;