sillyspec 3.25.11 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -114,7 +114,7 @@ execute 还有**第二道**独立的 stage 级审查:除逐 task review.json
114
114
 
115
115
  ## 派发模式(SillyHub MCP,可选)
116
116
 
117
- `execute` Wave 内的子代理默认用本机 Agent tool 执行。若消费方配置了 SillyHub MCP(`SILLYHUB_MCP_URL` / `SILLYHUB_MCP_TOKEN`),Wave 步骤 prompt 运行时可能注入一段 SillyHub 派发指令——**出现就照其中的指令执行**(创建 mission / dispatch_worker / 轮询结果 / 本机兜底),**没出现就用本机 Agent tool**。未配置 MCP 时完全不注入,行为与无此机制一致。
117
+ `execute` Wave 内的子代理默认用本机 Agent tool 执行。若消费方配置了 SillyHub MCP(`local.yaml` 的 `mcp` 段写 `mcp.url` / `mcp.token`——可由 `sillyspec platform connect` 同源写入或手填;或环境变量 `SILLYHUB_MCP_URL` / `SILLYHUB_MCP_TOKEN` 作回退),Wave 步骤 prompt 运行时可能注入一段 SillyHub 派发指令——**出现就照其中的指令执行**(创建 mission / dispatch_worker / 轮询结果 / 本机兜底),**没出现就用本机 Agent tool**。未配置 MCP 时完全不注入,行为与无此机制一致。
118
118
 
119
119
  可选:`sillyspec dispatch probe` 查看 SillyHub 是否可用。
120
120
 
@@ -49,6 +49,7 @@ sillyspec run quick --reopen --from-step N # 重新打开已完成阶段修
49
49
  |---|---|
50
50
  | `--linked-changes none\|a,b` | **显式关联变更(取代 `--change`,推荐)**。none=不关联,a,b=关联列表 |
51
51
  | `--files a.js,b.js` | 显式声明本次允许修改的文件(边界保护) |
52
+ | `--file-notes "p::注 \|\| p::注"` | quick `--done` 用:QUICKLOG「文件:」行落盘为多行带括注 bullet(省事后手改文件行)。格式 `path::括注`,`\|\|` 分隔多条 |
52
53
  | `--allow-new` | 允许新增文件(默认禁止,防意外创建) |
53
54
  | `--force-baseline` | 允许覆盖 baseline 受保护文件 / 压制 `.sillyspec/` 危险判定(危险,慎用) |
54
55
  | `--confirm` | ⚠️ 仅打印变更概览,**不解锁 blocked**(blocked 仍 exit 1)。真正解锁用 `--force-baseline`/`--allow-new` |
@@ -88,7 +89,8 @@ sillyspec run quick --files src/phone.ts,src/phone.test.ts
88
89
  - quick 直接在主工作区改代码(不创建 worktree),范围必须小且明确
89
90
  - 完成后立即 `--done`,不跳过
90
91
  - QUICKLOG 记录的**骨架由 CLI 接管**:启动时 CLI 自动分配 ql-ID 并在 `.sillyspec/quicklog/QUICKLOG-<user>.md` 写「进行中」条目(含关联变更 tasks.md),`--done` 时 CLI 自动翻「已完成」+ 勾选 task + 回填文件路径。ql-ID 分配/状态/task 你无需手写,只需用注入的 `<quicklog-id>` 在模块文档变更索引引用
91
- - **QUICKLOG 正文精修(`--done` 后必做,不可跳过)**:CLI 落盘的是简版骨架(标题可能是 `(quick 任务)`、文件是单行逗号无括注),**不合格**。`--done` 成功后必须立即编辑本次 `<quicklog-id>` 条目,手动精修成丰富格式:① 标题改真实需求摘要(禁 `(quick 任务)`);② 文件改多行 bullet、每行带关键改动括注(禁 `(见实际改动)` 或无括注纯路径);③ 正文 `需求:`/`根因:`/`方案:`/`结果:` 四段逐项充实(禁只留一段「结果:」)。参照同文件早期丰富条目。一条 quick = 一条独立 ql,不追加到旧条目
92
+ - **QUICKLOG 落盘已结构化(`--done` 后按需核对,多数无需手改)**:CLI 已落盘结构化条目——标题从 `--output` 的「需求:」自动提取、正文四字段自动分行、文件行用 `--file-notes` 时为多行带括注。`--done` 后只需核对:标题弱才改(禁留 `(quick 任务)` 占位);没用 `--file-notes` 时文件行是单行、可事后补括注(参照同文件早期丰富条目);正文 `需求:`/`根因:`/`方案:`/`结果:` 四段按需充实(禁只留一段「结果:」)。一条 quick = 一条独立 ql,不追加到旧条目
93
+ - **收尾顺序(模块文档在 `--done` 前,QUICKLOG 在 `--done` 后,别记混)**:① 命中模块→改模块文档→`git add`;② `sillyspec run quick --done --change <id> --output "四字段" [--file-notes "..."]`(CLI 自动翻完成 + 勾 task + 落盘 QUICKLOG 标题/文件/正文);③ 核对 QUICKLOG 标题(CLI 已从「需求:」提取,仅弱标题才改)→若改了再 `git add`
92
94
  - **最后一步 `--done --output` 必须按结构化结果模板给全四字段**(逐项一句话):`需求:… 根因:… 方案:… 结果:…`。这是 QUICKLOG「结果:」归档的唯一来源;CLI 校验缺字段会拒绝 `--done`(exit 1),补全后重跑即可。前两个 step 的 `--output` 是中间摘要,不用此模板
