sillyspec 3.26.11 → 3.26.13

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 (58) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  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 +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +161 -12
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +184 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +31 -8
  31. package/src/run/complete-handlers.js +21 -14
  32. package/src/run/complete.js +1181 -1121
  33. package/src/run/gates.js +148 -25
  34. package/src/run/prompt.js +9 -5
  35. package/src/run/shared.js +138 -58
  36. package/src/run/stage.js +41 -1
  37. package/src/setup.js +8 -10
  38. package/src/sillyhub-mcp/client.js +74 -6
  39. package/src/spec-dir-typo.js +3 -24
  40. package/src/spec-sync.js +43 -8
  41. package/src/stage-contract.js +12 -38
  42. package/src/stages/archive.js +4 -3
  43. package/src/stages/brainstorm-auto.js +1 -1
  44. package/src/stages/brainstorm.js +2 -1
  45. package/src/stages/doctor.js +28 -13
  46. package/src/stages/execute.js +164 -153
  47. package/src/stages/plan-postcheck.js +49 -28
  48. package/src/stages/plan.js +57 -40
  49. package/src/stages/quick.js +3 -1
  50. package/src/stages/verify.js +1 -1
  51. package/src/sync.js +323 -221
  52. package/src/task-review.js +246 -61
  53. package/src/taskcard.js +178 -0
  54. package/src/verify-postcheck.js +48 -35
  55. package/src/worktree-apply.js +58 -34
  56. package/src/worktree-deps.js +120 -20
  57. package/src/worktree.js +55 -9
  58. package/.npmrc-publish +0 -2
package/src/sync.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * HTTP 请求:Node.js 原生 fetch(Node 22+)
9
9
  */
10
10
 
11
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { resolvePlatformSpecDir } from './progress.js';
14
14
  import { safeGit } from './git-helper.js';
