sillyspec 3.26.11 → 3.26.14

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