sillyspec 3.26.6 → 3.26.8

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 (42) hide show
  1. package/.claude/skills/sillyspec-doctor/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
  5. package/.husky/pre-push +9 -2
  6. package/bin/sillyspec.js +1 -1
  7. package/package.json +3 -3
  8. package/packages/dashboard/server/index.js +8 -1
  9. package/src/config-schema.js +305 -287
  10. package/src/docs-check.js +333 -0
  11. package/src/docs-debt.js +226 -0
  12. package/src/docs-gate.js +106 -0
  13. package/src/doctor-diagnostics.js +23 -2
  14. package/src/index.js +1880 -1750
  15. package/src/init.js +81 -14
  16. package/src/modules.js +490 -486
  17. package/src/progress.js +37 -13
  18. package/src/quicklog.js +19 -4
  19. package/src/run/command.js +54 -24
  20. package/src/run/complete-handlers.js +1254 -1128
  21. package/src/run/complete.js +8 -34
  22. package/src/run/gates.js +32 -3
  23. package/src/run/prompt.js +713 -633
  24. package/src/run/quick-audit.js +137 -123
  25. package/src/run/shared.js +1109 -929
  26. package/src/run/stage.js +8 -3
  27. package/src/scan-staleness.js +90 -0
  28. package/src/setup.js +27 -9
  29. package/src/sillyhub-mcp/client.js +149 -14
  30. package/src/stage-contract-spec.js +15 -0
  31. package/src/stage-contract.js +8 -5
  32. package/src/stages/brainstorm-auto.js +0 -1
  33. package/src/stages/brainstorm.js +1 -1
  34. package/src/stages/execute.js +985 -967
  35. package/src/stages/plan-postcheck.js +93 -36
  36. package/src/stages/verify.js +1 -1
  37. package/src/sync.js +60 -32
  38. package/src/task-review.js +38 -6
  39. package/src/verify-postcheck.js +22 -5
  40. package/src/worktree-apply.js +6 -3
  41. package/src/worktree-deps.js +5 -3
  42. package/src/worktree.js +4 -2