@@ -135,7 +135,11 @@ function replaceTopLevelSection(text, name, body) {
135
135
  function writeLocalYamlRaw(cwd, text) {
136
136
  const dir = join(cwd, '.sillyspec');
137
137
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
138
- writeFileSync(join(cwd, LOCAL_YAML), text, 'utf8');
138
+ const yamlPath = join(cwd, LOCAL_YAML);
139
+ writeFileSync(yamlPath, text, 'utf8');
140
+ // 体检 SEC-04:local.yaml 含明文 token,收紧为仅属主可读写。POSIX chmod 0600 生效;
141
+ // Windows 上 Node chmod 只映射只读位(近似 no-op),属可接受 best-effort
142
+ try { chmodSync(yamlPath, 0o600); } catch { /* chmod 失败不阻断连接流程 */ }
139
143
  }
140
144
 
141
145
  function parseSimpleYaml(content) {
@@ -195,11 +199,18 @@ function resolvePlatformUser(cwd, explicitUser) {
195
199
 
196
200
  // ── HTTP 辅助 ──
197
201
 
202
+ // HUB-09:单请求超时与外部 signal 合并——自动同步熔断(run/shared.js trigger* 的 race)
203
+ // 触发 abort 时在飞 fetch 被真实取消,而不是熔断后任由请求自行完成(平台可能已接受,
204
+ // 客户端却当作超时放弃;spec 树推送无 base_ts 自愈兜底)。AbortSignal.any 需 Node ≥20.3。
205
+ function combineSignals(external) {
206
+ const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
207
+ return external ? AbortSignal.any([timeoutSignal, external]) : timeoutSignal;
208
+ }
209
+
198
210
  async function fetchJson(url, options = {}) {
199
- const controller = new AbortController();
200
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
211
+ const signal = combineSignals(options.signal);
201
212
  try {
202
- const res = await fetch(url, { ...options, signal: controller.signal });
213
+ const res = await fetch(url, { ...options, signal });
203
214
  if (!res.ok) {
204
215
  const text = await res.text().catch(() => '');
205
216
  console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
@@ -212,13 +223,11 @@ async function fetchJson(url, options = {}) {
212
223
  return null;
213
224
  } catch (err) {
214
225
  if (err.name === 'AbortError') {
215
- console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
226
+ console.warn(`[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`);
216
227
  } else {
217
228
  console.warn(`[sync] ${url} 请求失败: ${err.message}`);
218
229
  }
219
230
  return null;
220
- } finally {
221
- clearTimeout(timer);
222
231
  }
223
232
  }
224
233
 
@@ -228,10 +237,9 @@ async function fetchJson(url, options = {}) {
228
237
  * 仅 sync() 的 progress POST 使用(识别 base_ts 乐观锁冲突,D-015 / task-09);其余调用仍走 fetchJson。
229
238
  */
230
239
  async function fetchJsonWithStatus(url, options = {}) {
231
- const controller = new AbortController();
232
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
240
+ const signal = combineSignals(options.signal);
233
241
  try {
234
- const res = await fetch(url, { ...options, signal: controller.signal });
242
+ const res = await fetch(url, { ...options, signal });
235
243
  const text = await res.text().catch(() => '');
236
244
  let body = null;
237
245
  if (text) {
@@ -243,13 +251,11 @@ async function fetchJsonWithStatus(url, options = {}) {
243
251
  return { ok: res.ok, status: res.status, body };
244
252
  } catch (err) {
245
253
  if (err.name === 'AbortError') {
246
- console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
254
+ console.warn(`[sync] ${url} 请求超时/中断 (${REQUEST_TIMEOUT_MS}ms 上限或外部熔断)`);
247
255
  } else {
248
256
  console.warn(`[sync] ${url} 请求失败: ${err.message}`);
249
257
  }
250
258
  return { ok: false, status: 0, body: null };
251
- } finally {
252
- clearTimeout(timer);
253
259
  }
254
260
  }
255
261
 
@@ -268,8 +274,19 @@ export class SyncManager {
268
274
  * @param {string} [user] - 推送者身份(可空,回退 git user.name / env,见 resolvePlatformUser)
269
275
  */
270
276
  async connect(url, token, user) {
277
+ if (!token) {
278
+ // 体检 HUB-02:falsy token 不允许 connect——health ping 无鉴权会"连接成功",
279
+ // 但 local.yaml 会落盘字符串 "undefined",后续所有同步持续 401
280
+ console.error('[sync] 缺少 token,拒绝连接(请在平台获取 token 后带 --token 重试)');
281
+ return false;
282
+ }
271
283
  // 验证连接
272
284
  const normalizedUrl = url.replace(/\/+$/, '');
285
+ // 体检 SEC-04:非 https 且非本机回环时 Bearer token 明文上线——MCP client 侧
286
+ // (sillyhub-mcp/client.js 构造器)早有同款 warn,sync 侧此前完全静默
287
+ if (!/^https:\/\//i.test(normalizedUrl) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(normalizedUrl)) {
288
+ console.warn(`[sync] platform.url 非 https(${normalizedUrl.slice(0, 60)}),同步 token 将明文传输——请确认为受控内网环境`);
289
+ }
273
290
  const healthUrl = `${normalizedUrl}/api/health`;
274
291
  const result = await fetchJson(healthUrl);
275
292
  if (result === null) {
@@ -351,7 +368,10 @@ export class SyncManager {
351
368
  }
352
369
  }
353
370
  }
354
- // 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)
371
+ // 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)。
372
+ // HUB-12 有意不清 .sillyspec-platform-cleaned marker:它防的是「重新接入平台时 init
373
+ // 重复做残留清理」——disconnect 后重连若清掉 marker,init 会对现在装着本地数据的
374
+ // .sillyspec 再跑一次平台残留清理(误删风险)。marker 跨 disconnect 存活是保护语义。
355
375
  let cleaned = [];
356
376
  for (const f of ['.sillyspec-platform.json', PLATFORM_MANAGED_FILENAME]) {
357
377
  const fp = join(this.cwd, f);
@@ -368,8 +388,15 @@ export class SyncManager {
368
388
  * 读取 ProgressManager.serializeForSync() 的六表 JSON,POST 到平台。
369
389
  * 元字段(user/base_ts/pushed_at)走 HTTP header,body 保持裸 JSON(D-015,sillyhub 老版零回归)。
370
390
  * 同步完成后更新 changes 表的 platform_last_sync 字段;409 冲突读回平台最新 JSON(task-12 完整冲突处理)。
391
+ *
392
+ * push 409 自竞态自愈(坑 2026-08-19-platform-sync-base-ts-silent-conflict 根治侧):
393
+ * 同机多进程(CLI + daemon)并发 push 时,B 进程持旧 base_ts POST 撞 A 进程刚推完的 409——
394
+ * 但 A 的成功回填就写在本机共享 DB。409 时 fresh 重读 DB:base_ts 已 ≥ 平台 409 回执 ts
395
+ * ⇒ 赢者是自己人 → 刷新 base_ts 重试一次即收敛,不再落冲突文件卡死人工 resolve。
396
+ * 外来推送(他机/他用户)不可能推进本机 DB 的 base_ts → 走原冲突路径,无误放行。
371
397
  */
372
- async sync(changeName) {
398
+ async sync(changeName, opts = {}) {
399
+ const { fromResolve = false } = opts;
373
400
  const platform = this._getPlatform();
374
401
  if (!platform) {
375
402
  debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
@@ -388,112 +415,178 @@ export class SyncManager {
388
415
  console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
389
416
  }
390
417
 
391
- // 读取 progress 数据(serializeForSync 六表裸 JSON,task-02 / D-005@v2,替代 read() 聚合视图)
392
- let progressData;
393
- try {
394
- const { ProgressManager } = await import('./progress.js');
395
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
396
- progressData = pm.serializeForSync(this.cwd, changeName);
397
- } catch (err) {
398
- console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
399
- return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
400
- }
401
- if (progressData === null) {
402
- console.warn(`[sync] 变更无进度数据 (无活跃进度): ${changeName}`);
403
- return { synced: 0, errors: [`变更无进度数据: ${changeName}`] };
404
- }
418
+ const MAX_PUSH_ATTEMPTS = 2;
419
+ for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt++) {
420
+ // 读取 progress 数据(serializeForSync 六表裸 JSON,task-02 / D-005@v2,替代 read() 聚合视图)。
421
+ // 每次 attempt 重新 serialize:自愈重试时本地 DB 可能已被并发进程推进,base_ts/内容都要刷新
422
+ let progressData;
423
+ try {
424
+ const { ProgressManager } = await import('./progress.js');
425
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
426
+ progressData = pm.serializeForSync(this.cwd, changeName);
427
+ } catch (err) {
428
+ console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
429
+ return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
430
+ }
431
+ if (progressData === null) {
432
+ console.warn(`[sync] 变更无进度数据 (无活跃进度): ${changeName}`);
433
+ return { synced: 0, errors: [`变更无进度数据: ${changeName}`] };
434
+ }
405
435
 
406
- // 元字段走 HTTP header(D-015 / task-09):body 保持裸六表 JSON,sillyhub 老版忽略 header 零回归
407
- const headers = {
408
- 'Content-Type': 'application/json',
409
- Authorization: `Bearer ${platform.token}`,
410
- };
411
- const pushedAt = new Date().toISOString();
412
- // 推送者身份(task-08):local.yaml platform.user 显式配置优先,缺省回退
413
- // resolvePlatformUser(git user.name > env USER/USERNAME,与 connect 写入侧同口径)。
414
- // 修 last_pusher 空:此前仅 platform.user 配置时发送,本地直跑场景 local.yaml
415
- // 常无 user 字段 → header 缺失 → 平台 last_pusher 恒空(2026-08-19 坑文档改进点 4)。
416
- const pushUser = platform.user || resolvePlatformUser(this.cwd) || null;
417
- if (pushUser) headers['X-SillySpec-User'] = pushUser;
418
- const baseTs = progressData.changes && progressData.changes[0] && progressData.changes[0].last_synced_platform_ts;
419
- if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
420
- headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
421
-
422
- // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)。changeName encode 对齐 pull(:673)
423
- const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
424
- const res = await fetchJsonWithStatus(syncUrl, {
425
- method: 'POST',
426
- headers,
427
- body: JSON.stringify(progressData),
428
- });
436
+ // 元字段走 HTTP header(D-015 / task-09):body 保持裸六表 JSON,sillyhub 老版忽略 header 零回归
437
+ const headers = {
438
+ 'Content-Type': 'application/json',
439
+ Authorization: `Bearer ${platform.token}`,
440
+ };
441
+ const pushedAt = new Date().toISOString();
442
+ // 推送者身份(task-08):local.yaml platform.user 显式配置优先,缺省回退
443
+ // resolvePlatformUser(git user.name > env USER/USERNAME,与 connect 写入侧同口径)。
444
+ // 修 last_pusher 空:此前仅 platform.user 配置时发送,本地直跑场景 local.yaml
445
+ // 常无 user 字段 → header 缺失 → 平台 last_pusher 恒空(2026-08-19 坑文档改进点 4)。
446
+ const pushUser = platform.user || resolvePlatformUser(this.cwd) || null;
447
+ if (pushUser) headers['X-SillySpec-User'] = pushUser;
448
+ const baseTs = progressData.changes && progressData.changes[0] && progressData.changes[0].last_synced_platform_ts;
449
+ if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
450
+ headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
451
+
452
+ // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)。changeName encode 对齐 pull(:673)
453
+ const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
454
+ const res = await fetchJsonWithStatus(syncUrl, {
455
+ method: 'POST',
456
+ headers,
457
+ body: JSON.stringify(progressData),
458
+ signal: opts.signal, // HUB-09:熔断 abort 传到底层请求
459
+ });
460
+
461
+ if (res.ok) {
462
+ // 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
463
+ // 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
464
+ // 修复前该列从不写,下次 push 永不带 X-SillySpec-Base-Ts、pull 脏度检测恒 false)
465
+ // 回填加固:共享 SQLite 并发(daemon+CLI)下 WAL 忙窗口可能偶发失败——重试一次,
466
+ // 仍失败打醒目 warn(base_ts 停留在旧值,下次 push 会 409,届时自竞态自愈会收敛)
467
+ let backfilled = false;
468
+ for (let bt = 1; bt <= 2 && !backfilled; bt++) {
469
+ try {
470
+ const { ProgressManager } = await import('./progress.js');
471
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
472
+ const ackTs = res.body && typeof res.body.last_pushed_at === 'string' ? res.body.last_pushed_at : pushedAt;
473
+ pm._updatePlatformLastSync(this.cwd, changeName, ackTs);
474
+ backfilled = true;
475
+ } catch (err) {
476
+ if (bt === 2) {
477
+ console.warn(`⚠️ [sync] base_ts 回填失败(下次 push 可能 409,自愈机制会处理): ${err.message}`);
478
+ } else {
479
+ await new Promise((r) => setTimeout(r, 200));
480
+ }
481
+ }
482
+ }
429
483
 
430
- if (!res.ok) {
431
- // 409 = base_ts 乐观锁冲突(平台已有更新);读回平台最新 JSON 供 task-12 完整冲突处理
432
- if (res.status === 409) {
433
- // 醒目冲突横幅(坑 2026-08-19-platform-sync-base-ts-silent-conflict 改进点 2):
434
- // 此前仅单行 warn,冲突文件静默落 .runtime——同步已卡死但 Agent/用户无从察觉,
435
- // 平台页面永久停在旧阶段。横幅明确告知「已卡死 + 卡死原因 + 恢复命令」,不能只落文件。
436
- console.warn('');
437
- console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
438
- console.warn(`⚠️ 平台同步冲突:变更「${changeName}」推送被拒(base_ts 过期,平台已有更新)`);
439
- console.warn('⚠️ 该变更的自动同步已卡死,不会自愈——需人工 resolve 才能恢复:');
440
- console.warn(`⚠️ 1. sillyspec platform status # 查看未决冲突`);
441
- console.warn(`⚠️ 2. sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
442
- console.warn(`⚠️ 3. sillyspec platform sync --change ${changeName}`);
443
- console.warn('⚠️ 冲突详情已落盘: .sillyspec/.runtime/sync-conflict-' + changeName + '.json');
444
- console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
445
- // 平台 409 响应 { conflict:true, platform_progress, last_pushed_at },platform_progress 即平台最新 progress JSON
446
- const platformProgress = res.body && res.body.platform_progress ? res.body.platform_progress : res.body;
447
- const platformLastPushedAt = (res.body && res.body.last_pushed_at) || null;
448
- // 本地脏度(progressData 是 serializeForSync 输出,changes[0] 含 last_local_modified_ts)
449
- const localModified = (progressData.changes && progressData.changes[0] && progressData.changes[0].last_local_modified_ts) || null;
450
- // 写冲突文件(task-12 / D-002):base_ts=本次 push 的 base,强制提示走 resolve
451
- const conflictPath = this._writeConflictFile(changeName, {
452
- base_ts: baseTs || null,
453
- local_modified_ts: localModified,
454
- platform_last_pushed_at: platformLastPushedAt,
455
- platform_progress: platformProgress,
456
- });
457
- return { synced: 0, errors: [`冲突: ${changeName}`], conflict: true, platform_progress: platformProgress, conflictPath };
484
+ console.log(`[sync] 已同步变更: ${changeName}`);
485
+
486
+ // 2026-08-16-auto-sync-from-repo:进度上行成功后顺带推四件套文档(best-effort)。
487
+ // 根因:run 流程此前只推进度不推文档(本文件头注释),本地直跑 sillyspec 的产出
488
+ // 文档永远不自动到平台(变更中心"进度到了文档没到")。生产者直连,不依赖 daemon
489
+ // 缓存链路。失败仅 debugLog——文档同步失败不得影响进度上行的返回值与流程;
490
+ // 四件套全缺失时 syncDocuments 内部已有跳过(syncedCount===0 提前返回,不调端点)。
491
+ try {
492
+ await this.syncDocuments(changeName, { signal: opts.signal });
493
+ } catch (err) {
494
+ debugLog(`[sync] 文档同步失败(不影响进度): ${changeName}: ${err.message}`);
495
+ }
496
+
497
+ // 2026-08-17-spec-file-incremental-sync:进度上行+文档直推成功后顺带推整个 spec 树
498
+ // (plan.md、tasks/、module-impact.md 等),让变更中心文件树自动更新。
499
+ // 无 daemon 时 CLI 短进程无本地清单缓存,以服务器清单为锚做增量 diff。
500
+ // 树根与进度锚点同源(safePlatformSpecDir,平台模式 → specRoot):此前硬编码
501
+ // cwd/.sillyspec,平台模式下该目录只有 local.yaml(被 walk 排除)→ 本地树恒空
502
+ // → 服务器清单全量 delete(BUG-01,computeSpecOps 护栏为第二道防线)。
503
+ try {
504
+ await syncSpecTree(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), this._getPlatform(), changeName, { signal: opts.signal });
505
+ } catch (err) {
506
+ debugLog(`[sync] spec 树增量同步失败(不影响进度): ${changeName}: ${err.message}`);
507
+ }
508
+
509
+ return { synced: 1, errors: [] };
458
510
  }
459
- return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
460
- }
461
511
 
462
- // 更新 platform_last_sync + 推进 base_ts(ql-20260818-008:值优先平台回执 last_pushed_at,
463
- // 缺省用本次 pushedAt——服务器 _apply 存的就是客户端 X-SillySpec-Pushed-At 原值,回写一致。
464
- // 修复前该列从不写,下次 push 永不带 X-SillySpec-Base-Ts、pull 脏度检测恒 false)
465
- try {
466
- const { ProgressManager } = await import('./progress.js');
467
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
468
- const ackTs = res.body && typeof res.body.last_pushed_at === 'string' ? res.body.last_pushed_at : pushedAt;
469
- pm._updatePlatformLastSync(this.cwd, changeName, ackTs);
470
- } catch (err) {
471
- console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
472
- }
512
+ if (res.status !== 409) {
513
+ return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
514
+ }
473
515
 
474
- console.log(`[sync] 已同步变更: ${changeName}`);
516
+ // ── 409 = base_ts 乐观锁冲突(平台已有更新)──
517
+ // 平台 409 响应 { conflict:true, platform_progress, last_pushed_at },platform_progress 即平台最新 progress JSON
518
+ const platformProgress = res.body && res.body.platform_progress ? res.body.platform_progress : res.body;
519
+ const platformLastPushedAt = (res.body && res.body.last_pushed_at) || null;
520
+
521
+ // 自竞态自愈:fresh 重读本机 DB base_ts,若已被并发进程回填到 ≥ 平台 409 回执 ts,
522
+ // 说明赢者是本机自己人——刷新 base_ts 重试一次即收敛,不落冲突文件(外来推送不可能
523
+ // 推进本机 DB 的 base_ts,不满足条件自然落回下方真冲突分支,零误放行)
524
+ if (attempt < MAX_PUSH_ATTEMPTS && await this._localBaseTsCovers(changeName, platformLastPushedAt)) {
525
+ console.warn(`⚠️ [sync] push 409 自竞态判定:本机并发推送已回填 base_ts(平台 ts=${platformLastPushedAt}),刷新后自动重试`);
526
+ continue;
527
+ }
475
528
 
476
- // 2026-08-16-auto-sync-from-repo:进度上行成功后顺带推四件套文档(best-effort)。
477
- // 根因:run 流程此前只推进度不推文档(本文件头注释),本地直跑 sillyspec 的产出
478
- // 文档永远不自动到平台(变更中心"进度到了文档没到")。生产者直连,不依赖 daemon
479
- // 缓存链路。失败仅 debugLog——文档同步失败不得影响进度上行的返回值与流程;
480
- // 四件套全缺失时 syncDocuments 内部已有跳过(syncedCount===0 提前返回,不调端点)。
481
- try {
482
- await this.syncDocuments(changeName);
483
- } catch (err) {
484
- debugLog(`[sync] 文档同步失败(不影响进度): ${changeName}: ${err.message}`);
529
+ // resolve --keep-local 的自动重推再撞 409:不落新冲突文件、不打「已卡死」横幅——
530
+ // 原 conflict 已按用户裁决处理完(base_ts 已推进),此刻的 409 只是「平台在用户裁决期间
531
+ // 又动了」;立即落新文件会破坏「keep-local 清冲突文件回 clean」的生命周期契约(冲突文件
532
+ // 代表待人工三选一的未决状态,而用户刚做完选择)。下次常规同步会按新 base 重新判定,
533
+ // 真有新分歧自然再进 conflict 态。
534
+ if (fromResolve) {
535
+ console.warn(`⚠️ [sync] keep-local 自动重推被拒(平台又有更新,ts=${platformLastPushedAt});base_ts 已推进,下次常规同步将重新判定`);
536
+ return { synced: 0, errors: [`keep-local 自动重推被拒: ${changeName}`], conflict: true, platform_progress: platformProgress, suppressedConflictFile: true };
537
+ }
538
+
539
+ // 醒目冲突横幅(坑 2026-08-19-platform-sync-base-ts-silent-conflict 改进点 2):
540
+ // 此前仅单行 warn,冲突文件静默落 .runtime——同步已卡死但 Agent/用户无从察觉,
541
+ // 平台页面永久停在旧阶段。横幅明确告知「已卡死 + 卡死原因 + 恢复命令」,不能只落文件。
542
+ console.warn('');
543
+ console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
544
+ console.warn(`⚠️ 平台同步冲突:变更「${changeName}」推送被拒(base_ts 过期,平台已有更新)`);
545
+ console.warn('⚠️ 该变更的自动同步已卡死,不会自愈——需人工 resolve 才能恢复:');
546
+ console.warn(`⚠️ 1. sillyspec platform status # 查看未决冲突`);
547
+ console.warn(`⚠️ 2. sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
548
+ console.warn(`⚠️ 3. sillyspec platform sync --change ${changeName}`);
549
+ console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
550
+ // 本地脏度(progressData 是 serializeForSync 输出,changes[0] 含 last_local_modified_ts)
551
+ const localModified = (progressData.changes && progressData.changes[0] && progressData.changes[0].last_local_modified_ts) || null;
552
+ // 写冲突文件(task-12 / D-002):base_ts=本次 push 的 base,强制提示走 resolve
553
+ const conflictPath = this._writeConflictFile(changeName, {
554
+ base_ts: baseTs || null,
555
+ local_modified_ts: localModified,
556
+ platform_last_pushed_at: platformLastPushedAt,
557
+ platform_progress: platformProgress,
558
+ });
559
+ console.warn(`⚠️ 冲突详情已落盘: ${conflictPath}`);
560
+ return { synced: 0, errors: [`冲突: ${changeName}`], conflict: true, platform_progress: platformProgress, conflictPath };
485
561
  }
486
562
 
487
- // 2026-08-17-spec-file-incremental-sync:进度上行+文档直推成功后顺带推整个 spec 树
488
- // (plan.md、tasks/、module-impact.md 等),让变更中心文件树自动更新。
489
- // 无 daemon 时 CLI 短进程无本地清单缓存,以服务器清单为锚做增量 diff。
563
+ // 循环耗尽(自愈重试后仍 409——两次都撞且第二次非自竞态;理论上第二次会落上方真冲突分支返回,
564
+ // 此行只作结构兜底)
565
+ return { synced: 0, errors: [`同步重试耗尽(仍冲突): ${changeName}`] };
566
+ }
567
+
568
+ /**
569
+ * 自竞态判定辅助:fresh 读本地 DB 的 last_synced_platform_ts(base_ts),判断它是否已覆盖
570
+ * 平台 409 回执的 last_pushed_at(字符串字典序比较,契约 §4.2——ISO 8601 字典序 == 时间序)。
571
+ * 已覆盖 ⇒ 打赢 409 的推送来自本机并发进程(其成功回填写的就是这个共享 DB)。
572
+ * @param {string} changeName
573
+ * @param {string|null} platformTs 409 回执 last_pushed_at
574
+ * @returns {Promise<boolean>}
575
+ */
576
+ async _localBaseTsCovers(changeName, platformTs) {
577
+ if (!platformTs) return false;
490
578
  try {
491
- await syncSpecTree(join(this.cwd, '.sillyspec'), this._getPlatform(), changeName);
492
- } catch (err) {
493
- debugLog(`[sync] spec 树增量同步失败(不影响进度): ${changeName}: ${err.message}`);
579
+ const { ProgressManager } = await import('./progress.js');
580
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
581
+ const row = pm._ensureDB(this.cwd).getDb().prepare(
582
+ 'SELECT last_synced_platform_ts FROM changes WHERE name = ?'
583
+ ).get(changeName);
584
+ const localTs = row && row.last_synced_platform_ts;
585
+ if (!localTs) return false;
586
+ return localTs >= platformTs;
587
+ } catch {
588
+ return false; // 读失败按非自竞态处理(fail-closed 到真冲突分支)
494
589
  }
495
-
496
- return { synced: 1, errors: [] };
497
590
  }
498
591
 
499
592
  /**
@@ -558,6 +651,7 @@ export class SyncManager {
558
651
  Authorization: `Bearer ${platform.token}`,
559
652
  },
560
653
  body: JSON.stringify(documents),
654
+ signal: opts.signal, // HUB-09:熔断 abort 传到底层请求
561
655
  });
562
656
 
563
657
  if (!result) {
@@ -677,6 +771,31 @@ export class SyncManager {
677
771
  }
678
772
  }
679
773
 
774
+ // ── HUB-08:spec 树冲突文件(spec-sync-conflict-<change>.json,由 spec-sync.js 写入)──
775
+
776
+ /** 读取 spec 树冲突文件;无/损坏返回 null */
777
+ readSpecConflictFile(changeName) {
778
+ const specDir = safePlatformSpecDir(this.cwd);
779
+ if (!specDir) return null;
780
+ const p = join(specDir, '.runtime', `spec-sync-conflict-${changeName}.json`);
781
+ if (!existsSync(p)) return null;
782
+ try {
783
+ return JSON.parse(readFileSync(p, 'utf8'));
784
+ } catch {
785
+ return null;
786
+ }
787
+ }
788
+
789
+ /** 删除 spec 树冲突文件 */
790
+ clearSpecConflictFile(changeName) {
791
+ const specDir = safePlatformSpecDir(this.cwd);
792
+ if (!specDir) return false;
793
+ const p = join(specDir, '.runtime', `spec-sync-conflict-${changeName}.json`);
794
+ if (!existsSync(p)) return false;
795
+ try { unlinkSync(p); return true; }
796
+ catch { return false; }
797
+ }
798
+
680
799
  /**
681
800
  * 删除冲突文件(resolve 完成后清理,task-13 用)。
682
801
  * @returns {boolean} 是否删除(文件不存在返回 false)
@@ -747,6 +866,7 @@ export class SyncManager {
747
866
  const progressUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
748
867
  const result = await fetchJson(progressUrl, {
749
868
  headers: { Authorization: `Bearer ${platform.token}` },
869
+ signal: opts.signal, // HUB-09:熔断 abort 传到底层请求
750
870
  });
751
871
  if (result === null) {
752
872
  // sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort 降级
@@ -806,7 +926,6 @@ export class SyncManager {
806
926
  console.warn(`⚠️ 1. sillyspec platform status # 查看未决冲突`);
807
927
  console.warn(`⚠️ 2. sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
808
928
  console.warn(`⚠️ 3. sillyspec platform sync --change ${changeName}`);
809
- console.warn('⚠️ 冲突详情已落盘: .sillyspec/.runtime/sync-conflict-' + changeName + '.json');
810
929
  console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
811
930
  // 写冲突文件(task-12 / D-002):强制提示,绝不 auto-merge
812
931
  const conflictPath = this._writeConflictFile(changeName, {
@@ -815,6 +934,7 @@ export class SyncManager {
815
934
  platform_last_pushed_at: platformPushedAt,
816
935
  platform_progress: platformProgress,
817
936
  });
937
+ console.warn(`⚠️ 冲突详情已落盘: ${conflictPath}`); // HUB-12d:真实落点(平台模式在 specRoot/.runtime),勿硬编码
818
938
  return { ok: false, imported: false, conflict: true, reason: `冲突: ${changeName} 本地脏且平台更新`, conflictPath };
819
939
  }
820
940
  // ql-20260818-008:自动注入点(triggerPull/triggerPullActiveChange)保守守卫——本地有未同步
@@ -858,12 +978,16 @@ export class SyncManager {
858
978
  */
859
979
  async resolve(changeName, mode) {
860
980
  const cf = this.readConflictFile(changeName);
861
- if (!cf) {
862
- return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict 文件)` };
981
+ const specCf = this.readSpecConflictFile(changeName);
982
+ if (!cf && !specCf) {
983
+ return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict / spec-sync-conflict 文件)` };
863
984
  }
864
- const platformPushedAt = cf.platform_last_pushed_at || null;
985
+ const platformPushedAt = (cf && cf.platform_last_pushed_at) || null;
865
986
 
866
- if (mode === 'keep-local') {
987
+ // HUB-08:resolve 同时管辖进度冲突(sync-conflict-*)与 spec 树冲突(spec-sync-conflict-*),
988
+ // 各自产出 outcome 后合并返回;abort 统一清两类标记。
989
+ let progressOutcome = null;
990
+ if (mode === 'keep-local' && cf) {
867
991
  // base_ts 推进到平台最新 last_pushed_at;本地 DB 不 import(用户本地为准,后续手动 push)。
868
992
  // MAX() 单调防回退(坑 2026-08-19-resolve-keep-local-base-ts-rollback):冲突文件是历史快照,
869
993
  // 其 platform_last_pushed_at 可能早于 DB 已由后续成功 push 回填的 base_ts——无条件覆盖会把
@@ -879,32 +1003,87 @@ export class SyncManager {
879
1003
  return { ok: false, resolved: false, reason: `keep-local 更新 base_ts 失败: ${err.message}` };
880
1004
  }
881
1005
  this.clearConflictFile(changeName);
882
- return { ok: true, resolved: true, mode: 'keep-local', reason: '保留本地,base_ts 已推进到平台最新(后续请 platform sync push 本地)' };
1006
+ // 自动重推闭环(坑 2026-08-19-platform-sync-base-ts-silent-conflict 收尾侧):keep-local 语义 =
1007
+ // 本地为准,必然收尾是把本地推上平台。此前停在「后续请手动 platform sync」——忘了推,
1008
+ // 期间他人再推 → 下次自动同步又 409 落冲突文件(「冲突再现」的根源之一)。resolve 成功即
1009
+ // 自动 push 一次闭环;真撞上外来更新时软提示(fromResolve 抑制新冲突文件——冲突文件代表
1010
+ // 待人工三选一的未决状态,用户刚做完选择,下次常规同步按新 base 重新判定)。
1011
+ let repush = null;
1012
+ try {
1013
+ repush = await this.sync(changeName, { fromResolve: true });
1014
+ } catch (err) {
1015
+ debugLog(`[sync] keep-local 自动重推异常(不阻断 resolve 结果): ${changeName}: ${err.message}`);
1016
+ }
1017
+ if (repush && repush.synced === 1) {
1018
+ progressOutcome = { ok: true, resolved: true, reason: '保留本地,并已自动推送平台——冲突闭环,无需再手动 sync' };
1019
+ } else if (repush && repush.conflict) {
1020
+ progressOutcome = { ok: true, resolved: true, reason: '保留本地,base_ts 已推进;自动重推被拒(平台在裁决期间又有更新),下次常规同步将重新判定' };
1021
+ } else {
1022
+ progressOutcome = { ok: true, resolved: true, reason: '保留本地,base_ts 已推进;自动重推未成功(未连接/网络),请手动 sillyspec platform sync --change ' + changeName };
1023
+ }
883
1024
  }
884
1025
 
885
- if (mode === 'take-platform') {
1026
+ if (mode === 'take-platform' && cf) {
886
1027
  // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)
887
1028
  if (!cf.platform_progress) {
888
- return { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
1029
+ progressOutcome = { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
889
1030
  }
890
1031
  try {
891
1032
  const { ProgressManager } = await import('./progress.js');
892
1033
  const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
893
1034
  pm.import(this.cwd, { ...cf.platform_progress, pushed_at: platformPushedAt || new Date().toISOString() }, changeName);
894
1035
  } catch (err) {
895
- return { ok: false, resolved: false, reason: `take-platform import 失败: ${err.message}` };
1036
+ progressOutcome = { ok: false, resolved: false, reason: `take-platform import 失败: ${err.message}` };
1037
+ }
1038
+ if (progressOutcome === null) {
1039
+ this.clearConflictFile(changeName);
1040
+ progressOutcome = { ok: true, resolved: true, reason: '已用平台进度覆盖本地' };
896
1041
  }
897
- this.clearConflictFile(changeName);
898
- return { ok: true, resolved: true, mode: 'take-platform', reason: '已用平台进度覆盖本地' };
899
1042
  }
900
1043
 
1044
+ // ── spec 树冲突处置(HUB-08)──
1045
+ let specOutcome = null;
1046
+ if (specCf && mode === 'keep-local') {
1047
+ // keep-local = 本地胜出:重新 GET 清单重定 base 后 POST 本地内容(last-writer-wins),
1048
+ // 成功清冲突文件闭环;仍冲突(平台又有更新)保留文件待下次裁决
1049
+ try {
1050
+ const r = await syncSpecTree(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), this._getPlatform(), changeName);
1051
+ if (!r.conflict) {
1052
+ this.clearSpecConflictFile(changeName);
1053
+ specOutcome = { ok: true, resolved: true, reason: 'spec 树已以本地为准重定基线重推,冲突闭环' };
1054
+ } else {
1055
+ specOutcome = { ok: true, resolved: false, reason: 'spec 树重推仍冲突(平台在裁决期间又有更新),冲突文件保留待下次裁决' };
1056
+ }
1057
+ } catch (err) {
1058
+ specOutcome = { ok: false, resolved: false, reason: `spec 树重推异常: ${err.message}` };
1059
+ }
1060
+ } else if (specCf && mode === 'take-platform') {
1061
+ // fail-closed:平台无文件下载端点,无法把服务器内容写回本地——明确报错指路,不清文件
1062
+ specOutcome = { ok: false, resolved: false, reason: 'spec 树冲突不支持 take-platform(平台无文件下载端点,无法把服务器内容写回本地):请手动对齐本地文件后跑 --keep-local' };
1063
+ }
1064
+
1065
+ // ── abort:两类标记一并清(本地 DB 与文件均不变,下次 push/pull 重新检测)──
901
1066
  if (mode === 'abort') {
902
- // 本地 DB 与 base_ts 均不变,仅清冲突文件(放弃本次同步,下次 push/pull 会重新检测)
903
- this.clearConflictFile(changeName);
1067
+ if (cf) this.clearConflictFile(changeName);
1068
+ if (specCf) this.clearSpecConflictFile(changeName);
904
1069
  return { ok: true, resolved: true, mode: 'abort', reason: '放弃本次同步,本地不变' };
905
1070
  }
906
1071
 
907
- return { ok: false, resolved: false, reason: `未知 resolve 模式: ${mode}(--keep-local / --take-platform / --abort)` };
1072
+ if (mode !== 'keep-local' && mode !== 'take-platform') {
1073
+ return { ok: false, resolved: false, reason: `未知 resolve 模式: ${mode}(--keep-local / --take-platform / --abort)` };
1074
+ }
1075
+
1076
+ // ── 合并两类冲突的处置结果 ──
1077
+ const outcomes = [progressOutcome, specOutcome].filter(Boolean);
1078
+ if (outcomes.length === 0) {
1079
+ return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}` };
1080
+ }
1081
+ return {
1082
+ ok: outcomes.every((o) => o.ok),
1083
+ resolved: outcomes.every((o) => o.resolved),
1084
+ mode,
1085
+ reason: outcomes.map((o) => o.reason).join(';'),
1086
+ };
908
1087
  }
909
1088
 
910
1089
  /**
@@ -921,15 +1100,22 @@ export class SyncManager {
921
1100
  try { files = readdirSync(runtimeDir); }
922
1101
  catch { return []; }
923
1102
  const conflicts = [];
1103
+ // HUB-08:同时扫描进度冲突(sync-conflict-*)与 spec 树冲突(spec-sync-conflict-*),
1104
+ // type 字段供 status 展示与 resolve 分流
924
1105
  for (const f of files) {
925
- if (!f.startsWith('sync-conflict-') || !f.endsWith('.json')) continue;
1106
+ let prefix = null;
1107
+ if (f.startsWith('sync-conflict-') && f.endsWith('.json')) prefix = 'sync-conflict-';
1108
+ else if (f.startsWith('spec-sync-conflict-') && f.endsWith('.json')) prefix = 'spec-sync-conflict-';
1109
+ else continue;
1110
+ const type = prefix === 'sync-conflict-' ? 'progress' : 'spec-tree';
926
1111
  const filePath = join(runtimeDir, f);
927
1112
  try {
928
1113
  const cf = JSON.parse(readFileSync(filePath, 'utf8'));
929
1114
  conflicts.push({
930
- change: cf.change || f.replace(/^sync-conflict-/, '').replace(/\.json$/, ''),
1115
+ change: cf.change || f.replace(/^spec-sync-conflict-|^sync-conflict-/, '').replace(/\.json$/, ''),
931
1116
  created_at: cf.created_at || null,
932
1117
  path: filePath,
1118
+ type,
933
1119
  });
934
1120
  } catch {
935
1121
  // 损坏文件跳过不崩(constraints:容错)
@@ -984,21 +1170,10 @@ export class SyncManager {
984
1170
  // ── CLI 入口函数 ──
985
1171
 
986
1172
  /**
987
- * syncModule — sillyspec platform 子命令入口
988
- *
989
- * 用法:
990
- * sillyspec platform connect <url> <token>
991
- * sillyspec platform disconnect
992
- * sillyspec platform sync [changeName]
993
- * sillyspec platform sync-docs [changeName]
994
- * sillyspec platform approval <changeName>
995
- * sillyspec platform status
996
- *
997
- * @param {string[]} args — 子命令及参数
998
- * @param {string} cwd — 工作目录
999
- */
1000
- /**
1001
- * 便捷函数导出 — 供 index.js 和 run.js 直接调用
1173
+ * 便捷函数导出 — 供 index.js 直接调用(platform 子命令分发生在 index.js case 'platform')。
1174
+ * HUB-10:原此处另有内嵌 syncModule(args) CLI 分发死代码(全仓零调用、参数形态/子命令面与
1175
+ * index.js 实际分叉——位置参数 token、缺 pull/resolve/pointer、多余 approval/check-approval),
1176
+ * 误导维护者改错地方,已删除。
1002
1177
  */
1003
1178
  export async function connect(url, token, cwd) {
1004
1179
  return new SyncManager(cwd).connect(url, token);
@@ -1008,8 +1183,8 @@ export async function disconnect(cwd) {
1008
1183
  return new SyncManager(cwd).disconnect();
1009
1184
  }
1010
1185
 
1011
- export async function sync(changeName, cwd) {
1012
- return new SyncManager(cwd).sync(changeName);
1186
+ export async function sync(changeName, cwd, opts) {
1187
+ return new SyncManager(cwd).sync(changeName, opts);
1013
1188
  }
1014
1189
 
1015
1190
  // manual=true:本导出对应 CLI platform sync-docs 手动命令(index.js platform 分支唯一调用方),
@@ -1054,11 +1229,12 @@ export function listConflictFiles(cwd) {
1054
1229
  // 清单为锚做全树 diff,与变更目录无关——triggerSync 对 quick 会话降级只调本函数,
1055
1230
  // 不走 sync()(后者第二道 existsSync 门会以「变更不存在」提前 return)。
1056
1231
  // 未连接平台 → {synced: 0} 静默(本地合法状态,与 syncSpecTree 内部口径一致)。
1057
- export async function syncSpecTreeOnly(changeName, cwd) {
1232
+ export async function syncSpecTreeOnly(changeName, cwd, opts = {}) {
1058
1233
  const sm = new SyncManager(cwd);
1059
1234
  const platform = sm._getPlatform();
1060
1235
  if (!platform) return { synced: 0 };
1061
- return syncSpecTree(join(cwd, '.sillyspec'), platform, changeName);
1236
+ // 树根与 sync() 内链式推送同源(BUG-01:平台模式必须锚 specRoot,防本地空树触发全量 delete)
1237
+ return syncSpecTree(safePlatformSpecDir(cwd) || join(cwd, '.sillyspec'), platform, changeName, { signal: opts.signal });
1062
1238
  }
1063
1239
 
1064
1240
  // TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
@@ -1147,77 +1323,3 @@ export async function status(cwd) {
1147
1323
  console.log(`上次连接: ${st.lastSync || '未知'}`);
1148
1324
  }
1149
1325
  }
1150
-
1151
- /**
1152
- * syncModule — sillyspec platform 子命令入口
1153
- */
1154
- export async function syncModule(args, cwd) {
1155
- const sm = new SyncManager(cwd);
1156
-
1157
- const sub = args[0];
1158
-
1159
- switch (sub) {
1160
- case 'connect': {
1161
- const url = args[1];
1162
- const token = args[2];
1163
- if (!url || !token) {
1164
- console.error('用法: sillyspec platform connect <url> <token>');
1165
- process.exit(1);
1166
- }
1167
- await sm.connect(url, token);
1168
- break;
1169
- }
1170
-
1171
- case 'disconnect':
1172
- sm.disconnect();
1173
- break;
1174
-
1175
- case 'sync': {
1176
- const changeName = args[1];
1177
- const result = await sm.sync(changeName);
1178
- if (result.errors.length > 0) {
1179
- console.log(`同步完成,${result.errors.length} 个错误`);
1180
- }
1181
- break;
1182
- }
1183
-
1184
- case 'sync-docs':
1185
- case 'sync-documents': {
1186
- const changeName = args[1];
1187
- // manual=true:CLI 手动命令路径,四件套缺失打 warn(ql-20260818-008 措辞分级)
1188
- const result = await sm.syncDocuments(changeName, { manual: true });
1189
- if (result.errors.length > 0) {
1190
- console.log(`文档同步完成,${result.errors.length} 个错误`);
1191
- }
1192
- break;
1193
- }
1194
-
1195
- case 'approval':
1196
- case 'check-approval': {
1197
- const changeName = args[1];
1198
- if (!changeName) {
1199
- console.error('用法: sillyspec platform approval <changeName>');
1200
- process.exit(1);
1201
- }
1202
- const approval = await sm.checkApproval(changeName);
1203
- console.log(`审批状态: ${approval.status}${approval.reason ? ` (${approval.reason})` : ''}`);
1204
- break;
1205
- }
1206
-
1207
- case 'status': {
1208
- const st = sm.status();
1209
- if (!st.connected) {
1210
- console.log('平台: 未连接');
1211
- } else {
1212
- console.log(`平台: ${st.url}`);
1213
- console.log(`上次连接: ${st.lastSync || '未知'}`);
1214
- }
1215
- break;
1216
- }
1217
-
1218
- default:
1219
- console.error(`未知子命令: ${sub || '(无)'}`);
1220
- console.error('可用命令: connect, disconnect, sync, sync-docs, approval, status');
1221
- process.exit(1);
1222
- }
1223
- }