93
95
  - **禁止**在没有运行 CLI 的情况下自行决定流程
94
96
 
@@ -0,0 +1 @@
1
+ 2026-08-10T17:08:53.237Z
@@ -0,0 +1,7 @@
1
+ {
2
+ "specRoot": "C:\\Users\\qinyi\\IdeaProjects\\sillyspec\\.sillyspec",
3
+ "runtimeRoot": null,
4
+ "workspaceId": null,
5
+ "scanRunId": null,
6
+ "savedAt": "2026-08-10T17:33:41.549Z"
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.25.11",
3
+ "version": "3.26.0",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
package/src/db.js CHANGED
@@ -7,7 +7,7 @@ import { dirname } from 'path';
7
7
  // 每次 new ProgressManager 都过 init,靠版本戳跳过建表省开销)。
8
8
  // better-sqlite3 是原生 SQLite 引擎,打开即持久化(不像 sql.js 纯内存需整库 export 落盘),
9
9
  // _createSchema 内 DDL 直接落盘,无需额外 _save。
10
- const DB_SCHEMA_VERSION = 3;
10
+ const DB_SCHEMA_VERSION = 4;
11
11
 
12
12
  // SQLITE_BUSY 应用层有限重试(R-08 / NFR-03):WAL 单写者模型,并发写第二者在
13
13
  // busy_timeout=5000(init PRAGMA)后抛 SQLITE_BUSY。busy_timeout 已在引擎层处理大部分等待;
@@ -200,7 +200,7 @@ export class DB {
200
200
  CREATE TABLE IF NOT EXISTS project (
201
201
  id INTEGER PRIMARY KEY DEFAULT 1,
202
202
  name TEXT NOT NULL,
203
- schema_version INTEGER DEFAULT 3,
203
+ schema_version INTEGER DEFAULT 4,
204
204
  created_at TEXT NOT NULL,
205
205
  updated_at TEXT NOT NULL
206
206
  )
@@ -302,6 +302,14 @@ export class DB {
302
302
  this._migrateAddColumn('stages', 'reopened_from_step', 'TEXT');
303
303
  this._migrateAddColumn('stages', 'reopened_at', 'TEXT');
304
304
  this._migrateAddColumn('stages', 'stale_reason', 'TEXT');
305
+
306
+ // Platform sync support(2026-08-10-platform-progress-sync §8 / D-012):
307
+ // changes 表加 base_ts(last_synced_platform_ts)与本地脏度(last_local_modified_ts)两列,
308
+ // 为 serializeForSync()/import() 序列化与 base_ts 乐观锁冲突检测提供数据载体。
309
+ // NULL 语义(gap8):last_local_modified_ts NULL=本地无脏度(pull 不判冲突直接 import);
310
+ // last_synced_platform_ts NULL=首次同步(base_ts NULL 平台接受首次 push)。
311
+ this._migrateAddColumn('changes', 'last_synced_platform_ts', 'TEXT');
312
+ this._migrateAddColumn('changes', 'last_local_modified_ts', 'TEXT');
305
313
  }
306
314
 
307
315
  /**
@@ -16,6 +16,7 @@ import { existsSync, readFileSync } from 'node:fs';
16
16
  import { join, relative, isAbsolute, sep } from 'node:path';
17
17
  import jsYaml from 'js-yaml';
18
18
  import { SillyHubMcpClient } from '../sillyhub-mcp/client.js';
19
+ import { readMcpConfig } from '../sillyhub-mcp/config.js';
19
20
  import { setPathAProbeResult } from './backends/sillyhub-mcp.js';
20
21
 
21
22
  /** 默认负面缓存 TTL(毫秒)。可被 local.yaml dispatch.probe_ttl_ms 或 ttlMs 参数覆盖。 */
@@ -57,11 +58,11 @@ function resolveTtl(ttlMs, cwd) {
57
58
  }
58
59
 
59
60
  /**
60
- * 配置指纹(负面缓存 key)。用 env URL 标识 daemon 身份;token 不入 key(避免敏感信息驻留缓存)。
61
- * token 轮换后最迟 TTL 内自动失效重探。
61
+ * 配置指纹(负面缓存 key)。用 local.yaml mcp 段或 env 的 URL 标识 daemon 身份;
62
+ * token 不入 key(避免敏感信息驻留缓存,保密语义不变)。token 轮换后最迟 TTL 内自动失效重探。
62
63
  */
63
- function configFingerprint() {
64
- return process.env.SILLYHUB_MCP_URL || '';
64
+ function configFingerprint(cwd) {
65
+ return readMcpConfig(cwd)?.url || '';
65
66
  }
66
67
 
67
68
  /**
@@ -133,7 +134,7 @@ async function preheatPathAProbe(cli) {
133
134
  * 5. 全通过 → available=true(不缓存正面结果)
134
135
  *
135
136
  * @param {object} [opts]
136
- * @param {object} [opts.client] - SillyHubMcpClient 实例;缺省 new SillyHubMcpClient()(读 env)
137
+ * @param {object} [opts.client] - SillyHubMcpClient 实例;缺省 new SillyHubMcpClient()(经 readMcpConfig 读 local.yaml mcp 段 + env)
137
138
  * @param {string} [opts.worktreePath] - SillySpec worktree 绝对路径(root_path 校验用)
138
139
  * @param {string} [opts.rootPath] - daemon ws.root_path;未传(undefined)时 best-effort 从 daemon
139
140
  * 拿(task-12 #5),拿不到则跳过越界校验(不判 unavailable)
@@ -141,13 +142,14 @@ async function preheatPathAProbe(cli) {
141
142
  * @returns {Promise<{ available: boolean, reason?: string }>}
142
143
  */
143
144
  export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs } = {}) {
144
- // 1. 同步快速路径:env 缺任一即 unavailable(不发网络,零回归关键)
145
- if (!process.env.SILLYHUB_MCP_URL || !process.env.SILLYHUB_MCP_TOKEN) {
145
+ // 1. 同步快速路径:无 local.yaml mcp 段且 env 缺凭据即 unavailable(不发网络,零回归关键)。
146
+ // readMcpConfig 纯 fs + env 读(best-effort 不抛不发网络),返回 null = 两源都缺 → no-config。
147
+ if (!readMcpConfig(process.cwd())) {
146
148
  return { available: false, reason: 'no-config' };
147
149
  }
148
150
 
149
151
  // 2. 负面缓存命中(未过 TTL)→ 直接返回,不发网络(R-06 抖动期免反复探测)
150
- const fp = configFingerprint();
152
+ const fp = configFingerprint(process.cwd());
151
153
  const cached = negativeCache.get(fp);
152
154
  if (cached && cached.expiresAt > Date.now()) {
153
155
  return cached.result;
@@ -155,7 +157,7 @@ export async function probeSillyHub({ client, worktreePath, rootPath, ttlMs } =
155
157
 
156
158
  // 3. 连通性探测:client.probeDaemon 自身 best-effort 不抛;再兜一层 try/catch,
157
159
  // 即便 client 契约异常抛出也保守判 unavailable(铁律:绝不抛穿 execute)
158
- const cli = client || new SillyHubMcpClient();
160
+ const cli = client || new SillyHubMcpClient({ cwd: process.cwd() });
159
161
  let reachable;
160
162
  try {
161
163
  reachable = await cli.probeDaemon();
package/src/index.js CHANGED
@@ -84,6 +84,8 @@ SillySpec CLI — 规范驱动开发工具包
84
84
  sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
85
85
  sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
86
86
  sillyspec backfill-reviews --change <name> [--json] 为手动补的 task 生成 review.json 草稿(cannot_verify,解 archive 客观完成度阻断)
87
+ sillyspec register-stage-review --change <name> --stage <brainstorm|plan|execute> [--from <review.json>] [--json]
88
+ 生成/adopt stage 级 review.json(docHash 自动算 + 写 marker,治 tier=independent marker 死锁)
87
89
 
88
90
  sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
89
91
  (无子命令) 跑诊断,列出问题
@@ -458,6 +460,41 @@ async function main() {
458
460
  }
459
461
  break;
460
462
  }
463
+ case 'register-stage-review': {
464
+ // 手动注册 stage 级 review(brainstorm/plan/execute-acceptance)的确定性 writer ——
465
+ // 生成 run 目录 + review.json 骨架(cannot_verify,待独立审查子代理填 verdict)或 adopt
466
+ // agent 草稿(--from,保留 verdict/checklist 重算 docHash)+ 写 marker。治 tier=independent 时
467
+ // 调度者手动派独立子代理不写 marker 的死锁。docHash 由 CLI computeDocHash 算(部分实现 P6.1b defer)。
468
+ // 纯新增,不改 gate 语义。与 task 级 backfill-reviews 对称(stage 级等价物)。
469
+ const rsrChangeIdx = args.indexOf('--change');
470
+ const rsrChange = rsrChangeIdx >= 0 && args[rsrChangeIdx + 1] ? args[rsrChangeIdx + 1] : null;
471
+ const rsrStageIdx = args.indexOf('--stage');
472
+ const rsrStage = rsrStageIdx >= 0 && args[rsrStageIdx + 1] ? args[rsrStageIdx + 1] : null;
473
+ const rsrFromIdx = args.indexOf('--from');
474
+ const rsrFrom = rsrFromIdx >= 0 && args[rsrFromIdx + 1] ? args[rsrFromIdx + 1] : null;
475
+ if (!rsrChange || !rsrStage) {
476
+ 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 死锁');
477
+ process.exit(2);
478
+ }
479
+ const { registerStageReview } = await import('./stage-review.js');
480
+ const rsrPlatformOpts = {};
481
+ if (specDir) rsrPlatformOpts.specRoot = specDir;
482
+ try {
483
+ const result = registerStageReview({ changeName: rsrChange, stage: rsrStage, fromFile: rsrFrom, cwd: dir, platformOpts: rsrPlatformOpts });
484
+ if (json) {
485
+ process.stdout.write(JSON.stringify({ ok: true, command: 'register-stage-review', change: rsrChange, stage: rsrStage, ...result }));
486
+ } else {
487
+ console.log(`✅ 已注册 ${rsrStage} stage review [${result.reviewRunId}] → ${result.reviewPath}(mode: ${result.mode})`);
488
+ console.log(` marker → ${result.markerPath}`);
489
+ console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
490
+ }
491
+ } catch (e) {
492
+ if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', error: e.message }));
493
+ else console.error('❌ ' + e.message);
494
+ process.exitCode = 1;
495
+ }
496
+ break;
497
+ }
461
498
  case 'docs': {
462
499
  const docsSubCmd = filteredArgs[1];
463
500
  if (docsSubCmd === 'migrate') {
@@ -612,6 +649,10 @@ async function main() {
612
649
  const { runCommand } = await import('./run.js')
613
650
  const stageArgs = [command, ...filteredArgs.slice(1)]
614
651
  const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
652
+ // 下行 pull:stage 命令(run/--done,含 archive)启动时拉一次(task-10 / D-009 / FR-04)
653
+ // 不在每步 pull,仅低频边界点;未连接平台静默跳过
654
+ const { triggerPullActiveChange } = await import('./run/shared.js')
655
+ await triggerPullActiveChange(effectiveDir)
615
656
  await runCommand(stageArgs, effectiveDir, specDir, { json })
616
657
  break
617
658
  }
@@ -734,6 +775,12 @@ SillySpec worktree — git worktree 隔离管理
734
775
  for (const err of result.errors) {
735
776
  console.error(` ${err}`);
736
777
  }
778
+ if (result.rescueCommands) {
779
+ console.error('');
780
+ console.error(`🆘 Rescue commands (${result.rescueCommands.cpFileCount} safe / ${result.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
781
+ for (const c of result.rescueCommands.commands) console.error(` ${c}`);
782
+ for (const w of result.rescueCommands.warnings) console.error(` ⚠️ ${w}`);
783
+ }
737
784
  process.exit(1);
738
785
  }
739
786
 
@@ -795,6 +842,12 @@ SillySpec worktree — git worktree 隔离管理
795
842
  for (const w of assessment.warnings) console.log(` ⚠️ ${w}`);
796
843
  }
797
844
 
845
+ if (assessment.rescueCommands) {
846
+ console.log('');
847
+ console.log(`🆘 Rescue commands (${assessment.rescueCommands.cpFileCount} safe / ${assessment.rescueCommands.excludedCount} excluded,旁路 git apply,cp 后需手动 sillyspec worktree cleanup ${wtName}):`);
848
+ for (const c of assessment.rescueCommands.commands) console.log(` ${c}`);
849
+ for (const w of assessment.rescueCommands.warnings) console.log(` ⚠️ ${w}`);
850
+ }
798
851
  console.log('');
799
852
  if (assessment.decision === 'SAFE' || assessment.decision === 'WARNING') {
800
853
  console.log('Action: auto-applying...');
@@ -1045,6 +1098,8 @@ SillySpec platform — SillyHub 平台同步
1045
1098
  sillyspec platform disconnect
1046
1099
  sillyspec platform sync [--change <name>]
1047
1100
  sillyspec platform sync-docs [--change <name>]
1101
+ sillyspec platform pull [--change <name>]
1102
+ sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>
1048
1103
  sillyspec platform status
1049
1104
  sillyspec platform pointer [--cleanup]
1050
1105
  sillyspec platform approve <change-name>
@@ -1145,15 +1200,108 @@ SillySpec platform — SillyHub 平台同步
1145
1200
  await syncModule.syncDocuments(syncDocsChangeName, dir);
1146
1201
  break;
1147
1202
  }
1148
- case 'status':
1149
- await syncModule.status(dir);
1203
+ case 'status': {
1204
+ // task-14 / FR-05:扩展 status(落后标记 + 未决冲突列表);未连接输出与现状一致
1205
+ const st = await syncModule.collectStatus(dir);
1206
+ if (!st.connected) {
1207
+ console.log('平台: 未连接');
1208
+ break;
1209
+ }
1210
+ console.log(`平台: ${st.url}`);
1211
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
1212
+ if (st.listFailed) {
1213
+ console.log('ℹ️ 无法连接平台获取落后状态');
1214
+ } else if (st.behind.length > 0) {
1215
+ console.log(`⚠️ 本地可能落后(${st.behind.length} 个变更平台有更新):`);
1216
+ for (const b of st.behind) {
1217
+ console.log(` - ${b.name}: 平台 ${b.platform_pushed_at} > 本地 ${b.local_synced}`);
1218
+ }
1219
+ } else {
1220
+ console.log('✅ 本地与平台进度同步');
1221
+ }
1222
+ if (st.conflicts.length > 0) {
1223
+ console.log(`⚠️ 未决冲突(${st.conflicts.length} 个,请 platform resolve 处理):`);
1224
+ for (const c of st.conflicts) {
1225
+ console.log(` - ${c.change}${c.created_at ? ` (创建于 ${c.created_at})` : ''}`);
1226
+ }
1227
+ }
1150
1228
  break;
1229
+ }
1230
+ case 'pull': {
1231
+ // task-11 / D-006 / D-009 / FR-03:手动下行拉取平台进度到本地。
1232
+ // 与自动 triggerPull 共用 syncModule.pull(SyncManager.pull 实例方法),行为一致。
1233
+ const pullChangeIdx = args.indexOf('--change');
1234
+ const pullChangeName = pullChangeIdx >= 0 && args[pullChangeIdx + 1] ? args[pullChangeIdx + 1] : null;
1235
+ // 未连接明确提示不崩(acceptance:未连接平台输出明确提示)
1236
+ const smForCheck = new syncModule.SyncManager(dir);
1237
+ if (!smForCheck._getPlatform()) {
1238
+ console.error('❌ 未连接平台,请先 sillyspec platform connect');
1239
+ process.exit(1);
1240
+ }
1241
+ if (pullChangeName) {
1242
+ // 单变更完整 pull(两级 pull 第二级)
1243
+ const r = await syncModule.pull(pullChangeName, {}, dir);
1244
+ if (r.conflict) {
1245
+ console.log(`⚠️ ${pullChangeName}: 冲突(已写 sync-conflict 文件),请 sillyspec platform resolve 处理`);
1246
+ } else if (r.imported) {
1247
+ console.log(`✅ ${pullChangeName}: 已拉取并 import`);
1248
+ } else {
1249
+ console.log(`⏭️ ${pullChangeName}: 未 import(${r.reason || '无变更'})`);
1250
+ }
1251
+ } else {
1252
+ // 无 --change:先拉轻量列表(两级 pull 第一级),再对每个 change 按需完整 pull
1253
+ const list = await syncModule.pullList(dir);
1254
+ if (!list.ok) {
1255
+ console.error(`❌ 拉取变更列表失败: ${list.reason || '未知'}`);
1256
+ process.exit(1);
1257
+ }
1258
+ console.log(`平台变更 ${list.changes.length} 个:`);
1259
+ let importedCount = 0, conflictCount = 0;
1260
+ for (const ch of list.changes) {
1261
+ const name = typeof ch === 'string' ? ch : ch.name;
1262
+ if (!name) continue;
1263
+ const r = await syncModule.pull(name, {}, dir);
1264
+ if (r.imported) { importedCount++; console.log(` ✅ ${name}: 已 import`); }
1265
+ else if (r.conflict) { conflictCount++; console.log(` ⚠️ ${name}: 冲突,请 platform resolve`); }
1266
+ else { console.log(` ⏭️ ${name}: ${r.reason || '未 import'}`); }
1267
+ }
1268
+ console.log(`拉取完成:imported ${importedCount},冲突 ${conflictCount}`);
1269
+ }
1270
+ break;
1271
+ }
1272
+ case 'resolve': {
1273
+ // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(绝不字段级 auto-merge)
1274
+ const resolveName = platformArgs[0];
1275
+ if (!resolveName) {
1276
+ console.error('❌ 用法: sillyspec platform resolve <change-name> <--keep-local|--take-platform|--abort>');
1277
+ process.exit(1);
1278
+ }
1279
+ // 解析 mode flag(三选一互斥,多传/不传均报错)
1280
+ const resolveFlags = ['--keep-local', '--take-platform', '--abort'].filter((f) => args.includes(f));
1281
+ if (resolveFlags.length !== 1) {
1282
+ console.error('❌ 必须恰好指定 --keep-local / --take-platform / --abort 之一');
1283
+ process.exit(1);
1284
+ }
1285
+ const modeMap = { '--keep-local': 'keep-local', '--take-platform': 'take-platform', '--abort': 'abort' };
1286
+ const resolveMode = modeMap[resolveFlags[0]];
1287
+ const r = await syncModule.resolve(resolveName, resolveMode, dir);
1288
+ if (r.ok && r.resolved) {
1289
+ console.log(`✅ ${resolveName} [${resolveMode}]: ${r.reason}`);
1290
+ } else {
1291
+ console.error(`❌ ${resolveName}: ${r.reason}`);
1292
+ process.exit(1);
1293
+ }
1294
+ break;
1295
+ }
1151
1296
  case 'approve': {
1152
1297
  const approveName = platformArgs[0];
1153
1298
  if (!approveName) {
1154
1299
  console.error('❌ 用法: sillyspec platform approve <change-name>');
1155
1300
  process.exit(1);
1156
1301
  }
1302
+ // 审批前下行 pull(task-10 / D-009 / FR-06):拉最新进度避免基于过期状态审批;未连接跳过
1303
+ const { triggerPull } = await import('./run/shared.js');
1304
+ await triggerPull(dir, approveName);
1157
1305
  await syncModule.approve(approveName, dir);
1158
1306
  break;
1159
1307
  }
@@ -1481,7 +1629,7 @@ SillySpec modules — 模块文档管理
1481
1629
  break;
1482
1630
  }
1483
1631
  default: {
1484
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
1632
+ 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'];
1485
1633
  const suggestion = didYouMean(command, topCommands);
1486
1634
  console.error(`❌ 未知命令: ${command}`);
1487
1635
  if (command === '--status') {
@@ -1,110 +1,120 @@
1
- /**
2
- * Local Detect — 纯 fs 项目类型嗅探
3
- *
4
- * 把"生成本地配置"里的项目类型判定从 scan(半小时 + 大量 token)抽出来,
5
- * 变成纯 fs 同步读几秒可完成的独立探测。create/gate 只需 project.type +
6
- * commands,不该被迫跑完整 scan。
7
- *
8
- * 纯函数:不 spawn 任何子进程、不调用任何 AI/LLM、不消耗 token。
9
- * 不写 local.yaml 到磁盘——只返回数据结构,落盘由调用方(CLI 路由 / scan.js)负责。
10
- * 不引 yaml 依赖。
11
- */
12
-
13
- import { existsSync, readFileSync } from 'fs'
14
- import { join } from 'path'
15
-
16
- /**
17
- * 从 Makefile 文本提取 test 目标的命令(轻量正则,与 verify-postcheck 同风格)。
18
- * 支持两种常见写法:
19
- * - 同行命令:`test: pytest` → 'pytest'
20
- * - tab 续行:`test:\n\tpytest\n` → 'pytest'(命令在下一 tab 开头行)
21
- * 边界:
22
- * - 纯空目标(`test:` 无同行命令、无 tab 续行)→ 回退 'make test'
23
- * - 命令行内 `#` 注释截断;取第一个非空命令行
24
- * @param {string} makefileText
25
- * @returns {string}
26
- */
27
- function parseMakefileTestCommand(makefileText) {
28
- if (!makefileText) return 'make test'
29
- // 定位 `test:` 行;捕获 `:` 后同行内容(同行命令或空)
30
- const head = makefileText.match(/^test:\s*([^\n#]*?)\s*(?:#.*)?$/m)
31
- if (!head) return 'make test'
32
- const inline = (head[1] || '').trim()
33
- if (inline) return inline
34
- // 同行为空 → 找该行之后第一个 tab 续行命令(`\tcmd` 或 ` cmd`)
35
- const after = makefileText.slice(head.index + head[0].length)
36
- const cont = after.match(/^[ \t]+([^\n#]+?)\s*(?:#.*)?$/m)
37
- if (cont && cont[1]) return cont[1].trim()
38
- return 'make test'
39
- }
40
-
41
- /**
42
- * 嗅探 workdir 的项目类型与默认 commands(纯 fs,零 AI / 零 token)。
43
- *
44
- * 嗅探规则(按顺序,命中即返回):
45
- * - package.json → nodejs(npm run build / npm test / npm run lint)
46
- * - pom.xml → maven(mvn compile / mvn test / mvn checkstyle:check)
47
- * - build.gradle → gradle(./gradlew build / test / check)
48
- * - Makefile → make(test 命令从 test: 目标解析;build/lint 无则不写)
49
- * - 都没有 → generic(commands 为空对象)
50
- *
51
- * @param {string} workdir - 项目根目录
52
- * @returns {{
53
- * project: { type: 'nodejs'|'maven'|'gradle'|'make'|'generic' },
54
- * commands: { build?: string, test?: string, lint?: string }
55
- * }}
56
- */
57
- export function detectLocalYaml(workdir) {
58
- // 1. nodejs
59
- if (existsSync(join(workdir, 'package.json'))) {
60
- return {
61
- project: { type: 'nodejs' },
62
- commands: {
63
- build: 'npm run build',
64
- test: 'npm test',
65
- lint: 'npm run lint',
66
- },
67
- }
68
- }
69
-
70
- // 2. maven
71
- if (existsSync(join(workdir, 'pom.xml'))) {
72
- return {
73
- project: { type: 'maven' },
74
- commands: {
75
- build: 'mvn compile',
76
- test: 'mvn test',
77
- lint: 'mvn checkstyle:check',
78
- },
79
- }
80
- }
81
-
82
- // 3. gradle
83
- if (existsSync(join(workdir, 'build.gradle'))) {
84
- return {
85
- project: { type: 'gradle' },
86
- commands: {
87
- build: './gradlew build',
88
- test: './gradlew test',
89
- lint: './gradlew check',
90
- },
91
- }
92
- }
93
-
94
- // 4. make(test 命令从 Makefile 解析;build/lint 无则省略)
95
- const makefilePath = join(workdir, 'Makefile')
96
- if (existsSync(makefilePath)) {
97
- const makefileText = readFileSync(makefilePath, 'utf8')
98
- const test = parseMakefileTestCommand(makefileText)
99
- return {
100
- project: { type: 'make' },
101
- commands: { test },
102
- }
103
- }
104
-
105
- // 5. generic
106
- return {
107
- project: { type: 'generic' },
108
- commands: {},
109
- }
110
- }
1
+ /**
2
+ * Local Detect — 纯 fs 项目类型嗅探
3
+ *
4
+ * 把"生成本地配置"里的项目类型判定从 scan(半小时 + 大量 token)抽出来,
5
+ * 变成纯 fs 同步读几秒可完成的独立探测。create/gate 只需 project.type +
6
+ * commands,不该被迫跑完整 scan。
7
+ *
8
+ * 纯函数:不 spawn 任何子进程、不调用任何 AI/LLM、不消耗 token。
9
+ * 不写 local.yaml 到磁盘——只返回数据结构,落盘由调用方(CLI 路由 / scan.js)负责。
10
+ * 不引 yaml 依赖。
11
+ */
12
+
13
+ import { existsSync, readFileSync } from 'fs'
14
+ import { join } from 'path'
15
+
16
+ /**
17
+ * 从 Makefile 文本提取 test 目标的命令(轻量正则,与 verify-postcheck 同风格)。
18
+ * 支持两种常见写法:
19
+ * - 同行命令:`test: pytest` → 'pytest'
20
+ * - tab 续行:`test:\n\tpytest\n` → 'pytest'(命令在下一 tab 开头行)
21
+ * 边界:
22
+ * - 纯空目标(`test:` 无同行命令、无 tab 续行)→ 回退 'make test'
23
+ * - 命令行内 `#` 注释截断;取第一个非空命令行
24
+ * @param {string} makefileText
25
+ * @returns {string}
26
+ */
27
+ function parseMakefileTestCommand(makefileText) {
28
+ if (!makefileText) return 'make test'
29
+ // 定位 `test:` 行;捕获 `:` 后同行内容(同行命令或空)
30
+ const head = makefileText.match(/^test:\s*([^\n#]*?)\s*(?:#.*)?$/m)
31
+ if (!head) return 'make test'
32
+ const inline = (head[1] || '').trim()
33
+ if (inline) return inline
34
+ // 同行为空 → 找该行之后第一个 tab 续行命令(`\tcmd` 或 ` cmd`)
35
+ const after = makefileText.slice(head.index + head[0].length)
36
+ const cont = after.match(/^[ \t]+([^\n#]+?)\s*(?:#.*)?$/m)
37
+ if (cont && cont[1]) return cont[1].trim()
38
+ return 'make test'
39
+ }
40
+
41
+ /**
42
+ * 嗅探 workdir 的项目类型与默认 commands(纯 fs,零 AI / 零 token)。
43
+ *
44
+ * 嗅探规则(按顺序,命中即返回):
45
+ * - package.json → nodejs(核验 scripts.{build,test,lint} 存在才写对应键;缺失不写)
46
+ * - pom.xml → maven(mvn compile / mvn test / mvn checkstyle:check)
47
+ * - build.gradle → gradle(gradlew 存在用 ./gradlew 否则 gradle;build / test / check)
48
+ * - Makefile → make(test 命令从 test: 目标解析;build/lint 无则不写)
49
+ * - 都没有 → generic(commands 为空对象)
50
+ *
51
+ * @param {string} workdir - 项目根目录
52
+ * @returns {{
53
+ * project: { type: 'nodejs'|'maven'|'gradle'|'make'|'generic' },
54
+ * commands: { build?: string, test?: string, lint?: string }
55
+ * }}
56
+ */
57
+ export function detectLocalYaml(workdir) {
58
+ // 1. nodejs
59
+ if (existsSync(join(workdir, 'package.json'))) {
60
+ const pkgPath = join(workdir, 'package.json')
61
+ const raw = readFileSync(pkgPath, 'utf8')
62
+ let pkg
63
+ try {
64
+ pkg = JSON.parse(raw)
65
+ } catch (e) {
66
+ throw new Error('package.json 解析失败:' + pkgPath)
67
+ }
68
+ const scripts = (pkg && pkg.scripts) || {}
69
+ const commands = {}
70
+ if (scripts.build) commands.build = 'npm run build'
71
+ if (scripts.test) commands.test = 'npm test'
72
+ if (scripts.lint) commands.lint = 'npm run lint'
73
+ return {
74
+ project: { type: 'nodejs' },
75
+ commands,
76
+ }
77
+ }
78
+
79
+ // 2. maven
80
+ if (existsSync(join(workdir, 'pom.xml'))) {
81
+ return {
82
+ project: { type: 'maven' },
83
+ commands: {
84
+ build: 'mvn compile',
85
+ test: 'mvn test',
86
+ lint: 'mvn checkstyle:check',
87
+ },
88
+ }
89
+ }
90
+
91
+ // 3. gradle
92
+ if (existsSync(join(workdir, 'build.gradle'))) {
93
+ const gradlePrefix = existsSync(join(workdir, 'gradlew')) ? './gradlew' : 'gradle'
94
+ return {
95
+ project: { type: 'gradle' },
96
+ commands: {
97
+ build: `${gradlePrefix} build`,
98
+ test: `${gradlePrefix} test`,
99
+ lint: `${gradlePrefix} check`,
100
+ },
101
+ }
102
+ }
103
+
104
+ // 4. make(test 命令从 Makefile 解析;build/lint 无则省略)
105
+ const makefilePath = join(workdir, 'Makefile')
106
+ if (existsSync(makefilePath)) {
107
+ const makefileText = readFileSync(makefilePath, 'utf8')
108
+ const test = parseMakefileTestCommand(makefileText)
109
+ return {
110
+ project: { type: 'make' },
111
+ commands: { test },
112
+ }
113
+ }
114
+
115
+ // 5. generic
116
+ return {
117
+ project: { type: 'generic' },
118
+ commands: {},
119
+ }
120
+ }