package/src/index.js CHANGED
@@ -1,1750 +1,1880 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * SillySpec CLI — 安装工具
5
- *
6
- * 只负责两件事:init(安装命令模板)和 setup(安装 MCP 工具)。
7
- * 状态管理通过 sillyspec.db(SQLite)完成,使用 `sillyspec progress` 命令。
8
- */
9
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
10
- import { writeAtomicSync } from './fs-atomic.js';
11
- import { join, resolve } from 'path';
12
- import { execSync } from 'child_process';
13
- import { git } from './git-helper.js';
14
- import { getVersion } from './version.js';
15
- import { ProgressManager, resolvePlatformSpecDir } from './progress.js';
16
-
17
- // ── CLI 入口 ──
18
-
19
- // did-you-mean 从 ./run/shared.js 复用(命令级 + flag 级 typo 建议)。
20
- import { didYouMean, assertSafeChangeName, resolveSpecDir, detectWorktreeSpecDrift } from './run/shared.js'
21
- function printUsage() {
22
- console.log(`
23
- SillySpec CLI — 规范驱动开发工具包
24
-
25
- 用法:
26
- sillyspec init [--tool <name>] [--interactive] [--dir <path>] [--spec-dir <path>]
27
- 初始化(零交互,自动检测工具并安装命令模板)
28
- sillyspec setup [--list] 安装推荐 MCP 工具(--list 查看已安装状态)
29
-
30
- sillyspec run <stage> [options] 执行阶段步骤(核心命令)
31
- sillyspec <stage> [options] 顶层别名,等同 run <stage>
32
- stage: scan | brainstorm | plan | execute | verify | archive |
33
- quick | explore | status | doctor | auto
34
-
35
- run 通用参数(所有 stage 适用):
36
- --done --output "摘要" [--input "用户原话"] 完成当前步骤
37
- --status 查看阶段进度
38
- --skip 跳过可选步骤
39
- --reset 重置阶段(从头开始)
40
- --reopen --from-step <序号|名称> 重新打开已完成阶段进入修订
41
- --wait --reason "..." --options "A,B" 暂停等用户决策
42
- --continue --answer "..." 恢复等待中的步骤
43
- --done --answer "..." --output "..." 一步完成 wait+done
44
- --change <名> 指定变更名(多活跃变更必填,单变更可省)
45
- --spec-dir <path> 指定规范目录(默认 <项目>/.sillyspec)
46
- --non-interactive CI/脚本:禁用交互式 prompt
47
- --interactive 强制交互(即便 stdin 非 TTY)
48
- --skip-approval 跳过审批/校验门控(需明确意图)
49
- --json 输出 JSON(程序化读取)
50
-
51
- 阶段特有参数:
52
- quick: --linked-changes none|a,b 显式关联变更(取代 --change,推荐)
53
- --files a.js,b.js 显式声明允许修改的文件
54
- --allow-new 允许新增文件(默认禁止)
55
- --force-baseline 允许覆盖 baseline 受保护文件
56
- --confirm 完成时确认接受变更审计
57
- scan: --quick | --standard | --deep 显式选择 profile(优先于规模自动判定;三档互斥)
58
- --force-rescan 覆盖已有 scan 文档保护
59
- archive: --confirm 归档确认(必须)
60
- auto: --mode <模式> 显式指定流程模式
61
- 平台: --runtime-root <path> / --workspace-id <id> / --scan-run-id <id>
62
-
63
- sillyspec progress <cmd> 进度记录(轻量,不强制顺序)
64
- init | show | set-stage <stage> | add-step <stage> <name> |
65
- update-step <stage> <name> --status <st> [--output <t>] [--force] | complete-stage <stage> [--force] |
66
- check | repair [--apply] | validate | reset [--stage X] |
67
- batch --total N --completed M [--failed F] [--skipped K] | batch --status
68
-
69
- sillyspec worktree <cmd> git worktree 隔离管理(execute 阶段相关)
70
- create <change> [--base <branch>] 创建隔离 worktree
71
- apply <change> [--check-only] 校验并应用变更到主工作区
72
- assess <change> 风险审计 + 自动 apply
73
- list | meta <change> 列出 / 读取 meta.json
74
- cleanup <change> [--force] 清理 worktree
75
- doctor [--fix] [--stale-hours N] 健康检查 + 修复
76
-
77
- sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
78
- sillyspec config [schema] [--json] 打印 local.yaml 全部已知键 + 生效状态 + 读取点(堵外部 agent 配置发现缺口)
79
- sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
80
- sillyspec dispatch <probe | hint> SillyHub 派发能力探测 + 策略生成(agent 调用桥,仅渲染不执行 tool)
81
-
82
- sillyspec workflow check <name> [--project <p>] [--change <c>] [--json] [--save]
83
- 全局 workflow 校验(--save 归档到 .sillyspec/.runtime/workflow-runs/)
84
- sillyspec workflow list
85
- sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
86
- sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
87
- sillyspec backfill-reviews --change <name> [--json] 为手动补的 task 生成 review.json 草稿(cannot_verify,解 archive 客观完成度阻断)
88
- sillyspec register-stage-review --change <name> --stage <brainstorm|plan|execute> [--from <review.json>] [--json]
89
- 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker,治 tier=independent marker 死锁)
90
-
91
- sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
92
- (无子命令) 跑诊断,列出问题
93
- --align-execute-progress [--change <名>] [--confirm] 基于 plan.md 对齐 execute 派生戳(默认 dry-run)
94
- --cleanup-remnant [--confirm] 删除 0 字节空占位 db(默认 dry-run,仅 --confirm 落盘)
95
- --dump-db --path <db 路径> dump 指定 db 内容到文件(取证用)
96
- --json 结构化诊断 + 落盘 .sillyspec/.runtime/doctor-diagnosis.json
97
- sillyspec modules <rebuild | status | migrate>
98
- sillyspec change-rename <旧变更名> <新变更名>
99
- sillyspec knowledge <search --query "..." --limit N
100
- | inspect --id "..."
101
- | validate | refresh
102
- | propose --title "..." --category <name>>
103
- sillyspec platform <connect <url> [--token <t>]
104
- | disconnect
105
- | sync [--change <name>] | sync-docs [--change <name>]
106
- | status | pointer [--cleanup]
107
- | approve <change> | reject <change> [--reason <r>]>
108
- sillyspec docs migrate
109
- sillyspec dashboard [--port <N>] [--no-open]
110
-
111
- 全局选项:
112
- --json 输出 JSON
113
- --dir <path> 指定项目目录(默认当前目录)
114
- --spec-dir <path> 指定规范目录(默认 <项目目录>/.sillyspec)
115
- --tool <name> 指定工具(init 用)
116
- --interactive, -i 交互式引导
117
- --version, -v 查看版本
118
-
119
- 示例:
120
- sillyspec init
121
- sillyspec run brainstorm --change 2026-07-03-add-login
122
- sillyspec run quick --linked-changes none --done --output "修复手机号校验"
123
- sillyspec run verify --done --output "验证通过,测试全绿"
124
- sillyspec run archive --done --confirm --output "归档完成"
125
- sillyspec run plan --reopen --from-step 2 # 修订 plan,从第 2 步重做
126
- sillyspec run quick --non-interactive --done --output "CI 内的快修" # 脚本/CI
127
- sillyspec progress show
128
- sillyspec worktree apply 2026-07-03-add-login
129
- `);
130
- }
131
-
132
- // --json 模式输出纪律(design §8 风险表):machine-interface 的 runGate/runDerive 执行期间,
133
- // 被调模块(validators/task-review/verify-postcheck)的人类可读 console.log 会污染 stdout 破坏 JSON。
134
- // 在整个调用期间把 console.log/info 重定向到 stderr,stdout 留给最终 JSON envelope(D-005@v1)。
135
- async function withJsonOutput(json, fn) {
136
- if (!json) return fn();
137
- const origLog = console.log;
138
- const origInfo = console.info;
139
- console.log = (...a) => process.stderr.write(a.map(String).join(' ') + '\n');
140
- console.info = (...a) => process.stderr.write(a.map(String).join(' ') + '\n');
141
- try {
142
- return await fn();
143
- } finally {
144
- console.log = origLog;
145
- console.info = origInfo;
146
- }
147
- }
148
-
149
- async function main() {
150
- const args = process.argv.slice(2);
151
-
152
- if (args[0] === '--version' || args[0] === '-v') {
153
- console.log(getVersion());
154
- process.exit(0);
155
- }
156
-
157
- if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
158
- printUsage();
159
- process.exit(0);
160
- }
161
-
162
- // 解析全局选项
163
- let json = false;
164
- let saveWorkflowRunFlag = false;
165
- let targetDir = process.cwd();
166
- let specDir = null;
167
- let tool = null;
168
- let interactive = false;
169
- const filteredArgs = [];
170
-
171
- for (let i = 0; i < args.length; i++) {
172
- if (args[i] === '--json') {
173
- json = true;
174
- } else if (args[i] === '--save') {
175
- saveWorkflowRunFlag = true;
176
- } else if (args[i] === '--dir' && args[i + 1]) {
177
- targetDir = resolve(args[i + 1]);
178
- i++;
179
- } else if (args[i] === '--spec-dir' && args[i + 1]) {
180
- specDir = resolve(args[i + 1]);
181
- i++;
182
- } else if (args[i] === '--tool' && args[i + 1]) {
183
- tool = args[i + 1];
184
- i++;
185
- } else if (args[i] === '--interactive' || args[i] === '-i') {
186
- interactive = true;
187
- } else if (args[i] === '--list' || args[i] === '-l') {
188
- filteredArgs.push('--list');
189
- } else {
190
- filteredArgs.push(args[i]);
191
- }
192
- }
193
-
194
- // F3:--interactive(全局 flag,被上面吞进 interactive 变量)与 --non-interactive(透传到 filteredArgs)
195
- // 语义相反,同时给是矛盾。在此层检测(command.js 看不到 --interactive)。
196
- if (interactive && filteredArgs.includes('--non-interactive')) {
197
- console.error('❌ --non-interactive 与 --interactive 互斥(一个禁用交互、一个强制交互),请只保留一个。');
198
- process.exit(2);
199
- }
200
-
201
- const command = filteredArgs[0];
202
- // 支持 sillyspec init /path/to/project 语法:如果第二个参数看起来像路径,当作 targetDir
203
- if (command === 'init' && filteredArgs[1] && !filteredArgs[1].startsWith('-')) {
204
- targetDir = resolve(filteredArgs[1]);
205
- filteredArgs.splice(1, 1);
206
- }
207
- // ── 自动纠正 cwd ──
208
- // 当 agent 在 worktree 内跑 pnpm 等工具后 shell cwd 可能被改变,
209
- // 导致 sillyspec 命令找不到 .sillyspec。此函数尝试从 git root 解析。
210
- function resolveEffectiveDir(baseDir) {
211
- if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
212
- try {
213
- const gitRoot = execSync('git rev-parse --show-toplevel', {
214
- cwd: baseDir, encoding: 'utf8', timeout: 5000
215
- }).trim()
216
- if (gitRoot && existsSync(join(gitRoot, '.sillyspec'))) return gitRoot
217
- } catch {}
218
- return baseDir
219
- }
220
-
221
- const dir = targetDir;
222
-
223
- if (command === 'init' && !existsSync(dir)) {
224
- const { mkdirSync } = await import('fs');
225
- mkdirSync(dir, { recursive: true });
226
- }
227
-
228
- if (!existsSync(dir)) {
229
- console.error(`❌ 目录不存在: ${dir}`);
230
- process.exit(1);
231
- }
232
-
233
- switch (command) {
234
- case 'init':
235
- await (await import('./init.js')).cmdInit(dir, { tool, interactive, specDir });
236
- break;
237
- case 'setup':
238
- const setupList = filteredArgs.includes('--list') || filteredArgs.includes('-l');
239
- await (await import('./setup.js')).cmdSetup(dir, { json, list: setupList });
240
- break;
241
- case 'progress': {
242
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
243
- const progDir = specDir ? dir : resolveEffectiveDir(dir);
244
- const subCommand = filteredArgs[1];
245
- const stageIdx = filteredArgs.indexOf('--stage');
246
- const stage = stageIdx >= 0 && filteredArgs[stageIdx + 1] ? filteredArgs[stageIdx + 1] : null;
247
- // 解析 --change 参数
248
- const progChangeIdx = args.indexOf('--change');
249
- const progChangeName = progChangeIdx >= 0 && args[progChangeIdx + 1] ? args[progChangeIdx + 1] : null;
250
-
251
- switch (subCommand) {
252
- case 'init':
253
- pm.init(progDir);
254
- break;
255
- case 'status':
256
- case 'show':
257
- pm.show(progDir, progChangeName);
258
- break;
259
- case 'check': {
260
- const result = pm.checkConsistency(progDir, progChangeName);
261
- // checkConsistency 只返回 {ok, issues, warnings},不打印——必须在此输出,
262
- // 否则 agent 跑只读健康检查得到一片寂静,无法分辨「正常」还是「已损坏」。
263
- if (!result || result.ok) {
264
- console.log(`✅ 状态一致性检查通过${progChangeName ? `(变更 ${progChangeName})` : '(所有变更)'}`);
265
- } else {
266
- console.error(`❌ 状态一致性检查发现问题(${result.issues?.length || 0} 项):`);
267
- for (const issue of (result.issues || [])) console.error(` - ${issue}`);
268
- console.error(` 修复:sillyspec progress repair${progChangeName ? ` --change ${progChangeName}` : ''}(先 dry-run 看可修项,加 --apply 执行)`);
269
- }
270
- break;
271
- }
272
- case 'repair': {
273
- const repairApply = filteredArgs.includes('--apply');
274
- pm.repairConsistency(progDir, { apply: repairApply, changeName: progChangeName });
275
- break;
276
- }
277
- case 'validate':
278
- pm.validate(dir, progChangeName);
279
- break;
280
- case 'reset':
281
- pm.reset(dir, stage, progChangeName);
282
- break;
283
- case 'set-stage': {
284
- const setStageName = filteredArgs[2];
285
- if (!setStageName) { console.log('❌ 用法: sillyspec progress set-stage <stage> [--change <name>]'); break; }
286
- pm.setStage(dir, setStageName, progChangeName);
287
- break;
288
- }
289
- case 'add-step': {
290
- const addStepStage = filteredArgs[2];
291
- const addStepName = filteredArgs[3];
292
- if (!addStepStage || !addStepName) { console.log('❌ 用法: sillyspec progress add-step <stage> <step-name> [--change <name>]'); break; }
293
- pm.addStep(dir, addStepStage, addStepName, progChangeName);
294
- break;
295
- }
296
- case 'update-step': {
297
- const updStepStage = filteredArgs[2];
298
- const updStepName = filteredArgs[3];
299
- if (!updStepStage || !updStepName) { console.log('❌ 用法: sillyspec progress update-step <stage> <step-name> --status <status> [--output <text>] [--change <name>]'); break; }
300
- let updStatus = null, updOutput = undefined;
301
- for (let ai = 0; ai < args.length; ai++) {
302
- if (args[ai] === '--status' && args[ai + 1]) { updStatus = args[ai + 1]; ai++; }
303
- if (args[ai] === '--output' && args[ai + 1]) { updOutput = args[ai + 1]; ai++; }
304
- }
305
- const updForce = args.includes('--force');
306
- pm.updateStep(dir, updStepStage, updStepName, { status: updStatus, output: updOutput, force: updForce }, progChangeName);
307
- break;
308
- }
309
- case 'complete-stage': {
310
- const compStageName = filteredArgs[2];
311
- if (!compStageName) { console.log('❌ 用法: sillyspec progress complete-stage <stage> [--force]'); break; }
312
- pm.completeStage(dir, compStageName, progChangeName, { force: args.includes('--force') });
313
- break;
314
- }
315
- case 'batch': {
316
- if (filteredArgs.includes('--status')) {
317
- const bp = pm.readBatchProgress(dir, progChangeName);
318
- if (!bp) { console.log('📭 无批量进度数据'); break; }
319
- const line = pm._renderBatchProgress(bp);
320
- console.log(line || '📭 无批量进度数据');
321
- console.log(JSON.stringify(bp, null, 2));
322
- } else {
323
- let batchData = {};
324
- const a = args;
325
- for (let i = 0; i < a.length; i++) {
326
- if (a[i] === '--total' && a[i + 1]) { batchData.total = parseInt(a[i + 1]); i++; }
327
- if (a[i] === '--completed' && a[i + 1]) { batchData.completed = parseInt(a[i + 1]); i++; }
328
- if (a[i] === '--failed' && a[i + 1]) { batchData.failed = parseInt(a[i + 1]); i++; }
329
- if (a[i] === '--skipped' && a[i + 1]) { batchData.skipped = parseInt(a[i + 1]); i++; }
330
- }
331
- if (Object.keys(batchData).length === 0) {
332
- console.log('用法: sillyspec progress batch --total 100 --completed 73');
333
- console.log(' sillyspec progress batch --status');
334
- break;
335
- }
336
- pm.updateBatchProgress(dir, batchData, progChangeName);
337
- console.log('✅ 批量进度已更新');
338
- }
339
- break;
340
- }
341
- default:
342
- console.log('用法: sillyspec progress <init|show|validate|reset|set-stage|add-step|update-step|complete-stage>');
343
- }
344
- break;
345
- }
346
- case 'gate': {
347
- // 机器门控(machine-interface v1):聚合「变更的 <stage> 阶段此刻能否标记完成」的综合结论。
348
- // 只读查询,不依赖 worktree、不写状态(D-002@v1)。
349
- const gateStage = filteredArgs[1];
350
- const gateChangeIdx = args.indexOf('--change');
351
- const gateChange = gateChangeIdx >= 0 && args[gateChangeIdx + 1] ? args[gateChangeIdx + 1] : null;
352
- if (!gateStage || gateStage.startsWith('-') || !gateChange) {
353
- console.error('用法: sillyspec gate <stage> --change <name> [--json]\n stage: brainstorm | plan | execute | verify | archive | ...');
354
- process.exit(2);
355
- }
356
- const { runGate } = await import('./machine-interface.js');
357
- // --spec-dir 透传 runGate.specBase(gate CLI 接线修复,P3 坑 3 sillyspec 侧)。
358
- // runGate 已分离 cwd(跑测试) + specBase(读 local.yaml/spec),但 gate case 之前
359
- // 只传 cwd 致 --spec-dir 对 gate verify 无效。不传 --spec-dir 时 specBase 走默认
360
- // resolveSpecDir(cwd),向后兼容。
361
- const gateOpts = { cwd: dir };
362
- if (specDir) gateOpts.specBase = specDir;
363
- // drift 锚定(坑 execute-runs-isolation,对齐 command.js execute drift 守卫 + machine-interface
364
- // runGate 已扩展的 specDriftAnchor 入参):gate 是顶层命令不走 runCommand/drift 守卫,worktree cwd
365
- // 下 execute 段 marker 读取会落副本 .runtime。仅在未显式 --spec-dir 时算(对齐守卫条件 !specDir),
366
- // 命中则 specDriftAnchor 锚主仓——runGate execute 段的 resolveRuntimeRoot 读主仓
367
- // current-execute-run-id marker,不读 worktree 副本(副本随 cleanup 整目录删消失)。
368
- if (!specDir) {
369
- const wt = detectWorktreeSpecDrift(resolveSpecDir(dir));
370
- if (wt) gateOpts.specDriftAnchor = wt.mainSpecBase;
371
- }
372
- // W3 task-09:gate 是只读查询命令,best-effort 构造 ctx(D-013)。失败降级 null(不阻断只读命令)——
373
- // ctx 让 validateTaskReviews/runVerifyTestCheck 正确切跨仓 gitDir;配置错时单仓降级会让两函数
374
- // 自然报「跨仓 commit 找不到」等错误进入 check.errors,gate 结论仍如实反映问题(D-002@v1 只读契约不破)。
375
- try {
376
- const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
377
- const _ctx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: gateChange, platformOpts: specDir ? { specRoot: specDir } : {} });
378
- if (_ctx) gateOpts.ctx = _ctx;
379
- } catch (e) {
380
- console.warn(`⚠️ gate ctx 构造失败,降级单仓核验(${e.message})`);
381
- }
382
- const { envelope, exitCode } = await withJsonOutput(json, () => runGate(gateStage, gateChange, gateOpts));
383
- if (json) {
384
- process.stdout.write(JSON.stringify(envelope));
385
- } else {
386
- const icon = envelope.ok ? '✅' : '❌';
387
- console.log(`${icon} gate ${gateStage} [${gateChange}]: ${envelope.ok ? '通过' : '未通过'} (exit ${exitCode})`);
388
- for (const c of (envelope.checks || [])) {
389
- const ci = c.ok ? '✅' : '❌';
390
- const tag = c.informational ? ' (informational)' : '';
391
- console.log(` ${ci} ${c.id}${tag}`);
392
- for (const e of (c.errors || [])) console.log(` ✗ ${e}`);
393
- for (const w of (c.warnings || [])) console.log(` ⚠ ${w}`);
394
- }
395
- // 顶层 errors/warnings 是各 check 的聚合,已在上面按 check 分组显示;不再重复打印——
396
- // 非 json 是 agent 默认消费路径,重复文本纯耗 context(W1-J)。
397
- }
398
- process.exitCode = exitCode;
399
- break;
400
- }
401
- case 'derive': {
402
- // 单项事实核验(machine-interface v1):查询变更某一 facet 的真实状态,返回结构化 data。
403
- const facet = filteredArgs[1];
404
- const deriveChangeIdx = args.indexOf('--change');
405
- const deriveChange = deriveChangeIdx >= 0 && args[deriveChangeIdx + 1] ? args[deriveChangeIdx + 1] : null;
406
- if (!facet || facet.startsWith('-') || !deriveChange) {
407
- console.error('用法: sillyspec derive <facet> --change <name> [--json]\n facet: execute-evidence | verify-test | task-reviews | artifacts');
408
- process.exit(2);
409
- }
410
- const { runDerive } = await import('./machine-interface.js');
411
- // --spec-dir 透传 runDerive.specBase(与 gate case 对称,P3 坑 3 sillyspec 侧)。
412
- // derive verify-test facet 内部同样调 runVerifyTestCheck 依赖 specBase,平台模式
413
- // 下需透传;不传 --spec-dir 时走默认 resolveSpecDir(cwd),向后兼容。
414
- const deriveOpts = { cwd: dir };
415
- if (specDir) deriveOpts.specBase = specDir;
416
- // drift 锚定(同 gate case,对齐 command.js drift 守卫 + machine-interface runDerive 的 specDriftAnchor
417
- // 入参):derive 顶层命令不走 drift 守卫,worktree cwd 下 task-reviews facet 的 execute-run-id marker
418
- // 读取会落副本。未显式 --spec-dir 时算 anchor,runDerive task-reviews 的 resolveRuntimeRoot 读主仓 marker。
419
- if (!specDir) {
420
- const wt = detectWorktreeSpecDrift(resolveSpecDir(dir));
421
- if (wt) deriveOpts.specDriftAnchor = wt.mainSpecBase;
422
- }
423
- // W3 task-09:derive 同 gate,best-effort 构造 ctx 透传给 verify-test/task-reviews facet(D-013)。
424
- try {
425
- const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
426
- const _ctx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: deriveChange, platformOpts: specDir ? { specRoot: specDir } : {} });
427
- if (_ctx) deriveOpts.ctx = _ctx;
428
- } catch (e) {
429
- console.warn(`⚠️ derive ctx 构造失败,降级单仓核验(${e.message})`);
430
- }
431
- const { envelope, exitCode } = await withJsonOutput(json, () => runDerive(facet, deriveChange, deriveOpts));
432
- if (json) {
433
- process.stdout.write(JSON.stringify(envelope));
434
- } else {
435
- const icon = envelope.ok ? '✅' : '❌';
436
- console.log(`${icon} derive ${facet} [${deriveChange}]: ${envelope.ok ? '通过' : '未通过'} (exit ${exitCode})`);
437
- if (envelope.data) console.log(` data: ${JSON.stringify(envelope.data, null, 2)}`);
438
- for (const e of (envelope.errors || [])) console.log(` ✗ ${e}`);
439
- for (const w of (envelope.warnings || [])) console.log(` ⚠ ${w}`);
440
- }
441
- process.exitCode = exitCode;
442
- break;
443
- }
444
- case 'backfill-reviews': {
445
- // 手动补 task(reopen execute / 直接实现)缺 review.json 的官方入口(坑 verify-archive-flow-pitfalls 坑2)。
446
- // 复用 generateTaskReviewDrafts(execute --done 同源兜底,幂等、fail-open):据 git diff base..head + working-tree
447
- // 按 task allowed_paths 归属生成 cannot_verify 草稿,agent 复核后升级 pass/fail。execute 完成后手动补的 task
448
- // 无子代理 review 落盘 → archive step1 客观完成度(真相源=review.json verdict)判缺 → 阻断归档;本命令补齐
449
- // 缺失草稿,独立可随时跑(不必再 execute --done)。缺数据(无 tasks/ 目录 / 改动未 commit / 无 worktree meta)
450
- // 时 generateTaskReviewDrafts 提前返回 reason,如实打印不报错。
451
- const brChangeIdx = args.indexOf('--change');
452
- const brChange = brChangeIdx >= 0 && args[brChangeIdx + 1] ? args[brChangeIdx + 1] : null;
453
- if (!brChange) {
454
- console.error('用法: sillyspec backfill-reviews --change <name> [--json] [--spec-dir <path>]\n 为手动补的 task 生成 review.json 草稿(cannot_verify),解 archive 客观完成度阻断');
455
- process.exit(2);
456
- }
457
- const { generateTaskReviewDrafts } = await import('./task-review.js');
458
- // --spec-dir 透传 platformOpts.specRoot(与 gate/derive 对称);不传走默认 join(cwd,'.sillyspec')。
459
- const brPlatformOpts = {};
460
- if (specDir) brPlatformOpts.specRoot = specDir;
461
- // W3 task-09:best-effort 构造 ctx 透传(D-013),让 task-04 generateTaskReviewDrafts 跨仓 task
462
- // 用跨仓 gitDir 取 base..head diff。失败降级 null(与 gate/derive 同语义,辅助修复命令不阻断)。
463
- let _brCtx = null;
464
- try {
465
- const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
466
- _brCtx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: brChange, platformOpts: brPlatformOpts });
467
- } catch (e) {
468
- console.warn(`⚠️ backfill-reviews ctx 构造失败,降级单仓草稿(${e.message})`);
469
- }
470
- const result = await generateTaskReviewDrafts({ changeName: brChange, cwd: dir, platformOpts: brPlatformOpts, ctx: _brCtx });
471
- if (json) {
472
- process.stdout.write(JSON.stringify({ ok: true, command: 'backfill-reviews', change: brChange, ...result }));
473
- } else {
474
- if (result.generated > 0) {
475
- console.log('📄 已补写 ' + result.generated + ' 个 per-task review.json 草稿(cannot_verify,需 agent 复核后升级 pass/fail) [' + brChange + ']');
476
- } else {
477
- console.log('ℹ️ 无草稿可补 [' + brChange + ']' + (result.reason ? ':' + result.reason : ''));
478
- }
479
- if (result.skipped > 0) {
480
- console.log(' 跳过 ' + result.skipped + ' 个 task(review.json 已存在 / 空 diff 无从归属)');
481
- }
482
- if (result.unattributed && result.unattributed.length > 0) {
483
- console.warn(' ⚠️ ' + result.unattributed.length + ' 个变更文件未归属任何 task(顺带修复/非源码),草稿未覆盖:' + result.unattributed.join(', '));
484
- }
485
- if (result.executeRunId) {
486
- console.log(' execute run id: ' + result.executeRunId);
487
- }
488
- }
489
- break;
490
- }
491
- case 'register-stage-review': {
492
- // 手动注册 stage 级 review(brainstorm/plan/execute-acceptance)的确定性 writer ——
493
- // 生成 run 目录 + review.json 骨架(cannot_verify,待独立审查子代理填 verdict)或 adopt
494
- // agent 草稿(--from,保留 verdict/checklist 重算 docHash)+ 写 marker。治 tier=independent 时
495
- // 调度者手动派独立子代理不写 marker 的死锁。docHash 由 CLI computeDocHash 算(部分实现 P6.1b defer)。
496
- // 纯新增,不改 gate 语义。与 task 级 backfill-reviews 对称(stage 级等价物)。
497
- const rsrChangeIdx = args.indexOf('--change');
498
- const rsrChange = rsrChangeIdx >= 0 && args[rsrChangeIdx + 1] ? args[rsrChangeIdx + 1] : null;
499
- const rsrStageIdx = args.indexOf('--stage');
500
- const rsrStage = rsrStageIdx >= 0 && args[rsrStageIdx + 1] ? args[rsrStageIdx + 1] : null;
501
- const rsrFromIdx = args.indexOf('--from');
502
- const rsrFrom = rsrFromIdx >= 0 && args[rsrFromIdx + 1] ? args[rsrFromIdx + 1] : null;
503
- if (!rsrChange || !rsrStage) {
504
- console.error('用法: sillyspec register-stage-review --change <名> --stage <brainstorm|plan|execute> [--from <review.json>] [--spec-dir <path>] [--json]\n 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker),治 tier=independent marker 死锁');
505
- process.exit(2);
506
- }
507
- const { registerStageReview } = await import('./stage-review.js');
508
- const rsrPlatformOpts = {};
509
- if (specDir) rsrPlatformOpts.specRoot = specDir;
510
- try {
511
- const result = registerStageReview({ changeName: rsrChange, stage: rsrStage, fromFile: rsrFrom, cwd: dir, platformOpts: rsrPlatformOpts });
512
- if (json) {
513
- process.stdout.write(JSON.stringify({ ok: true, command: 'register-stage-review', change: rsrChange, stage: rsrStage, ...result }));
514
- } else {
515
- console.log(`✅ 已注册 ${rsrStage} stage review [${result.reviewRunId}] → ${result.reviewPath}(mode: ${result.mode})`);
516
- console.log(` marker → ${result.markerPath}`);
517
- console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
518
- }
519
- } catch (e) {
520
- if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', error: e.message }));
521
- else console.error('❌ ' + e.message);
522
- process.exitCode = 1;
523
- }
524
- break;
525
- }
526
- case 'docs': {
527
- const docsSubCmd = filteredArgs[1];
528
- if (docsSubCmd === 'migrate') {
529
- const { migrateDocs } = await import('./migrate.js');
530
- migrateDocs(dir);
531
- } else {
532
- console.log('用法: sillyspec docs migrate');
533
- }
534
- break;
535
- }
536
- case 'run': {
537
- // doctor --json 等价 doctor 顶层 --json(结构化诊断),与 case 'doctor' 行为一致——
538
- // 否则 sillyspec doctor --json 走 runDoctorDiagnostics,sillyspec run doctor --json 走 runCommand
539
- // prompt 自检,两者输出不等价(违反"顶层别名 == run <stage>"契约)。
540
- if (filteredArgs[1] === 'doctor' && json) {
541
- const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir)
542
- const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js')
543
- const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir })
544
- const output = formatDoctorJson(result, { source_root: dir })
545
- const written = writeDoctorDiagnosis(output, result.authoritySpecDir)
546
- if (written) console.error(`📁 诊断结果已写入: ${written}`)
547
- console.log(JSON.stringify(output, null, 2))
548
- process.exitCode = output.overall_status === 'pass' ? 0 : 1
549
- break
550
- }
551
- const { runCommand } = await import('./run.js')
552
- // 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
553
- const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
554
- await runCommand(filteredArgs.slice(1), effectiveDir, specDir, { json })
555
- break
556
- }
557
- // 顶层命令别名:转发 runCommand,与 case 'run': 路径行为一致。
558
- // printUsage 宣称所有 stage 都可直接使用,这里补齐全部路由避免落 default 分支。
559
- // 注意:filteredArgs[0] === command,直接透传 filteredArgs 即可让 runCommand
560
- // 从 args[0] 取到 stage 名。与 case 'run': 的 filteredArgs.slice(1) 区别只在于
561
- // slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
562
- case 'doctor': {
563
- const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir);
564
- // 执行流:--cleanup-remnant / --dump-db(结构化诊断之外的修复/取证动作)
565
- const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
566
- const dumpDbFlag = filteredArgs.includes('--dump-db');
567
- const doctorConfirm = filteredArgs.includes('--confirm');
568
- const pathIdx = filteredArgs.indexOf('--path');
569
- const dbPath = pathIdx >= 0 && filteredArgs[pathIdx + 1] ? filteredArgs[pathIdx + 1] : null;
570
-
571
- // --align-execute-progress:基于 plan.md 声明对齐 execute 派生戳(仿 --cleanup-remnant 范式)
572
- // 默认 dry-run(只报告将补哪些 step),加 --confirm 才写。命中即 break,绝不 fall-through。
573
- const alignFlag = filteredArgs.includes('--align-execute-progress');
574
- if (alignFlag) {
575
- // --change 解析:显式优先,缺省用单活跃变更自动兜底(与 run.js resolveChangeNameAuto 同逻辑)
576
- const alignChangeIdx = filteredArgs.indexOf('--change');
577
- let alignChange = alignChangeIdx >= 0 && filteredArgs[alignChangeIdx + 1] ? filteredArgs[alignChangeIdx + 1] : null;
578
- if (!alignChange) {
579
- const changesDir = join(resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec'), 'changes');
580
- if (existsSync(changesDir)) {
581
- const activeChanges = readdirSync(changesDir, { withFileTypes: true })
582
- .filter(e => e.isDirectory() && e.name !== 'archive')
583
- .map(e => e.name);
584
- if (activeChanges.length === 1) alignChange = activeChanges[0];
585
- }
586
- }
587
- if (!alignChange) {
588
- console.error('❌ 无法确定变更名:请用 --change <name>,或确保仅有一个活跃变更');
589
- process.exitCode = 2;
590
- break;
591
- }
592
-
593
- // 复用顶层静态 import(line 12),不动态 await import
594
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(doctorEffectiveDir, specDir) });
595
- const specBase = resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec');
596
- let r;
597
- try {
598
- r = pm.alignExecuteToPlan(doctorEffectiveDir, alignChange, specBase, { confirm: doctorConfirm });
599
- } catch (e) {
600
- console.error(`❌ 对齐失败:${e.message}`);
601
- process.exitCode = 1;
602
- break;
603
- }
604
-
605
- if (json) {
606
- console.log(JSON.stringify(r, null, 2));
607
- } else if (r.ok === false) {
608
- console.error(`❌ ${r.reason || '对齐未执行'}`);
609
- } else {
610
- console.log(`✅ 已基于 plan.md 声明对齐 ${r.aligned} 个 step(plan: ${r.planChecked}/${r.planTotal})。`);
611
- if (!doctorConfirm) {
612
- console.log(` (dry-run:未写盘。加 --confirm 实际落盘,并置 execute 阶段 status=completed。)`);
613
- } else {
614
- console.log(` 已落盘,execute 阶段 status=completed。请确认 verify 通过。`);
615
- }
616
- }
617
- // ok=false 或 reason 非空 → 1;否则 0(与 --cleanup-remnant 的 r.errors 逻辑同构)
618
- process.exitCode = (r.ok === false || r.reason) ? 1 : 0;
619
- break;
620
- }
621
-
622
- if (cleanupRemnant) {
623
- const { cleanupRemnantDbs } = await import('./doctor-diagnostics.js');
624
- const r = await cleanupRemnantDbs({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
625
- if (json) {
626
- console.log(JSON.stringify(r, null, 2));
627
- } else {
628
- const list = doctorConfirm ? r.deleted : r.would_delete.map((x) => x.path);
629
- console.log(`🗑️ 空占位 db ${doctorConfirm ? '已删除' : '待清理(dry-run)'}:${r.count} 个`);
630
- for (const p of list) console.log(` ${doctorConfirm ? '✅' : '-'} ${p}`);
631
- for (const e of r.errors) console.log(` ❌ ${e.path}: ${e.error}`);
632
- if (!doctorConfirm && r.count > 0) console.log(`\n加 --confirm 执行删除(仅删 0 字节占位,不动有内容的 db)。`);
633
- }
634
- process.exitCode = r.errors.length > 0 ? 1 : 0;
635
- break;
636
- }
637
- if (dumpDbFlag) {
638
- if (!dbPath) { console.error('❌ --dump-db 需要 --path <db 路径>'); process.exitCode = 2; break; }
639
- const { dumpDb } = await import('./doctor-diagnostics.js');
640
- const r = await dumpDb({ dbPath, cwd: doctorEffectiveDir });
641
- if (json) {
642
- console.log(JSON.stringify(r, null, 2));
643
- } else if (r.ok === false && r.error) {
644
- console.error(`❌ ${r.error}`);
645
- } else {
646
- console.log(`📦 dump ${dbPath} (${r.meta?.size}B):${r.changes?.length || 0} changes, ${r.stages?.length || 0} stages`);
647
- if (r.written_to) console.log(` 写入:${r.written_to}`);
648
- }
649
- process.exitCode = r.ok === false ? 1 : 0;
650
- break;
651
- }
652
- // --json:走结构化诊断(doctor-diagnostics.js),输出 JSON + 落盘 doctor-diagnosis.json
653
- if (json) {
654
- const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js');
655
- const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir });
656
- const output = formatDoctorJson(result, { source_root: dir });
657
- const written = writeDoctorDiagnosis(output, result.authoritySpecDir);
658
- if (written) console.error(`📁 诊断结果已写入: ${written}`);
659
- console.log(JSON.stringify(output, null, 2));
660
- process.exitCode = output.overall_status === 'pass' ? 0 : 1;
661
- break;
662
- }
663
- // 否则:保持原有 prompt 驱动的 bash 自检流程
664
- const { runCommand } = await import('./run.js');
665
- await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir, { json });
666
- break;
667
- }
668
- case 'scan':
669
- case 'status':
670
- case 'quick':
671
- case 'explore':
672
- case 'brainstorm':
673
- case 'plan':
674
- case 'execute':
675
- case 'verify':
676
- case 'archive': {
677
- const { runCommand } = await import('./run.js')
678
- const stageArgs = [command, ...filteredArgs.slice(1)]
679
- const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
680
- // 下行 pull:stage 命令(run/--done,含 archive)启动时拉一次(task-10 / D-009 / FR-04)
681
- // 不在每步 pull,仅低频边界点;未连接平台静默跳过
682
- const { triggerPullActiveChange } = await import('./run/shared.js')
683
- await triggerPullActiveChange(effectiveDir)
684
- await runCommand(stageArgs, effectiveDir, specDir, { json })
685
- break
686
- }
687
- case 'knowledge': {
688
- const { cmdKnowledge } = await import('./stages/knowledge.js')
689
- await cmdKnowledge(filteredArgs.slice(1), specDir ? dir : resolveEffectiveDir(dir), { specDir })
690
- break
691
- }
692
- case 'dashboard': {
693
- // Parse dashboard options
694
- let port = 3456;
695
- let openBrowser = true;
696
-
697
- for (let i = 1; i < args.length; i++) {
698
- if (args[i] === '--port' && args[i + 1]) {
699
- port = parseInt(args[i + 1], 10);
700
- i++;
701
- } else if (args[i] === '--no-open') {
702
- openBrowser = false;
703
- }
704
- }
705
-
706
- // Import and start dashboard server
707
- const { startServer } = await import('../packages/dashboard/server/index.js');
708
- startServer({ port, open: openBrowser });
709
-
710
- // Keep process alive
711
- console.log('按 Ctrl+C 停止服务器');
712
- break;
713
- }
714
- case 'worktree': {
715
- const { WorktreeManager } = await import('./worktree.js');
716
- const { ProgressManager } = await import('./progress.js');
717
- const wtSubCmd = filteredArgs[1];
718
- const wtName = filteredArgs.slice(2).find(a => !a.startsWith('-'));
719
- const wm = new WorktreeManager({ cwd: dir });
720
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
721
-
722
- // isolation 写入 DB 的辅助函数
723
- function _writeIsolationToDB(cwd, changeName, info) {
724
- if (info.blocked) {
725
- pm.updateChangeIsolation(cwd, changeName, {
726
- status: 'blocked',
727
- mode: null,
728
- reason: info.reason,
729
- });
730
- } else {
731
- const mode = info.mode || 'worktree';
732
- const statusMap = { 'worktree': 'verified', 'native-worktree': 'verified', 'in-place-fallback': 'degraded' };
733
- pm.updateChangeIsolation(cwd, changeName, {
734
- status: statusMap[mode] || 'verified',
735
- mode,
736
- });
737
- }
738
- }
739
-
740
- if (!wtSubCmd || wtSubCmd === 'help' || wtSubCmd === '--help' || wtSubCmd === '-h') {
741
- console.log(`
742
- SillySpec worktree — git worktree 隔离管理
743
-
744
- 用法:
745
- sillyspec worktree create <change-name> [--base <branch>] 创建隔离 worktree
746
- sillyspec worktree apply <change-name> [--check-only] 校验并应用变更到主工作区
747
- sillyspec worktree assess <change-name> 风险审计 + 自动 apply
748
- sillyspec worktree diff <change-name> [--base <commit>] 查看 worktree 相对 base 的变更
749
- sillyspec worktree list 列出所有活跃 worktree
750
- sillyspec worktree meta <change-name> 读取 worktree meta.json
751
- sillyspec worktree cleanup <change-name> [--force] 强制清理 worktree
752
- sillyspec worktree doctor [--fix] [--stale-hours N] [--change <name>] 健康检查 + 修复(--change 仅扫指定 change)
753
-
754
- 选项:
755
- --base <branch> create: 指定基础分支(默认当前 HEAD)
756
- --check-only apply: 只输出检查结果,不实际 apply
757
- `);
758
- break;
759
- }
760
-
761
- switch (wtSubCmd) {
762
- case 'create': {
763
- if (!wtName) {
764
- console.error('❌ 用法: sillyspec worktree create <change-name> [--base <branch>]');
765
- process.exit(1);
766
- }
767
- // F6 路径穿越消毒:worktree 名用于分支名 + worktree 目录,含 ../ 会逃出 .sillyspec/worktrees/。
768
- try { assertSafeChangeName(wtName, 'worktree 变更名'); }
769
- catch (e) { console.error(`❌ ${e.message}`); process.exit(2); }
770
- const baseIdx = args.indexOf('--base');
771
- const base = baseIdx >= 0 && args[baseIdx + 1] ? args[baseIdx + 1] : undefined;
772
- try {
773
- const info = wm.create(wtName, { base });
774
- console.log(`✅ worktree 已创建`);
775
- console.log(` 分支: ${info.branch}`);
776
- console.log(` 路径: ${info.worktreePath}`);
777
- console.log(` 基准: ${info.baseHash.slice(0, 8)}`);
778
- if (info.mode) {
779
- console.log(` 模式: ${info.mode}`);
780
- }
781
- // 写入 isolation 信息到 sillyspec.db(_writeIsolationToDB)
782
- await _writeIsolationToDB(dir, wtName, info);
783
- } catch (e) {
784
- console.error(`❌ ${e.message}`);
785
- // 写入 blocked 状态到 sillyspec.db(_writeIsolationToDB)
786
- await _writeIsolationToDB(dir, wtName, { blocked: true, reason: e.message });
787
- process.exit(1);
788
- }
789
- break;
790
- }
791
- case 'apply': {
792
- if (!wtName) {
793
- console.error('❌ 用法: sillyspec worktree apply <change-name> [--check-only] [--merge]');
794
- process.exit(1);
795
- }
796
- const checkOnly = args.includes('--check-only');
797
- const merge = args.includes('--merge');
798
- const { applyWorktree } = await import('./worktree-apply.js');
799
- // W3 task-09:apply 链路构造 ctx(D-013),让 task-05 applyWorktree 按 ctx 区分主仓 A5 / 跨仓 no-op。
800
- // 跨仓 apply=no-op(G1 D-009):校验 review.head 是跨仓真实 commit + 跳过 wm.cleanup。
801
- // ctx 构造 fail-closed(约束②)——跨仓配置错时 apply 必须阻断(走错仓=数据所有权事故),不降级。
802
- let _applyCtx = null;
803
- try {
804
- const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
805
- _applyCtx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: wtName, platformOpts: specDir ? { specRoot: specDir } : {} });
806
- } catch (e) {
807
- console.error(`❌ apply 失败:跨仓 MultiRepoContext 构造失败(${e.message})`);
808
- process.exit(1);
809
- }
810
- const result = applyWorktree(wtName, { cwd: dir, checkOnly, merge, ctx: _applyCtx });
811
-
812
- if (result.errors.length > 0) {
813
- console.error(`❌ 校验失败:`);
814
- for (const err of result.errors) {
815
- console.error(` ${err}`);
816
- }
817
- if (result.rescueCommands) {
818
- console.error('');
819
- console.error(`🆘 Rescue commands (${result.rescueCommands.cpFileCount} safe / ${result.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
820
- for (const c of result.rescueCommands.commands) console.error(` ${c}`);
821
- for (const w of result.rescueCommands.warnings) console.error(` ⚠️ ${w}`);
822
- }
823
- process.exit(1);
824
- }
825
-
826
- if (result.changedFiles.length === 0) {
827
- console.log('📭 无变更需要应用');
828
- break;
829
- }
830
-
831
- if (checkOnly) {
832
- console.log(`✅ 检查通过 (${result.changedFiles.length} 个文件):`);
833
- for (const f of result.changedFiles) {
834
- console.log(` ${f}`);
835
- }
836
- } else if (result.merged) {
837
- console.log(`✅ 已通过 git merge 应用变更(baseline 漂移降级,引入合并提交)${result.mergeSummary ? ':' + result.mergeSummary : ''}`);
838
- } else {
839
- console.log(`✅ 已应用 ${result.changedFiles.length} 个文件变更:`);
840
- for (const f of result.changedFiles) {
841
- console.log(` ${f}`);
842
- }
843
- // filterDeliverableFiles 精细化排除(保留 .sillyspec/docs/,排 changes/+.runtime/+quicklog/ + meta.json):
844
- // 模块文档(.sillyspec/docs/)= 交付物,会 auto-apply 到主工作区;变更文档/运行时/quicklog 不 apply。
845
- // 须告知 agent 哪些未落地,否则它期望这些文件落地却找不到(memory: worktree-apply-excludes-module-docs)。
846
- console.log(` ℹ️ 注:.sillyspec/changes、.sillyspec/.runtime、.sillyspec/quicklog 下的文件按规则不自动 apply(模块文档 .sillyspec/docs/ 会自动 apply),如需请手动从 worktree 分支或 dangling commit 取(git show sillyspec/${wtName}:<path>)。`);
847
- }
848
- if (result.warnings && result.warnings.length > 0) {
849
- for (const w of result.warnings) {
850
- console.log(`⚠️ ${w}`);
851
- }
852
- }
853
- break;
854
- }
855
- case 'assess': {
856
- if (!wtName) {
857
- console.error('❌ 用法: sillyspec worktree assess <change-name>');
858
- process.exit(1);
859
- }
860
- const { assessApplyRisk } = await import('./worktree-apply.js');
861
- const assessment = assessApplyRisk(wtName, { cwd: dir });
862
-
863
- // W3 task-09:assess 自动 apply 路径构造 ctx(D-013),SAFE/WARNING 时透传给 applyWorktree。
864
- // 构造 fail-closed(跨仓配置错阻断,与显式 apply case 同语义)。
865
- let _assessCtx = null;
866
- try {
867
- const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
868
- _assessCtx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: wtName, platformOpts: specDir ? { specRoot: specDir } : {} });
869
- } catch (e) {
870
- if (assessment.decision === 'SAFE' || assessment.decision === 'WARNING') {
871
- console.error(`❌ assess 自动 apply 失败:跨仓 MultiRepoContext 构造失败(${e.message})`);
872
- process.exit(1);
873
- }
874
- // blocked 决策不触发 apply,ctx 失败仅记 warn(不阻断只读 assess 展示)
875
- console.warn(`⚠️ ctx 构造失败(${e.message})`);
876
- }
877
- const SEPARATOR = '─'.repeat(32);
878
- console.log('Worktree Apply Decision');
879
- console.log(SEPARATOR);
880
- const decisionIcon = assessment.decision === 'SAFE' ? '✅' : assessment.decision === 'WARNING' ? '⚠️ ' : '🚫';
881
- console.log(`Decision: ${decisionIcon} ${assessment.decision}`);
882
- console.log(`Changed files: ${assessment.changedFiles.length}`);
883
- if (assessment.stats.additions > 0 || assessment.stats.deletions > 0) {
884
- console.log(`Additions: +${assessment.stats.additions} Deletions: -${assessment.stats.deletions}`);
885
- }
886
-
887
- if (assessment.reasons.length > 0) {
888
- console.log('');
889
- console.log('Blocked reasons:');
890
- for (const r of assessment.reasons) r.split('\n').forEach(l => console.log(` ${l}`));
891
- }
892
- if (assessment.warnings.length > 0) {
893
- console.log('');
894
- console.log('Warnings:');
895
- for (const w of assessment.warnings) console.log(` ⚠️ ${w}`);
896
- }
897
-
898
- if (assessment.rescueCommands) {
899
- console.log('');
900
- console.log(`🆘 Rescue commands (${assessment.rescueCommands.cpFileCount} safe / ${assessment.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
901
- for (const c of assessment.rescueCommands.commands) console.log(` ${c}`);
902
- for (const w of assessment.rescueCommands.warnings) console.log(` ⚠️ ${w}`);
903
- }
904
- console.log('');
905
- if (assessment.decision === 'SAFE' || assessment.decision === 'WARNING') {
906
- console.log('Action: auto-applying...');
907
- const { applyWorktree } = await import('./worktree-apply.js');
908
- const applyResult = applyWorktree(wtName, { cwd: dir, ctx: _assessCtx });
909
- if (applyResult.errors.length > 0) {
910
- console.error('❌ apply 失败:', applyResult.errors.join('; '));
911
- } else {
912
- console.log(`✅ 已自动应用 ${applyResult.changedFiles.length} 个文件变更:`);
913
- for (const f of applyResult.changedFiles) console.log(` ${f}`);
914
- console.log(` ℹ️ 注:.sillyspec/changes、.sillyspec/.runtime、.sillyspec/quicklog 下的文件按规则不自动 apply(模块文档 .sillyspec/docs/ 会自动 apply),如需请手动从分支取(git show sillyspec/${wtName}:<path>)。`);
915
- }
916
- } else {
917
- console.log('Action: blocked');
918
- console.log(' → 检查变更: sillyspec worktree diff ' + wtName);
919
- console.log(' → 丢弃变更: sillyspec worktree cleanup ' + wtName);
920
- console.log(' → baseline 漂移降级: sillyspec worktree apply ' + wtName + ' --merge');
921
- }
922
- break;
923
- }
924
- case 'meta': {
925
- if (!wtName) {
926
- console.error('❌ 用法: sillyspec worktree meta <change-name>');
927
- process.exit(1);
928
- }
929
- const meta = wm.getMeta(wtName);
930
- if (!meta) {
931
- console.error(`❌ 未找到 worktree meta: ${wtName}(可能未创建或已被清理)`);
932
- process.exit(1);
933
- }
934
- console.log(`change: ${wtName}`);
935
- console.log(`worktreePath: ${meta.worktreePath || '(未设置)'}`);
936
- console.log(`branch: ${meta.branch || '(未设置)'}`);
937
- console.log(`mode: ${meta.mode || '(未设置)'}`);
938
- if (meta.baseBranch) console.log(`baseBranch: ${meta.baseBranch}`);
939
- if (meta.baseHash) console.log(`baseHash: ${String(meta.baseHash).slice(0, 8)}`);
940
- console.log('');
941
- console.log('JSON:');
942
- console.log(JSON.stringify(meta, null, 2));
943
- break;
944
- }
945
- case 'diff': {
946
- if (!wtName) {
947
- console.error('❌ 用法: sillyspec worktree diff <change-name> [--base <commit>]');
948
- process.exit(1);
949
- }
950
- const meta = wm.getMeta(wtName);
951
- if (!meta) {
952
- console.error(`❌ 未找到 worktree meta: ${wtName}(可能未创建或已被清理)`);
953
- process.exit(1);
954
- }
955
- if (!meta.worktreePath || !existsSync(meta.worktreePath)) {
956
- console.error(`❌ worktree 目录不存在: ${meta.worktreePath || '(未设置)'}(可能已被清理,可跑 sillyspec worktree doctor)`);
957
- process.exit(1);
958
- }
959
- // base 优先级:显式 --base > meta.baseHash(create 时锚的主仓 HEAD)> 当前 HEAD。
960
- // diff 比较 worktree 工作区(含未提交)vs base,与 _changesAlreadyOnMain 同口径。
961
- const baseIdx = filteredArgs.indexOf('--base');
962
- const explicitBase = baseIdx >= 0 ? filteredArgs[baseIdx + 1] : null;
963
- const base = explicitBase || meta.baseHash || 'HEAD';
964
- let out = '';
965
- try {
966
- // base 作为独立 argv 元素传入,不经 shell 拆词/注入;worktreePath 经 cwd,不插值。
967
- // trim:false 保留 git diff 原始输出(含尾换行)供 stdout.write;空检查靠临时 .trim()。
968
- // timeout 30s:diff 属潜在长操作(对齐 design R3 与 verify-postcheck 口径)。
969
- out = git(meta.worktreePath, ['--no-pager', 'diff', '--no-renames', base], { trim: false, timeout: 30000 });
970
- } catch (e) {
971
- console.error(`❌ git diff 失败(base=${String(base).slice(0, 8)}): ${e.message}`);
972
- process.exit(1);
973
- }
974
- if (!out.trim()) {
975
- console.log(`(无变更:worktree 工作区与 base ${String(base).slice(0, 8)} 一致)`);
976
- } else {
977
- process.stdout.write(out);
978
- }
979
- break;
980
- }
981
- case 'list': {
982
- const items = wm.list();
983
- if (items.length === 0) {
984
- console.log('📭 无活跃 worktree');
985
- break;
986
- }
987
- // 计算列宽
988
- const maxName = Math.max('Change Name'.length, ...items.map(i => i.changeName.length));
989
- const maxBranch = Math.max('Branch'.length, ...items.map(i => i.branch.length));
990
- const header = ` ${'Change Name'.padEnd(maxName)} ${'Branch'.padEnd(maxBranch)} Created`;
991
- const sep = ` ${'─'.repeat(maxName)} ${'─'.repeat(maxBranch)} ${'─'.repeat(19)}`;
992
- console.log(header);
993
- console.log(sep);
994
- for (const item of items) {
995
- const created = item.createdAt ? item.createdAt.replace('T', ' ').replace('Z', '').slice(0, 19) : '-';
996
- console.log(` ${item.changeName.padEnd(maxName)} ${item.branch.padEnd(maxBranch)} ${created}`);
997
- }
998
- break;
999
- }
1000
- case 'cleanup': {
1001
- if (!wtName) {
1002
- console.error('❌ 用法: sillyspec worktree cleanup <change-name>');
1003
- process.exit(1);
1004
- }
1005
- const forceFlag = args.includes('--force');
1006
- try {
1007
- const result = wm.cleanup(wtName, { force: forceFlag });
1008
- if (result.result === 'cleaned' || result.result === 'force-cleaned') {
1009
- console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
1010
- if (result.details?.length > 0) {
1011
- for (const d of result.details) {
1012
- if (d.startsWith('⚠️')) console.log(` ${d}`);
1013
- }
1014
- }
1015
- } else if (result.result === 'skipped') {
1016
- console.log(`⏭️ worktree 跳过清理: ${wtName} (mode: ${result.mode})`);
1017
- console.log(` 原因: in-place 模式没有隔离目录需要清理`);
1018
- } else if (result.result === 'blocked') {
1019
- console.error(`🚫 拒绝清理:有未落主仓交付变更,请先 sillyspec worktree apply ${wtName} 或 --force`);
1020
- } else {
1021
- console.log(`ℹ️ worktree 未找到: ${wtName}`);
1022
- }
1023
- } catch (e) {
1024
- console.error(`❌ ${e.message}`);
1025
- process.exit(1);
1026
- }
1027
- break;
1028
- }
1029
- case 'doctor': {
1030
- const fixFlag = args.includes('--fix');
1031
- const staleIdx = args.indexOf('--stale-hours');
1032
- const staleHours = staleIdx !== -1 && args[staleIdx + 1] ? parseInt(args[staleIdx + 1], 10) : 24;
1033
- const changeIdx = args.indexOf('--change');
1034
- const changeName = changeIdx !== -1 && args[changeIdx + 1] ? args[changeIdx + 1] : null;
1035
- const diag = wm.doctor({ fix: fixFlag, staleHours, changeName });
1036
- if (diag.issues.length === 0) {
1037
- console.log('✅ worktree 健康检查通过,无异常');
1038
- } else {
1039
- console.log(`🔍 发现 ${diag.issues.length} 个问题:\n`);
1040
- for (const issue of diag.issues) {
1041
- const icon = issue.fixable ? '⚠️' : '❌';
1042
- console.log(` ${icon} [${issue.type}] ${issue.name}: ${issue.detail}`);
1043
- }
1044
- if (fixFlag) {
1045
- console.log(`\n🔧 修复完成:`);
1046
- for (const f of diag.fixed) console.log(` ✅ ${f}`);
1047
- if (diag.unfixable.length > 0) {
1048
- for (const u of diag.unfixable) console.log(` ❌ ${u}`);
1049
- }
1050
- if (diag.fixed.length === 0 && diag.unfixable.length === 0) {
1051
- console.log(' 无需修复');
1052
- }
1053
- } else {
1054
- console.log(`\n💡 运行 sillyspec worktree doctor --fix 自动修复`);
1055
- }
1056
- }
1057
- break;
1058
- }
1059
- default:
1060
- console.error(`❌ 未知子命令: worktree ${wtSubCmd}`);
1061
- console.log(' 运行 sillyspec worktree --help 查看帮助');
1062
- process.exit(1);
1063
- }
1064
- break;
1065
- }
1066
- case 'dispatch': {
1067
- // SillyHub 派发抽象层的 agent 调用桥(design.md §Phase2 / D-007@v1):
1068
- // 仅做能力探测(probe)与派发策略生成(hint),**不执行任何 tool 调用**——
1069
- // 实际 tool 调用(本机 Agent tool / SillyHub MCP tool)由 agent 据指令执行。
1070
- // 仿 worktree 子命令的参数解析(filteredArgs[1] 取子命令)与错误处理模式。
1071
- const dispatchSubCmd = filteredArgs[1];
1072
- const dispatchSubs = ['probe', 'hint'];
1073
-
1074
- if (!dispatchSubCmd || dispatchSubCmd === 'help' || dispatchSubCmd === '--help' || dispatchSubCmd === '-h') {
1075
- console.log(`
1076
- SillySpec dispatch — SillyHub 派发能力探测与策略生成(agent 调用桥,仅渲染与探测)
1077
-
1078
- 用法:
1079
- sillyspec dispatch probe [--json] 能力探测,输出 ProbeResult
1080
- sillyspec dispatch hint --contract <json> [--json] 派发策略,输出 {instruction, backend}
1081
-
1082
- 子命令:
1083
- probe 探测 SillyHub MCP 是否可用(无配置返回 available=false,正常降级不报错)
1084
- hint 据 contract + probe 结果生成派发指令文本(agent 拿指令后执行实际 tool 调用)
1085
-
1086
- 选项:
1087
- --contract <json> hint 必填:DispatchContract(brief/worktreePath/branch/allowedPaths/readOnly/...)
1088
- --json 程序化输出(probe → ProbeResult;hint → {backend, instruction})
1089
- `);
1090
- break;
1091
- }
1092
-
1093
- if (dispatchSubCmd === 'probe') {
1094
- const { probeSillyHub } = await import('./dispatch/probe.js');
1095
- const result = await probeSillyHub();
1096
- // result = { available, reason? };无配置时 {available:false,reason:'no-config'} 不报错(正常降级)
1097
- if (json) {
1098
- process.stdout.write(JSON.stringify(result));
1099
- } else if (result.available) {
1100
- console.log('✅ SillyHub 可用');
1101
- } else {
1102
- console.log(`⚠️ SillyHub 不可用:${result.reason || 'unknown'}`);
1103
- }
1104
- break;
1105
- }
1106
-
1107
- if (dispatchSubCmd === 'hint') {
1108
- // 解析 --contract <json>(仿 worktree 用 args.indexOf 取 flag 后一个值)
1109
- const contractIdx = args.indexOf('--contract');
1110
- const contractRaw = contractIdx >= 0 && args[contractIdx + 1] ? args[contractIdx + 1] : null;
1111
- if (!contractRaw) {
1112
- console.error('用法: sillyspec dispatch hint --contract <json> [--json]\n 生成派发指令(DispatchContract:brief/worktreePath/branch/allowedPaths/readOnly/...)');
1113
- process.exit(2);
1114
- }
1115
- let contract;
1116
- try {
1117
- contract = JSON.parse(contractRaw);
1118
- } catch (e) {
1119
- console.error(`❌ --contract JSON 解析失败: ${e.message}`);
1120
- process.exit(2);
1121
- }
1122
- const { probeSillyHub } = await import('./dispatch/probe.js');
1123
- const { renderDispatchInstruction } = await import('./dispatch/strategy.js');
1124
- const probe = await probeSillyHub();
1125
- const { instruction, backend } = renderDispatchInstruction(contract, probe);
1126
- if (json) {
1127
- process.stdout.write(JSON.stringify({ backend, instruction }));
1128
- } else {
1129
- console.log(`Backend: ${backend}`);
1130
- console.log(instruction);
1131
- }
1132
- break;
1133
- }
1134
-
1135
- // 未知子命令 → did-you-mean 建议 + 退出 1(仿 worktree default)
1136
- const sug = didYouMean(dispatchSubCmd, dispatchSubs);
1137
- console.error(`❌ 未知子命令: dispatch ${dispatchSubCmd}`);
1138
- if (sug) console.error(` 你是想输入「dispatch ${sug}」吗?`);
1139
- console.error(` 可用子命令:${dispatchSubs.join(' | ')}(运行 sillyspec dispatch 查看用法)`);
1140
- process.exit(1);
1141
- break;
1142
- }
1143
- case 'platform': {
1144
- const platformSub = filteredArgs[1];
1145
- const platformArgs = filteredArgs.slice(2);
1146
-
1147
- if (!platformSub || platformSub === 'help' || platformSub === '--help' || platformSub === '-h') {
1148
- console.log(`
1149
- SillySpec platform — SillyHub 平台同步
1150
-
1151
- 用法:
1152
- sillyspec platform connect <url> [--token <token>]
1153
- sillyspec platform disconnect
1154
- sillyspec platform sync [--change <name>]
1155
- sillyspec platform sync-docs [--change <name>]
1156
- sillyspec platform pull [--change <name>]
1157
- sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>
1158
- sillyspec platform status
1159
- sillyspec platform pointer [--cleanup]
1160
- sillyspec platform approve <change-name>
1161
- sillyspec platform reject <change-name> [--reason <reason>]
1162
- `);
1163
- break;
1164
- }
1165
-
1166
- let syncModule;
1167
- try {
1168
- syncModule = await import('./sync.js');
1169
- } catch {
1170
- console.error('❌ 平台同步功能不可用(sync.js 未实现)');
1171
- process.exit(1);
1172
- }
1173
-
1174
- switch (platformSub) {
1175
- case 'pointer': {
1176
- // 指针状态检查(不依赖 sync 模块)
1177
- const { readFileSync, existsSync } = await import('fs')
1178
- const { join } = await import('path')
1179
- const { POINTER_STATUS, isPointerStale, isPointerCorrupted } = await import('./constants.js')
1180
- const pointerPath = join(dir, '.sillyspec-platform.json')
1181
-
1182
- if (!existsSync(pointerPath)) {
1183
- console.log('ℹ️ 无平台指针文件。当前不在平台模式或未进行过平台 scan。')
1184
- break
1185
- }
1186
-
1187
- try {
1188
- const pointer = JSON.parse(readFileSync(pointerPath, 'utf8'))
1189
- console.log(`📄 指针文件: ${pointerPath}`)
1190
- console.log(` specRoot: ${pointer.specRoot || '(缺失 ❌)'}`)
1191
- console.log(` runtimeRoot: ${pointer.runtimeRoot || '(未设置)'}`)
1192
- console.log(` workspaceId: ${pointer.workspaceId || '(未设置)'}`)
1193
- console.log(` scanRunId: ${pointer.scanRunId || '(未设置)'}`)
1194
- console.log(` savedAt: ${pointer.savedAt || '(未知)'}`)
1195
-
1196
- if (isPointerCorrupted(pointer)) {
1197
- console.log(` 状态: ${POINTER_STATUS.CORRUPTED} ❌`)
1198
- console.log(` ⚠️ 指针损坏(缺少 specRoot),建议删除后重新运行平台 scan。`)
1199
- if (platformArgs.includes('--cleanup')) {
1200
- const { unlinkSync } = await import('fs')
1201
- unlinkSync(pointerPath)
1202
- console.log(` 🗑️ 已清理损坏指针。`)
1203
- }
1204
- } else if (pointer.status === POINTER_STATUS.SCAN_COMPLETED) {
1205
- if (isPointerStale(pointer)) {
1206
- console.log(` 状态: ${POINTER_STATUS.STALE} ⚠️`)
1207
- console.log(` completedAt: ${pointer.completedAt}`)
1208
- console.log(` scanStatus: ${pointer.scanStatus || '(未知)'}`)
1209
- console.log(` ⚠️ 指针已过时(完成超过 24h),可以安全删除。`)
1210
- if (platformArgs.includes('--cleanup')) {
1211
- const { unlinkSync } = await import('fs')
1212
- unlinkSync(pointerPath)
1213
- console.log(` 🗑️ 已清理过时指针。`)
1214
- }
1215
- } else {
1216
- console.log(` 状态: ${pointer.status} ✅`)
1217
- console.log(` completedAt: ${pointer.completedAt}`)
1218
- console.log(` scanStatus: ${pointer.scanStatus || '(未知)'}`)
1219
- }
1220
- } else {
1221
- console.log(` 状态: ${POINTER_STATUS.ACTIVE} 🔄`)
1222
- }
1223
- } catch (e) {
1224
- console.log(` 状态: ${POINTER_STATUS.CORRUPTED} ❌`)
1225
- console.log(` ⚠️ 指针文件损坏: ${e.message}`)
1226
- }
1227
- break;
1228
- }
1229
- case 'connect': {
1230
- const url = platformArgs[0];
1231
- if (!url) {
1232
- console.error('❌ 用法: sillyspec platform connect <url> [--token <token>]');
1233
- process.exit(1);
1234
- }
1235
- const tokenIdx = args.indexOf('--token');
1236
- const token = tokenIdx >= 0 && args[tokenIdx + 1] ? args[tokenIdx + 1] : undefined;
1237
- if (!token) {
1238
- console.error('⚠️ 未提供 --token,将使用交互式输入(TODO: task-11)');
1239
- }
1240
- await syncModule.connect(url, token, dir);
1241
- break;
1242
- }
1243
- case 'disconnect':
1244
- await syncModule.disconnect(dir);
1245
- break;
1246
- case 'sync': {
1247
- const syncChangeIdx = args.indexOf('--change');
1248
- const syncChangeName = syncChangeIdx >= 0 && args[syncChangeIdx + 1] ? args[syncChangeIdx + 1] : null;
1249
- await syncModule.sync(syncChangeName, dir);
1250
- break;
1251
- }
1252
- case 'sync-docs': {
1253
- const syncDocsChangeIdx = args.indexOf('--change');
1254
- const syncDocsChangeName = syncDocsChangeIdx >= 0 && args[syncDocsChangeIdx + 1] ? args[syncDocsChangeIdx + 1] : null;
1255
- await syncModule.syncDocuments(syncDocsChangeName, dir);
1256
- break;
1257
- }
1258
- case 'status': {
1259
- // task-14 / FR-05:扩展 status(落后标记 + 未决冲突列表);未连接输出与现状一致
1260
- const st = await syncModule.collectStatus(dir);
1261
- if (!st.connected) {
1262
- console.log('平台: 未连接');
1263
- break;
1264
- }
1265
- console.log(`平台: ${st.url}`);
1266
- console.log(`上次连接: ${st.lastSync || '未知'}`);
1267
- if (st.listFailed) {
1268
- console.log('ℹ️ 无法连接平台获取落后状态');
1269
- } else if (st.behind.length > 0) {
1270
- console.log(`⚠️ 本地可能落后(${st.behind.length} 个变更平台有更新):`);
1271
- for (const b of st.behind) {
1272
- console.log(` - ${b.name}: 平台 ${b.platform_pushed_at} > 本地 ${b.local_synced}`);
1273
- }
1274
- } else {
1275
- console.log('✅ 本地与平台进度同步');
1276
- }
1277
- if (st.conflicts.length > 0) {
1278
- console.log(`⚠️ 未决冲突(${st.conflicts.length} 个,请 platform resolve 处理):`);
1279
- for (const c of st.conflicts) {
1280
- console.log(` - ${c.change}${c.created_at ? ` (创建于 ${c.created_at})` : ''}`);
1281
- }
1282
- }
1283
- break;
1284
- }
1285
- case 'pull': {
1286
- // task-11 / D-006 / D-009 / FR-03:手动下行拉取平台进度到本地。
1287
- // 与自动 triggerPull 共用 syncModule.pull(SyncManager.pull 实例方法),行为一致。
1288
- const pullChangeIdx = args.indexOf('--change');
1289
- const pullChangeName = pullChangeIdx >= 0 && args[pullChangeIdx + 1] ? args[pullChangeIdx + 1] : null;
1290
- // 未连接明确提示不崩(acceptance:未连接平台输出明确提示)
1291
- const smForCheck = new syncModule.SyncManager(dir);
1292
- if (!smForCheck._getPlatform()) {
1293
- console.error('❌ 未连接平台,请先 sillyspec platform connect');
1294
- process.exit(1);
1295
- }
1296
- if (pullChangeName) {
1297
- // 单变更完整 pull(两级 pull 第二级)
1298
- const r = await syncModule.pull(pullChangeName, {}, dir);
1299
- if (r.conflict) {
1300
- console.log(`⚠️ ${pullChangeName}: 冲突(已写 sync-conflict 文件),请 sillyspec platform resolve 处理`);
1301
- } else if (r.imported) {
1302
- console.log(`✅ ${pullChangeName}: 已拉取并 import`);
1303
- } else {
1304
- console.log(`⏭️ ${pullChangeName}: 未 import(${r.reason || '无变更'})`);
1305
- }
1306
- } else {
1307
- // 无 --change:先拉轻量列表(两级 pull 第一级),再对每个 change 按需完整 pull
1308
- const list = await syncModule.pullList(dir);
1309
- if (!list.ok) {
1310
- console.error(`❌ 拉取变更列表失败: ${list.reason || '未知'}`);
1311
- process.exit(1);
1312
- }
1313
- console.log(`平台变更 ${list.changes.length} 个:`);
1314
- let importedCount = 0, conflictCount = 0;
1315
- for (const ch of list.changes) {
1316
- const name = typeof ch === 'string' ? ch : ch.name;
1317
- if (!name) continue;
1318
- const r = await syncModule.pull(name, {}, dir);
1319
- if (r.imported) { importedCount++; console.log(` ✅ ${name}: 已 import`); }
1320
- else if (r.conflict) { conflictCount++; console.log(` ⚠️ ${name}: 冲突,请 platform resolve`); }
1321
- else { console.log(` ⏭️ ${name}: ${r.reason || '未 import'}`); }
1322
- }
1323
- console.log(`拉取完成:imported ${importedCount},冲突 ${conflictCount}`);
1324
- }
1325
- break;
1326
- }
1327
- case 'resolve': {
1328
- // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(绝不字段级 auto-merge)
1329
- const resolveName = platformArgs[0];
1330
- if (!resolveName) {
1331
- console.error('❌ 用法: sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>');
1332
- process.exit(1);
1333
- }
1334
- // 解析 mode flag(三选一互斥,多传/不传均报错)
1335
- const resolveFlags = ['--keep-local', '--take-platform', '--abort'].filter((f) => args.includes(f));
1336
- if (resolveFlags.length !== 1) {
1337
- console.error('❌ 必须恰好指定 --keep-local / --take-platform / --abort 之一');
1338
- process.exit(1);
1339
- }
1340
- const modeMap = { '--keep-local': 'keep-local', '--take-platform': 'take-platform', '--abort': 'abort' };
1341
- const resolveMode = modeMap[resolveFlags[0]];
1342
- const r = await syncModule.resolve(resolveName, resolveMode, dir);
1343
- if (r.ok && r.resolved) {
1344
- console.log(`✅ ${resolveName} [${resolveMode}]: ${r.reason}`);
1345
- } else {
1346
- console.error(`❌ ${resolveName}: ${r.reason}`);
1347
- process.exit(1);
1348
- }
1349
- break;
1350
- }
1351
- case 'approve': {
1352
- const approveName = platformArgs[0];
1353
- if (!approveName) {
1354
- console.error('❌ 用法: sillyspec platform approve <change-name>');
1355
- process.exit(1);
1356
- }
1357
- // 审批前下行 pull(task-10 / D-009 / FR-06):拉最新进度避免基于过期状态审批;未连接跳过
1358
- const { triggerPull } = await import('./run/shared.js');
1359
- await triggerPull(dir, approveName);
1360
- await syncModule.approve(approveName, dir);
1361
- break;
1362
- }
1363
- case 'reject': {
1364
- const rejectName = platformArgs[0];
1365
- if (!rejectName) {
1366
- console.error('❌ 用法: sillyspec platform reject <change-name> [--reason <reason>]');
1367
- process.exit(1);
1368
- }
1369
- const reasonIdx = args.indexOf('--reason');
1370
- const reason = reasonIdx >= 0 && args[reasonIdx + 1] ? args[reasonIdx + 1] : undefined;
1371
- await syncModule.reject(rejectName, reason, dir);
1372
- break;
1373
- }
1374
- default:
1375
- console.error(`❌ 未知子命令: platform ${platformSub}`);
1376
- console.log(' 运行 sillyspec platform --help 查看帮助');
1377
- process.exit(1);
1378
- }
1379
- break;
1380
- }
1381
- case 'change-rename': {
1382
- const oldName = filteredArgs[1];
1383
- const newName = filteredArgs[2];
1384
- if (!oldName || !newName) {
1385
- console.error('❌ 用法: sillyspec change-rename <旧变更名> <新变更名>');
1386
- process.exit(1);
1387
- }
1388
- // F6 路径穿越消毒:renameChange 会 mv changes/<old> → changes/<new>,名含 ../ 会逃出 changes/。
1389
- try {
1390
- assertSafeChangeName(oldName, '旧变更名');
1391
- assertSafeChangeName(newName, '新变更名');
1392
- } catch (e) {
1393
- console.error(`❌ ${e.message}`);
1394
- process.exit(2);
1395
- }
1396
- const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
1397
- pm.renameChange(dir, oldName, newName);
1398
- break;
1399
- }
1400
- case 'workflow': {
1401
- const wfSub = filteredArgs[1];
1402
- if (!wfSub || wfSub === 'help' || wfSub === '--help') {
1403
- console.log(`
1404
- SillySpec workflow — 工作流管理
1405
-
1406
- 用法:
1407
- sillyspec workflow check <name> [--project <project>] [--json]
1408
- sillyspec workflow list
1409
- `);
1410
- break;
1411
- }
1412
- if (wfSub === 'list') {
1413
- const { listWorkflows } = await import('./workflow.js');
1414
- const names = listWorkflows(dir);
1415
- if (names.length === 0) {
1416
- console.log('未找到 workflow 定义(.sillyspec/workflows/*.yaml)');
1417
- } else {
1418
- console.log(`可用 workflow:`);
1419
- for (const name of names) {
1420
- const { loadWorkflow } = await import('./workflow.js');
1421
- const wf = loadWorkflow(dir, name);
1422
- const specVer = wf?.spec_version || wf?.version || '?';
1423
- const mode = wf?.orchestration?.mode || '?';
1424
- const roles = wf?.roles?.length || 0;
1425
- console.log(` ${name} (spec v${specVer}, ${mode}, ${roles} roles)`);
1426
- }
1427
- }
1428
- break;
1429
- }
1430
- if (wfSub === 'check') {
1431
- const { loadWorkflow, runPostCheck, listWorkflows, saveWorkflowRun } = await import('./workflow.js');
1432
- const wfName = filteredArgs[2];
1433
- if (!wfName) {
1434
- console.error('❌ 请指定 workflow 名称,例如:sillyspec workflow check scan-docs --project sillyspec');
1435
- process.exit(2);
1436
- }
1437
- const wf = loadWorkflow(dir, wfName);
1438
- if (!wf) {
1439
- console.error(`❌ 未找到 workflow: ${wfName}`);
1440
- console.error(`可用 workflow:${listWorkflows(dir).join(', ') || '无'}`);
1441
- process.exit(2);
1442
- }
1443
- // depends_on 校验
1444
- if (wf._validationErrors && wf._validationErrors.length > 0) {
1445
- console.error('❌ workflow YAML 校验失败:');
1446
- for (const err of wf._validationErrors) {
1447
- console.error(` ${err}`);
1448
- }
1449
- process.exit(2);
1450
- }
1451
- // spec_version 校验
1452
- const specVer = wf.spec_version || wf.version;
1453
- if (!specVer) {
1454
- console.error('❌ workflow YAML 缺少 spec_version 字段');
1455
- process.exit(2);
1456
- }
1457
- const SUPPORTED_SPECS = [1];
1458
- if (!SUPPORTED_SPECS.includes(specVer)) {
1459
- console.error(`❌ 不支持的 spec_version: ${specVer}(支持: ${SUPPORTED_SPECS.join(', ')})`);
1460
- process.exit(2);
1461
- }
1462
- // 解析 --project
1463
- const projectIdx = filteredArgs.indexOf('--project');
1464
- const project = projectIdx !== -1 && filteredArgs[projectIdx + 1] ? filteredArgs[projectIdx + 1] : null;
1465
- // 解析 --json(已在顶层解析)
1466
- const isJson = json;
1467
- // 解析 --change
1468
- const changeIdx = filteredArgs.indexOf('--change');
1469
- const changeName = changeIdx !== -1 && filteredArgs[changeIdx + 1] ? filteredArgs[changeIdx + 1] : null;
1470
-
1471
- if (!project && wfName !== 'archive-impact') {
1472
- console.error('❌ 请指定 --project,例如:--project sillyspec');
1473
- process.exit(2);
1474
- }
1475
-
1476
- // 执行检查
1477
- let resolvedWf = wf;
1478
- const placeholders = {};
1479
- if (changeName) placeholders['change-name'] = changeName;
1480
- // 替换占位符
1481
- let jsonStr = JSON.stringify(resolvedWf);
1482
- if (changeName) jsonStr = jsonStr.replace(/<change-name>/g, changeName);
1483
- resolvedWf = JSON.parse(jsonStr);
1484
-
1485
- const projectName = project || 'sillyspec';
1486
- const result = runPostCheck(resolvedWf, dir, projectName, placeholders);
1487
-
1488
- if (isJson) {
1489
- console.log(JSON.stringify(result, null, 2));
1490
- } else {
1491
- // 带项目维度前缀的输出(从统一结果对象格式化)
1492
- const lines = [`\n📋 Workflow Post-Check: ${result.workflow} (project: ${result.project})\n`];
1493
- for (const r of (result.roles || [])) {
1494
- const icon = r.status === 'pass' ? '✅' : '❌';
1495
- lines.push(`${icon} [${result.project}] ${r.name} (${r.id})`);
1496
- const roleFailures = (result.failures || []).filter(f => f.role_id === r.id);
1497
- for (const f of roleFailures) {
1498
- lines.push(` └─ ${f.message}`);
1499
- }
1500
- }
1501
- const wfFailures = (result.workflow_checks || []).filter(c => c.status === 'fail');
1502
- if (wfFailures.length > 0) {
1503
- lines.push('');
1504
- for (const f of wfFailures) {
1505
- lines.push(`❌ [${result.project}] 全局: ${f.detail}`);
1506
- }
1507
- }
1508
- lines.push('');
1509
- if (result.status === 'pass') {
1510
- lines.push('✅ 全部检查通过');
1511
- } else {
1512
- lines.push('❌ 存在失败项');
1513
- }
1514
- console.log(lines.join('\n'));
1515
- }
1516
-
1517
- // exit code: 0=通过, 1=检查失败, 2=参数/YAML错误
1518
- if (saveWorkflowRunFlag) {
1519
- const saved = saveWorkflowRun(result, { cwd: dir, source: 'cli' });
1520
- if (saved) {
1521
- if (!isJson) console.log(`\n📁 结果已归档:${saved}`);
1522
- }
1523
- }
1524
- process.exit(result.status === 'pass' ? 0 : 1);
1525
- } else {
1526
- const wfSubs = ['check', 'list'];
1527
- const sug = didYouMean(wfSub, wfSubs);
1528
- console.error(`❌ 未知子命令: workflow ${wfSub}`);
1529
- if (sug) console.error(` 你是想输入「workflow ${sug}」吗?`);
1530
- console.error(` 可用子命令:${wfSubs.join(' | ')}(运行 sillyspec workflow 查看用法)`);
1531
- process.exit(1);
1532
- }
1533
- break;
1534
- }
1535
- case 'modules': {
1536
- const modulesSub = filteredArgs[1];
1537
- if (!modulesSub || modulesSub === 'help' || modulesSub === '--help') {
1538
- console.log(`
1539
- SillySpec modules — 模块文档管理
1540
-
1541
- 用法:
1542
- sillyspec modules rebuild [--force] 从模块卡片 + 源码重建 _module-map.yaml(默认 dry-run 预览,--force 才覆盖)
1543
- sillyspec modules status 显示模块索引状态
1544
- sillyspec modules migrate 旧格式模块文档迁移到新格式
1545
- `);
1546
- break;
1547
- }
1548
- if (modulesSub === 'rebuild') {
1549
- const { rebuildModuleMap } = await import('./modules.js');
1550
- const isForce = filteredArgs.includes('--force');
1551
- await rebuildModuleMap(dir, { force: isForce });
1552
- } else if (modulesSub === 'status') {
1553
- const { showModuleStatus } = await import('./modules.js');
1554
- await showModuleStatus(dir);
1555
- } else if (modulesSub === 'migrate') {
1556
- const { migrateModuleDocs } = await import('./modules.js');
1557
- await migrateModuleDocs(dir);
1558
- } else {
1559
- const modSubs = ['rebuild', 'status', 'migrate'];
1560
- const sug = didYouMean(modulesSub, modSubs);
1561
- console.error(`❌ 未知子命令: modules ${modulesSub}`);
1562
- if (sug) console.error(` 你是想输入「modules ${sug}」吗?`);
1563
- console.error(` 可用子命令:${modSubs.join(' | ')}(运行 sillyspec modules 查看用法)`);
1564
- process.exit(1);
1565
- }
1566
- break;
1567
- }
1568
- case 'local': {
1569
- // 本地配置探测(task-04 / D-001@v1):纯 fs 嗅探项目类型 → 生成 local.yaml。
1570
- // 轻量独立路由,不跑 scan、不消耗 token。探测逻辑归属 local-detect.js(task-02)。
1571
- const localSubCmd = filteredArgs[1];
1572
- if (localSubCmd !== 'detect') {
1573
- console.error('用法: sillyspec local detect [--dir <path>]\n 纯 fs 嗅探项目类型并生成 local.yaml(不跑 scan、零 token)');
1574
- process.exit(2);
1575
- }
1576
-
1577
- const detected = (await import('./local-detect.js')).detectLocalYaml(dir);
1578
- const specRoot = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
1579
- const localYamlPath = join(specRoot, 'local.yaml');
1580
-
1581
- if (existsSync(localYamlPath)) {
1582
- console.log(`ℹ️ local.yaml 已存在,跳过: ${localYamlPath}`);
1583
- break;
1584
- }
1585
-
1586
- // 序列化为 local.yaml 文本(与 scan.js 生成本地配置步骤的 yaml 模板格式一致,向后兼容)
1587
- const c = detected.commands || {};
1588
- const lines = [];
1589
- lines.push('# SillySpec 本地配置(自动生成,可手动修改)');
1590
- lines.push('project:');
1591
- lines.push(` type: ${detected.project.type} # nodejs/maven/gradle/make/generic`);
1592
- lines.push('');
1593
- lines.push('commands:');
1594
- if (c.build) lines.push(` build: "${c.build}"`);
1595
- if (c.test) lines.push(` test: "${c.test}"`);
1596
- if (c.lint) lines.push(` lint: "${c.lint}"`);
1597
- lines.push('');
1598
- lines.push('# 测试策略:full=全量测试, module=只测变更模块, skip=跳过测试');
1599
- lines.push('test_strategy: module');
1600
- lines.push('');
1601
- lines.push('# monorepo 子模块映射(test_strategy: module 时按 git diff 命中模块收窄测试;inline flow 形态)');
1602
- lines.push('# modules:');
1603
- lines.push('# user-service: { path: "user/", test: "cd user && npm test" }');
1604
- lines.push('# order-service: { path: "order/", test: "cd order && npm test" }');
1605
- lines.push('# 已知预存失败(可选):verify 实测时这些失败被豁免、不阻断归档;fail-safe 仅豁免能检测到的失败行');
1606
- lines.push('# known_failures:');
1607
- lines.push('# - "tests/test_legacy.py::test_old"');
1608
- const yamlText = lines.join('\n') + '\n';
1609
-
1610
- // 原子写(tmp + rename + Windows EPERM 重试),避免半截 local.yaml
1611
- mkdirSync(specRoot, { recursive: true });
1612
- writeAtomicSync(localYamlPath, yamlText);
1613
-
1614
- console.log(`✅ 已生成 local.yaml (type: ${detected.project.type})`);
1615
- console.log(` 路径: ${localYamlPath}`);
1616
- break;
1617
- }
1618
- case 'config': {
1619
- // local.yaml 键发现缺口(2026-08-11):键散落 ~10 个 reader,外部项目 agent 无从得知。
1620
- // config schema 把全部已知键 + 生效状态 + 读取点打印出来,堵住发现缺口。
1621
- // 数据源唯一 = src/config-schema.js(与 init 落盘的 local.yaml.example 同源)。
1622
- const configSub = filteredArgs[1];
1623
- const wantHelp = configSub === 'help' || configSub === '--help' || configSub === '-h';
1624
- const isSchema = !configSub || configSub === 'schema';
1625
- if (wantHelp) {
1626
- console.log(`
1627
- SillySpec config — local.yaml 配置键速查
1628
-
1629
- 用法:
1630
- sillyspec config 打印 local.yaml 全部已知键(人类可读树,默认)
1631
- sillyspec config schema 同上(显式子命令)
1632
- sillyspec config --json 机读 JSON(程序化消费)
1633
- sillyspec config schema --json 同上
1634
-
1635
- 说明:
1636
- - 数据源:src/config-schema.js(唯一真相;reader 见各键「读取点」)
1637
- - 键分两类:【生效】配了即生效;【声明但未接线】代码/JSDoc 提及但无 reader,配了不生效
1638
- - 脱敏示例文件:sillyspec init 生成 local.yaml.example(可提交;真实 local.yaml 是 gitignored)
1639
- `);
1640
- break;
1641
- }
1642
- if (!isSchema) {
1643
- const sug = didYouMean(configSub, ['schema']);
1644
- console.error(`❌ 未知子命令: config ${configSub}`);
1645
- if (sug) console.error(` 你是想输入「config ${sug}」吗?`);
1646
- console.error('用法: sillyspec config [schema] [--json] 打印 local.yaml 配置键');
1647
- process.exit(2);
1648
- }
1649
- const { renderSchemaHuman, renderSchemaJson } = await import('./config-schema.js');
1650
- if (json) console.log(renderSchemaJson());
1651
- else console.log(renderSchemaHuman());
1652
- break;
1653
- }
1654
- case 'runtime': {
1655
- // D2:.runtime/ 产物索引。CLI 往 .runtime/ 写各种证据/状态文件(db、doctor-diagnosis、
1656
- // workflow-runs、user-inputs…),却没有命令让 agent 一眼看到「我手上有哪些产物」。
1657
- // 多会话/压缩后尤其痛:上轮写的证据文件,下轮不知去哪找。纯只读枚举,不写盘。
1658
- const runtimeSubCmd = filteredArgs[1];
1659
- if (runtimeSubCmd && runtimeSubCmd !== 'list') {
1660
- const sug = didYouMean(runtimeSubCmd, ['list']);
1661
- console.error(`❌ 未知子命令: runtime ${runtimeSubCmd}`);
1662
- if (sug) console.error(` 你是想输入「runtime ${sug}」吗?`);
1663
- console.error('用法: sillyspec runtime list 枚举 .sillyspec/.runtime/ 产物(只读)');
1664
- process.exit(2);
1665
- }
1666
- const specRoot = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
1667
- const runtimeDir = join(specRoot, '.runtime');
1668
- if (!existsSync(runtimeDir)) {
1669
- if (json) { console.log(JSON.stringify({ runtimeDir, exists: false, artifacts: [] }, null, 2)); break; }
1670
- console.log(`📭 无 .runtime/ 目录(${runtimeDir})——尚未产生任何运行时产物。`);
1671
- break;
1672
- }
1673
- // 已知产物 → 用途说明。agent 据此判断每份产物能否作为证据/该读哪个。
1674
- const KNOWN = {
1675
- 'sillyspec.db': '进度库 SQLite(stages/steps/changes 表)',
1676
- 'sillyspec.db.bak': '进度库备份(sql.js 时代写前快照遗留,node:sqlite 不再写入,仅 _openWithFallback 向后兼容恢复用)',
1677
- 'sillyspec.db.schema-version': '进度库 schema 版本标记',
1678
- 'doctor-diagnosis.json': 'doctor --json 结构化诊断快照',
1679
- 'doctor-dumps': 'doctor --dump-db 取证输出目录',
1680
- 'workflow-runs': 'workflow check --save 归档目录',
1681
- 'scan-guard.json': 'scan 覆盖保护记录',
1682
- 'quick-sessions': 'quick 会话记录目录',
1683
- 'quick-guard.json': 'quick baseline 守卫(旧位置)',
1684
- 'worktrees': 'worktree meta(hooks 侧)',
1685
- 'user-inputs.md': '步骤输出/输入历史追加日志',
1686
- 'current-quick-run-id': '当前 quick sessionId 指针(--done 无 --change 时 fallback)',
1687
- 'scan-runs': '平台模式 scan/workflow 取证目录',
1688
- 'artifacts': '派生产物目录(init 预建)',
1689
- 'contract-artifacts': 'execute endpoint 契约产物(verify 阶段读取)',
1690
- 'history': '历史记录目录(init 预建)',
1691
- 'logs': '日志目录(init 预建)',
1692
- 'templates': '工作流模板目录(init 预建)',
1693
- };
1694
- let entries;
1695
- try {
1696
- entries = readdirSync(runtimeDir, { withFileTypes: true });
1697
- } catch (e) {
1698
- console.error(`❌ 读取 .runtime/ 失败: ${e.message}`);
1699
- process.exit(1);
1700
- }
1701
- entries.sort((a, b) => a.name.localeCompare(b.name));
1702
- if (json) {
1703
- console.log(JSON.stringify({
1704
- runtimeDir, exists: true,
1705
- artifacts: entries.map(e => ({ name: e.name, type: e.isDirectory() ? 'dir' : 'file', desc: KNOWN[e.name] || null })),
1706
- }, null, 2));
1707
- break;
1708
- }
1709
- console.log(`📂 .runtime/ 产物(${runtimeDir})`);
1710
- if (entries.length === 0) { console.log(' (空目录)'); break; }
1711
- for (const e of entries) {
1712
- const kind = e.isDirectory() ? '📁' : '📄';
1713
- const desc = KNOWN[e.name] ? ` — ${KNOWN[e.name]}` : '';
1714
- console.log(` ${kind} ${e.name}${desc}`);
1715
- }
1716
- const unknown = entries.filter(e => !KNOWN[e.name]);
1717
- if (unknown.length > 0) {
1718
- console.log(`\n (${unknown.length} 项未登记:${unknown.map(e => e.name).join(', ')})`);
1719
- }
1720
- break;
1721
- }
1722
- default: {
1723
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
1724
- const suggestion = didYouMean(command, topCommands);
1725
- console.error(`❌ 未知命令: ${command}`);
1726
- if (command === '--status') {
1727
- // --status 是阶段内 flag(sillyspec run <stage> --status),裸用会落到 default
1728
- console.error(' --status 是阶段内 flag。查看进度请用:sillyspec status 或 sillyspec progress show');
1729
- } else if (suggestion) {
1730
- console.error(` 你是想输入「${suggestion}」吗?`);
1731
- }
1732
- printUsage();
1733
- process.exit(1);
1734
- }
1735
- }
1736
- }
1737
-
1738
- main().catch((err) => {
1739
- // 平台指针失效:fail-closed,打印修复引导而非静默回退/stack trace
1740
- if (err?.name === 'PointerUnreachableError') {
1741
- console.error(`❌ ${err.message}`);
1742
- process.exit(1);
1743
- }
1744
- // message 提首行(Agent 可直接解析 stderr 首行判断成败),堆栈降级为附件;
1745
- // 用 exitCode 而非 exit(1) 让事件循环自然 drain,缓解 Windows libuv teardown
1746
- // 把退出码污染成 127(与 shell "command not found" 撞码)的问题。
1747
- console.error(`❌ SillySpec 发生错误:${err?.message || err}`);
1748
- if (err?.stack) console.error(err.stack);
1749
- process.exitCode = 1;
1750
- });
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * SillySpec CLI — 安装工具
5
+ *
6
+ * 只负责两件事:init(安装命令模板)和 setup(安装 MCP 工具)。
7
+ * 状态管理通过 sillyspec.db(SQLite)完成,使用 `sillyspec progress` 命令。
8
+ */
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
10
+ import { writeAtomicSync } from './fs-atomic.js';
11
+ import { join, resolve } from 'path';
12
+ import { execSync } from 'child_process';
13
+ import { git } from './git-helper.js';
14
+ import { getVersion } from './version.js';
15
+ import { ProgressManager, resolvePlatformSpecDir } from './progress.js';
16
+
17
+ // ── CLI 入口 ──
18
+
19
+ // did-you-mean 从 ./run/shared.js 复用(命令级 + flag 级 typo 建议)。
20
+ import { didYouMean, assertSafeChangeName, resolveSpecDir, detectWorktreeSpecDrift } from './run/shared.js'
21
+ function printUsage() {
22
+ console.log(`
23
+ SillySpec CLI — 规范驱动开发工具包
24
+
25
+ 用法:
26
+ sillyspec init [--tool <name>] [--interactive] [--dir <path>] [--spec-dir <path>]
27
+ 初始化(零交互,自动检测工具并安装命令模板)
28
+ sillyspec setup [--list] 安装推荐 MCP 工具(--list 查看已安装状态)
29
+
30
+ sillyspec run <stage> [options] 执行阶段步骤(核心命令)
31
+ sillyspec <stage> [options] 顶层别名,等同 run <stage>
32
+ stage: scan | brainstorm | plan | execute | verify | archive |
33
+ quick | explore | status | doctor | auto
34
+
35
+ run 通用参数(所有 stage 适用):
36
+ --done --output "摘要" [--input "用户原话"] 完成当前步骤
37
+ --status 查看阶段进度
38
+ --skip 跳过可选步骤
39
+ --reset 重置阶段(从头开始)
40
+ --reopen --from-step <序号|名称> 重新打开已完成阶段进入修订
41
+ --wait --reason "..." --options "A,B" 暂停等用户决策
42
+ --continue --answer "..." 恢复等待中的步骤
43
+ --done --answer "..." --output "..." 一步完成 wait+done
44
+ --change <名> 指定变更名(多活跃变更必填,单变更可省)
45
+ --spec-dir <path> 指定规范目录(默认 <项目>/.sillyspec)
46
+ --non-interactive CI/脚本:禁用交互式 prompt
47
+ --interactive 强制交互(即便 stdin 非 TTY)
48
+ --skip-approval 跳过审批/校验门控(需明确意图)
49
+ --json 输出 JSON(程序化读取)
50
+
51
+ 阶段特有参数:
52
+ quick: --linked-changes none|a,b 显式关联变更(取代 --change,推荐)
53
+ --files a.js,b.js 显式声明允许修改的文件
54
+ --allow-new 允许新增文件(默认禁止)
55
+ --force-baseline 允许覆盖 baseline 受保护文件
56
+ --confirm 完成时确认接受变更审计
57
+ scan: --quick | --standard | --deep 显式选择 profile(优先于规模自动判定;三档互斥)
58
+ --force-rescan 覆盖已有 scan 文档保护
59
+ archive: --confirm 归档确认(必须)
60
+ auto: --mode <模式> 显式指定流程模式
61
+ 平台: --runtime-root <path> / --workspace-id <id> / --scan-run-id <id>
62
+
63
+ sillyspec progress <cmd> 进度记录(轻量,不强制顺序)
64
+ init | show | set-stage <stage> | add-step <stage> <name> |
65
+ update-step <stage> <name> --status <st> [--output <t>] [--force] | complete-stage <stage> [--force] |
66
+ check | repair [--apply] | validate | reset [--stage X] |
67
+ batch --total N --completed M [--failed F] [--skipped K] | batch --status
68
+
69
+ sillyspec worktree <cmd> git worktree 隔离管理(execute 阶段相关)
70
+ create <change> [--base <branch>] 创建隔离 worktree
71
+ apply <change> [--check-only] 校验并应用变更到主工作区
72
+ assess <change> 风险审计 + 自动 apply
73
+ list | meta <change> 列出 / 读取 meta.json
74
+ cleanup <change> [--force] 清理 worktree
75
+ doctor [--fix] [--stale-hours N] 健康检查 + 修复
76
+
77
+ sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
78
+ sillyspec config [schema] [--json] 打印 local.yaml 全部已知键 + 生效状态 + 读取点(堵外部 agent 配置发现缺口)
79
+ sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
80
+ sillyspec dispatch <probe | hint> SillyHub 派发能力探测 + 策略生成(agent 调用桥,仅渲染不执行 tool)
81
+
82
+ sillyspec workflow check <name> [--project <p>] [--change <c>] [--json] [--save]
83
+ 全局 workflow 校验(--save 归档到 .sillyspec/.runtime/workflow-runs/)
84
+ sillyspec workflow list
85
+ sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
86
+ sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
87
+ sillyspec backfill-reviews --change <name> [--json] 为手动补的 task 生成 review.json 草稿(cannot_verify,解 archive 客观完成度阻断)
88
+ sillyspec register-stage-review --change <name> --stage <brainstorm|plan|execute> [--from <review.json>] [--json]
89
+ 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker,治 tier=independent marker 死锁)
90
+
91
+ sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
92
+ (无子命令) 跑诊断,列出问题
93
+ --align-execute-progress [--change <名>] [--confirm] 基于 plan.md 对齐 execute 派生戳(默认 dry-run)
94
+ --cleanup-remnant [--confirm] 删除 0 字节空占位 db(默认 dry-run,仅 --confirm 落盘)
95
+ --dump-db --path <db 路径> dump 指定 db 内容到文件(取证用)
96
+ --json 结构化诊断 + 落盘 .sillyspec/.runtime/doctor-diagnosis.json
97
+ sillyspec modules <rebuild | status | migrate>
98
+ sillyspec change-rename <旧变更名> <新变更名>
99
+ sillyspec knowledge <search --query "..." --limit N
100
+ | inspect --id "..."
101
+ | validate | refresh
102
+ | propose --title "..." --category <name>>
103
+ sillyspec platform <connect <url> [--token <t>]
104
+ | disconnect
105
+ | sync [--change <name>] | sync-docs [--change <name>]
106
+ | status | pointer [--cleanup]
107
+ | approve <change> | reject <change> [--reason <r>]>
108
+ sillyspec docs migrate
109
+ sillyspec dashboard [--port <N>] [--no-open]
110
+
111
+ 全局选项:
112
+ --json 输出 JSON
113
+ --dir <path> 指定项目目录(默认当前目录)
114
+ --spec-dir <path> 指定规范目录(默认 <项目目录>/.sillyspec)
115
+ --tool <name> 指定工具(init 用)
116
+ --interactive, -i 交互式引导
117
+ --version, -v 查看版本
118
+
119
+ 示例:
120
+ sillyspec init
121
+ sillyspec run brainstorm --change 2026-07-03-add-login
122
+ sillyspec run quick --linked-changes none --done --output "修复手机号校验"
123
+ sillyspec run verify --done --output "验证通过,测试全绿"
124
+ sillyspec run archive --done --confirm --output "归档完成"
125
+ sillyspec run plan --reopen --from-step 2 # 修订 plan,从第 2 步重做
126
+ sillyspec run quick --non-interactive --done --output "CI 内的快修" # 脚本/CI
127
+ sillyspec progress show
128
+ sillyspec worktree apply 2026-07-03-add-login
129
+ `);
130
+ }
131
+
132
+ // --json 模式输出纪律(design §8 风险表):machine-interface 的 runGate/runDerive 执行期间,
133
+ // 被调模块(validators/task-review/verify-postcheck)的人类可读 console.log 会污染 stdout 破坏 JSON。
134
+ // 在整个调用期间把 console.log/info 重定向到 stderr,stdout 留给最终 JSON envelope(D-005@v1)。
135
+ async function withJsonOutput(json, fn) {
136
+ if (!json) return fn();
137
+ const origLog = console.log;
138
+ const origInfo = console.info;
139
+ console.log = (...a) => process.stderr.write(a.map(String).join(' ') + '\n');
140
+ console.info = (...a) => process.stderr.write(a.map(String).join(' ') + '\n');
141
+ try {
142
+ return await fn();
143
+ } finally {
144
+ console.log = origLog;
145
+ console.info = origInfo;
146
+ }
147
+ }
148
+
149
+ async function main() {
150
+ const args = process.argv.slice(2);
151
+
152
+ if (args[0] === '--version' || args[0] === '-v') {
153
+ console.log(getVersion());
154
+ process.exit(0);
155
+ }
156
+
157
+ if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
158
+ printUsage();
159
+ process.exit(0);
160
+ }
161
+
162
+ // 解析全局选项
163
+ let json = false;
164
+ let saveWorkflowRunFlag = false;
165
+ let targetDir = process.cwd();
166
+ let specDir = null;
167
+ let tool = null;
168
+ // --tool 多值收集(逗号分隔 + 重复 flag);空数组 = 未提供,cmdInit 侧自动检测
169
+ const toolValues = [];
170
+ let interactive = false;
171
+ // init 专属:--no-skills 跳过 skills 复制段(platform init 勿污染项目内工具目录)
172
+ let noSkills = false;
173
+ // 平台模式 flag(init 落平台指针用;非 init 命令时忽略——runCommand 自行解析 filteredArgs)
174
+ let platformWorkspaceId = null;
175
+ let platformRuntimeRoot = null;
176
+ const filteredArgs = [];
177
+
178
+ for (let i = 0; i < args.length; i++) {
179
+ if (args[i] === '--json') {
180
+ json = true;
181
+ } else if (args[i] === '--save') {
182
+ saveWorkflowRunFlag = true;
183
+ } else if (args[i] === '--dir' && args[i + 1]) {
184
+ targetDir = resolve(args[i + 1]);
185
+ i++;
186
+ } else if (args[i] === '--spec-dir' && args[i + 1]) {
187
+ specDir = resolve(args[i + 1]);
188
+ i++;
189
+ } else if (args[i] === '--tool' && args[i + 1]) {
190
+ // 多值支持:逗号分隔(--tool claude,codex)与重复 flag(--tool a --tool b)都收集进数组
191
+ for (const v of args[i + 1].split(',')) {
192
+ const t = v.trim();
193
+ if (t) toolValues.push(t);
194
+ }
195
+ i++;
196
+ } else if (args[i] === '--no-skills') {
197
+ // init 专属:跳过 skills 复制段(吞进变量,不透传 filteredArgs)
198
+ noSkills = true;
199
+ } else if (args[i] === '--workspace-id' && args[i + 1]) {
200
+ // 平台模式专属 flag(init 落平台指针用;--runtime-root/--scan-run-id 保持在
201
+ // filteredArgs 由 runCommand 解析,init 侧只需要 workspaceId 作为平台信号 + runtimeRoot)
202
+ platformWorkspaceId = args[i + 1];
203
+ filteredArgs.push(args[i], args[i + 1]);
204
+ i++;
205
+ } else if (args[i] === '--runtime-root' && args[i + 1]) {
206
+ platformRuntimeRoot = resolve(args[i + 1]);
207
+ filteredArgs.push(args[i], args[i + 1]);
208
+ i++;
209
+ } else if (args[i] === '--interactive' || args[i] === '-i') {
210
+ interactive = true;
211
+ } else if (args[i] === '--list' || args[i] === '-l') {
212
+ filteredArgs.push('--list');
213
+ } else {
214
+ filteredArgs.push(args[i]);
215
+ }
216
+ }
217
+
218
+ // F3:--interactive(全局 flag,被上面吞进 interactive 变量)与 --non-interactive(透传到 filteredArgs)
219
+ // 语义相反,同时给是矛盾。在此层检测(command.js 看不到 --interactive)。
220
+ if (interactive && filteredArgs.includes('--non-interactive')) {
221
+ console.error('❌ --non-interactive 与 --interactive 互斥(一个禁用交互、一个强制交互),请只保留一个。');
222
+ process.exit(2);
223
+ }
224
+
225
+ const command = filteredArgs[0];
226
+ // 支持 sillyspec init /path/to/project 语法:如果第二个参数看起来像路径,当作 targetDir
227
+ if (command === 'init' && filteredArgs[1] && !filteredArgs[1].startsWith('-')) {
228
+ targetDir = resolve(filteredArgs[1]);
229
+ filteredArgs.splice(1, 1);
230
+ }
231
+ // ── 自动纠正 cwd ──
232
+ // 当 agent 在 worktree 内跑 pnpm 等工具后 shell cwd 可能被改变,
233
+ // 导致 sillyspec 命令找不到 .sillyspec。此函数尝试从 git root 解析。
234
+ function resolveEffectiveDir(baseDir) {
235
+ if (existsSync(join(baseDir, '.sillyspec'))) return baseDir
236
+ try {
237
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
238
+ cwd: baseDir, encoding: 'utf8', timeout: 5000
239
+ }).trim()
240
+ if (gitRoot && existsSync(join(gitRoot, '.sillyspec'))) return gitRoot
241
+ } catch {}
242
+ return baseDir
243
+ }
244
+
245
+ const dir = targetDir;
246
+
247
+ if (command === 'init' && !existsSync(dir)) {
248
+ const { mkdirSync } = await import('fs');
249
+ mkdirSync(dir, { recursive: true });
250
+ }
251
+
252
+ if (!existsSync(dir)) {
253
+ console.error(`❌ 目录不存在: ${dir}`);
254
+ process.exit(1);
255
+ }
256
+
257
+ switch (command) {
258
+ case 'init':
259
+ await (await import('./init.js')).cmdInit(dir, { tool, tools: toolValues.length > 0 ? toolValues : null, interactive, specDir, noSkills, platformOpts: (platformWorkspaceId || platformRuntimeRoot) ? { workspaceId: platformWorkspaceId, runtimeRoot: platformRuntimeRoot } : null });
260
+ break;
261
+ case 'setup':
262
+ const setupList = filteredArgs.includes('--list') || filteredArgs.includes('-l');
263
+ await (await import('./setup.js')).cmdSetup(dir, { json, list: setupList });
264
+ break;
265
+ case 'progress': {
266
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
267
+ const progDir = specDir ? dir : resolveEffectiveDir(dir);
268
+ const subCommand = filteredArgs[1];
269
+ const stageIdx = filteredArgs.indexOf('--stage');
270
+ const stage = stageIdx >= 0 && filteredArgs[stageIdx + 1] ? filteredArgs[stageIdx + 1] : null;
271
+ // 解析 --change 参数
272
+ const progChangeIdx = args.indexOf('--change');
273
+ const progChangeName = progChangeIdx >= 0 && args[progChangeIdx + 1] ? args[progChangeIdx + 1] : null;
274
+ // 与 run 入口同源消毒(防路径穿越;progress 下游 completeStage 写 history 文件名拼 change 名)
275
+ if (progChangeName) assertSafeChangeName(progChangeName, '--change 变更名');
276
+
277
+ switch (subCommand) {
278
+ case 'init':
279
+ pm.init(progDir);
280
+ break;
281
+ case 'status':
282
+ case 'show':
283
+ pm.show(progDir, progChangeName);
284
+ break;
285
+ case 'check': {
286
+ const result = pm.checkConsistency(progDir, progChangeName);
287
+ // checkConsistency 只返回 {ok, issues, warnings},不打印——必须在此输出,
288
+ // 否则 agent 跑只读健康检查得到一片寂静,无法分辨「正常」还是「已损坏」。
289
+ if (!result || result.ok) {
290
+ console.log(`✅ 状态一致性检查通过${progChangeName ? `(变更 ${progChangeName})` : '(所有变更)'}`);
291
+ } else {
292
+ console.error(`❌ 状态一致性检查发现问题(${result.issues?.length || 0} 项):`);
293
+ for (const issue of (result.issues || [])) console.error(` - ${issue}`);
294
+ console.error(` 修复:sillyspec progress repair${progChangeName ? ` --change ${progChangeName}` : ''}(先 dry-run 看可修项,加 --apply 执行)`);
295
+ }
296
+ break;
297
+ }
298
+ case 'repair': {
299
+ const repairApply = filteredArgs.includes('--apply');
300
+ pm.repairConsistency(progDir, { apply: repairApply, changeName: progChangeName });
301
+ break;
302
+ }
303
+ case 'validate':
304
+ pm.validate(dir, progChangeName);
305
+ break;
306
+ case 'reset':
307
+ pm.reset(dir, stage, progChangeName);
308
+ break;
309
+ case 'set-stage': {
310
+ const setStageName = filteredArgs[2];
311
+ if (!setStageName) { console.log('❌ 用法: sillyspec progress set-stage <stage> [--change <name>]'); break; }
312
+ pm.setStage(dir, setStageName, progChangeName);
313
+ break;
314
+ }
315
+ case 'add-step': {
316
+ const addStepStage = filteredArgs[2];
317
+ const addStepName = filteredArgs[3];
318
+ if (!addStepStage || !addStepName) { console.log('❌ 用法: sillyspec progress add-step <stage> <step-name> [--change <name>]'); break; }
319
+ pm.addStep(dir, addStepStage, addStepName, progChangeName);
320
+ break;
321
+ }
322
+ case 'update-step': {
323
+ const updStepStage = filteredArgs[2];
324
+ const updStepName = filteredArgs[3];
325
+ if (!updStepStage || !updStepName) { console.log('❌ 用法: sillyspec progress update-step <stage> <step-name> --status <status> [--output <text>] [--change <name>]'); break; }
326
+ let updStatus = null, updOutput = undefined;
327
+ for (let ai = 0; ai < args.length; ai++) {
328
+ if (args[ai] === '--status' && args[ai + 1]) { updStatus = args[ai + 1]; ai++; }
329
+ if (args[ai] === '--output' && args[ai + 1]) { updOutput = args[ai + 1]; ai++; }
330
+ }
331
+ const updForce = args.includes('--force');
332
+ pm.updateStep(dir, updStepStage, updStepName, { status: updStatus, output: updOutput, force: updForce }, progChangeName);
333
+ break;
334
+ }
335
+ case 'complete-stage': {
336
+ const compStageName = filteredArgs[2];
337
+ if (!compStageName) { console.log('❌ 用法: sillyspec progress complete-stage <stage> [--force]'); break; }
338
+ pm.completeStage(dir, compStageName, progChangeName, { force: args.includes('--force') });
339
+ break;
340
+ }
341
+ case 'batch': {
342
+ if (filteredArgs.includes('--status')) {
343
+ const bp = pm.readBatchProgress(dir, progChangeName);
344
+ if (!bp) { console.log('📭 无批量进度数据'); break; }
345
+ const line = pm._renderBatchProgress(bp);
346
+ console.log(line || '📭 无批量进度数据');
347
+ console.log(JSON.stringify(bp, null, 2));
348
+ } else {
349
+ let batchData = {};
350
+ const a = args;
351
+ for (let i = 0; i < a.length; i++) {
352
+ if (a[i] === '--total' && a[i + 1]) { batchData.total = parseInt(a[i + 1]); i++; }
353
+ if (a[i] === '--completed' && a[i + 1]) { batchData.completed = parseInt(a[i + 1]); i++; }
354
+ if (a[i] === '--failed' && a[i + 1]) { batchData.failed = parseInt(a[i + 1]); i++; }
355
+ if (a[i] === '--skipped' && a[i + 1]) { batchData.skipped = parseInt(a[i + 1]); i++; }
356
+ }
357
+ if (Object.keys(batchData).length === 0) {
358
+ console.log('用法: sillyspec progress batch --total 100 --completed 73');
359
+ console.log(' sillyspec progress batch --status');
360
+ break;
361
+ }
362
+ pm.updateBatchProgress(dir, batchData, progChangeName);
363
+ console.log('✅ 批量进度已更新');
364
+ }
365
+ break;
366
+ }
367
+ default:
368
+ console.log('用法: sillyspec progress <init|show|validate|reset|set-stage|add-step|update-step|complete-stage>');
369
+ }
370
+ break;
371
+ }
372
+ case 'gate': {
373
+ // 机器门控(machine-interface v1):聚合「变更的 <stage> 阶段此刻能否标记完成」的综合结论。
374
+ // 只读查询,不依赖 worktree、不写状态(D-002@v1)。
375
+ const gateStage = filteredArgs[1];
376
+ const gateChangeIdx = args.indexOf('--change');
377
+ const gateChange = gateChangeIdx >= 0 && args[gateChangeIdx + 1] ? args[gateChangeIdx + 1] : null;
378
+ if (!gateStage || gateStage.startsWith('-') || !gateChange) {
379
+ console.error('用法: sillyspec gate <stage> --change <name> [--json]\n stage: brainstorm | plan | execute | verify | archive | ...');
380
+ process.exit(2);
381
+ }
382
+ // 与 run 入口同源消毒(防路径穿越;gate 下游拼 marker/changes 路径)
383
+ assertSafeChangeName(gateChange, '--change 变更名');
384
+ const { runGate } = await import('./machine-interface.js');
385
+ // --spec-dir 透传 runGate.specBase(gate CLI 接线修复,P3 坑 3 sillyspec 侧)。
386
+ // runGate 已分离 cwd(跑测试) + specBase(读 local.yaml/spec),但 gate case 之前
387
+ // 只传 cwd 致 --spec-dir 对 gate verify 无效。不传 --spec-dir 时 specBase 走默认
388
+ // resolveSpecDir(cwd),向后兼容。
389
+ const gateOpts = { cwd: dir };
390
+ if (specDir) gateOpts.specBase = specDir;
391
+ // drift 锚定(坑 execute-runs-isolation,对齐 command.js execute drift 守卫 + machine-interface
392
+ // runGate 已扩展的 specDriftAnchor 入参):gate 是顶层命令不走 runCommand/drift 守卫,worktree cwd
393
+ // 下 execute 段 marker 读取会落副本 .runtime。仅在未显式 --spec-dir 时算(对齐守卫条件 !specDir),
394
+ // 命中则 specDriftAnchor 锚主仓——runGate execute 段的 resolveRuntimeRoot 读主仓
395
+ // current-execute-run-id marker,不读 worktree 副本(副本随 cleanup 整目录删消失)。
396
+ if (!specDir) {
397
+ const wt = detectWorktreeSpecDrift(resolveSpecDir(dir));
398
+ if (wt) gateOpts.specDriftAnchor = wt.mainSpecBase;
399
+ }
400
+ // W3 task-09:gate 是只读查询命令,best-effort 构造 ctx(D-013)。失败降级 null(不阻断只读命令)——
401
+ // ctx 让 validateTaskReviews/runVerifyTestCheck 正确切跨仓 gitDir;配置错时单仓降级会让两函数
402
+ // 自然报「跨仓 commit 找不到」等错误进入 check.errors,gate 结论仍如实反映问题(D-002@v1 只读契约不破)。
403
+ try {
404
+ const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
405
+ const _ctx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: gateChange, platformOpts: specDir ? { specRoot: specDir } : {} });
406
+ if (_ctx) gateOpts.ctx = _ctx;
407
+ } catch (e) {
408
+ console.warn(`⚠️ gate ctx 构造失败,降级单仓核验(${e.message})`);
409
+ }
410
+ const { envelope, exitCode } = await withJsonOutput(json, () => runGate(gateStage, gateChange, gateOpts));
411
+ if (json) {
412
+ process.stdout.write(JSON.stringify(envelope));
413
+ } else {
414
+ const icon = envelope.ok ? '✅' : '❌';
415
+ console.log(`${icon} gate ${gateStage} [${gateChange}]: ${envelope.ok ? '通过' : '未通过'} (exit ${exitCode})`);
416
+ for (const c of (envelope.checks || [])) {
417
+ const ci = c.ok ? '✅' : '❌';
418
+ const tag = c.informational ? ' (informational)' : '';
419
+ console.log(` ${ci} ${c.id}${tag}`);
420
+ for (const e of (c.errors || [])) console.log(` ✗ ${e}`);
421
+ for (const w of (c.warnings || [])) console.log(` ⚠ ${w}`);
422
+ }
423
+ // 顶层 errors/warnings 是各 check 的聚合,已在上面按 check 分组显示;不再重复打印——
424
+ // 非 json 是 agent 默认消费路径,重复文本纯耗 context(W1-J)。
425
+ }
426
+ process.exitCode = exitCode;
427
+ break;
428
+ }
429
+ case 'derive': {
430
+ // 单项事实核验(machine-interface v1):查询变更某一 facet 的真实状态,返回结构化 data。
431
+ const facet = filteredArgs[1];
432
+ const deriveChangeIdx = args.indexOf('--change');
433
+ const deriveChange = deriveChangeIdx >= 0 && args[deriveChangeIdx + 1] ? args[deriveChangeIdx + 1] : null;
434
+ if (!facet || facet.startsWith('-') || !deriveChange) {
435
+ console.error('用法: sillyspec derive <facet> --change <name> [--json]\n facet: execute-evidence | verify-test | task-reviews | artifacts');
436
+ process.exit(2);
437
+ }
438
+ // 与 run 入口同源消毒(防路径穿越;derive 下游拼 marker/changes 路径)
439
+ assertSafeChangeName(deriveChange, '--change 变更名');
440
+ const { runDerive } = await import('./machine-interface.js');
441
+ // --spec-dir 透传 runDerive.specBase(与 gate case 对称,P3 坑 3 sillyspec 侧)。
442
+ // derive verify-test facet 内部同样调 runVerifyTestCheck 依赖 specBase,平台模式
443
+ // 下需透传;不传 --spec-dir 时走默认 resolveSpecDir(cwd),向后兼容。
444
+ const deriveOpts = { cwd: dir };
445
+ if (specDir) deriveOpts.specBase = specDir;
446
+ // drift 锚定(同 gate case,对齐 command.js drift 守卫 + machine-interface runDerive 的 specDriftAnchor
447
+ // 入参):derive 顶层命令不走 drift 守卫,worktree cwd 下 task-reviews facet 的 execute-run-id marker
448
+ // 读取会落副本。未显式 --spec-dir 时算 anchor,runDerive task-reviews 的 resolveRuntimeRoot 读主仓 marker。
449
+ if (!specDir) {
450
+ const wt = detectWorktreeSpecDrift(resolveSpecDir(dir));
451
+ if (wt) deriveOpts.specDriftAnchor = wt.mainSpecBase;
452
+ }
453
+ // W3 task-09:derive 同 gate,best-effort 构造 ctx 透传给 verify-test/task-reviews facet(D-013)。
454
+ try {
455
+ const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
456
+ const _ctx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: deriveChange, platformOpts: specDir ? { specRoot: specDir } : {} });
457
+ if (_ctx) deriveOpts.ctx = _ctx;
458
+ } catch (e) {
459
+ console.warn(`⚠️ derive ctx 构造失败,降级单仓核验(${e.message})`);
460
+ }
461
+ const { envelope, exitCode } = await withJsonOutput(json, () => runDerive(facet, deriveChange, deriveOpts));
462
+ if (json) {
463
+ process.stdout.write(JSON.stringify(envelope));
464
+ } else {
465
+ const icon = envelope.ok ? '✅' : '❌';
466
+ console.log(`${icon} derive ${facet} [${deriveChange}]: ${envelope.ok ? '通过' : '未通过'} (exit ${exitCode})`);
467
+ if (envelope.data) console.log(` data: ${JSON.stringify(envelope.data, null, 2)}`);
468
+ for (const e of (envelope.errors || [])) console.log(` ✗ ${e}`);
469
+ for (const w of (envelope.warnings || [])) console.log(` ⚠ ${w}`);
470
+ }
471
+ process.exitCode = exitCode;
472
+ break;
473
+ }
474
+ case 'backfill-reviews': {
475
+ // 手动补 task(reopen execute / 直接实现)缺 review.json 的官方入口(坑 verify-archive-flow-pitfalls 坑2)。
476
+ // 复用 generateTaskReviewDrafts(execute --done 同源兜底,幂等、fail-open):据 git diff base..head + working-tree
477
+ // 按 task allowed_paths 归属生成 cannot_verify 草稿,agent 复核后升级 pass/fail。execute 完成后手动补的 task
478
+ // 无子代理 review 落盘 → archive step1 客观完成度(真相源=review.json verdict)判缺 → 阻断归档;本命令补齐
479
+ // 缺失草稿,独立可随时跑(不必再 execute --done)。缺数据(无 tasks/ 目录 / 改动未 commit / 无 worktree meta)
480
+ // 时 generateTaskReviewDrafts 提前返回 reason,如实打印不报错。
481
+ const brChangeIdx = args.indexOf('--change');
482
+ const brChange = brChangeIdx >= 0 && args[brChangeIdx + 1] ? args[brChangeIdx + 1] : null;
483
+ if (!brChange) {
484
+ console.error('用法: sillyspec backfill-reviews --change <name> [--json] [--spec-dir <path>]\n 为手动补的 task 生成 review.json 草稿(cannot_verify),解 archive 客观完成度阻断');
485
+ process.exit(2);
486
+ }
487
+ // 与 run 入口同源消毒(防路径穿越;backfill 下游拼 marker/changes/review 路径)
488
+ assertSafeChangeName(brChange, '--change 变更名');
489
+ const { generateTaskReviewDrafts } = await import('./task-review.js');
490
+ // --spec-dir 透传 platformOpts.specRoot(与 gate/derive 对称);不传走默认 join(cwd,'.sillyspec')。
491
+ const brPlatformOpts = {};
492
+ if (specDir) brPlatformOpts.specRoot = specDir;
493
+ // W3 task-09:best-effort 构造 ctx 透传(D-013),让 task-04 generateTaskReviewDrafts 跨仓 task
494
+ // 用跨仓 gitDir 取 base..head diff。失败降级 null(与 gate/derive 同语义,辅助修复命令不阻断)。
495
+ let _brCtx = null;
496
+ try {
497
+ const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
498
+ _brCtx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: brChange, platformOpts: brPlatformOpts });
499
+ } catch (e) {
500
+ console.warn(`⚠️ backfill-reviews ctx 构造失败,降级单仓草稿(${e.message})`);
501
+ }
502
+ const result = await generateTaskReviewDrafts({ changeName: brChange, cwd: dir, platformOpts: brPlatformOpts, ctx: _brCtx });
503
+ if (json) {
504
+ process.stdout.write(JSON.stringify({ ok: true, command: 'backfill-reviews', change: brChange, ...result }));
505
+ } else {
506
+ if (result.generated > 0) {
507
+ console.log('📄 已补写 ' + result.generated + ' 个 per-task review.json 草稿(cannot_verify,需 agent 复核后升级 pass/fail) [' + brChange + ']');
508
+ } else {
509
+ console.log('ℹ️ 无草稿可补 [' + brChange + ']' + (result.reason ? ':' + result.reason : ''));
510
+ }
511
+ if (result.skipped > 0) {
512
+ console.log(' 跳过 ' + result.skipped + ' 个 task(review.json 已存在 / 空 diff 无从归属)');
513
+ }
514
+ if (result.unattributed && result.unattributed.length > 0) {
515
+ console.warn(' ⚠️ ' + result.unattributed.length + ' 个变更文件未归属任何 task(顺带修复/非源码),草稿未覆盖:' + result.unattributed.join(', '));
516
+ }
517
+ if (result.executeRunId) {
518
+ console.log(' execute run id: ' + result.executeRunId);
519
+ }
520
+ }
521
+ break;
522
+ }
523
+ case 'register-stage-review': {
524
+ // 手动注册 stage 级 review(brainstorm/plan/execute-acceptance)的确定性 writer ——
525
+ // 生成 run 目录 + review.json 骨架(cannot_verify,待独立审查子代理填 verdict)或 adopt
526
+ // agent 草稿(--from,保留 verdict/checklist 重算 docHash)+ 写 marker。治 tier=independent 时
527
+ // 调度者手动派独立子代理不写 marker 的死锁。docHash 由 CLI computeDocHash 算(部分实现 P6.1b defer)。
528
+ // 纯新增,不改 gate 语义。与 task 级 backfill-reviews 对称(stage 级等价物)。
529
+ const rsrChangeIdx = args.indexOf('--change');
530
+ const rsrChange = rsrChangeIdx >= 0 && args[rsrChangeIdx + 1] ? args[rsrChangeIdx + 1] : null;
531
+ const rsrStageIdx = args.indexOf('--stage');
532
+ const rsrStage = rsrStageIdx >= 0 && args[rsrStageIdx + 1] ? args[rsrStageIdx + 1] : null;
533
+ const rsrFromIdx = args.indexOf('--from');
534
+ const rsrFrom = rsrFromIdx >= 0 && args[rsrFromIdx + 1] ? args[rsrFromIdx + 1] : null;
535
+ if (!rsrChange || !rsrStage) {
536
+ console.error('用法: sillyspec register-stage-review --change <名> --stage <brainstorm|plan|execute> [--from <review.json>] [--spec-dir <path>] [--json]\n 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker),治 tier=independent marker 死锁');
537
+ process.exit(2);
538
+ }
539
+ // 与 run 入口同源消毒(防路径穿越;register 下游写 marker、拼 changes/review 路径)
540
+ assertSafeChangeName(rsrChange, '--change 变更名');
541
+ const { registerStageReview } = await import('./stage-review.js');
542
+ const rsrPlatformOpts = {};
543
+ if (specDir) rsrPlatformOpts.specRoot = specDir;
544
+ try {
545
+ const result = registerStageReview({ changeName: rsrChange, stage: rsrStage, fromFile: rsrFrom, cwd: dir, platformOpts: rsrPlatformOpts });
546
+ if (json) {
547
+ process.stdout.write(JSON.stringify({ ok: true, command: 'register-stage-review', change: rsrChange, stage: rsrStage, ...result }));
548
+ } else {
549
+ console.log(`✅ 已注册 ${rsrStage} stage review [${result.reviewRunId}] → ${result.reviewPath}(mode: ${result.mode})`);
550
+ console.log(` marker → ${result.markerPath}`);
551
+ console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
552
+ }
553
+ } catch (e) {
554
+ if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', error: e.message }));
555
+ else console.error('❌ ' + e.message);
556
+ process.exitCode = 1;
557
+ }
558
+ break;
559
+ }
560
+ case 'docs': {
561
+ const docsSubCmd = filteredArgs[1];
562
+ if (docsSubCmd === 'migrate') {
563
+ const { migrateDocs } = await import('./migrate.js');
564
+ migrateDocs(dir);
565
+ } else if (docsSubCmd === 'check') {
566
+ // docs check(2026-08-15 docs-check-productize D-6):文档 file:line 引用校验。
567
+ // exit code 三档(D-003):0 全绿 / 1 存在无效引用 / 2 配置/IO 错误(DocsCheckConfigError)。
568
+ // ⚠ flag 解析(F-1 白名单化,docs-signals-o12 D-003 治模式):--json 由全局解析器吞进
569
+ // json 变量(filteredArgs 不含);分支内 BARE_FLAGS(无值)置位剔除、PAIRED_FLAGS(带值)
570
+ // 成对剔除;未知 --xxx 显式 exit 2——不再静默落入文档路径误报"不存在"
571
+ // (历史两次踩坑:--json 恒失效、--suggest 被当文档路径)。
572
+ const BARE_FLAGS = ['--suggest'];
573
+ const PAIRED_FLAGS = ['--paths'];
574
+ const rawDocsArgs = filteredArgs.slice(2);
575
+ const docsCheckFlags = [];
576
+ let cliPaths = null;
577
+ let suggest = false;
578
+ for (let i = 0; i < rawDocsArgs.length; i++) {
579
+ const a = rawDocsArgs[i];
580
+ if (a === '--paths' && rawDocsArgs[i + 1] !== undefined) {
581
+ cliPaths = rawDocsArgs[i + 1].split(',').map(s => s.trim()).filter(Boolean);
582
+ i++; // 跳过值
583
+ } else if (a === '--paths') {
584
+ console.error('❌ docs check: --paths 缺值(逗号分隔 glob,如 --paths "docs/**/*.md")');
585
+ process.exit(2);
586
+ } else if (BARE_FLAGS.includes(a)) {
587
+ if (a === '--suggest') suggest = true;
588
+ } else if (a.startsWith('--')) {
589
+ console.error(`❌ docs check: 未知 flag「${a}」。已知 flag:${[...BARE_FLAGS, ...PAIRED_FLAGS].join(' ')}(--json 为全局 flag)`);
590
+ process.exit(2);
591
+ } else {
592
+ docsCheckFlags.push(a);
593
+ }
594
+ }
595
+ const { runDocsCheck, readDocsCheckConfig, DocsCheckConfigError } = await import('./docs-check.js');
596
+ try {
597
+ // 配置优先级:--paths > local.yaml docs-check.paths > 缺省 docs/**/*.md
598
+ const cfg = readDocsCheckConfig(dir);
599
+ const result = runDocsCheck({
600
+ projectRoot: dir,
601
+ docs: docsCheckFlags.length > 0 ? docsCheckFlags : null,
602
+ paths: cliPaths || cfg.paths,
603
+ skip: cfg.skip,
604
+ keywordAssert: cfg.keywordAssert,
605
+ });
606
+ if (json) {
607
+ console.log(JSON.stringify(result, null, 2));
608
+ } else {
609
+ for (const w of result.warnings) console.warn(`⚠️ ${w}`);
610
+ if (result.ok) {
611
+ console.log(`✅ docs check: ${result.total} 处引用全通过(其中 ${result.kwChecked} 处带关键词断言)`);
612
+ } else {
613
+ console.error(`\n❌ docs check: ${result.invalid.length}/${result.total} 处引用失效:`);
614
+ for (const inv of result.invalid) {
615
+ console.error(` ❌ [${inv.doc}:L${inv.docLine}] ${inv.ref} → ${inv.reason}`);
616
+ if (suggest && inv.suggest && inv.suggest.length > 0) {
617
+ console.error(` 💡 候选行号: ${inv.suggest.join(', ')}(token 命中行,人工确认后更新文档锚)`);
618
+ }
619
+ }
620
+ console.error(`\n修复指引:行号漂移 → 更新文档行号到当前源码;文件删改名 → 更新引用路径;`);
621
+ console.error(`关键词缺失但行号正确 → 确认符号是否改名,改文档 token 或行号。`);
622
+ }
623
+ }
624
+ process.exit(result.ok ? 0 : 1);
625
+ } catch (e) {
626
+ if (e instanceof DocsCheckConfigError) {
627
+ console.error(`❌ docs check 配置错误:${e.message}`);
628
+ process.exit(2);
629
+ }
630
+ throw e;
631
+ }
632
+ } else {
633
+ console.log('用法: sillyspec docs migrate | sillyspec docs check [--paths <glob,...>] [--json] [--suggest] [--suggest]');
634
+ }
635
+ break;
636
+ }
637
+ case 'run': {
638
+ // doctor --json 等价 doctor 顶层 --json(结构化诊断),与 case 'doctor' 行为一致——
639
+ // 否则 sillyspec doctor --json 走 runDoctorDiagnostics,sillyspec run doctor --json 走 runCommand
640
+ // prompt 自检,两者输出不等价(违反"顶层别名 == run <stage>"契约)。
641
+ if (filteredArgs[1] === 'doctor' && json) {
642
+ const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir)
643
+ const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js')
644
+ const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir })
645
+ const output = formatDoctorJson(result, { source_root: dir })
646
+ const written = writeDoctorDiagnosis(output, result.authoritySpecDir)
647
+ if (written) console.error(`📁 诊断结果已写入: ${written}`)
648
+ console.log(JSON.stringify(output, null, 2))
649
+ process.exitCode = output.overall_status === 'pass' ? 0 : 1
650
+ break
651
+ }
652
+ const { runCommand } = await import('./run.js')
653
+ // 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
654
+ const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
655
+ await runCommand(filteredArgs.slice(1), effectiveDir, specDir, { json })
656
+ break
657
+ }
658
+ // 顶层命令别名:转发 runCommand,与 case 'run': 路径行为一致。
659
+ // printUsage 宣称所有 stage 都可直接使用,这里补齐全部路由避免落 default 分支。
660
+ // 注意:filteredArgs[0] === command,直接透传 filteredArgs 即可让 runCommand
661
+ // 从 args[0] 取到 stage 名。与 case 'run': 的 filteredArgs.slice(1) 区别只在于
662
+ // slice(1) 去掉的是 'run' 字面量,这里 command 本身就是 stage 名不能丢。
663
+ case 'doctor': {
664
+ const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir);
665
+ // 执行流:--cleanup-remnant / --dump-db(结构化诊断之外的修复/取证动作)
666
+ const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
667
+ const dumpDbFlag = filteredArgs.includes('--dump-db');
668
+ const doctorConfirm = filteredArgs.includes('--confirm');
669
+ const pathIdx = filteredArgs.indexOf('--path');
670
+ const dbPath = pathIdx >= 0 && filteredArgs[pathIdx + 1] ? filteredArgs[pathIdx + 1] : null;
671
+
672
+ // --align-execute-progress:基于 plan.md 声明对齐 execute 派生戳(仿 --cleanup-remnant 范式)
673
+ // 默认 dry-run(只报告将补哪些 step),加 --confirm 才写。命中即 break,绝不 fall-through。
674
+ const alignFlag = filteredArgs.includes('--align-execute-progress');
675
+ if (alignFlag) {
676
+ // --change 解析:显式优先,缺省用单活跃变更自动兜底(与 run.js resolveChangeNameAuto 同逻辑)
677
+ const alignChangeIdx = filteredArgs.indexOf('--change');
678
+ let alignChange = alignChangeIdx >= 0 && filteredArgs[alignChangeIdx + 1] ? filteredArgs[alignChangeIdx + 1] : null;
679
+ if (!alignChange) {
680
+ const changesDir = join(resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec'), 'changes');
681
+ if (existsSync(changesDir)) {
682
+ const activeChanges = readdirSync(changesDir, { withFileTypes: true })
683
+ .filter(e => e.isDirectory() && e.name !== 'archive')
684
+ .map(e => e.name);
685
+ if (activeChanges.length === 1) alignChange = activeChanges[0];
686
+ }
687
+ }
688
+ if (!alignChange) {
689
+ console.error('❌ 无法确定变更名:请用 --change <name>,或确保仅有一个活跃变更');
690
+ process.exitCode = 2;
691
+ break;
692
+ }
693
+
694
+ // 复用顶层静态 import(line 12),不动态 await import
695
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(doctorEffectiveDir, specDir) });
696
+ const specBase = resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec');
697
+ let r;
698
+ try {
699
+ r = pm.alignExecuteToPlan(doctorEffectiveDir, alignChange, specBase, { confirm: doctorConfirm });
700
+ } catch (e) {
701
+ console.error(`❌ 对齐失败:${e.message}`);
702
+ process.exitCode = 1;
703
+ break;
704
+ }
705
+
706
+ if (json) {
707
+ console.log(JSON.stringify(r, null, 2));
708
+ } else if (r.ok === false) {
709
+ console.error(`❌ ${r.reason || '对齐未执行'}`);
710
+ } else {
711
+ console.log(`✅ 已基于 plan.md 声明对齐 ${r.aligned} 个 step(plan: ${r.planChecked}/${r.planTotal})。`);
712
+ if (!doctorConfirm) {
713
+ console.log(` (dry-run:未写盘。加 --confirm 实际落盘,并置 execute 阶段 status=completed。)`);
714
+ } else {
715
+ console.log(` 已落盘,execute 阶段 status=completed。请确认 verify 通过。`);
716
+ }
717
+ }
718
+ // ok=false 或 reason 非空 → 1;否则 0(与 --cleanup-remnant 的 r.errors 逻辑同构)
719
+ process.exitCode = (r.ok === false || r.reason) ? 1 : 0;
720
+ break;
721
+ }
722
+
723
+ if (cleanupRemnant) {
724
+ const { cleanupRemnantDbs } = await import('./doctor-diagnostics.js');
725
+ const r = await cleanupRemnantDbs({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
726
+ if (json) {
727
+ console.log(JSON.stringify(r, null, 2));
728
+ } else {
729
+ const list = doctorConfirm ? r.deleted : r.would_delete.map((x) => x.path);
730
+ console.log(`🗑️ 空占位 db ${doctorConfirm ? '已删除' : '待清理(dry-run)'}:${r.count} 个`);
731
+ for (const p of list) console.log(` ${doctorConfirm ? '✅' : '-'} ${p}`);
732
+ for (const e of r.errors) console.log(` ❌ ${e.path}: ${e.error}`);
733
+ if (!doctorConfirm && r.count > 0) console.log(`\n加 --confirm 执行删除(仅删 0 字节占位,不动有内容的 db)。`);
734
+ }
735
+ process.exitCode = r.errors.length > 0 ? 1 : 0;
736
+ break;
737
+ }
738
+ if (dumpDbFlag) {
739
+ if (!dbPath) { console.error('❌ --dump-db 需要 --path <db 路径>'); process.exitCode = 2; break; }
740
+ const { dumpDb } = await import('./doctor-diagnostics.js');
741
+ const r = await dumpDb({ dbPath, cwd: doctorEffectiveDir });
742
+ if (json) {
743
+ console.log(JSON.stringify(r, null, 2));
744
+ } else if (r.ok === false && r.error) {
745
+ console.error(`❌ ${r.error}`);
746
+ } else {
747
+ console.log(`📦 dump ${dbPath} (${r.meta?.size}B):${r.changes?.length || 0} changes, ${r.stages?.length || 0} stages`);
748
+ if (r.written_to) console.log(` 写入:${r.written_to}`);
749
+ }
750
+ process.exitCode = r.ok === false ? 1 : 0;
751
+ break;
752
+ }
753
+ // --json:走结构化诊断(doctor-diagnostics.js),输出 JSON + 落盘 doctor-diagnosis.json
754
+ if (json) {
755
+ const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js');
756
+ const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir });
757
+ const output = formatDoctorJson(result, { source_root: dir });
758
+ const written = writeDoctorDiagnosis(output, result.authoritySpecDir);
759
+ if (written) console.error(`📁 诊断结果已写入: ${written}`);
760
+ console.log(JSON.stringify(output, null, 2));
761
+ process.exitCode = output.overall_status === 'pass' ? 0 : 1;
762
+ break;
763
+ }
764
+ // 否则:保持原有 prompt 驱动的 bash 自检流程
765
+ const { runCommand } = await import('./run.js');
766
+ await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir, { json });
767
+ break;
768
+ }
769
+ case 'scan':
770
+ case 'status':
771
+ case 'quick':
772
+ case 'explore':
773
+ case 'brainstorm':
774
+ case 'plan':
775
+ case 'execute':
776
+ case 'verify':
777
+ case 'archive': {
778
+ const { runCommand } = await import('./run.js')
779
+ const stageArgs = [command, ...filteredArgs.slice(1)]
780
+ const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
781
+ // 下行 pull:stage 命令(run/--done,含 archive)启动时拉一次(task-10 / D-009 / FR-04)
782
+ // 不在每步 pull,仅低频边界点;未连接平台静默跳过
783
+ const { triggerPullActiveChange } = await import('./run/shared.js')
784
+ await triggerPullActiveChange(effectiveDir)
785
+ await runCommand(stageArgs, effectiveDir, specDir, { json })
786
+ break
787
+ }
788
+ case 'knowledge': {
789
+ const { cmdKnowledge } = await import('./stages/knowledge.js')
790
+ await cmdKnowledge(filteredArgs.slice(1), specDir ? dir : resolveEffectiveDir(dir), { specDir })
791
+ break
792
+ }
793
+ case 'dashboard': {
794
+ // Parse dashboard options
795
+ let port = 3456;
796
+ let openBrowser = true;
797
+
798
+ for (let i = 1; i < args.length; i++) {
799
+ if (args[i] === '--port' && args[i + 1]) {
800
+ port = parseInt(args[i + 1], 10);
801
+ i++;
802
+ } else if (args[i] === '--no-open') {
803
+ openBrowser = false;
804
+ }
805
+ }
806
+
807
+ // Import and start dashboard server
808
+ const { startServer } = await import('../packages/dashboard/server/index.js');
809
+ startServer({ port, open: openBrowser });
810
+
811
+ // Keep process alive
812
+ console.log('按 Ctrl+C 停止服务器');
813
+ break;
814
+ }
815
+ case 'worktree': {
816
+ const { WorktreeManager } = await import('./worktree.js');
817
+ const { ProgressManager } = await import('./progress.js');
818
+ const wtSubCmd = filteredArgs[1];
819
+ const wtName = filteredArgs.slice(2).find(a => !a.startsWith('-'));
820
+ const wm = new WorktreeManager({ cwd: dir });
821
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
822
+
823
+ // isolation 写入 DB 的辅助函数
824
+ function _writeIsolationToDB(cwd, changeName, info) {
825
+ if (info.blocked) {
826
+ pm.updateChangeIsolation(cwd, changeName, {
827
+ status: 'blocked',
828
+ mode: null,
829
+ reason: info.reason,
830
+ });
831
+ } else {
832
+ const mode = info.mode || 'worktree';
833
+ const statusMap = { 'worktree': 'verified', 'native-worktree': 'verified', 'in-place-fallback': 'degraded' };
834
+ pm.updateChangeIsolation(cwd, changeName, {
835
+ status: statusMap[mode] || 'verified',
836
+ mode,
837
+ });
838
+ }
839
+ }
840
+
841
+ if (!wtSubCmd || wtSubCmd === 'help' || wtSubCmd === '--help' || wtSubCmd === '-h') {
842
+ console.log(`
843
+ SillySpec worktree — git worktree 隔离管理
844
+
845
+ 用法:
846
+ sillyspec worktree create <change-name> [--base <branch>] 创建隔离 worktree
847
+ sillyspec worktree apply <change-name> [--check-only] 校验并应用变更到主工作区
848
+ sillyspec worktree assess <change-name> 风险审计 + 自动 apply
849
+ sillyspec worktree diff <change-name> [--base <commit>] 查看 worktree 相对 base 的变更
850
+ sillyspec worktree list 列出所有活跃 worktree
851
+ sillyspec worktree meta <change-name> 读取 worktree meta.json
852
+ sillyspec worktree cleanup <change-name> [--force] 强制清理 worktree
853
+ sillyspec worktree doctor [--fix] [--stale-hours N] [--change <name>] 健康检查 + 修复(--change 仅扫指定 change)
854
+
855
+ 选项:
856
+ --base <branch> create: 指定基础分支(默认当前 HEAD)
857
+ --check-only apply: 只输出检查结果,不实际 apply
858
+ `);
859
+ break;
860
+ }
861
+
862
+ switch (wtSubCmd) {
863
+ case 'create': {
864
+ if (!wtName) {
865
+ console.error('❌ 用法: sillyspec worktree create <change-name> [--base <branch>]');
866
+ process.exit(1);
867
+ }
868
+ // F6 路径穿越消毒:worktree 名用于分支名 + worktree 目录,含 ../ 会逃出 .sillyspec/worktrees/。
869
+ try { assertSafeChangeName(wtName, 'worktree 变更名'); }
870
+ catch (e) { console.error(`❌ ${e.message}`); process.exit(2); }
871
+ const baseIdx = args.indexOf('--base');
872
+ const base = baseIdx >= 0 && args[baseIdx + 1] ? args[baseIdx + 1] : undefined;
873
+ try {
874
+ const info = wm.create(wtName, { base });
875
+ console.log(`✅ worktree 已创建`);
876
+ console.log(` 分支: ${info.branch}`);
877
+ console.log(` 路径: ${info.worktreePath}`);
878
+ console.log(` 基准: ${info.baseHash.slice(0, 8)}`);
879
+ if (info.mode) {
880
+ console.log(` 模式: ${info.mode}`);
881
+ }
882
+ // 写入 isolation 信息到 sillyspec.db(_writeIsolationToDB)
883
+ await _writeIsolationToDB(dir, wtName, info);
884
+ } catch (e) {
885
+ console.error(`❌ ${e.message}`);
886
+ // 写入 blocked 状态到 sillyspec.db(_writeIsolationToDB)
887
+ await _writeIsolationToDB(dir, wtName, { blocked: true, reason: e.message });
888
+ process.exit(1);
889
+ }
890
+ break;
891
+ }
892
+ case 'apply': {
893
+ if (!wtName) {
894
+ console.error('❌ 用法: sillyspec worktree apply <change-name> [--check-only] [--merge]');
895
+ process.exit(1);
896
+ }
897
+ const checkOnly = args.includes('--check-only');
898
+ const merge = args.includes('--merge');
899
+ const { applyWorktree } = await import('./worktree-apply.js');
900
+ // W3 task-09:apply 链路构造 ctx(D-013),让 task-05 applyWorktree 按 ctx 区分主仓 A5 / 跨仓 no-op。
901
+ // 跨仓 apply=no-op(G1 D-009):校验 review.head 是跨仓真实 commit + 跳过 wm.cleanup。
902
+ // ctx 构造 fail-closed(约束②)——跨仓配置错时 apply 必须阻断(走错仓=数据所有权事故),不降级。
903
+ let _applyCtx = null;
904
+ try {
905
+ const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
906
+ _applyCtx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: wtName, platformOpts: specDir ? { specRoot: specDir } : {} });
907
+ } catch (e) {
908
+ console.error(`❌ apply 失败:跨仓 MultiRepoContext 构造失败(${e.message})`);
909
+ process.exit(1);
910
+ }
911
+ const result = applyWorktree(wtName, { cwd: dir, checkOnly, merge, ctx: _applyCtx });
912
+
913
+ if (result.errors.length > 0) {
914
+ console.error(`❌ 校验失败:`);
915
+ for (const err of result.errors) {
916
+ console.error(` ${err}`);
917
+ }
918
+ if (result.rescueCommands) {
919
+ console.error('');
920
+ console.error(`🆘 Rescue commands (${result.rescueCommands.cpFileCount} safe / ${result.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
921
+ for (const c of result.rescueCommands.commands) console.error(` ${c}`);
922
+ for (const w of result.rescueCommands.warnings) console.error(` ⚠️ ${w}`);
923
+ }
924
+ process.exit(1);
925
+ }
926
+
927
+ if (result.changedFiles.length === 0) {
928
+ console.log('📭 无变更需要应用');
929
+ break;
930
+ }
931
+
932
+ if (checkOnly) {
933
+ console.log(`✅ 检查通过 (${result.changedFiles.length} 个文件):`);
934
+ for (const f of result.changedFiles) {
935
+ console.log(` ${f}`);
936
+ }
937
+ } else if (result.merged) {
938
+ console.log(`✅ 已通过 git merge 应用变更(baseline 漂移降级,引入合并提交)${result.mergeSummary ? ':' + result.mergeSummary : ''}`);
939
+ } else {
940
+ console.log(`✅ 已应用 ${result.changedFiles.length} 个文件变更:`);
941
+ for (const f of result.changedFiles) {
942
+ console.log(` ${f}`);
943
+ }
944
+ // filterDeliverableFiles 精细化排除(保留 .sillyspec/docs/,排 changes/+.runtime/+quicklog/ + meta.json):
945
+ // 模块文档(.sillyspec/docs/)= 交付物,会 auto-apply 到主工作区;变更文档/运行时/quicklog 不 apply。
946
+ // 须告知 agent 哪些未落地,否则它期望这些文件落地却找不到(memory: worktree-apply-excludes-module-docs)。
947
+ console.log(` ℹ️ 注:.sillyspec/changes、.sillyspec/.runtime、.sillyspec/quicklog 下的文件按规则不自动 apply(模块文档 .sillyspec/docs/ 会自动 apply),如需请手动从 worktree 分支或 dangling commit 取(git show sillyspec/${wtName}:<path>)。`);
948
+ }
949
+ if (result.warnings && result.warnings.length > 0) {
950
+ for (const w of result.warnings) {
951
+ console.log(`⚠️ ${w}`);
952
+ }
953
+ }
954
+ break;
955
+ }
956
+ case 'assess': {
957
+ if (!wtName) {
958
+ console.error('❌ 用法: sillyspec worktree assess <change-name>');
959
+ process.exit(1);
960
+ }
961
+ const { assessApplyRisk } = await import('./worktree-apply.js');
962
+ const assessment = assessApplyRisk(wtName, { cwd: dir });
963
+
964
+ // W3 task-09:assess 自动 apply 路径构造 ctx(D-013),SAFE/WARNING 时透传给 applyWorktree。
965
+ // 构造 fail-closed(跨仓配置错阻断,与显式 apply case 同语义)。
966
+ let _assessCtx = null;
967
+ try {
968
+ const { getOrCreateMultiRepoContext } = await import('./run/shared.js');
969
+ _assessCtx = await getOrCreateMultiRepoContext({ cwd: dir, changeName: wtName, platformOpts: specDir ? { specRoot: specDir } : {} });
970
+ } catch (e) {
971
+ if (assessment.decision === 'SAFE' || assessment.decision === 'WARNING') {
972
+ console.error(`❌ assess 自动 apply 失败:跨仓 MultiRepoContext 构造失败(${e.message})`);
973
+ process.exit(1);
974
+ }
975
+ // blocked 决策不触发 apply,ctx 失败仅记 warn(不阻断只读 assess 展示)
976
+ console.warn(`⚠️ ctx 构造失败(${e.message})`);
977
+ }
978
+ const SEPARATOR = '─'.repeat(32);
979
+ console.log('Worktree Apply Decision');
980
+ console.log(SEPARATOR);
981
+ const decisionIcon = assessment.decision === 'SAFE' ? '✅' : assessment.decision === 'WARNING' ? '⚠️ ' : '🚫';
982
+ console.log(`Decision: ${decisionIcon} ${assessment.decision}`);
983
+ console.log(`Changed files: ${assessment.changedFiles.length}`);
984
+ if (assessment.stats.additions > 0 || assessment.stats.deletions > 0) {
985
+ console.log(`Additions: +${assessment.stats.additions} Deletions: -${assessment.stats.deletions}`);
986
+ }
987
+
988
+ if (assessment.reasons.length > 0) {
989
+ console.log('');
990
+ console.log('Blocked reasons:');
991
+ for (const r of assessment.reasons) r.split('\n').forEach(l => console.log(` ${l}`));
992
+ }
993
+ if (assessment.warnings.length > 0) {
994
+ console.log('');
995
+ console.log('Warnings:');
996
+ for (const w of assessment.warnings) console.log(` ⚠️ ${w}`);
997
+ }
998
+
999
+ if (assessment.rescueCommands) {
1000
+ console.log('');
1001
+ console.log(`🆘 Rescue commands (${assessment.rescueCommands.cpFileCount} safe / ${assessment.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
1002
+ for (const c of assessment.rescueCommands.commands) console.log(` ${c}`);
1003
+ for (const w of assessment.rescueCommands.warnings) console.log(` ⚠️ ${w}`);
1004
+ }
1005
+ console.log('');
1006
+ if (assessment.decision === 'SAFE' || assessment.decision === 'WARNING') {
1007
+ console.log('Action: auto-applying...');
1008
+ const { applyWorktree } = await import('./worktree-apply.js');
1009
+ const applyResult = applyWorktree(wtName, { cwd: dir, ctx: _assessCtx });
1010
+ if (applyResult.errors.length > 0) {
1011
+ console.error('❌ apply 失败:', applyResult.errors.join('; '));
1012
+ } else {
1013
+ console.log(`✅ 已自动应用 ${applyResult.changedFiles.length} 个文件变更:`);
1014
+ for (const f of applyResult.changedFiles) console.log(` ${f}`);
1015
+ console.log(` ℹ️ 注:.sillyspec/changes、.sillyspec/.runtime、.sillyspec/quicklog 下的文件按规则不自动 apply(模块文档 .sillyspec/docs/ 会自动 apply),如需请手动从分支取(git show sillyspec/${wtName}:<path>)。`);
1016
+ }
1017
+ } else {
1018
+ console.log('Action: blocked');
1019
+ console.log(' → 检查变更: sillyspec worktree diff ' + wtName);
1020
+ console.log(' → 丢弃变更: sillyspec worktree cleanup ' + wtName);
1021
+ console.log(' → baseline 漂移降级: sillyspec worktree apply ' + wtName + ' --merge');
1022
+ }
1023
+ break;
1024
+ }
1025
+ case 'meta': {
1026
+ if (!wtName) {
1027
+ console.error('❌ 用法: sillyspec worktree meta <change-name>');
1028
+ process.exit(1);
1029
+ }
1030
+ const meta = wm.getMeta(wtName);
1031
+ if (!meta) {
1032
+ console.error(`❌ 未找到 worktree meta: ${wtName}(可能未创建或已被清理)`);
1033
+ process.exit(1);
1034
+ }
1035
+ console.log(`change: ${wtName}`);
1036
+ console.log(`worktreePath: ${meta.worktreePath || '(未设置)'}`);
1037
+ console.log(`branch: ${meta.branch || '(未设置)'}`);
1038
+ console.log(`mode: ${meta.mode || '(未设置)'}`);
1039
+ if (meta.baseBranch) console.log(`baseBranch: ${meta.baseBranch}`);
1040
+ if (meta.baseHash) console.log(`baseHash: ${String(meta.baseHash).slice(0, 8)}`);
1041
+ console.log('');
1042
+ console.log('JSON:');
1043
+ console.log(JSON.stringify(meta, null, 2));
1044
+ break;
1045
+ }
1046
+ case 'diff': {
1047
+ if (!wtName) {
1048
+ console.error('❌ 用法: sillyspec worktree diff <change-name> [--base <commit>]');
1049
+ process.exit(1);
1050
+ }
1051
+ const meta = wm.getMeta(wtName);
1052
+ if (!meta) {
1053
+ console.error(`❌ 未找到 worktree meta: ${wtName}(可能未创建或已被清理)`);
1054
+ process.exit(1);
1055
+ }
1056
+ if (!meta.worktreePath || !existsSync(meta.worktreePath)) {
1057
+ console.error(`❌ worktree 目录不存在: ${meta.worktreePath || '(未设置)'}(可能已被清理,可跑 sillyspec worktree doctor)`);
1058
+ process.exit(1);
1059
+ }
1060
+ // base 优先级:显式 --base > meta.baseHash(create 时锚的主仓 HEAD)> 当前 HEAD。
1061
+ // diff 比较 worktree 工作区(含未提交)vs base,与 _changesAlreadyOnMain 同口径。
1062
+ const baseIdx = filteredArgs.indexOf('--base');
1063
+ const explicitBase = baseIdx >= 0 ? filteredArgs[baseIdx + 1] : null;
1064
+ const base = explicitBase || meta.baseHash || 'HEAD';
1065
+ let out = '';
1066
+ try {
1067
+ // base 作为独立 argv 元素传入,不经 shell 拆词/注入;worktreePath 经 cwd,不插值。
1068
+ // trim:false 保留 git diff 原始输出(含尾换行)供 stdout.write;空检查靠临时 .trim()。
1069
+ // timeout 30s:diff 属潜在长操作(对齐 design R3 与 verify-postcheck 口径)。
1070
+ out = git(meta.worktreePath, ['--no-pager', 'diff', '--no-renames', base], { trim: false, timeout: 30000 });
1071
+ } catch (e) {
1072
+ console.error(`❌ git diff 失败(base=${String(base).slice(0, 8)}): ${e.message}`);
1073
+ process.exit(1);
1074
+ }
1075
+ if (!out.trim()) {
1076
+ console.log(`(无变更:worktree 工作区与 base ${String(base).slice(0, 8)} 一致)`);
1077
+ } else {
1078
+ process.stdout.write(out);
1079
+ }
1080
+ break;
1081
+ }
1082
+ case 'list': {
1083
+ const items = wm.list();
1084
+ if (items.length === 0) {
1085
+ console.log('📭 无活跃 worktree');
1086
+ break;
1087
+ }
1088
+ // 计算列宽
1089
+ const maxName = Math.max('Change Name'.length, ...items.map(i => i.changeName.length));
1090
+ const maxBranch = Math.max('Branch'.length, ...items.map(i => i.branch.length));
1091
+ const header = ` ${'Change Name'.padEnd(maxName)} ${'Branch'.padEnd(maxBranch)} Created`;
1092
+ const sep = ` ${'─'.repeat(maxName)} ${'─'.repeat(maxBranch)} ${'─'.repeat(19)}`;
1093
+ console.log(header);
1094
+ console.log(sep);
1095
+ for (const item of items) {
1096
+ const created = item.createdAt ? item.createdAt.replace('T', ' ').replace('Z', '').slice(0, 19) : '-';
1097
+ console.log(` ${item.changeName.padEnd(maxName)} ${item.branch.padEnd(maxBranch)} ${created}`);
1098
+ }
1099
+ break;
1100
+ }
1101
+ case 'cleanup': {
1102
+ if (!wtName) {
1103
+ console.error('❌ 用法: sillyspec worktree cleanup <change-name>');
1104
+ process.exit(1);
1105
+ }
1106
+ const forceFlag = args.includes('--force');
1107
+ try {
1108
+ const result = wm.cleanup(wtName, { force: forceFlag });
1109
+ if (result.result === 'cleaned' || result.result === 'force-cleaned') {
1110
+ console.log(`✅ worktree 已清理: ${wtName} (mode: ${result.mode})`);
1111
+ if (result.details?.length > 0) {
1112
+ for (const d of result.details) {
1113
+ if (d.startsWith('⚠️')) console.log(` ${d}`);
1114
+ }
1115
+ }
1116
+ } else if (result.result === 'skipped') {
1117
+ console.log(`⏭️ worktree 跳过清理: ${wtName} (mode: ${result.mode})`);
1118
+ console.log(` 原因: in-place 模式没有隔离目录需要清理`);
1119
+ } else if (result.result === 'blocked') {
1120
+ console.error(`🚫 拒绝清理:有未落主仓交付变更,请先 sillyspec worktree apply ${wtName} 或 --force`);
1121
+ } else {
1122
+ console.log(`ℹ️ worktree 未找到: ${wtName}`);
1123
+ }
1124
+ } catch (e) {
1125
+ console.error(`❌ ${e.message}`);
1126
+ process.exit(1);
1127
+ }
1128
+ break;
1129
+ }
1130
+ case 'doctor': {
1131
+ const fixFlag = args.includes('--fix');
1132
+ const staleIdx = args.indexOf('--stale-hours');
1133
+ const staleHours = staleIdx !== -1 && args[staleIdx + 1] ? parseInt(args[staleIdx + 1], 10) : 24;
1134
+ const changeIdx = args.indexOf('--change');
1135
+ const changeName = changeIdx !== -1 && args[changeIdx + 1] ? args[changeIdx + 1] : null;
1136
+ const diag = wm.doctor({ fix: fixFlag, staleHours, changeName });
1137
+ if (diag.issues.length === 0) {
1138
+ console.log('✅ worktree 健康检查通过,无异常');
1139
+ } else {
1140
+ console.log(`🔍 发现 ${diag.issues.length} 个问题:\n`);
1141
+ for (const issue of diag.issues) {
1142
+ const icon = issue.fixable ? '⚠️' : '❌';
1143
+ console.log(` ${icon} [${issue.type}] ${issue.name}: ${issue.detail}`);
1144
+ }
1145
+ if (fixFlag) {
1146
+ console.log(`\n🔧 修复完成:`);
1147
+ for (const f of diag.fixed) console.log(` ✅ ${f}`);
1148
+ if (diag.unfixable.length > 0) {
1149
+ for (const u of diag.unfixable) console.log(` ❌ ${u}`);
1150
+ }
1151
+ if (diag.fixed.length === 0 && diag.unfixable.length === 0) {
1152
+ console.log(' 无需修复');
1153
+ }
1154
+ } else {
1155
+ console.log(`\n💡 运行 sillyspec worktree doctor --fix 自动修复`);
1156
+ }
1157
+ }
1158
+ break;
1159
+ }
1160
+ default:
1161
+ console.error(`❌ 未知子命令: worktree ${wtSubCmd}`);
1162
+ console.log(' 运行 sillyspec worktree --help 查看帮助');
1163
+ process.exit(1);
1164
+ }
1165
+ break;
1166
+ }
1167
+ case 'dispatch': {
1168
+ // SillyHub 派发抽象层的 agent 调用桥(design.md §Phase2 / D-007@v1):
1169
+ // 仅做能力探测(probe)与派发策略生成(hint),**不执行任何 tool 调用**——
1170
+ // 实际 tool 调用(本机 Agent tool / SillyHub MCP tool)由 agent 据指令执行。
1171
+ // 仿 worktree 子命令的参数解析(filteredArgs[1] 取子命令)与错误处理模式。
1172
+ const dispatchSubCmd = filteredArgs[1];
1173
+ const dispatchSubs = ['probe', 'hint'];
1174
+
1175
+ if (!dispatchSubCmd || dispatchSubCmd === 'help' || dispatchSubCmd === '--help' || dispatchSubCmd === '-h') {
1176
+ console.log(`
1177
+ SillySpec dispatch — SillyHub 派发能力探测与策略生成(agent 调用桥,仅渲染与探测)
1178
+
1179
+ 用法:
1180
+ sillyspec dispatch probe [--json] 能力探测,输出 ProbeResult
1181
+ sillyspec dispatch hint --contract <json> [--json] 派发策略,输出 {instruction, backend}
1182
+
1183
+ 子命令:
1184
+ probe 探测 SillyHub MCP 是否可用(无配置返回 available=false,正常降级不报错)
1185
+ hint 据 contract + probe 结果生成派发指令文本(agent 拿指令后执行实际 tool 调用)
1186
+
1187
+ 选项:
1188
+ --contract <json> hint 必填:DispatchContract(brief/worktreePath/branch/allowedPaths/readOnly/...)
1189
+ --json 程序化输出(probe → ProbeResult;hint → {backend, instruction})
1190
+ `);
1191
+ break;
1192
+ }
1193
+
1194
+ if (dispatchSubCmd === 'probe') {
1195
+ const { probeSillyHub } = await import('./dispatch/probe.js');
1196
+ const result = await probeSillyHub();
1197
+ // result = { available, reason? };无配置时 {available:false,reason:'no-config'} 不报错(正常降级)
1198
+ if (json) {
1199
+ process.stdout.write(JSON.stringify(result));
1200
+ } else if (result.available) {
1201
+ console.log('✅ SillyHub 可用');
1202
+ } else {
1203
+ console.log(`⚠️ SillyHub 不可用:${result.reason || 'unknown'}`);
1204
+ }
1205
+ break;
1206
+ }
1207
+
1208
+ if (dispatchSubCmd === 'hint') {
1209
+ // 解析 --contract <json>(仿 worktree 用 args.indexOf 取 flag 后一个值)
1210
+ const contractIdx = args.indexOf('--contract');
1211
+ const contractRaw = contractIdx >= 0 && args[contractIdx + 1] ? args[contractIdx + 1] : null;
1212
+ if (!contractRaw) {
1213
+ console.error('用法: sillyspec dispatch hint --contract <json> [--json]\n 生成派发指令(DispatchContract:brief/worktreePath/branch/allowedPaths/readOnly/...)');
1214
+ process.exit(2);
1215
+ }
1216
+ let contract;
1217
+ try {
1218
+ contract = JSON.parse(contractRaw);
1219
+ } catch (e) {
1220
+ console.error(`❌ --contract JSON 解析失败: ${e.message}`);
1221
+ process.exit(2);
1222
+ }
1223
+ const { probeSillyHub } = await import('./dispatch/probe.js');
1224
+ const { renderDispatchInstruction } = await import('./dispatch/strategy.js');
1225
+ const probe = await probeSillyHub();
1226
+ const { instruction, backend } = renderDispatchInstruction(contract, probe);
1227
+ if (json) {
1228
+ process.stdout.write(JSON.stringify({ backend, instruction }));
1229
+ } else {
1230
+ console.log(`Backend: ${backend}`);
1231
+ console.log(instruction);
1232
+ }
1233
+ break;
1234
+ }
1235
+
1236
+ // 未知子命令 → did-you-mean 建议 + 退出 1(仿 worktree default)
1237
+ const sug = didYouMean(dispatchSubCmd, dispatchSubs);
1238
+ console.error(`❌ 未知子命令: dispatch ${dispatchSubCmd}`);
1239
+ if (sug) console.error(` 你是想输入「dispatch ${sug}」吗?`);
1240
+ console.error(` 可用子命令:${dispatchSubs.join(' | ')}(运行 sillyspec dispatch 查看用法)`);
1241
+ process.exit(1);
1242
+ break;
1243
+ }
1244
+ case 'platform': {
1245
+ const platformSub = filteredArgs[1];
1246
+ const platformArgs = filteredArgs.slice(2);
1247
+
1248
+ if (!platformSub || platformSub === 'help' || platformSub === '--help' || platformSub === '-h') {
1249
+ console.log(`
1250
+ SillySpec platform — SillyHub 平台同步
1251
+
1252
+ 用法:
1253
+ sillyspec platform connect <url> [--token <token>]
1254
+ sillyspec platform disconnect
1255
+ sillyspec platform sync [--change <name>]
1256
+ sillyspec platform sync-docs [--change <name>]
1257
+ sillyspec platform pull [--change <name>]
1258
+ sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>
1259
+ sillyspec platform status
1260
+ sillyspec platform pointer [--cleanup]
1261
+ sillyspec platform approve <change-name>
1262
+ sillyspec platform reject <change-name> [--reason <reason>]
1263
+ `);
1264
+ break;
1265
+ }
1266
+
1267
+ let syncModule;
1268
+ try {
1269
+ syncModule = await import('./sync.js');
1270
+ } catch {
1271
+ console.error('❌ 平台同步功能不可用(sync.js 未实现)');
1272
+ process.exit(1);
1273
+ }
1274
+
1275
+ switch (platformSub) {
1276
+ case 'pointer': {
1277
+ // 指针状态检查(不依赖 sync 模块)
1278
+ const { readFileSync, existsSync } = await import('fs')
1279
+ const { join } = await import('path')
1280
+ const { POINTER_STATUS, isPointerStale, isPointerCorrupted } = await import('./constants.js')
1281
+ const pointerPath = join(dir, '.sillyspec-platform.json')
1282
+
1283
+ if (!existsSync(pointerPath)) {
1284
+ console.log('ℹ️ 无平台指针文件。当前不在平台模式或未进行过平台 scan。')
1285
+ break
1286
+ }
1287
+
1288
+ try {
1289
+ const pointer = JSON.parse(readFileSync(pointerPath, 'utf8'))
1290
+ console.log(`📄 指针文件: ${pointerPath}`)
1291
+ console.log(` specRoot: ${pointer.specRoot || '(缺失 ❌)'}`)
1292
+ console.log(` runtimeRoot: ${pointer.runtimeRoot || '(未设置)'}`)
1293
+ console.log(` workspaceId: ${pointer.workspaceId || '(未设置)'}`)
1294
+ console.log(` scanRunId: ${pointer.scanRunId || '(未设置)'}`)
1295
+ console.log(` savedAt: ${pointer.savedAt || '(未知)'}`)
1296
+
1297
+ if (isPointerCorrupted(pointer)) {
1298
+ console.log(` 状态: ${POINTER_STATUS.CORRUPTED} ❌`)
1299
+ console.log(` ⚠️ 指针损坏(缺少 specRoot),建议删除后重新运行平台 scan。`)
1300
+ if (platformArgs.includes('--cleanup')) {
1301
+ const { unlinkSync } = await import('fs')
1302
+ unlinkSync(pointerPath)
1303
+ console.log(` 🗑️ 已清理损坏指针。`)
1304
+ console.log(` ℹ️ 如需彻底脱离平台(含接管声明 .sillyspec-platform-managed),请使用 sillyspec platform disconnect。`)
1305
+ }
1306
+ } else if (pointer.status === POINTER_STATUS.SCAN_COMPLETED) {
1307
+ if (isPointerStale(pointer)) {
1308
+ console.log(` 状态: ${POINTER_STATUS.STALE} ⚠️`)
1309
+ console.log(` completedAt: ${pointer.completedAt}`)
1310
+ console.log(` scanStatus: ${pointer.scanStatus || '(未知)'}`)
1311
+ console.log(` ⚠️ 指针已过时(完成超过 24h),可以安全删除。`)
1312
+ if (platformArgs.includes('--cleanup')) {
1313
+ const { unlinkSync } = await import('fs')
1314
+ unlinkSync(pointerPath)
1315
+ console.log(` 🗑️ 已清理过时指针。`)
1316
+ console.log(` ℹ️ 如需彻底脱离平台(含接管声明 .sillyspec-platform-managed),请使用 sillyspec platform disconnect。`)
1317
+ }
1318
+ } else {
1319
+ console.log(` 状态: ${pointer.status} ✅`)
1320
+ console.log(` completedAt: ${pointer.completedAt}`)
1321
+ console.log(` scanStatus: ${pointer.scanStatus || '(未知)'}`)
1322
+ }
1323
+ } else {
1324
+ console.log(` 状态: ${POINTER_STATUS.ACTIVE} 🔄`)
1325
+ }
1326
+ } catch (e) {
1327
+ console.log(` 状态: ${POINTER_STATUS.CORRUPTED} ❌`)
1328
+ console.log(` ⚠️ 指针文件损坏: ${e.message}`)
1329
+ }
1330
+ break;
1331
+ }
1332
+ case 'connect': {
1333
+ const url = platformArgs[0];
1334
+ if (!url) {
1335
+ console.error('❌ 用法: sillyspec platform connect <url> [--token <token>]');
1336
+ process.exit(1);
1337
+ }
1338
+ const tokenIdx = args.indexOf('--token');
1339
+ const token = tokenIdx >= 0 && args[tokenIdx + 1] ? args[tokenIdx + 1] : undefined;
1340
+ if (!token) {
1341
+ console.error('⚠️ 未提供 --token,将使用交互式输入(TODO: task-11)');
1342
+ }
1343
+ await syncModule.connect(url, token, dir);
1344
+ break;
1345
+ }
1346
+ case 'disconnect':
1347
+ await syncModule.disconnect(dir);
1348
+ break;
1349
+ case 'sync': {
1350
+ const syncChangeIdx = args.indexOf('--change');
1351
+ const syncChangeName = syncChangeIdx >= 0 && args[syncChangeIdx + 1] ? args[syncChangeIdx + 1] : null;
1352
+ await syncModule.sync(syncChangeName, dir);
1353
+ break;
1354
+ }
1355
+ case 'sync-docs': {
1356
+ const syncDocsChangeIdx = args.indexOf('--change');
1357
+ const syncDocsChangeName = syncDocsChangeIdx >= 0 && args[syncDocsChangeIdx + 1] ? args[syncDocsChangeIdx + 1] : null;
1358
+ await syncModule.syncDocuments(syncDocsChangeName, dir);
1359
+ break;
1360
+ }
1361
+ case 'status': {
1362
+ // task-14 / FR-05:扩展 status(落后标记 + 未决冲突列表);未连接输出与现状一致
1363
+ const st = await syncModule.collectStatus(dir);
1364
+ if (!st.connected) {
1365
+ console.log('平台: 未连接');
1366
+ break;
1367
+ }
1368
+ console.log(`平台: ${st.url}`);
1369
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
1370
+ if (st.listFailed) {
1371
+ console.log('ℹ️ 无法连接平台获取落后状态');
1372
+ } else if (st.behind.length > 0) {
1373
+ console.log(`⚠️ 本地可能落后(${st.behind.length} 个变更平台有更新):`);
1374
+ for (const b of st.behind) {
1375
+ console.log(` - ${b.name}: 平台 ${b.platform_pushed_at} > 本地 ${b.local_synced}`);
1376
+ }
1377
+ } else {
1378
+ console.log('✅ 本地与平台进度同步');
1379
+ }
1380
+ if (st.conflicts.length > 0) {
1381
+ console.log(`⚠️ 未决冲突(${st.conflicts.length} 个,请 platform resolve 处理):`);
1382
+ for (const c of st.conflicts) {
1383
+ console.log(` - ${c.change}${c.created_at ? ` (创建于 ${c.created_at})` : ''}`);
1384
+ }
1385
+ }
1386
+ break;
1387
+ }
1388
+ case 'pull': {
1389
+ // task-11 / D-006 / D-009 / FR-03:手动下行拉取平台进度到本地。
1390
+ // 与自动 triggerPull 共用 syncModule.pull(SyncManager.pull 实例方法),行为一致。
1391
+ const pullChangeIdx = args.indexOf('--change');
1392
+ const pullChangeName = pullChangeIdx >= 0 && args[pullChangeIdx + 1] ? args[pullChangeIdx + 1] : null;
1393
+ // 未连接明确提示不崩(acceptance:未连接平台输出明确提示)
1394
+ const smForCheck = new syncModule.SyncManager(dir);
1395
+ if (!smForCheck._getPlatform()) {
1396
+ console.error('❌ 未连接平台,请先 sillyspec platform connect');
1397
+ process.exit(1);
1398
+ }
1399
+ if (pullChangeName) {
1400
+ // 单变更完整 pull(两级 pull 第二级)
1401
+ const r = await syncModule.pull(pullChangeName, {}, dir);
1402
+ if (r.conflict) {
1403
+ console.log(`⚠️ ${pullChangeName}: 冲突(已写 sync-conflict 文件),请 sillyspec platform resolve 处理`);
1404
+ } else if (r.imported) {
1405
+ console.log(`✅ ${pullChangeName}: 已拉取并 import`);
1406
+ } else {
1407
+ console.log(`⏭️ ${pullChangeName}: 未 import(${r.reason || '无变更'})`);
1408
+ }
1409
+ } else {
1410
+ // 无 --change:先拉轻量列表(两级 pull 第一级),再对每个 change 按需完整 pull
1411
+ const list = await syncModule.pullList(dir);
1412
+ if (!list.ok) {
1413
+ console.error(`❌ 拉取变更列表失败: ${list.reason || '未知'}`);
1414
+ process.exit(1);
1415
+ }
1416
+ console.log(`平台变更 ${list.changes.length} 个:`);
1417
+ let importedCount = 0, conflictCount = 0;
1418
+ for (const ch of list.changes) {
1419
+ const name = typeof ch === 'string' ? ch : ch.name;
1420
+ if (!name) continue;
1421
+ const r = await syncModule.pull(name, {}, dir);
1422
+ if (r.imported) { importedCount++; console.log(` ✅ ${name}: 已 import`); }
1423
+ else if (r.conflict) { conflictCount++; console.log(` ⚠️ ${name}: 冲突,请 platform resolve`); }
1424
+ else { console.log(` ⏭️ ${name}: ${r.reason || '未 import'}`); }
1425
+ }
1426
+ console.log(`拉取完成:imported ${importedCount},冲突 ${conflictCount}`);
1427
+ }
1428
+ break;
1429
+ }
1430
+ case 'resolve': {
1431
+ // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(绝不字段级 auto-merge)
1432
+ // 变更名解析(顺序):--change <name>(与 pull/sync 兄弟命令写法一致)→ platformArgs 中
1433
+ // 第一个非 flag 位置参数 → 唯一未决冲突自动选中。修复:旧实现盲取 platformArgs[0],
1434
+ // flag 放前面时(resolve --keep-local --change x)把 '--keep-local' 当变更名去读冲突文件。
1435
+ const changeIdx = args.indexOf('--change');
1436
+ let resolveName = changeIdx >= 0 && args[changeIdx + 1] ? args[changeIdx + 1] : null;
1437
+ if (!resolveName) {
1438
+ resolveName = platformArgs.find((a) => a && !a.startsWith('--')) || null;
1439
+ }
1440
+ if (!resolveName) {
1441
+ // 无变更名:恰有一个未决冲突时自动选中,多个/零个则列出候选
1442
+ const pendingConflicts = syncModule.listConflictFiles(dir);
1443
+ if (pendingConflicts.length === 1) {
1444
+ resolveName = pendingConflicts[0].change;
1445
+ console.log(`ℹ️ 自动选中唯一未决冲突: ${resolveName}`);
1446
+ } else {
1447
+ console.error('❌ 用法: sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>');
1448
+ console.error(' 或: sillyspec platform resolve --change <name> <--keep-local|--take-platform|--abort>');
1449
+ if (pendingConflicts.length > 1) {
1450
+ console.error(` 现有 ${pendingConflicts.length} 个未决冲突,请指定变更名:`);
1451
+ for (const c of pendingConflicts) console.error(` - ${c.change}`);
1452
+ } else {
1453
+ console.error(' 当前无 sync-conflict 文件(可先运行 sillyspec platform status 查看)');
1454
+ }
1455
+ process.exit(1);
1456
+ }
1457
+ }
1458
+ // 解析 mode flag(三选一互斥,多传/不传均报错)
1459
+ const resolveFlags = ['--keep-local', '--take-platform', '--abort'].filter((f) => args.includes(f));
1460
+ if (resolveFlags.length !== 1) {
1461
+ console.error('❌ 必须恰好指定 --keep-local / --take-platform / --abort 之一');
1462
+ process.exit(1);
1463
+ }
1464
+ const modeMap = { '--keep-local': 'keep-local', '--take-platform': 'take-platform', '--abort': 'abort' };
1465
+ const resolveMode = modeMap[resolveFlags[0]];
1466
+ const r = await syncModule.resolve(resolveName, resolveMode, dir);
1467
+ if (r.ok && r.resolved) {
1468
+ console.log(`✅ ${resolveName} [${resolveMode}]: ${r.reason}`);
1469
+ } else {
1470
+ console.error(`❌ ${resolveName}: ${r.reason}`);
1471
+ // 报错兜底:指定变更名无冲突文件时,列出 .runtime 下实际存在的冲突文件(防 flag 误当变更名再次误导)
1472
+ const pendingConflicts = syncModule.listConflictFiles(dir);
1473
+ if (pendingConflicts.length > 0 && !pendingConflicts.some((c) => c.change === resolveName)) {
1474
+ console.error(' 当前未决冲突:');
1475
+ for (const c of pendingConflicts) console.error(` - ${c.change}`);
1476
+ }
1477
+ process.exit(1);
1478
+ }
1479
+ break;
1480
+ }
1481
+ case 'approve': {
1482
+ const approveName = platformArgs[0];
1483
+ if (!approveName) {
1484
+ console.error('❌ 用法: sillyspec platform approve <change-name>');
1485
+ process.exit(1);
1486
+ }
1487
+ // 审批前下行 pull(task-10 / D-009 / FR-06):拉最新进度避免基于过期状态审批;未连接跳过
1488
+ const { triggerPull } = await import('./run/shared.js');
1489
+ await triggerPull(dir, approveName);
1490
+ await syncModule.approve(approveName, dir);
1491
+ break;
1492
+ }
1493
+ case 'reject': {
1494
+ const rejectName = platformArgs[0];
1495
+ if (!rejectName) {
1496
+ console.error('❌ 用法: sillyspec platform reject <change-name> [--reason <reason>]');
1497
+ process.exit(1);
1498
+ }
1499
+ const reasonIdx = args.indexOf('--reason');
1500
+ const reason = reasonIdx >= 0 && args[reasonIdx + 1] ? args[reasonIdx + 1] : undefined;
1501
+ await syncModule.reject(rejectName, reason, dir);
1502
+ break;
1503
+ }
1504
+ default:
1505
+ console.error(`❌ 未知子命令: platform ${platformSub}`);
1506
+ console.log(' 运行 sillyspec platform --help 查看帮助');
1507
+ process.exit(1);
1508
+ }
1509
+ break;
1510
+ }
1511
+ case 'change-rename': {
1512
+ const oldName = filteredArgs[1];
1513
+ const newName = filteredArgs[2];
1514
+ if (!oldName || !newName) {
1515
+ console.error('❌ 用法: sillyspec change-rename <旧变更名> <新变更名>');
1516
+ process.exit(1);
1517
+ }
1518
+ // F6 路径穿越消毒:renameChange 会 mv changes/<old> → changes/<new>,名含 ../ 会逃出 changes/。
1519
+ try {
1520
+ assertSafeChangeName(oldName, '旧变更名');
1521
+ assertSafeChangeName(newName, '新变更名');
1522
+ } catch (e) {
1523
+ console.error(`❌ ${e.message}`);
1524
+ process.exit(2);
1525
+ }
1526
+ const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
1527
+ pm.renameChange(dir, oldName, newName);
1528
+ break;
1529
+ }
1530
+ case 'workflow': {
1531
+ const wfSub = filteredArgs[1];
1532
+ if (!wfSub || wfSub === 'help' || wfSub === '--help') {
1533
+ console.log(`
1534
+ SillySpec workflow — 工作流管理
1535
+
1536
+ 用法:
1537
+ sillyspec workflow check <name> [--project <project>] [--json]
1538
+ sillyspec workflow list
1539
+ `);
1540
+ break;
1541
+ }
1542
+ if (wfSub === 'list') {
1543
+ const { listWorkflows } = await import('./workflow.js');
1544
+ const names = listWorkflows(dir);
1545
+ if (names.length === 0) {
1546
+ console.log('未找到 workflow 定义(.sillyspec/workflows/*.yaml)');
1547
+ } else {
1548
+ console.log(`可用 workflow:`);
1549
+ for (const name of names) {
1550
+ const { loadWorkflow } = await import('./workflow.js');
1551
+ const wf = loadWorkflow(dir, name);
1552
+ const specVer = wf?.spec_version || wf?.version || '?';
1553
+ const mode = wf?.orchestration?.mode || '?';
1554
+ const roles = wf?.roles?.length || 0;
1555
+ console.log(` ${name} (spec v${specVer}, ${mode}, ${roles} roles)`);
1556
+ }
1557
+ }
1558
+ break;
1559
+ }
1560
+ if (wfSub === 'check') {
1561
+ const { loadWorkflow, runPostCheck, listWorkflows, saveWorkflowRun } = await import('./workflow.js');
1562
+ const wfName = filteredArgs[2];
1563
+ if (!wfName) {
1564
+ console.error('❌ 请指定 workflow 名称,例如:sillyspec workflow check scan-docs --project sillyspec');
1565
+ process.exit(2);
1566
+ }
1567
+ const wf = loadWorkflow(dir, wfName);
1568
+ if (!wf) {
1569
+ console.error(`❌ 未找到 workflow: ${wfName}`);
1570
+ console.error(`可用 workflow:${listWorkflows(dir).join(', ') || '无'}`);
1571
+ process.exit(2);
1572
+ }
1573
+ // depends_on 校验
1574
+ if (wf._validationErrors && wf._validationErrors.length > 0) {
1575
+ console.error('❌ workflow YAML 校验失败:');
1576
+ for (const err of wf._validationErrors) {
1577
+ console.error(` ${err}`);
1578
+ }
1579
+ process.exit(2);
1580
+ }
1581
+ // spec_version 校验
1582
+ const specVer = wf.spec_version || wf.version;
1583
+ if (!specVer) {
1584
+ console.error('❌ workflow YAML 缺少 spec_version 字段');
1585
+ process.exit(2);
1586
+ }
1587
+ const SUPPORTED_SPECS = [1];
1588
+ if (!SUPPORTED_SPECS.includes(specVer)) {
1589
+ console.error(`❌ 不支持的 spec_version: ${specVer}(支持: ${SUPPORTED_SPECS.join(', ')})`);
1590
+ process.exit(2);
1591
+ }
1592
+ // 解析 --project
1593
+ const projectIdx = filteredArgs.indexOf('--project');
1594
+ const project = projectIdx !== -1 && filteredArgs[projectIdx + 1] ? filteredArgs[projectIdx + 1] : null;
1595
+ // 解析 --json(已在顶层解析)
1596
+ const isJson = json;
1597
+ // 解析 --change
1598
+ const changeIdx = filteredArgs.indexOf('--change');
1599
+ const changeName = changeIdx !== -1 && filteredArgs[changeIdx + 1] ? filteredArgs[changeIdx + 1] : null;
1600
+
1601
+ if (!project && wfName !== 'archive-impact') {
1602
+ console.error('❌ 请指定 --project,例如:--project sillyspec');
1603
+ process.exit(2);
1604
+ }
1605
+
1606
+ // 执行检查
1607
+ let resolvedWf = wf;
1608
+ const placeholders = {};
1609
+ if (changeName) placeholders['change-name'] = changeName;
1610
+ // 替换占位符
1611
+ let jsonStr = JSON.stringify(resolvedWf);
1612
+ if (changeName) jsonStr = jsonStr.replace(/<change-name>/g, changeName);
1613
+ resolvedWf = JSON.parse(jsonStr);
1614
+
1615
+ const projectName = project || 'sillyspec';
1616
+ const result = runPostCheck(resolvedWf, dir, projectName, placeholders);
1617
+
1618
+ if (isJson) {
1619
+ console.log(JSON.stringify(result, null, 2));
1620
+ } else {
1621
+ // 带项目维度前缀的输出(从统一结果对象格式化)
1622
+ const lines = [`\n📋 Workflow Post-Check: ${result.workflow} (project: ${result.project})\n`];
1623
+ for (const r of (result.roles || [])) {
1624
+ const icon = r.status === 'pass' ? '✅' : '❌';
1625
+ lines.push(`${icon} [${result.project}] ${r.name} (${r.id})`);
1626
+ const roleFailures = (result.failures || []).filter(f => f.role_id === r.id);
1627
+ for (const f of roleFailures) {
1628
+ lines.push(` └─ ${f.message}`);
1629
+ }
1630
+ }
1631
+ const wfFailures = (result.workflow_checks || []).filter(c => c.status === 'fail');
1632
+ if (wfFailures.length > 0) {
1633
+ lines.push('');
1634
+ for (const f of wfFailures) {
1635
+ lines.push(`❌ [${result.project}] 全局: ${f.detail}`);
1636
+ }
1637
+ }
1638
+ lines.push('');
1639
+ if (result.status === 'pass') {
1640
+ lines.push('✅ 全部检查通过');
1641
+ } else {
1642
+ lines.push('❌ 存在失败项');
1643
+ }
1644
+ console.log(lines.join('\n'));
1645
+ }
1646
+
1647
+ // exit code: 0=通过, 1=检查失败, 2=参数/YAML错误
1648
+ if (saveWorkflowRunFlag) {
1649
+ const saved = saveWorkflowRun(result, { cwd: dir, source: 'cli' });
1650
+ if (saved) {
1651
+ if (!isJson) console.log(`\n📁 结果已归档:${saved}`);
1652
+ }
1653
+ }
1654
+ process.exit(result.status === 'pass' ? 0 : 1);
1655
+ } else {
1656
+ const wfSubs = ['check', 'list'];
1657
+ const sug = didYouMean(wfSub, wfSubs);
1658
+ console.error(`❌ 未知子命令: workflow ${wfSub}`);
1659
+ if (sug) console.error(` 你是想输入「workflow ${sug}」吗?`);
1660
+ console.error(` 可用子命令:${wfSubs.join(' | ')}(运行 sillyspec workflow 查看用法)`);
1661
+ process.exit(1);
1662
+ }
1663
+ break;
1664
+ }
1665
+ case 'modules': {
1666
+ const modulesSub = filteredArgs[1];
1667
+ if (!modulesSub || modulesSub === 'help' || modulesSub === '--help') {
1668
+ console.log(`
1669
+ SillySpec modules — 模块文档管理
1670
+
1671
+ 用法:
1672
+ sillyspec modules rebuild [--force] 从模块卡片 + 源码重建 _module-map.yaml(默认 dry-run 预览,--force 才覆盖)
1673
+ sillyspec modules status 显示模块索引状态
1674
+ sillyspec modules migrate 旧格式模块文档迁移到新格式
1675
+ `);
1676
+ break;
1677
+ }
1678
+ if (modulesSub === 'rebuild') {
1679
+ const { rebuildModuleMap } = await import('./modules.js');
1680
+ const isForce = filteredArgs.includes('--force');
1681
+ await rebuildModuleMap(dir, { force: isForce });
1682
+ } else if (modulesSub === 'status') {
1683
+ const { showModuleStatus } = await import('./modules.js');
1684
+ await showModuleStatus(dir);
1685
+ } else if (modulesSub === 'migrate') {
1686
+ const { migrateModuleDocs } = await import('./modules.js');
1687
+ await migrateModuleDocs(dir);
1688
+ } else {
1689
+ const modSubs = ['rebuild', 'status', 'migrate'];
1690
+ const sug = didYouMean(modulesSub, modSubs);
1691
+ console.error(`❌ 未知子命令: modules ${modulesSub}`);
1692
+ if (sug) console.error(` 你是想输入「modules ${sug}」吗?`);
1693
+ console.error(` 可用子命令:${modSubs.join(' | ')}(运行 sillyspec modules 查看用法)`);
1694
+ process.exit(1);
1695
+ }
1696
+ break;
1697
+ }
1698
+ case 'local': {
1699
+ // 本地配置探测(task-04 / D-001@v1):纯 fs 嗅探项目类型 → 生成 local.yaml。
1700
+ // 轻量独立路由,不跑 scan、不消耗 token。探测逻辑归属 local-detect.js(task-02)。
1701
+ const localSubCmd = filteredArgs[1];
1702
+ if (localSubCmd !== 'detect') {
1703
+ console.error('用法: sillyspec local detect [--dir <path>]\n 纯 fs 嗅探项目类型并生成 local.yaml(不跑 scan、零 token)');
1704
+ process.exit(2);
1705
+ }
1706
+
1707
+ const detected = (await import('./local-detect.js')).detectLocalYaml(dir);
1708
+ const specRoot = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
1709
+ const localYamlPath = join(specRoot, 'local.yaml');
1710
+
1711
+ if (existsSync(localYamlPath)) {
1712
+ console.log(`ℹ️ local.yaml 已存在,跳过: ${localYamlPath}`);
1713
+ break;
1714
+ }
1715
+
1716
+ // 序列化为 local.yaml 文本(与 scan.js 生成本地配置步骤的 yaml 模板格式一致,向后兼容)
1717
+ const c = detected.commands || {};
1718
+ const lines = [];
1719
+ lines.push('# SillySpec 本地配置(自动生成,可手动修改)');
1720
+ lines.push('project:');
1721
+ lines.push(` type: ${detected.project.type} # nodejs/maven/gradle/make/generic`);
1722
+ lines.push('');
1723
+ lines.push('commands:');
1724
+ if (c.build) lines.push(` build: "${c.build}"`);
1725
+ if (c.test) lines.push(` test: "${c.test}"`);
1726
+ if (c.lint) lines.push(` lint: "${c.lint}"`);
1727
+ lines.push('');
1728
+ lines.push('# 测试策略:full=全量测试, module=只测变更模块, skip=跳过测试');
1729
+ lines.push('test_strategy: module');
1730
+ lines.push('');
1731
+ lines.push('# monorepo 子模块映射(test_strategy: module 时按 git diff 命中模块收窄测试;inline flow 形态)');
1732
+ lines.push('# modules:');
1733
+ lines.push('# user-service: { path: "user/", test: "cd user && npm test" }');
1734
+ lines.push('# order-service: { path: "order/", test: "cd order && npm test" }');
1735
+ lines.push('# 已知预存失败(可选):verify 实测时这些失败被豁免、不阻断归档;fail-safe 仅豁免能检测到的失败行');
1736
+ lines.push('# known_failures:');
1737
+ lines.push('# - "tests/test_legacy.py::test_old"');
1738
+ const yamlText = lines.join('\n') + '\n';
1739
+
1740
+ // 原子写(tmp + rename + Windows EPERM 重试),避免半截 local.yaml
1741
+ mkdirSync(specRoot, { recursive: true });
1742
+ writeAtomicSync(localYamlPath, yamlText);
1743
+
1744
+ console.log(`✅ 已生成 local.yaml (type: ${detected.project.type})`);
1745
+ console.log(` 路径: ${localYamlPath}`);
1746
+ break;
1747
+ }
1748
+ case 'config': {
1749
+ // local.yaml 键发现缺口(2026-08-11):键散落 ~10 个 reader,外部项目 agent 无从得知。
1750
+ // config schema 把全部已知键 + 生效状态 + 读取点打印出来,堵住发现缺口。
1751
+ // 数据源唯一 = src/config-schema.js(与 init 落盘的 local.yaml.example 同源)。
1752
+ const configSub = filteredArgs[1];
1753
+ const wantHelp = configSub === 'help' || configSub === '--help' || configSub === '-h';
1754
+ const isSchema = !configSub || configSub === 'schema';
1755
+ if (wantHelp) {
1756
+ console.log(`
1757
+ SillySpec config — local.yaml 配置键速查
1758
+
1759
+ 用法:
1760
+ sillyspec config 打印 local.yaml 全部已知键(人类可读树,默认)
1761
+ sillyspec config schema 同上(显式子命令)
1762
+ sillyspec config --json 机读 JSON(程序化消费)
1763
+ sillyspec config schema --json 同上
1764
+
1765
+ 说明:
1766
+ - 数据源:src/config-schema.js(唯一真相;reader 见各键「读取点」)
1767
+ - 键分两类:【生效】配了即生效;【声明但未接线】代码/JSDoc 提及但无 reader,配了不生效
1768
+ - 脱敏示例文件:sillyspec init 生成 local.yaml.example(可提交;真实 local.yaml 是 gitignored)
1769
+ `);
1770
+ break;
1771
+ }
1772
+ if (!isSchema) {
1773
+ const sug = didYouMean(configSub, ['schema']);
1774
+ console.error(`❌ 未知子命令: config ${configSub}`);
1775
+ if (sug) console.error(` 你是想输入「config ${sug}」吗?`);
1776
+ console.error('用法: sillyspec config [schema] [--json] 打印 local.yaml 配置键');
1777
+ process.exit(2);
1778
+ }
1779
+ const { renderSchemaHuman, renderSchemaJson } = await import('./config-schema.js');
1780
+ if (json) console.log(renderSchemaJson());
1781
+ else console.log(renderSchemaHuman());
1782
+ break;
1783
+ }
1784
+ case 'runtime': {
1785
+ // D2:.runtime/ 产物索引。CLI 往 .runtime/ 写各种证据/状态文件(db、doctor-diagnosis、
1786
+ // workflow-runs、user-inputs…),却没有命令让 agent 一眼看到「我手上有哪些产物」。
1787
+ // 多会话/压缩后尤其痛:上轮写的证据文件,下轮不知去哪找。纯只读枚举,不写盘。
1788
+ const runtimeSubCmd = filteredArgs[1];
1789
+ if (runtimeSubCmd && runtimeSubCmd !== 'list') {
1790
+ const sug = didYouMean(runtimeSubCmd, ['list']);
1791
+ console.error(`❌ 未知子命令: runtime ${runtimeSubCmd}`);
1792
+ if (sug) console.error(` 你是想输入「runtime ${sug}」吗?`);
1793
+ console.error('用法: sillyspec runtime list 枚举 .sillyspec/.runtime/ 产物(只读)');
1794
+ process.exit(2);
1795
+ }
1796
+ const specRoot = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
1797
+ const runtimeDir = join(specRoot, '.runtime');
1798
+ if (!existsSync(runtimeDir)) {
1799
+ if (json) { console.log(JSON.stringify({ runtimeDir, exists: false, artifacts: [] }, null, 2)); break; }
1800
+ console.log(`📭 无 .runtime/ 目录(${runtimeDir})——尚未产生任何运行时产物。`);
1801
+ break;
1802
+ }
1803
+ // 已知产物 → 用途说明。agent 据此判断每份产物能否作为证据/该读哪个。
1804
+ const KNOWN = {
1805
+ 'sillyspec.db': '进度库 SQLite(stages/steps/changes 表)',
1806
+ 'sillyspec.db.bak': '进度库备份(sql.js 时代写前快照遗留,node:sqlite 不再写入,仅 _openWithFallback 向后兼容恢复用)',
1807
+ 'sillyspec.db.schema-version': '进度库 schema 版本标记',
1808
+ 'doctor-diagnosis.json': 'doctor --json 结构化诊断快照',
1809
+ 'doctor-dumps': 'doctor --dump-db 取证输出目录',
1810
+ 'workflow-runs': 'workflow check --save 归档目录',
1811
+ 'scan-guard.json': 'scan 覆盖保护记录',
1812
+ 'quick-sessions': 'quick 会话记录目录',
1813
+ 'quick-guard.json': 'quick baseline 守卫(旧位置)',
1814
+ 'worktrees': 'worktree meta(hooks 侧)',
1815
+ 'user-inputs.md': '步骤输出/输入历史追加日志',
1816
+ 'current-quick-run-id': '当前 quick sessionId 指针(--done 无 --change 时 fallback)',
1817
+ 'scan-runs': '平台模式 scan/workflow 取证目录',
1818
+ 'artifacts': '派生产物目录(init 预建)',
1819
+ 'contract-artifacts': 'execute endpoint 契约产物(verify 阶段读取)',
1820
+ 'history': '历史记录目录(init 预建)',
1821
+ 'logs': '日志目录(init 预建)',
1822
+ 'templates': '工作流模板目录(init 预建)',
1823
+ };
1824
+ let entries;
1825
+ try {
1826
+ entries = readdirSync(runtimeDir, { withFileTypes: true });
1827
+ } catch (e) {
1828
+ console.error(`❌ 读取 .runtime/ 失败: ${e.message}`);
1829
+ process.exit(1);
1830
+ }
1831
+ entries.sort((a, b) => a.name.localeCompare(b.name));
1832
+ if (json) {
1833
+ console.log(JSON.stringify({
1834
+ runtimeDir, exists: true,
1835
+ artifacts: entries.map(e => ({ name: e.name, type: e.isDirectory() ? 'dir' : 'file', desc: KNOWN[e.name] || null })),
1836
+ }, null, 2));
1837
+ break;
1838
+ }
1839
+ console.log(`📂 .runtime/ 产物(${runtimeDir})`);
1840
+ if (entries.length === 0) { console.log(' (空目录)'); break; }
1841
+ for (const e of entries) {
1842
+ const kind = e.isDirectory() ? '📁' : '📄';
1843
+ const desc = KNOWN[e.name] ? ` — ${KNOWN[e.name]}` : '';
1844
+ console.log(` ${kind} ${e.name}${desc}`);
1845
+ }
1846
+ const unknown = entries.filter(e => !KNOWN[e.name]);
1847
+ if (unknown.length > 0) {
1848
+ console.log(`\n (${unknown.length} 项未登记:${unknown.map(e => e.name).join(', ')})`);
1849
+ }
1850
+ break;
1851
+ }
1852
+ default: {
1853
+ const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
1854
+ const suggestion = didYouMean(command, topCommands);
1855
+ console.error(`❌ 未知命令: ${command}`);
1856
+ if (command === '--status') {
1857
+ // --status 是阶段内 flag(sillyspec run <stage> --status),裸用会落到 default
1858
+ console.error(' --status 是阶段内 flag。查看进度请用:sillyspec status 或 sillyspec progress show');
1859
+ } else if (suggestion) {
1860
+ console.error(` 你是想输入「${suggestion}」吗?`);
1861
+ }
1862
+ printUsage();
1863
+ process.exit(1);
1864
+ }
1865
+ }
1866
+ }
1867
+
1868
+ main().catch((err) => {
1869
+ // 平台指针失效:fail-closed,打印修复引导而非静默回退/stack trace
1870
+ if (err?.name === 'PointerUnreachableError') {
1871
+ console.error(`❌ ${err.message}`);
1872
+ process.exit(1);
1873
+ }
1874
+ // message 提首行(Agent 可直接解析 stderr 首行判断成败),堆栈降级为附件;
1875
+ // 用 exitCode 而非 exit(1) 让事件循环自然 drain,缓解 Windows libuv teardown
1876
+ // 把退出码污染成 127(与 shell "command not found" 撞码)的问题。
1877
+ console.error(`❌ SillySpec 发生错误:${err?.message || err}`);
1878
+ if (err?.stack) console.error(err.stack);
1879
+ process.exitCode = 1;
1880
+ });