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.
- package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
- package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
- package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
- package/README.md +7 -5
- package/SKILL.md +5 -4
- package/package.json +1 -1
- package/packages/dashboard/server/executor.js +10 -1
- package/packages/dashboard/server/index.js +108 -46
- package/src/config-schema.js +3 -0
- package/src/contract-matrix.js +22 -13
- package/src/db.js +24 -4
- package/src/dispatch/probe.js +99 -63
- package/src/docs-check.js +58 -13
- package/src/docs-debt.js +6 -2
- package/src/docs-gate.js +1 -0
- package/src/doctor-diagnostics.js +208 -21
- package/src/fs-atomic.js +3 -1
- package/src/git-helper.js +7 -4
- package/src/hooks/worktree-guard.js +8 -2
- package/src/index.js +161 -12
- package/src/init.js +3 -2
- package/src/local-detect.js +9 -1
- package/src/modules.js +7 -5
- package/src/progress/stage-machine.js +589 -578
- package/src/progress/step-store.js +1 -1
- package/src/progress.js +184 -30
- package/src/quicklog.js +47 -16
- package/src/run/command.js +31 -8
- package/src/run/complete-handlers.js +21 -14
- package/src/run/complete.js +1181 -1121
- package/src/run/gates.js +148 -25
- package/src/run/prompt.js +9 -5
- package/src/run/shared.js +138 -58
- package/src/run/stage.js +41 -1
- package/src/setup.js +8 -10
- package/src/sillyhub-mcp/client.js +74 -6
- package/src/spec-dir-typo.js +3 -24
- package/src/spec-sync.js +43 -8
- package/src/stage-contract.js +12 -38
- package/src/stages/archive.js +4 -3
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +2 -1
- package/src/stages/doctor.js +28 -13
- package/src/stages/execute.js +164 -153
- package/src/stages/plan-postcheck.js +49 -28
- package/src/stages/plan.js +57 -40
- package/src/stages/quick.js +3 -1
- package/src/stages/verify.js +1 -1
- package/src/sync.js +323 -221
- package/src/task-review.js +246 -61
- package/src/taskcard.js +178 -0
- package/src/verify-postcheck.js +48 -35
- package/src/worktree-apply.js +58 -34
- package/src/worktree-deps.js +120 -20
- package/src/worktree.js +55 -9
- 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
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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}
|
|
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
|
-
|
|
392
|
-
let
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
//
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
//
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
-
//
|
|
488
|
-
//
|
|
489
|
-
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
862
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
903
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
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
|
-
|
|
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
|
-
}
|