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.
- package/.claude/CLAUDE.md +23 -0
- package/.claude/skills/sillyspec-archive/SKILL.md +86 -21
- package/.claude/skills/sillyspec-auto/SKILL.md +98 -83
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +78 -44
- package/.claude/skills/sillyspec-doctor/SKILL.md +61 -31
- package/.claude/skills/sillyspec-execute/SKILL.md +90 -30
- package/.claude/skills/sillyspec-explore/SKILL.md +96 -109
- package/.claude/skills/sillyspec-export/SKILL.md +4 -0
- package/.claude/skills/sillyspec-init/SKILL.md +7 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +74 -21
- package/.claude/skills/sillyspec-propose/SKILL.md +61 -21
- package/.claude/skills/sillyspec-quick/SKILL.md +84 -21
- package/.claude/skills/sillyspec-scan/SKILL.md +74 -21
- package/.claude/skills/sillyspec-state/SKILL.md +11 -1
- package/.claude/skills/sillyspec-status/SKILL.md +55 -21
- package/.claude/skills/sillyspec-verify/SKILL.md +82 -21
- package/.claude/skills/sillyspec-workspace/SKILL.md +12 -0
- package/CLAUDE.md +4 -0
- package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +5 -0
- package/docs/sillyspec/file-lifecycle/stage-artifacts.md +3 -3
- package/docs/sillyspec/file-lifecycle.md +17 -5
- package/docs/worktree-isolation.md +1 -1
- package/package.json +2 -2
- package/src/doctor-diagnostics.js +575 -0
- package/src/hooks/worktree-guard.js +111 -111
- package/src/index.js +158 -86
- package/src/progress.js +68 -0
- package/src/quick-recommend.js +115 -0
- package/src/run.js +272 -51
- package/src/stage-contract.js +23 -5
- package/src/stages/quick.js +36 -26
- package/src/sync.js +14 -3
- package/src/worktree.js +69 -28
- package/test/decision-ref-version.mjs +85 -0
- package/test/platform-scan-p0.test.mjs +18 -7
- package/test/quick-recommend.test.mjs +146 -0
- 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
|
-
'
|
|
631
|
-
'
|
|
632
|
-
'
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
--done --output "
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--reset
|
|
35
|
-
--reopen
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
39
|
-
--
|
|
40
|
-
--
|
|
41
|
-
--
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
sillyspec
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
--
|
|
88
|
-
--
|
|
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
|
|
94
|
-
sillyspec run
|
|
95
|
-
sillyspec run
|
|
96
|
-
sillyspec run
|
|
97
|
-
sillyspec run
|
|
98
|
-
sillyspec run
|
|
99
|
-
sillyspec
|
|
100
|
-
sillyspec
|
|
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
|
-
//
|
|
302
|
-
//
|
|
306
|
+
// 顶层命令别名:转发 runCommand,与 case 'run': 路径行为一致。
|
|
307
|
+
// printUsage 宣称所有 stage 都可直接使用,这里补齐全部路由避免落 default 分支。
|
|
303
308
|
// 注意:filteredArgs[0] === command,直接透传 filteredArgs 即可让 runCommand
|
|
304
|
-
// 从 args[0] 取到 stage
|
|
305
|
-
//
|
|
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;
|