sillyspec 3.26.9 → 3.26.11

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 (98) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +85 -85
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-commit/SKILL.md +109 -109
  4. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  5. package/.claude/skills/sillyspec-doctor/SKILL.md +76 -76
  6. package/.claude/skills/sillyspec-execute/SKILL.md +200 -200
  7. package/.claude/skills/sillyspec-explore/SKILL.md +95 -95
  8. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  9. package/.claude/skills/sillyspec-plan/SKILL.md +148 -148
  10. package/.claude/skills/sillyspec-quick/SKILL.md +111 -111
  11. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  12. package/.claude/skills/sillyspec-scan/SKILL.md +79 -79
  13. package/.claude/skills/sillyspec-state/SKILL.md +64 -64
  14. package/.claude/skills/sillyspec-status/SKILL.md +54 -54
  15. package/.claude/skills/sillyspec-verify/SKILL.md +83 -81
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/.npmrc-publish +2 -0
  20. package/README.md +201 -201
  21. package/SKILL.md +90 -90
  22. package/package.json +39 -39
  23. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  24. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  25. package/packages/dashboard/dist/index.html +16 -16
  26. package/packages/dashboard/package.json +25 -25
  27. package/packages/dashboard/server/executor.js +86 -86
  28. package/packages/dashboard/server/index.js +601 -601
  29. package/packages/dashboard/server/parser.js +526 -526
  30. package/src/change-risk-profile.js +254 -254
  31. package/src/classify-change.js +109 -109
  32. package/src/config-schema.js +310 -310
  33. package/src/contract-matrix.js +379 -379
  34. package/src/db.js +350 -350
  35. package/src/dispatch/backends/local-agent.js +155 -155
  36. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  37. package/src/dispatch/probe.js +210 -210
  38. package/src/dispatch/strategy.js +121 -121
  39. package/src/docs-check.js +131 -4
  40. package/src/doctor-diagnostics.js +711 -711
  41. package/src/endpoint-extractor.js +401 -401
  42. package/src/fs-atomic.js +69 -69
  43. package/src/git-helper.js +79 -79
  44. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  45. package/src/hooks/worktree-guard.js +848 -848
  46. package/src/index.js +90 -8
  47. package/src/init.js +623 -623
  48. package/src/knowledge-match.js +130 -130
  49. package/src/local-detect.js +120 -120
  50. package/src/machine-interface.js +543 -543
  51. package/src/migrate.js +127 -127
  52. package/src/progress/change-registry.js +297 -260
  53. package/src/progress/consistency-doctor.js +355 -355
  54. package/src/progress/shared.js +36 -36
  55. package/src/progress/stage-machine.js +29 -1
  56. package/src/progress/step-store.js +261 -261
  57. package/src/progress.js +1131 -1127
  58. package/src/quicklog.js +693 -675
  59. package/src/review-tier.js +77 -77
  60. package/src/run/command.js +1385 -1364
  61. package/src/run/complete-handlers.js +198 -9
  62. package/src/run/complete.js +225 -24
  63. package/src/run/concurrent-detect.js +22 -0
  64. package/src/run/gates.js +755 -755
  65. package/src/run/prompt.js +761 -721
  66. package/src/run/quick-audit.js +155 -146
  67. package/src/run/shared.js +1268 -1197
  68. package/src/run/stage.js +487 -479
  69. package/src/scan-postcheck.js +371 -371
  70. package/src/setup.js +416 -416
  71. package/src/sillyhub-mcp/client.js +529 -529
  72. package/src/spec-sync.js +323 -0
  73. package/src/stage-contract.js +897 -897
  74. package/src/stage-review.js +549 -549
  75. package/src/stages/archive.js +155 -154
  76. package/src/stages/brainstorm-auto.js +236 -236
  77. package/src/stages/brainstorm.js +596 -597
  78. package/src/stages/cmd-existence.js +145 -145
  79. package/src/stages/doctor.js +371 -371
  80. package/src/stages/execute.js +1003 -991
  81. package/src/stages/explore.js +34 -34
  82. package/src/stages/index.js +29 -29
  83. package/src/stages/knowledge.js +498 -498
  84. package/src/stages/plan-postcheck.js +1302 -1281
  85. package/src/stages/plan.js +625 -604
  86. package/src/stages/quick.js +151 -143
  87. package/src/stages/scan.js +606 -605
  88. package/src/stages/status.js +64 -64
  89. package/src/stages/verify.js +264 -261
  90. package/src/sync.js +1223 -1125
  91. package/src/task-review.js +1019 -1019
  92. package/src/verify-postcheck.js +1186 -1186
  93. package/src/workflow.js +600 -600
  94. package/src/worktree-apply.js +1213 -1136
  95. package/src/worktree-deps.js +353 -353
  96. package/src/worktree.js +1481 -1437
  97. package/templates/workflows/archive-impact.yaml +79 -79
  98. package/templates/workflows/scan-docs.yaml +132 -132
package/src/sync.js CHANGED
@@ -1,1125 +1,1223 @@
1
- /**
2
- * SillySpec SyncManager — SillyHub 平台同步模块
3
- *
4
- * 独立于 ProgressManager,由 run.js 和 index.js 调用。
5
- * Best effort:所有网络失败 console.warn,不抛错,不阻塞主流程。
6
- *
7
- * 配置来源:.sillyspec/local.yaml 中的 platform 段
8
- * HTTP 请求:Node.js 原生 fetch(Node 22+)
9
- */
10
-
11
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
12
- import { join } from 'path';
13
- import { resolvePlatformSpecDir } from './progress.js';
14
- import { safeGit } from './git-helper.js';
15
- import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
16
-
17
- // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
18
- function safePlatformSpecDir(cwd) {
19
- try {
20
- return resolvePlatformSpecDir(cwd);
21
- } catch (e) {
22
- console.warn(`[sync] 平台指针不可用,跳过平台同步:${String(e.message).split('\n')[0]}`);
23
- return undefined;
24
- }
25
- }
26
-
27
- const LOCAL_YAML = '.sillyspec/local.yaml';
28
- const CHANGES_DIR = '.sillyspec/changes';
29
- const REQUEST_TIMEOUT_MS = 10_000;
30
-
31
- // 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
32
- // 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
33
- // 命令触发(index.js sync-docs 分支),run 流程不自动推文档。未连接时默认静默跳过——不每步
34
- // 催连平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
35
- function debugLog(msg) {
36
- if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg)
37
- }
38
-
39
- /** 四件套文档文件名 */
40
- const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'];
41
-
42
- // ── YAML 辅助 ──
43
-
44
- /**
45
- * 简易 YAML 读取(解析为扁平 obj),供 _getPlatform / status 只读用。
46
- * 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
47
- *
48
- * 写入侧(connect/disconnect)**不再**走 parse→modify→write 有损往返——
49
- * readLocalYaml/parseSimpleYaml 丢注释(:85 跳 # 行)、丢数组/深嵌套(只认一层 key:value),
50
- * round-trip 会清空用户手填的注释与其他段。改为 readLocalYamlRaw + replaceTopLevelSection
51
- * 文本级定向替换 platform 段,原文件注释/空行/其他段/数组/深嵌套/CRLF 字节级保留。
52
- */
53
- function readLocalYaml(cwd) {
54
- const p = join(cwd, LOCAL_YAML);
55
- if (!existsSync(p)) return {};
56
- try {
57
- return parseSimpleYaml(readFileSync(p, 'utf8'));
58
- } catch {
59
- return {};
60
- }
61
- }
62
-
63
- /** 读 local.yaml 原始文本(保留注释/换行/结构),不存在返回 ''。供 connect/disconnect 文本级改写。 */
64
- function readLocalYamlRaw(cwd) {
65
- const p = join(cwd, LOCAL_YAML);
66
- if (!existsSync(p)) return '';
67
- try {
68
- return readFileSync(p, 'utf8');
69
- } catch {
70
- return '';
71
- }
72
- }
73
-
74
- /**
75
- * 定位顶层 YAML 段(如 platform:/mcp:)的行范围 [start, end)。
76
- * 段 = key 行(行首非空白 + 以 'name:' 开头)+ 后续连续缩进行(以空格/tab 开头)。
77
- * 遇空行/注释/下一个顶层 key 即段结束——这些行不属于本段,保留不动。
78
- * split('\n')/join('\n') 操作:CRLF 下 '\r' 留在行尾,重组原样还原(Windows 兼容,CLAUDE.md #13)。
79
- * @param {string} text
80
- * @param {string} name - 段名(不含冒号,如 'platform')
81
- * @returns {{start: number, end: number} | null} 不存在返回 null
82
- */
83
- function findTopLevelSectionRange(text, name) {
84
- const lines = text.split('\n');
85
- const prefix = `${name}:`;
86
- let start = -1;
87
- for (let i = 0; i < lines.length; i++) {
88
- const line = lines[i];
89
- // 顶层 key 行:行首非空白(排除缩进子段)+ 以 'name:' 开头
90
- if (/^\S/.test(line) && line.startsWith(prefix)) {
91
- start = i;
92
- break;
93
- }
94
- }
95
- if (start === -1) return null;
96
- let end = start + 1;
97
- while (end < lines.length && /^[ \t]/.test(lines[end])) {
98
- end++;
99
- }
100
- return { start, end };
101
- }
102
-
103
- /**
104
- * 文本级定向替换/删除/追加一个顶层 YAML 段,保留文件其余所有字节
105
- * (注释/空行/其他段/数组/深嵌套/CRLF 全保留)。
106
- * @param {string} text - 原始文件文本
107
- * @param {string} name - 段名(不含冒号)
108
- * @param {string|null} body - 段体(不含 key 行);null=删除该段;string=替换或追加
109
- * @returns {string} 新文本
110
- */
111
- function replaceTopLevelSection(text, name, body) {
112
- const lines = text.split('\n');
113
- const range = findTopLevelSectionRange(text, name);
114
- if (range) {
115
- const before = lines.slice(0, range.start);
116
- const after = lines.slice(range.end);
117
- if (body === null) {
118
- return [...before, ...after].join('\n');
119
- }
120
- const sectionLines = [`${name}:`, ...body.split('\n')];
121
- return [...before, ...sectionLines, ...after].join('\n');
122
- }
123
- // 段不存在
124
- if (body === null) return text; // 删不存在的段,原样返回
125
- // 追加:去尾换行后加空行分隔 + 新段;空文件直接起段
126
- const stripped = text.replace(/(\r?\n)+$/, '');
127
- if (stripped === '') {
128
- return `${name}:\n${body}\n`;
129
- }
130
- return `${stripped}\n\n${name}:\n${body}\n`;
131
- }
132
-
133
- /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。 */
134
- function writeLocalYamlRaw(cwd, text) {
135
- const dir = join(cwd, '.sillyspec');
136
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
137
- writeFileSync(join(cwd, LOCAL_YAML), text, 'utf8');
138
- }
139
-
140
- function parseSimpleYaml(content) {
141
- // 剥 YAML 双/单引号包裹(connect 写侧 yamlStr 加引号防 # : 注入,读侧对称剥;无引号值零影响)
142
- const unquote = (v) => {
143
- if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
144
- return v.slice(1, -1);
145
- }
146
- return v;
147
- };
148
- const result = {};
149
- let currentSection = null;
150
- for (const line of content.split('\n')) {
151
- const trimmed = line.trim();
152
- if (!trimmed || trimmed.startsWith('#')) continue;
153
- // 用原始 line(非 trimmed)判断缩进:trimmed 已去前导空格,startsWith(' ') 恒 false
154
- // 会导致缩进子段(如 platform 下 url/token)被误判为 root 行、section 恒为空 {}
155
- if (!line.startsWith(' ')) {
156
- const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
157
- if (m) {
158
- const key = m[1];
159
- const val = unquote(m[2].trim());
160
- if (val) {
161
- result[key] = val;
162
- currentSection = null;
163
- } else {
164
- result[key] = {};
165
- currentSection = key;
166
- }
167
- }
168
- } else if (currentSection) {
169
- const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
170
- if (m && result[currentSection] && typeof result[currentSection] === 'object') {
171
- result[currentSection][m[1]] = unquote(m[2].trim());
172
- }
173
- }
174
- }
175
- return result;
176
- }
177
-
178
- /**
179
- * 解析推送者身份(user):区分多用户,供 push 时 X-SillySpec-User 标识(design D-004)。
180
- * 优先级:显式参数 > git user.name(与 quicklog / prompt <git-user> 同口径)> process.env.USER
181
- * (Unix)/ USERNAME(Windows)。Best effort:全失败返回 null,调用方据此决定是否写 user 字段
182
- * (缺字段等价于「未知推送者」,平台侧自行兜底,不报错)。
183
- */
184
- function resolvePlatformUser(cwd, explicitUser) {
185
- if (typeof explicitUser === 'string' && explicitUser.trim()) {
186
- return explicitUser.trim();
187
- }
188
- // 回退 1:git user.name(safeGit 失败返回 null,不抛)
189
- const gitUser = safeGit(cwd, ['config', 'user.name']).value;
190
- if (gitUser) return gitUser;
191
- // 回退 2:环境变量(USER=Unix / USERNAME=Windows;跨平台兼容 CLAUDE.md #13)
192
- return process.env.USER || process.env.USERNAME || null;
193
- }
194
-
195
- // ── HTTP 辅助 ──
196
-
197
- async function fetchJson(url, options = {}) {
198
- const controller = new AbortController();
199
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
200
- try {
201
- const res = await fetch(url, { ...options, signal: controller.signal });
202
- if (!res.ok) {
203
- const text = await res.text().catch(() => '');
204
- console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
205
- return null;
206
- }
207
- const ct = res.headers.get('content-type') || '';
208
- if (ct.includes('application/json')) {
209
- return res.json();
210
- }
211
- return null;
212
- } catch (err) {
213
- if (err.name === 'AbortError') {
214
- console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
215
- } else {
216
- console.warn(`[sync] ${url} 请求失败: ${err.message}`);
217
- }
218
- return null;
219
- } finally {
220
- clearTimeout(timer);
221
- }
222
- }
223
-
224
- /**
225
- * 带状态码的 fetch:不吞非 2xx(fetchJson 在 !res.ok 时返回 null 丢状态,无法区分 409 冲突)。
226
- * 返回 { ok, status, body }:body 尽力 JSON.parse(平台冲突响应带 progress JSON,读回给调用方)。
227
- * 仅 sync() 的 progress POST 使用(识别 base_ts 乐观锁冲突,D-015 / task-09);其余调用仍走 fetchJson。
228
- */
229
- async function fetchJsonWithStatus(url, options = {}) {
230
- const controller = new AbortController();
231
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
232
- try {
233
- const res = await fetch(url, { ...options, signal: controller.signal });
234
- const text = await res.text().catch(() => '');
235
- let body = null;
236
- if (text) {
237
- try { body = JSON.parse(text); } catch { body = null; }
238
- }
239
- if (!res.ok) {
240
- console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
241
- }
242
- return { ok: res.ok, status: res.status, body };
243
- } catch (err) {
244
- if (err.name === 'AbortError') {
245
- console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
246
- } else {
247
- console.warn(`[sync] ${url} 请求失败: ${err.message}`);
248
- }
249
- return { ok: false, status: 0, body: null };
250
- } finally {
251
- clearTimeout(timer);
252
- }
253
- }
254
-
255
- // ── SyncManager ──
256
-
257
- export class SyncManager {
258
- constructor(cwd) {
259
- this.cwd = cwd;
260
- }
261
-
262
- /**
263
- * 连接 SillyHub 平台。
264
- * 保存配置到 .sillyspec/local.yaml,发送 ping 验证连接。
265
- * @param {string} url - 平台地址
266
- * @param {string} token - 认证 token
267
- * @param {string} [user] - 推送者身份(可空,回退 git user.name / env,见 resolvePlatformUser)
268
- */
269
- async connect(url, token, user) {
270
- // 验证连接
271
- const normalizedUrl = url.replace(/\/+$/, '');
272
- const healthUrl = `${normalizedUrl}/api/health`;
273
- const result = await fetchJson(healthUrl);
274
- if (result === null) {
275
- console.warn(`[sync] 平台连接验证失败: ${url}`);
276
- return;
277
- }
278
- console.log(`[sync] 平台连接成功: ${url}`);
279
-
280
- // 换发 workspace-scoped 同步 token(task-09 / D-005@v1):用传入 user 级 token
281
- // (shk_live_ / JWT)作 Bearer + 本地 root_path(connect 的 cwd,与平台
282
- // Workspace.root_path 绑定值等值匹配)调 resolve-by-root-path。成功取 shpsync_
283
- // 覆盖 token 写入 platform 段;404(root_path 未绑)/403(无 WORKSPACE_WRITE)/断网
284
- // 均降级沿用原 token 继续(best-effort 不阻断 connect)。
285
- let effectiveToken = token;
286
- const resolveUrl = `${normalizedUrl}/api/workspaces/resolve-by-root-path`;
287
- const resolved = await fetchJson(resolveUrl, {
288
- method: 'POST',
289
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
290
- body: JSON.stringify({ root_path: this.cwd }),
291
- });
292
- if (resolved && resolved.token && resolved.token.startsWith('shpsync_')) {
293
- effectiveToken = resolved.token;
294
- console.log(
295
- `[sync] 已换发 workspace-scoped 同步 token (workspace: ${resolved.workspace_id})`
296
- );
297
- } else {
298
- console.warn(
299
- '[sync] 换发 workspace-scoped token 失败(404/403/断网),沿用原 token 继续'
300
- );
301
- }
302
-
303
- // 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
304
- const resolvedUser = resolvePlatformUser(this.cwd, user);
305
-
306
- // 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
307
- // (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
308
- // YAML 值加引号:token/url 含 # : 或首尾空白时裸写会破坏段结构或注入额外键(shpsync_ token
309
- // 当前不含这些字符,防御性包裹零成本;parseSimpleYaml 读侧剥引号兼容)
310
- const yamlStr = (v) => JSON.stringify(String(v));
311
- const platformEntries = [
312
- ` url: ${yamlStr(normalizedUrl)}`,
313
- ` token: ${yamlStr(effectiveToken)}`,
314
- ` last_connected: ${yamlStr(new Date().toISOString())}`,
315
- ];
316
- if (resolvedUser) {
317
- platformEntries.push(` user: ${yamlStr(resolvedUser)}`);
318
- }
319
- let text = readLocalYamlRaw(this.cwd);
320
- text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
321
- // mcp 段同源假设(design §7.4):url 复用 platform 的 url;token 用原始 user 级 token
322
- // (非换发的 effectiveToken——MCP 派发需 user 级权限,platform 段才是最小权限的 shpsync_)。
323
- // 用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
324
- if (findTopLevelSectionRange(text, 'mcp') === null) {
325
- text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(normalizedUrl)}\n token: ${yamlStr(token)}`);
326
- }
327
- writeLocalYamlRaw(this.cwd, text);
328
- }
329
-
330
- /**
331
- * 断开平台连接(三清,D-C@v2)。
332
- * 1. 从 local.yaml 删除 platform 配置段;
333
- * 2. 删恢复指针 <cwd>/.sillyspec-platform.json;
334
- * 3. 删平台接管声明 <cwd>/.sillyspec-platform-managed。
335
- * 不删后两者则"disconnect 后恢复本地模式"不可达(指针健在时 resolvePlatformSpecDir
336
- * 仍解析平台 specRoot;声明健在时指针缺失会 fail-closed)。
337
- */
338
- disconnect() {
339
- const p = join(this.cwd, LOCAL_YAML);
340
- if (existsSync(p)) {
341
- const text = readFileSync(p, 'utf8');
342
- if (findTopLevelSectionRange(text, 'platform') !== null) {
343
- // 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
344
- const newText = replaceTopLevelSection(text, 'platform', null);
345
- if (newText.trim() === '') {
346
- // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
347
- try { unlinkSync(p); } catch { /* best effort */ }
348
- } else {
349
- writeFileSync(p, newText, 'utf8');
350
- }
351
- }
352
- }
353
- // 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)
354
- let cleaned = [];
355
- for (const f of ['.sillyspec-platform.json', PLATFORM_MANAGED_FILENAME]) {
356
- const fp = join(this.cwd, f);
357
- if (existsSync(fp)) {
358
- try { unlinkSync(fp); cleaned.push(f); } catch { /* best effort */ }
359
- }
360
- }
361
- if (cleaned.length > 0) console.log(`[sync] 已清理平台指针与接管声明: ${cleaned.join(', ')}`);
362
- console.log('[sync] 已断开连接');
363
- }
364
-
365
- /**
366
- * 增量同步变更的 progress 状态到平台。
367
- * 读取 ProgressManager.serializeForSync() 的六表 JSON,POST 到平台。
368
- * 元字段(user/base_ts/pushed_at)走 HTTP header,body 保持裸 JSON(D-015,sillyhub 老版零回归)。
369
- * 同步完成后更新 changes 表的 platform_last_sync 字段;409 冲突读回平台最新 JSON(task-12 完整冲突处理)。
370
- */
371
- async sync(changeName) {
372
- const platform = this._getPlatform();
373
- if (!platform) {
374
- debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
375
- return { synced: 0, errors: ['未连接平台'] };
376
- }
377
-
378
- if (!changeName) {
379
- console.warn('[sync] sync 需要指定变更名称 (changeName)');
380
- return { synced: 0, errors: ['未指定变更名称'] };
381
- }
382
-
383
- // 检查变更是否存在
384
- const changeDir = join(this.cwd, CHANGES_DIR, changeName);
385
- if (!existsSync(changeDir)) {
386
- console.warn(`[sync] 变更不存在: ${changeName}`);
387
- return { synced: 0, errors: [`变更不存在: ${changeName}`] };
388
- }
389
-
390
- // 读取 progress 数据(serializeForSync 六表裸 JSON,task-02 / D-005@v2,替代 read() 聚合视图)
391
- let progressData;
392
- try {
393
- const { ProgressManager } = await import('./progress.js');
394
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
395
- progressData = pm.serializeForSync(this.cwd, changeName);
396
- } catch (err) {
397
- console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
398
- return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
399
- }
400
- if (progressData === null) {
401
- console.warn(`[sync] 变更无进度数据 (无活跃进度): ${changeName}`);
402
- return { synced: 0, errors: [`变更无进度数据: ${changeName}`] };
403
- }
404
-
405
- // 元字段走 HTTP header(D-015 / task-09):body 保持裸六表 JSON,sillyhub 老版忽略 header 零回归
406
- const headers = {
407
- 'Content-Type': 'application/json',
408
- Authorization: `Bearer ${platform.token}`,
409
- };
410
- const pushedAt = new Date().toISOString();
411
- if (platform.user) headers['X-SillySpec-User'] = platform.user; // 推送者身份(task-08 local.yaml platform.user)
412
- const baseTs = progressData.changes && progressData.changes[0] && progressData.changes[0].last_synced_platform_ts;
413
- if (baseTs) headers['X-SillySpec-Base-Ts'] = baseTs; // base_ts 乐观锁(NULL=首次同步不设,平台接受首次 push)
414
- headers['X-SillySpec-Pushed-At'] = pushedAt; // 平台存 last_pushed_at,作下次其他用户 push 的 base_ts 比对基准
415
-
416
- // POST 到平台(带状态码版本:识别 409 冲突,读回平台最新 JSON)。changeName encode 对齐 pull(:673)
417
- const syncUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
418
- const res = await fetchJsonWithStatus(syncUrl, {
419
- method: 'POST',
420
- headers,
421
- body: JSON.stringify(progressData),
422
- });
423
-
424
- if (!res.ok) {
425
- // 409 = base_ts 乐观锁冲突(平台已有更新);读回平台最新 JSON 供 task-12 完整冲突处理
426
- if (res.status === 409) {
427
- console.warn(`[sync] 冲突: ${changeName} 平台已有更新(base_ts 过期),请 platform status / resolve 处理`);
428
- // 平台 409 响应 { conflict:true, platform_progress, last_pushed_at },platform_progress 即平台最新 progress JSON
429
- const platformProgress = res.body && res.body.platform_progress ? res.body.platform_progress : res.body;
430
- const platformLastPushedAt = (res.body && res.body.last_pushed_at) || null;
431
- // 本地脏度(progressData 是 serializeForSync 输出,changes[0] 含 last_local_modified_ts)
432
- const localModified = (progressData.changes && progressData.changes[0] && progressData.changes[0].last_local_modified_ts) || null;
433
- // 写冲突文件(task-12 / D-002):base_ts=本次 push 的 base,强制提示走 resolve
434
- const conflictPath = this._writeConflictFile(changeName, {
435
- base_ts: baseTs || null,
436
- local_modified_ts: localModified,
437
- platform_last_pushed_at: platformLastPushedAt,
438
- platform_progress: platformProgress,
439
- });
440
- return { synced: 0, errors: [`冲突: ${changeName}`], conflict: true, platform_progress: platformProgress, conflictPath };
441
- }
442
- return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
443
- }
444
-
445
- // 更新 platform_last_sync
446
- try {
447
- const { ProgressManager } = await import('./progress.js');
448
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
449
- pm._updatePlatformLastSync(this.cwd, changeName);
450
- } catch (err) {
451
- console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
452
- }
453
-
454
- console.log(`[sync] 已同步变更: ${changeName}`);
455
-
456
- // 2026-08-16-auto-sync-from-repo:进度上行成功后顺带推四件套文档(best-effort)。
457
- // 根因:run 流程此前只推进度不推文档(本文件头注释),本地直跑 sillyspec 的产出
458
- // 文档永远不自动到平台(变更中心"进度到了文档没到")。生产者直连,不依赖 daemon
459
- // 缓存链路。失败仅 debugLog——文档同步失败不得影响进度上行的返回值与流程;
460
- // 四件套全缺失时 syncDocuments 内部已有跳过(syncedCount===0 提前返回,不调端点)。
461
- try {
462
- await this.syncDocuments(changeName);
463
- } catch (err) {
464
- debugLog(`[sync] 文档同步失败(不影响进度): ${changeName}: ${err.message}`);
465
- }
466
-
467
- return { synced: 1, errors: [] };
468
- }
469
-
470
- /**
471
- * 同步四件套文档到平台(全量同步)。
472
- * POST {url}/api/changes/{changeName}/documents
473
- */
474
- async syncDocuments(changeName) {
475
- const platform = this._getPlatform();
476
- if (!platform) {
477
- debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
478
- return { synced: 0, errors: ['未连接平台'] };
479
- }
480
-
481
- if (!changeName) {
482
- console.warn('[sync] syncDocuments 需要指定变更名称 (changeName)');
483
- return { synced: 0, errors: ['未指定变更名称'] };
484
- }
485
-
486
- const changeDir = join(this.cwd, CHANGES_DIR, changeName);
487
- if (!existsSync(changeDir)) {
488
- console.warn(`[sync] 变更不存在: ${changeName}`);
489
- return { synced: 0, errors: [`变更不存在: ${changeName}`] };
490
- }
491
-
492
- const documents = {};
493
- let syncedCount = 0;
494
- const errors = [];
495
-
496
- for (const docFile of DOCUMENT_FILES) {
497
- const docPath = join(changeDir, docFile);
498
- if (existsSync(docPath)) {
499
- try {
500
- documents[docFile] = readFileSync(docPath, 'utf8');
501
- syncedCount++;
502
- } catch (err) {
503
- errors.push(`读取 ${docFile} 失败: ${err.message}`);
504
- }
505
- }
506
- }
507
-
508
- if (syncedCount === 0) {
509
- console.warn(`[sync] 未找到可同步的文档: ${changeName}`);
510
- return { synced: 0, errors: [...errors, '无可用文档'] };
511
- }
512
-
513
- const docUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/documents`;
514
- const result = await fetchJson(docUrl, {
515
- method: 'POST',
516
- headers: {
517
- 'Content-Type': 'application/json',
518
- Authorization: `Bearer ${platform.token}`,
519
- },
520
- body: JSON.stringify(documents),
521
- });
522
-
523
- if (!result) {
524
- return { synced: 0, errors: [...errors, '文档同步请求失败'] };
525
- }
526
-
527
- console.log(`[sync] 已同步 ${syncedCount} 个文档: ${changeName}`);
528
- return { synced: syncedCount, errors };
529
- }
530
-
531
- /**
532
- * 检查变更的审批状态。
533
- * GET {url}/api/changes/{changeName}/approval
534
- * 返回 { status: 'pending'|'approved'|'rejected', reason?: string }
535
- */
536
- async checkApproval(changeName) {
537
- const platform = this._getPlatform();
538
- if (!platform) {
539
- debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
540
- return { status: 'pending', reason: '未连接平台' };
541
- }
542
-
543
- if (!changeName) {
544
- console.warn('[sync] checkApproval 需要指定变更名称 (changeName)');
545
- return { status: 'pending', reason: '未指定变更名称' };
546
- }
547
-
548
- const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
549
- const result = await fetchJson(approvalUrl, {
550
- headers: { Authorization: `Bearer ${platform.token}` },
551
- });
552
-
553
- if (!result) {
554
- console.warn(`[sync] 检查审批状态失败: ${changeName}`);
555
- return { status: 'unknown', reason: '请求失败(404/断网/超时),无法核实审批状态' };
556
- }
557
-
558
- // 更新本地 approvals 表
559
- try {
560
- const { ProgressManager } = await import('./progress.js');
561
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
562
- pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
563
- } catch (err) {
564
- console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
565
- }
566
-
567
- if (result.status === 'rejected') {
568
- console.warn(`[sync] 审批被拒绝 (${changeName}): ${result.reason || '无原因'}`);
569
- }
570
-
571
- return result;
572
- }
573
-
574
- /**
575
- * 查看同步状态。
576
- * 读取 local.yaml 中的 platform 配置,返回连接信息。
577
- */
578
- status() {
579
- const config = readLocalYaml(this.cwd);
580
- const platform = config.platform;
581
- if (!platform) {
582
- return { connected: false };
583
- }
584
- return {
585
- connected: true,
586
- url: platform.url,
587
- lastSync: platform.last_connected || null,
588
- };
589
- }
590
-
591
- /** 获取当前平台配置,未连接返回 null */
592
- _getPlatform() {
593
- const config = readLocalYaml(this.cwd);
594
- return config.platform || null;
595
- }
596
-
597
- /**
598
- * 写冲突文件 .runtime/sync-conflict-<change>.json(task-12 / D-002 / D-008 / D-010 / FR-05)。
599
- * push 409 与 pull 本地脏度双向冲突命中时调用,强制提示用户走 platform resolve 三选一。
600
- * 绝不字段级 auto-merge;文件在 .runtime 下不入版本控制(gitignore)。
601
- * @param {string} changeName
602
- * @param {{base_ts?: string|null, local_modified_ts?: string|null, platform_last_pushed_at?: string|null, platform_progress?: object|null}} info
603
- * @returns {string|null} 冲突文件路径(specDir 不可达返回 null)
604
- */
605
- _writeConflictFile(changeName, info = {}) {
606
- const specDir = safePlatformSpecDir(this.cwd);
607
- if (!specDir) return null;
608
- const runtimeDir = join(specDir, '.runtime');
609
- if (!existsSync(runtimeDir)) mkdirSync(runtimeDir, { recursive: true });
610
- const conflictPath = join(runtimeDir, `sync-conflict-${changeName}.json`);
611
- const payload = {
612
- change: changeName,
613
- base_ts: info.base_ts ?? null,
614
- local_modified_ts: info.local_modified_ts ?? null,
615
- platform_last_pushed_at: info.platform_last_pushed_at ?? null,
616
- created_at: new Date().toISOString(),
617
- // 额外存平台最新 progress JSON,供 resolve --take-platform 直接 import(无需再 pull)
618
- platform_progress: info.platform_progress ?? null,
619
- };
620
- writeFileSync(conflictPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
621
- return conflictPath;
622
- }
623
-
624
- /**
625
- * 读取冲突文件(task-13 resolve / task-14 status 用)。
626
- * @returns {object|null} 冲突文件内容;无文件返回 null
627
- */
628
- readConflictFile(changeName) {
629
- const specDir = safePlatformSpecDir(this.cwd);
630
- if (!specDir) return null;
631
- const conflictPath = join(specDir, '.runtime', `sync-conflict-${changeName}.json`);
632
- if (!existsSync(conflictPath)) return null;
633
- try {
634
- return JSON.parse(readFileSync(conflictPath, 'utf8'));
635
- } catch {
636
- return null;
637
- }
638
- }
639
-
640
- /**
641
- * 删除冲突文件(resolve 完成后清理,task-13 用)。
642
- * @returns {boolean} 是否删除(文件不存在返回 false)
643
- */
644
- clearConflictFile(changeName) {
645
- const specDir = safePlatformSpecDir(this.cwd);
646
- if (!specDir) return false;
647
- const conflictPath = join(specDir, '.runtime', `sync-conflict-${changeName}.json`);
648
- if (!existsSync(conflictPath)) return false;
649
- try { unlinkSync(conflictPath); return true; }
650
- catch { return false; }
651
- }
652
-
653
- /**
654
- * 两级 pull 第一级:轻量 change 列表(design §7 / D-001 / D-006 / task-06 / FR-01 / FR-03)。
655
- * GET {url}/api/changes → [{ name, current_stage, last_pushed_at, last_pusher }](兼容 { changes: [...] } 包裹)。
656
- * CLI 比对本地决定哪些 change 需更新,控制 pull 性能(不每次全量拉单 change JSON)。
657
- * Best Effort:未连接 / 网络失败 console.warn 不抛,返回 PullListResult { ok, changes, reason? }。
658
- * @returns {Promise<{ok: boolean, changes: Array<object>, reason?: string}>}
659
- */
660
- async pullList() {
661
- const platform = this._getPlatform();
662
- if (!platform) {
663
- debugLog('[sync] 未连接平台(本地合法状态);pullList 跳过');
664
- return { ok: false, changes: [], reason: '未连接平台' };
665
- }
666
-
667
- const listUrl = `${platform.url}/api/changes`;
668
- const result = await fetchJson(listUrl, {
669
- headers: { Authorization: `Bearer ${platform.token}` },
670
- });
671
-
672
- if (result === null) {
673
- console.warn(`[sync] 拉取变更列表失败: ${listUrl}`);
674
- return { ok: false, changes: [], reason: '拉取变更列表失败' };
675
- }
676
-
677
- // 兼容两种响应形态:裸数组 / { changes: [...] } 包裹(sillyhub 后端实际 API 而定)
678
- const changes = Array.isArray(result) ? result : (Array.isArray(result.changes) ? result.changes : []);
679
- return { ok: true, changes, reason: undefined };
680
- }
681
-
682
- /**
683
- * 两级 pull 第二级:按需拉平台单 change 完整 JSON 并 import 重建本地 DB 行(design §7 / D-001 / D-006 / D-014 / task-07 / FR-01 / FR-03 / FR-09)。
684
- * GET {url}/api/changes/{name}/progress → 平台权威 JSON(serializeForSync 六表 + 顶层 last_pushed_at)。
685
- * 本地脏度比对:last_local_modified_ts > last_synced_platform_ts(本地有未同步改动)且 平台 last_pushed_at 更新 → 冲突,不 import 返回 conflict:true(task-12 负责写 sync-conflict 文件)。
686
- * 无冲突 → import() 重建 DB 行;import 后本地 base_ts/脏度重置为 pushed_at(D-013)。
687
- * Best Effort:未连接 / 网络失败 / 404 console.warn 不抛,返回 PullResult { ok, imported, conflict, reason? }。
688
- * @param {string} changeName
689
- * @param {{force?: boolean}} [opts] - force 跳过本地脏度冲突检测直接 import(task-12 resolve --take-platform 用)
690
- * @returns {Promise<{ok: boolean, imported: boolean, conflict: boolean, reason?: string}>}
691
- */
692
- async pull(changeName, opts = {}) {
693
- const { force = false } = opts;
694
- const platform = this._getPlatform();
695
- if (!platform) {
696
- debugLog('[sync] 未连接平台(本地合法状态);pull 跳过');
697
- return { ok: false, imported: false, conflict: false, reason: '未连接平台' };
698
- }
699
- if (!changeName) {
700
- console.warn('[sync] pull 需要指定变更名称 (changeName)');
701
- return { ok: false, imported: false, conflict: false, reason: '未指定变更名称' };
702
- }
703
-
704
- // 1. GET 平台完整 progress JSON
705
- const progressUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
706
- const result = await fetchJson(progressUrl, {
707
- headers: { Authorization: `Bearer ${platform.token}` },
708
- });
709
- if (result === null) {
710
- // sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort 降级
711
- console.warn(`[sync] 拉取变更进度失败: ${changeName}(sillyhub 未就绪或变更不存在)`);
712
- return { ok: false, imported: false, conflict: false, reason: '拉取变更进度失败' };
713
- }
714
-
715
- // 平台响应:serializeForSync 六表 + 顶层 last_pushed_at(兼容 { progress: {...} } 包裹)
716
- const platformProgress = (result && result.project && result.changes) ? result : (result.progress || result);
717
- const platformPushedAt = (result && result.last_pushed_at) || null;
718
-
719
- // 2. 本地脏度比对(非 force 时):本地脏 AND 平台更新 → 冲突
720
- if (!force) {
721
- let localLastModified = null;
722
- let localLastSynced = null;
723
- try {
724
- const { ProgressManager } = await import('./progress.js');
725
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
726
- const row = pm._ensureDB(this.cwd).getDb().prepare(
727
- 'SELECT last_local_modified_ts, last_synced_platform_ts FROM changes WHERE name = ?'
728
- ).get(changeName);
729
- if (row) {
730
- localLastModified = row.last_local_modified_ts;
731
- localLastSynced = row.last_synced_platform_ts;
732
- }
733
- } catch (err) {
734
- console.warn(`[sync] 读取本地脏度失败 (${changeName}): ${err.message}`);
735
- }
736
- const localDirty = localLastModified && localLastSynced && localLastModified > localLastSynced;
737
- const platformNewer = platformPushedAt && (!localLastSynced || platformPushedAt > localLastSynced);
738
- if (localDirty && platformNewer) {
739
- console.warn(`[sync] pull 冲突: ${changeName} 本地有未同步改动且平台已更新(base_ts 过期),请 platform resolve 处理`);
740
- // 写冲突文件(task-12 / D-002):强制提示,绝不 auto-merge
741
- const conflictPath = this._writeConflictFile(changeName, {
742
- base_ts: localLastSynced,
743
- local_modified_ts: localLastModified,
744
- platform_last_pushed_at: platformPushedAt,
745
- platform_progress: platformProgress,
746
- });
747
- return { ok: false, imported: false, conflict: true, reason: `冲突: ${changeName} 本地脏且平台更新`, conflictPath };
748
- }
749
- }
750
-
751
- // 3. 无冲突(或 force)→ import 平台 JSON 重建本地 DB 行
752
- try {
753
- const { ProgressManager } = await import('./progress.js');
754
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
755
- const importInput = { ...platformProgress, pushed_at: platformPushedAt || new Date().toISOString() };
756
- pm.import(this.cwd, importInput, changeName);
757
- console.log(`[sync] 已拉取变更进度: ${changeName}`);
758
- return { ok: true, imported: true, conflict: false, reason: undefined };
759
- } catch (err) {
760
- console.warn(`[sync] import 失败 (${changeName}): ${err.message}`);
761
- return { ok: false, imported: false, conflict: false, reason: `import 失败: ${err.message}` };
762
- }
763
- }
764
-
765
- /**
766
- * 冲突解决三选一(task-13 / D-002 / D-010 / D-013 / FR-05)。
767
- * 读 task-12 写的 sync-conflict-<change>.json,按 mode 处理后必清冲突文件防累积(R-04 / constraints)。
768
- *
769
- * 三种语义(design 生命周期契约表):
770
- * - keep-local:本地 DB 不变,仅把 last_synced_platform_ts(base_ts)推进到平台 last_pushed_at
771
- * (表示「已知平台最新,本地为准」),用户后续手动 platform sync push 本地。
772
- * - take-platform:用冲突文件存的 platform_progress 调 import() 覆盖本地(保隔离状态,D-013 重置脏度)。
773
- * - abort:本地 DB 与 base_ts 均不变,仅清冲突文件(放弃本次同步)。
774
- *
775
- * 无冲突文件 → ok:false resolved:false(index.js 据此提示「无可解决冲突」)。
776
- * 绝不字段级 auto-merge(D-002)。
777
- * @param {string} changeName
778
- * @param {'keep-local'|'take-platform'|'abort'} mode
779
- * @returns {Promise<{ok: boolean, resolved: boolean, mode?: string, reason: string}>}
780
- */
781
- async resolve(changeName, mode) {
782
- const cf = this.readConflictFile(changeName);
783
- if (!cf) {
784
- return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict 文件)` };
785
- }
786
- const platformPushedAt = cf.platform_last_pushed_at || null;
787
-
788
- if (mode === 'keep-local') {
789
- // base_ts 推进到平台最新 last_pushed_at;本地 DB 不 import(用户本地为准,后续手动 push)
790
- try {
791
- const { ProgressManager } = await import('./progress.js');
792
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
793
- const db = pm._ensureDB(this.cwd).getDb();
794
- db.prepare('UPDATE changes SET last_synced_platform_ts = ? WHERE name = ?')
795
- .run(platformPushedAt, changeName);
796
- } catch (err) {
797
- return { ok: false, resolved: false, reason: `keep-local 更新 base_ts 失败: ${err.message}` };
798
- }
799
- this.clearConflictFile(changeName);
800
- return { ok: true, resolved: true, mode: 'keep-local', reason: '保留本地,base_ts 已推进到平台最新(后续请 platform sync push 本地)' };
801
- }
802
-
803
- if (mode === 'take-platform') {
804
- // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)
805
- if (!cf.platform_progress) {
806
- return { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
807
- }
808
- try {
809
- const { ProgressManager } = await import('./progress.js');
810
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
811
- pm.import(this.cwd, { ...cf.platform_progress, pushed_at: platformPushedAt || new Date().toISOString() }, changeName);
812
- } catch (err) {
813
- return { ok: false, resolved: false, reason: `take-platform import 失败: ${err.message}` };
814
- }
815
- this.clearConflictFile(changeName);
816
- return { ok: true, resolved: true, mode: 'take-platform', reason: '已用平台进度覆盖本地' };
817
- }
818
-
819
- if (mode === 'abort') {
820
- // 本地 DB 与 base_ts 均不变,仅清冲突文件(放弃本次同步,下次 push/pull 会重新检测)
821
- this.clearConflictFile(changeName);
822
- return { ok: true, resolved: true, mode: 'abort', reason: '放弃本次同步,本地不变' };
823
- }
824
-
825
- return { ok: false, resolved: false, reason: `未知 resolve 模式: ${mode}(--keep-local / --take-platform / --abort)` };
826
- }
827
-
828
- /**
829
- * 扫描 .runtime/sync-conflict-*.json 列出未决冲突(task-14 / D-002 / D-010 / FR-05)。
830
- * 只读 + 容错:损坏文件跳过不崩(constraints)。供 collectStatus / platform status 展示。
831
- * @returns {Array<{change: string, created_at: string|null, path: string}>}
832
- */
833
- listConflictFiles() {
834
- const specDir = safePlatformSpecDir(this.cwd);
835
- if (!specDir) return [];
836
- const runtimeDir = join(specDir, '.runtime');
837
- if (!existsSync(runtimeDir)) return [];
838
- let files = [];
839
- try { files = readdirSync(runtimeDir); }
840
- catch { return []; }
841
- const conflicts = [];
842
- for (const f of files) {
843
- if (!f.startsWith('sync-conflict-') || !f.endsWith('.json')) continue;
844
- const filePath = join(runtimeDir, f);
845
- try {
846
- const cf = JSON.parse(readFileSync(filePath, 'utf8'));
847
- conflicts.push({
848
- change: cf.change || f.replace(/^sync-conflict-/, '').replace(/\.json$/, ''),
849
- created_at: cf.created_at || null,
850
- path: filePath,
851
- });
852
- } catch {
853
- // 损坏文件跳过不崩(constraints:容错)
854
- }
855
- }
856
- return conflicts;
857
- }
858
-
859
- /**
860
- * 扩展 status(task-14 / FR-05):连接信息 + 落后标记 + 未决冲突列表。
861
- * 只读展示,不修改任何进度(constraints)。未连接返回 base.status(connected:false,behind/conflicts 空)。
862
- * 连接时调 pullList(轻量 GET)拿平台各 change last_pushed_at,比对本地 last_synced_platform_ts
863
- * 标记本地可能落后;扫描 sync-conflict-*.json 列未决冲突。网络失败 listFailed=true 不崩。
864
- * @returns {Promise<{connected: boolean, url?: string, lastSync?: string|null, behind: Array<object>, conflicts: Array<object>, listFailed: boolean}>}
865
- */
866
- async collectStatus() {
867
- const base = this.status();
868
- if (!base.connected) {
869
- return { ...base, behind: [], conflicts: [], listFailed: false };
870
- }
871
- const behind = [];
872
- let listFailed = false;
873
- const list = await this.pullList();
874
- if (list.ok) {
875
- for (const ch of list.changes) {
876
- const name = typeof ch === 'string' ? ch : ch.name;
877
- if (!name) continue;
878
- const platformPushedAt = (typeof ch === 'object' && (ch.last_pushed_at || ch.last_active)) || null;
879
- if (!platformPushedAt) continue;
880
- let localSynced = null;
881
- try {
882
- const { ProgressManager } = await import('./progress.js');
883
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
884
- const row = pm._ensureDB(this.cwd).getDb().prepare('SELECT last_synced_platform_ts FROM changes WHERE name = ?').get(name);
885
- if (row) localSynced = row.last_synced_platform_ts;
886
- } catch {
887
- // 本地无该 change 记录 / DB 不可达 → 不算落后,跳过
888
- }
889
- // 仅本地有同步基准(last_synced_platform_ts)且平台更新晚于本地 → 落后
890
- if (localSynced && platformPushedAt > localSynced) {
891
- behind.push({ name, local_synced: localSynced, platform_pushed_at: platformPushedAt });
892
- }
893
- }
894
- } else {
895
- listFailed = true;
896
- }
897
- const conflicts = this.listConflictFiles();
898
- return { ...base, behind, conflicts, listFailed };
899
- }
900
- }
901
-
902
- // ── CLI 入口函数 ──
903
-
904
- /**
905
- * syncModule — sillyspec platform 子命令入口
906
- *
907
- * 用法:
908
- * sillyspec platform connect <url> <token>
909
- * sillyspec platform disconnect
910
- * sillyspec platform sync [changeName]
911
- * sillyspec platform sync-docs [changeName]
912
- * sillyspec platform approval <changeName>
913
- * sillyspec platform status
914
- *
915
- * @param {string[]} args — 子命令及参数
916
- * @param {string} cwd — 工作目录
917
- */
918
- /**
919
- * 便捷函数导出 — 供 index.js 和 run.js 直接调用
920
- */
921
- export async function connect(url, token, cwd) {
922
- return new SyncManager(cwd).connect(url, token);
923
- }
924
-
925
- export async function disconnect(cwd) {
926
- return new SyncManager(cwd).disconnect();
927
- }
928
-
929
- export async function sync(changeName, cwd) {
930
- return new SyncManager(cwd).sync(changeName);
931
- }
932
-
933
- export async function syncDocuments(changeName, cwd) {
934
- return new SyncManager(cwd).syncDocuments(changeName);
935
- }
936
-
937
- export async function checkApproval(changeName, cwd) {
938
- return new SyncManager(cwd).checkApproval(changeName);
939
- }
940
-
941
- // task-11 / D-006 / D-009 / FR-03:手动 pull 便捷导出(index.js platform pull 子命令用,
942
- // 与自动 triggerPull 共用 SyncManager.pull 实例方法,行为一致)
943
- export async function pull(changeName, opts, cwd) {
944
- return new SyncManager(cwd).pull(changeName, opts);
945
- }
946
-
947
- // 两级 pull 第一级(轻量 change 列表),platform pull 无 --change 时先拉列表再按需 pull
948
- export async function pullList(cwd) {
949
- return new SyncManager(cwd).pullList();
950
- }
951
-
952
- // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(index.js platform resolve 子命令用)
953
- export async function resolve(changeName, mode, cwd) {
954
- return new SyncManager(cwd).resolve(changeName, mode);
955
- }
956
-
957
- // task-14 / FR-05:扩展 status(连接信息 + 落后标记 + 未决冲突列表),index.js platform status 用
958
- export async function collectStatus(cwd) {
959
- return new SyncManager(cwd).collectStatus();
960
- }
961
-
962
- // 未决冲突文件列表便捷导出(index.js platform resolve 参数解析/报错兜底用,只读)
963
- export function listConflictFiles(cwd) {
964
- return new SyncManager(cwd).listConflictFiles();
965
- }
966
-
967
- // TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
968
- // 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
969
- /**
970
- * 向平台提交审批决定(approve/reject 共用)。
971
- *
972
- * 显式用户/daemon 动作:网络/平台失败必须可见(decisions.md D-006@v1)——
973
- * 与 best-effort 的 sync 不同,此处失败打 error 并置 process.exitCode = 1。
974
- *
975
- * @param {string} cwd - 工作目录
976
- * @param {string} changeName - 变更名称
977
- * @param {'approved'|'rejected'} decision - 审批决定
978
- * @param {string|null} reason - rejected 时的原因(approved 时忽略)
979
- * @returns {Promise<boolean>} 是否成功
980
- */
981
- async function _submitApproval(cwd, changeName, decision, reason = null) {
982
- const sm = new SyncManager(cwd);
983
- const platform = sm._getPlatform();
984
- if (!platform) {
985
- console.error('❌ 未连接平台,请先 sillyspec platform connect');
986
- process.exitCode = 1;
987
- return false;
988
- }
989
-
990
- if (!changeName) {
991
- console.error('❌ 审批需要指定变更名称 (changeName)');
992
- process.exitCode = 1;
993
- return false;
994
- }
995
-
996
- // TBD-hub-api: 端点路径/body 以 SillyHub 实际 API 为准,对齐时只改此处
997
- const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
998
- const body = decision === 'rejected'
999
- ? { decision: 'rejected', reason }
1000
- : { decision: 'approved' };
1001
-
1002
- const result = await fetchJson(approvalUrl, {
1003
- method: 'POST',
1004
- headers: {
1005
- 'Content-Type': 'application/json',
1006
- Authorization: `Bearer ${platform.token}`,
1007
- },
1008
- body: JSON.stringify(body),
1009
- });
1010
-
1011
- // fetchJson 在非 2xx / 网络错 / 超时 / 非 JSON 时返回 null
1012
- if (result === null) {
1013
- console.error(`❌ 审批请求失败 (${changeName} → ${decision}),详情见上方 [sync] 警告`);
1014
- process.exitCode = 1;
1015
- return false;
1016
- }
1017
-
1018
- // HTTP 成功后更新本地 approvals 表(HTTP 已成功是主要目标,落库失败只 warn 不阻断)
1019
- try {
1020
- const { ProgressManager } = await import('./progress.js');
1021
- const pm = new ProgressManager({ specDir: safePlatformSpecDir(cwd) });
1022
- pm._updateApprovalStatus(cwd, changeName, decision, reason);
1023
- } catch (err) {
1024
- console.warn(`[sync] 更新本地审批状态失败 (${changeName}): ${err.message}`);
1025
- }
1026
-
1027
- if (decision === 'approved') {
1028
- console.log(`✅ 已批准变更 ${changeName}`);
1029
- } else {
1030
- console.log(`✅ 已拒绝变更 ${changeName}${reason ? `(原因: ${reason})` : ''}`);
1031
- }
1032
- return true;
1033
- }
1034
-
1035
- export async function approve(changeName, cwd) {
1036
- return _submitApproval(cwd, changeName, 'approved');
1037
- }
1038
-
1039
- export async function reject(changeName, reason, cwd) {
1040
- return _submitApproval(cwd, changeName, 'rejected', reason);
1041
- }
1042
-
1043
- export async function status(cwd) {
1044
- const sm = new SyncManager(cwd);
1045
- const st = sm.status();
1046
- if (!st.connected) {
1047
- console.log('平台: 未连接');
1048
- } else {
1049
- console.log(`平台: ${st.url}`);
1050
- console.log(`上次连接: ${st.lastSync || '未知'}`);
1051
- }
1052
- }
1053
-
1054
- /**
1055
- * syncModule — sillyspec platform 子命令入口
1056
- */
1057
- export async function syncModule(args, cwd) {
1058
- const sm = new SyncManager(cwd);
1059
-
1060
- const sub = args[0];
1061
-
1062
- switch (sub) {
1063
- case 'connect': {
1064
- const url = args[1];
1065
- const token = args[2];
1066
- if (!url || !token) {
1067
- console.error('用法: sillyspec platform connect <url> <token>');
1068
- process.exit(1);
1069
- }
1070
- await sm.connect(url, token);
1071
- break;
1072
- }
1073
-
1074
- case 'disconnect':
1075
- sm.disconnect();
1076
- break;
1077
-
1078
- case 'sync': {
1079
- const changeName = args[1];
1080
- const result = await sm.sync(changeName);
1081
- if (result.errors.length > 0) {
1082
- console.log(`同步完成,${result.errors.length} 个错误`);
1083
- }
1084
- break;
1085
- }
1086
-
1087
- case 'sync-docs':
1088
- case 'sync-documents': {
1089
- const changeName = args[1];
1090
- const result = await sm.syncDocuments(changeName);
1091
- if (result.errors.length > 0) {
1092
- console.log(`文档同步完成,${result.errors.length} 个错误`);
1093
- }
1094
- break;
1095
- }
1096
-
1097
- case 'approval':
1098
- case 'check-approval': {
1099
- const changeName = args[1];
1100
- if (!changeName) {
1101
- console.error('用法: sillyspec platform approval <changeName>');
1102
- process.exit(1);
1103
- }
1104
- const approval = await sm.checkApproval(changeName);
1105
- console.log(`审批状态: ${approval.status}${approval.reason ? ` (${approval.reason})` : ''}`);
1106
- break;
1107
- }
1108
-
1109
- case 'status': {
1110
- const st = sm.status();
1111
- if (!st.connected) {
1112
- console.log('平台: 未连接');
1113
- } else {
1114
- console.log(`平台: ${st.url}`);
1115
- console.log(`上次连接: ${st.lastSync || '未知'}`);
1116
- }
1117
- break;
1118
- }
1119
-
1120
- default:
1121
- console.error(`未知子命令: ${sub || '(无)'}`);
1122
- console.error('可用命令: connect, disconnect, sync, sync-docs, approval, status');
1123
- process.exit(1);
1124
- }
1125
- }
1
+ /**
2
+ * SillySpec SyncManager — SillyHub 平台同步模块
3
+ *
4
+ * 独立于 ProgressManager,由 run.js 和 index.js 调用。
5
+ * Best effort:所有网络失败 console.warn,不抛错,不阻塞主流程。
6
+ *
7
+ * 配置来源:.sillyspec/local.yaml 中的 platform 段
8
+ * HTTP 请求:Node.js 原生 fetch(Node 22+)
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { resolvePlatformSpecDir } from './progress.js';
14
+ import { safeGit } from './git-helper.js';
15
+ import { PLATFORM_MANAGED_FILENAME } from './run/shared.js';
16
+ import { syncSpecTree } from './spec-sync.js';
17
+
18
+ // sync 是 best-effort(网络失败只 warn):平台指针失效时不抛,跳过平台、回退本地。
19
+ function safePlatformSpecDir(cwd) {
20
+ try {
21
+ return resolvePlatformSpecDir(cwd);
22
+ } catch (e) {
23
+ console.warn(`[sync] 平台指针不可用,跳过平台同步:${String(e.message).split('\n')[0]}`);
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ const LOCAL_YAML = '.sillyspec/local.yaml';
29
+ const CHANGES_DIR = '.sillyspec/changes';
30
+ const REQUEST_TIMEOUT_MS = 10_000;
31
+
32
+ // 未连接平台是本地独立用户的合法默认状态。sync / checkApproval 由 run 流程在后台 best-effort
33
+ // 触发(每步完成、execute 阶段启动);syncDocuments 仅由手动 `sillyspec platform sync-docs`
34
+ // 命令触发(index.js sync-docs 分支),run 流程不自动推文档。未连接时默认静默跳过——不每步
35
+ // 催连平台制造噪音(本地用户根本不需要平台)。需要排查同步行为时设 SILLYSPEC_DEBUG_SYNC=1。
36
+ function debugLog(msg) {
37
+ if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg)
38
+ }
39
+
40
+ /** 四件套文档文件名 */
41
+ const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'];
42
+
43
+ // ── YAML 辅助 ──
44
+
45
+ /**
46
+ * 简易 YAML 读取(解析为扁平 obj),供 _getPlatform / status 只读用。
47
+ * 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
48
+ *
49
+ * 写入侧(connect/disconnect)**不再**走 parse→modify→write 有损往返——
50
+ * readLocalYaml/parseSimpleYaml 丢注释(:85 跳 # 行)、丢数组/深嵌套(只认一层 key:value),
51
+ * round-trip 会清空用户手填的注释与其他段。改为 readLocalYamlRaw + replaceTopLevelSection
52
+ * 文本级定向替换 platform 段,原文件注释/空行/其他段/数组/深嵌套/CRLF 字节级保留。
53
+ */
54
+ function readLocalYaml(cwd) {
55
+ const p = join(cwd, LOCAL_YAML);
56
+ if (!existsSync(p)) return {};
57
+ try {
58
+ return parseSimpleYaml(readFileSync(p, 'utf8'));
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+
64
+ /** 读 local.yaml 原始文本(保留注释/换行/结构),不存在返回 ''。供 connect/disconnect 文本级改写。 */
65
+ function readLocalYamlRaw(cwd) {
66
+ const p = join(cwd, LOCAL_YAML);
67
+ if (!existsSync(p)) return '';
68
+ try {
69
+ return readFileSync(p, 'utf8');
70
+ } catch {
71
+ return '';
72
+ }
73
+ }
74
+
75
+ /**
76
+ * 定位顶层 YAML 段(如 platform:/mcp:)的行范围 [start, end)。
77
+ * 段 = key 行(行首非空白 + 以 'name:' 开头)+ 后续连续缩进行(以空格/tab 开头)。
78
+ * 遇空行/注释/下一个顶层 key 即段结束——这些行不属于本段,保留不动。
79
+ * split('\n')/join('\n') 操作:CRLF 下 '\r' 留在行尾,重组原样还原(Windows 兼容,CLAUDE.md #13)。
80
+ * @param {string} text
81
+ * @param {string} name - 段名(不含冒号,如 'platform')
82
+ * @returns {{start: number, end: number} | null} 不存在返回 null
83
+ */
84
+ function findTopLevelSectionRange(text, name) {
85
+ const lines = text.split('\n');
86
+ const prefix = `${name}:`;
87
+ let start = -1;
88
+ for (let i = 0; i < lines.length; i++) {
89
+ const line = lines[i];
90
+ // 顶层 key 行:行首非空白(排除缩进子段)+ 以 'name:' 开头
91
+ if (/^\S/.test(line) && line.startsWith(prefix)) {
92
+ start = i;
93
+ break;
94
+ }
95
+ }
96
+ if (start === -1) return null;
97
+ let end = start + 1;
98
+ while (end < lines.length && /^[ \t]/.test(lines[end])) {
99
+ end++;
100
+ }
101
+ return { start, end };
102
+ }
103
+
104
+ /**
105
+ * 文本级定向替换/删除/追加一个顶层 YAML 段,保留文件其余所有字节
106
+ * (注释/空行/其他段/数组/深嵌套/CRLF 全保留)。
107
+ * @param {string} text - 原始文件文本
108
+ * @param {string} name - 段名(不含冒号)
109
+ * @param {string|null} body - 段体(不含 key 行);null=删除该段;string=替换或追加
110
+ * @returns {string} 新文本
111
+ */
112
+ function replaceTopLevelSection(text, name, body) {
113
+ const lines = text.split('\n');
114
+ const range = findTopLevelSectionRange(text, name);
115
+ if (range) {
116
+ const before = lines.slice(0, range.start);
117
+ const after = lines.slice(range.end);
118
+ if (body === null) {
119
+ return [...before, ...after].join('\n');
120
+ }
121
+ const sectionLines = [`${name}:`, ...body.split('\n')];
122
+ return [...before, ...sectionLines, ...after].join('\n');
123
+ }
124
+ // 段不存在
125
+ if (body === null) return text; // 删不存在的段,原样返回
126
+ // 追加:去尾换行后加空行分隔 + 新段;空文件直接起段
127
+ const stripped = text.replace(/(\r?\n)+$/, '');
128
+ if (stripped === '') {
129
+ return `${name}:\n${body}\n`;
130
+ }
131
+ return `${stripped}\n\n${name}:\n${body}\n`;
132
+ }
133
+
134
+ /** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。 */
135
+ function writeLocalYamlRaw(cwd, text) {
136
+ const dir = join(cwd, '.sillyspec');
137
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
138
+ writeFileSync(join(cwd, LOCAL_YAML), text, 'utf8');
139
+ }
140
+
141
+ function parseSimpleYaml(content) {
142
+ // 剥 YAML 双/单引号包裹(connect 写侧 yamlStr 加引号防 # : 注入,读侧对称剥;无引号值零影响)
143
+ const unquote = (v) => {
144
+ if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
145
+ return v.slice(1, -1);
146
+ }
147
+ return v;
148
+ };
149
+ const result = {};
150
+ let currentSection = null;
151
+ for (const line of content.split('\n')) {
152
+ const trimmed = line.trim();
153
+ if (!trimmed || trimmed.startsWith('#')) continue;
154
+ // 用原始 line(非 trimmed)判断缩进:trimmed 已去前导空格,startsWith(' ') 恒 false
155
+ // 会导致缩进子段(如 platform 下 url/token)被误判为 root 行、section 恒为空 {}
156
+ if (!line.startsWith(' ')) {
157
+ const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
158
+ if (m) {
159
+ const key = m[1];
160
+ const val = unquote(m[2].trim());
161
+ if (val) {
162
+ result[key] = val;
163
+ currentSection = null;
164
+ } else {
165
+ result[key] = {};
166
+ currentSection = key;
167
+ }
168
+ }
169
+ } else if (currentSection) {
170
+ const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
171
+ if (m && result[currentSection] && typeof result[currentSection] === 'object') {
172
+ result[currentSection][m[1]] = unquote(m[2].trim());
173
+ }
174
+ }
175
+ }
176
+ return result;
177
+ }
178
+
179
+ /**
180
+ * 解析推送者身份(user):区分多用户,供 push 时 X-SillySpec-User 标识(design D-004)。
181
+ * 优先级:显式参数 > git user.name(与 quicklog / prompt <git-user> 同口径)> process.env.USER
182
+ * (Unix)/ USERNAME(Windows)。Best effort:全失败返回 null,调用方据此决定是否写 user 字段
183
+ * (缺字段等价于「未知推送者」,平台侧自行兜底,不报错)。
184
+ */
185
+ function resolvePlatformUser(cwd, explicitUser) {
186
+ if (typeof explicitUser === 'string' && explicitUser.trim()) {
187
+ return explicitUser.trim();
188
+ }
189
+ // 回退 1:git user.name(safeGit 失败返回 null,不抛)
190
+ const gitUser = safeGit(cwd, ['config', 'user.name']).value;
191
+ if (gitUser) return gitUser;
192
+ // 回退 2:环境变量(USER=Unix / USERNAME=Windows;跨平台兼容 CLAUDE.md #13)
193
+ return process.env.USER || process.env.USERNAME || null;
194
+ }
195
+
196
+ // ── HTTP 辅助 ──
197
+
198
+ async function fetchJson(url, options = {}) {
199
+ const controller = new AbortController();
200
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
201
+ try {
202
+ const res = await fetch(url, { ...options, signal: controller.signal });
203
+ if (!res.ok) {
204
+ const text = await res.text().catch(() => '');
205
+ console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
206
+ return null;
207
+ }
208
+ const ct = res.headers.get('content-type') || '';
209
+ if (ct.includes('application/json')) {
210
+ return res.json();
211
+ }
212
+ return null;
213
+ } catch (err) {
214
+ if (err.name === 'AbortError') {
215
+ console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
216
+ } else {
217
+ console.warn(`[sync] ${url} 请求失败: ${err.message}`);
218
+ }
219
+ return null;
220
+ } finally {
221
+ clearTimeout(timer);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 带状态码的 fetch:不吞非 2xx(fetchJson 在 !res.ok 时返回 null 丢状态,无法区分 409 冲突)。
227
+ * 返回 { ok, status, body }:body 尽力 JSON.parse(平台冲突响应带 progress JSON,读回给调用方)。
228
+ * 仅 sync() 的 progress POST 使用(识别 base_ts 乐观锁冲突,D-015 / task-09);其余调用仍走 fetchJson。
229
+ */
230
+ async function fetchJsonWithStatus(url, options = {}) {
231
+ const controller = new AbortController();
232
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
233
+ try {
234
+ const res = await fetch(url, { ...options, signal: controller.signal });
235
+ const text = await res.text().catch(() => '');
236
+ let body = null;
237
+ if (text) {
238
+ try { body = JSON.parse(text); } catch { body = null; }
239
+ }
240
+ if (!res.ok) {
241
+ console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
242
+ }
243
+ return { ok: res.ok, status: res.status, body };
244
+ } catch (err) {
245
+ if (err.name === 'AbortError') {
246
+ console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
247
+ } else {
248
+ console.warn(`[sync] ${url} 请求失败: ${err.message}`);
249
+ }
250
+ return { ok: false, status: 0, body: null };
251
+ } finally {
252
+ clearTimeout(timer);
253
+ }
254
+ }
255
+
256
+ // ── SyncManager ──
257
+
258
+ export class SyncManager {
259
+ constructor(cwd) {
260
+ this.cwd = cwd;
261
+ }
262
+
263
+ /**
264
+ * 连接 SillyHub 平台。
265
+ * 保存配置到 .sillyspec/local.yaml,发送 ping 验证连接。
266
+ * @param {string} url - 平台地址
267
+ * @param {string} token - 认证 token
268
+ * @param {string} [user] - 推送者身份(可空,回退 git user.name / env,见 resolvePlatformUser)
269
+ */
270
+ async connect(url, token, user) {
271
+ // 验证连接
272
+ const normalizedUrl = url.replace(/\/+$/, '');
273
+ const healthUrl = `${normalizedUrl}/api/health`;
274
+ const result = await fetchJson(healthUrl);
275
+ if (result === null) {
276
+ console.warn(`[sync] 平台连接验证失败: ${url}`);
277
+ return;
278
+ }
279
+ console.log(`[sync] 平台连接成功: ${url}`);
280
+
281
+ // 换发 workspace-scoped 同步 token(task-09 / D-005@v1):用传入 user 级 token
282
+ // (shk_live_ / JWT)作 Bearer + 本地 root_path(connect 的 cwd,与平台
283
+ // Workspace.root_path 绑定值等值匹配)调 resolve-by-root-path。成功取 shpsync_
284
+ // 覆盖 token 写入 platform 段;404(root_path 未绑)/403(无 WORKSPACE_WRITE)/断网
285
+ // 均降级沿用原 token 继续(best-effort 不阻断 connect)。
286
+ let effectiveToken = token;
287
+ const resolveUrl = `${normalizedUrl}/api/workspaces/resolve-by-root-path`;
288
+ const resolved = await fetchJson(resolveUrl, {
289
+ method: 'POST',
290
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
291
+ body: JSON.stringify({ root_path: this.cwd }),
292
+ });
293
+ if (resolved && resolved.token && resolved.token.startsWith('shpsync_')) {
294
+ effectiveToken = resolved.token;
295
+ console.log(
296
+ `[sync] 已换发 workspace-scoped 同步 token (workspace: ${resolved.workspace_id})`
297
+ );
298
+ } else {
299
+ console.warn(
300
+ '[sync] 换发 workspace-scoped token 失败(404/403/断网),沿用原 token 继续'
301
+ );
302
+ }
303
+
304
+ // 解析推送者身份(D-004):显式 > git user.name > env;全失败留空不写 user 字段
305
+ const resolvedUser = resolvePlatformUser(this.cwd, user);
306
+
307
+ // 文本级改写 local.yaml:定向替换 platform 段,原文件注释/其他段/数组/深嵌套/CRLF 字节级保留
308
+ // (旧 read-modify-write 经 parseSimpleYaml 丢注释+丢非扁平结构,round-trip 清空用户手填内容)。
309
+ // YAML 值加引号:token/url 含 # : 或首尾空白时裸写会破坏段结构或注入额外键(shpsync_ token
310
+ // 当前不含这些字符,防御性包裹零成本;parseSimpleYaml 读侧剥引号兼容)
311
+ const yamlStr = (v) => JSON.stringify(String(v));
312
+ const platformEntries = [
313
+ ` url: ${yamlStr(normalizedUrl)}`,
314
+ ` token: ${yamlStr(effectiveToken)}`,
315
+ ` last_connected: ${yamlStr(new Date().toISOString())}`,
316
+ ];
317
+ if (resolvedUser) {
318
+ platformEntries.push(` user: ${yamlStr(resolvedUser)}`);
319
+ }
320
+ let text = readLocalYamlRaw(this.cwd);
321
+ text = replaceTopLevelSection(text, 'platform', platformEntries.join('\n'));
322
+ // mcp 段同源假设(design §7.4):url 复用 platform 的 url;token 用原始 user 级 token
323
+ // (非换发的 effectiveToken——MCP 派发需 user 级权限,platform 段才是最小权限的 shpsync_)。
324
+ // 用户已手填 mcp 段则保留不覆盖(R-09,文本级检测)
325
+ if (findTopLevelSectionRange(text, 'mcp') === null) {
326
+ text = replaceTopLevelSection(text, 'mcp', ` url: ${yamlStr(normalizedUrl)}\n token: ${yamlStr(token)}`);
327
+ }
328
+ writeLocalYamlRaw(this.cwd, text);
329
+ }
330
+
331
+ /**
332
+ * 断开平台连接(三清,D-C@v2)。
333
+ * 1. 从 local.yaml 删除 platform 配置段;
334
+ * 2. 删恢复指针 <cwd>/.sillyspec-platform.json;
335
+ * 3. 删平台接管声明 <cwd>/.sillyspec-platform-managed。
336
+ * 不删后两者则"disconnect 后恢复本地模式"不可达(指针健在时 resolvePlatformSpecDir
337
+ * 仍解析平台 specRoot;声明健在时指针缺失会 fail-closed)。
338
+ */
339
+ disconnect() {
340
+ const p = join(this.cwd, LOCAL_YAML);
341
+ if (existsSync(p)) {
342
+ const text = readFileSync(p, 'utf8');
343
+ if (findTopLevelSectionRange(text, 'platform') !== null) {
344
+ // 文本级删除 platform 段,保留注释/其他段/数组/深嵌套/CRLF
345
+ const newText = replaceTopLevelSection(text, 'platform', null);
346
+ if (newText.trim() === '') {
347
+ // 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
348
+ try { unlinkSync(p); } catch { /* best effort */ }
349
+ } else {
350
+ writeFileSync(p, newText, 'utf8');
351
+ }
352
+ }
353
+ }
354
+ // 三清之二/三:指针 + 接管声明(disconnect 是声明的唯一退出路径——design.md §5.4)
355
+ let cleaned = [];
356
+ for (const f of ['.sillyspec-platform.json', PLATFORM_MANAGED_FILENAME]) {
357
+ const fp = join(this.cwd, f);
358
+ if (existsSync(fp)) {
359
+ try { unlinkSync(fp); cleaned.push(f); } catch { /* best effort */ }
360
+ }
361
+ }
362
+ if (cleaned.length > 0) console.log(`[sync] 已清理平台指针与接管声明: ${cleaned.join(', ')}`);
363
+ console.log('[sync] 已断开连接');
364
+ }
365
+
366
+ /**
367
+ * 增量同步变更的 progress 状态到平台。
368
+ * 读取 ProgressManager.serializeForSync() 的六表 JSON,POST 到平台。
369
+ * 元字段(user/base_ts/pushed_at)走 HTTP header,body 保持裸 JSON(D-015,sillyhub 老版零回归)。
370
+ * 同步完成后更新 changes 表的 platform_last_sync 字段;409 冲突读回平台最新 JSON(task-12 完整冲突处理)。
371
+ */
372
+ async sync(changeName) {
373
+ const platform = this._getPlatform();
374
+ if (!platform) {
375
+ debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
376
+ return { synced: 0, errors: ['未连接平台'] };
377
+ }
378
+
379
+ if (!changeName) {
380
+ console.warn('[sync] sync 需要指定变更名称 (changeName)');
381
+ return { synced: 0, errors: ['未指定变更名称'] };
382
+ }
383
+
384
+ // 检查变更目录是否存在(warn 不拦:归档后目录已移走但 DB 仍有最终状态需推平台,
385
+ // serializeForSync 从 DB 读不依赖文件系统目录;目录存在时 warn 也无害,仅辅助排查)
386
+ const changeDir = join(this.cwd, CHANGES_DIR, changeName);
387
+ if (!existsSync(changeDir)) {
388
+ console.warn(`[sync] 变更目录不存在(可能是已归档,继续从 DB 同步最终状态): ${changeName}`);
389
+ }
390
+
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
+ }
405
+
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
+ });
429
+
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 };
458
+ }
459
+ return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
460
+ }
461
+
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
+ }
473
+
474
+ console.log(`[sync] 已同步变更: ${changeName}`);
475
+
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}`);
485
+ }
486
+
487
+ // 2026-08-17-spec-file-incremental-sync:进度上行+文档直推成功后顺带推整个 spec 树
488
+ // (plan.md、tasks/、module-impact.md 等),让变更中心文件树自动更新。
489
+ // 无 daemon 时 CLI 短进程无本地清单缓存,以服务器清单为锚做增量 diff。
490
+ try {
491
+ await syncSpecTree(join(this.cwd, '.sillyspec'), this._getPlatform(), changeName);
492
+ } catch (err) {
493
+ debugLog(`[sync] spec 树增量同步失败(不影响进度): ${changeName}: ${err.message}`);
494
+ }
495
+
496
+ return { synced: 1, errors: [] };
497
+ }
498
+
499
+ /**
500
+ * 同步四件套文档到平台(全量同步)。
501
+ * POST {url}/api/changes/{changeName}/documents
502
+ * @param {string} changeName
503
+ * @param {{manual?: boolean}} [opts] - manual=true 手动 sync-docs 命令路径(四件套缺失打 warn);
504
+ * 默认自动路径(sync() 顺带推)四件套缺失是流程早期正常状态,降 debug 不噪音(ql-20260818-008)
505
+ */
506
+ async syncDocuments(changeName, opts = {}) {
507
+ const { manual = false } = opts;
508
+ const platform = this._getPlatform();
509
+ if (!platform) {
510
+ debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
511
+ return { synced: 0, errors: ['未连接平台'] };
512
+ }
513
+
514
+ if (!changeName) {
515
+ console.warn('[sync] syncDocuments 需要指定变更名称 (changeName)');
516
+ return { synced: 0, errors: ['未指定变更名称'] };
517
+ }
518
+
519
+ const changeDir = join(this.cwd, CHANGES_DIR, changeName);
520
+ if (!existsSync(changeDir)) {
521
+ console.warn(`[sync] 变更不存在: ${changeName}`);
522
+ return { synced: 0, errors: [`变更不存在: ${changeName}`] };
523
+ }
524
+
525
+ const documents = {};
526
+ let syncedCount = 0;
527
+ const errors = [];
528
+
529
+ for (const docFile of DOCUMENT_FILES) {
530
+ const docPath = join(changeDir, docFile);
531
+ if (existsSync(docPath)) {
532
+ try {
533
+ documents[docFile] = readFileSync(docPath, 'utf8');
534
+ syncedCount++;
535
+ } catch (err) {
536
+ errors.push(`读取 ${docFile} 失败: ${err.message}`);
537
+ }
538
+ }
539
+ }
540
+
541
+ if (syncedCount === 0) {
542
+ // ql-20260818-008:四件套缺失在流程早期是正常状态(brainstorm 中途 design/proposal 还没写),
543
+ // 自动路径降 debug——原 warn「未找到可同步的文档」不说只找四件套,曾让用户误判同步逻辑错了;
544
+ // 手动 platform sync-docs 是显式意图,保留 warn 但说清范围。
545
+ if (manual) {
546
+ console.warn(`[sync] 未找到可同步的四件套文档(proposal/design/requirements/tasks.md): ${changeName}`);
547
+ } else {
548
+ debugLog(`[sync] 四件套尚未生成,跳过文档直推: ${changeName}`);
549
+ }
550
+ return { synced: 0, errors: [...errors, '无可用文档'] };
551
+ }
552
+
553
+ const docUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/documents`;
554
+ const result = await fetchJson(docUrl, {
555
+ method: 'POST',
556
+ headers: {
557
+ 'Content-Type': 'application/json',
558
+ Authorization: `Bearer ${platform.token}`,
559
+ },
560
+ body: JSON.stringify(documents),
561
+ });
562
+
563
+ if (!result) {
564
+ return { synced: 0, errors: [...errors, '文档同步请求失败'] };
565
+ }
566
+
567
+ console.log(`[sync] 已同步 ${syncedCount} 个文档: ${changeName}`);
568
+ return { synced: syncedCount, errors };
569
+ }
570
+
571
+ /**
572
+ * 检查变更的审批状态。
573
+ * GET {url}/api/changes/{changeName}/approval
574
+ * 返回 { status: 'pending'|'approved'|'rejected', reason?: string }
575
+ */
576
+ async checkApproval(changeName) {
577
+ const platform = this._getPlatform();
578
+ if (!platform) {
579
+ debugLog('[sync] 未连接平台(本地合法状态);如需平台同步:sillyspec platform connect');
580
+ return { status: 'pending', reason: '未连接平台' };
581
+ }
582
+
583
+ if (!changeName) {
584
+ console.warn('[sync] checkApproval 需要指定变更名称 (changeName)');
585
+ return { status: 'pending', reason: '未指定变更名称' };
586
+ }
587
+
588
+ const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
589
+ const result = await fetchJson(approvalUrl, {
590
+ headers: { Authorization: `Bearer ${platform.token}` },
591
+ });
592
+
593
+ if (!result) {
594
+ console.warn(`[sync] 检查审批状态失败: ${changeName}`);
595
+ return { status: 'unknown', reason: '请求失败(404/断网/超时),无法核实审批状态' };
596
+ }
597
+
598
+ // 更新本地 approvals 表
599
+ try {
600
+ const { ProgressManager } = await import('./progress.js');
601
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
602
+ pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
603
+ } catch (err) {
604
+ console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
605
+ }
606
+
607
+ if (result.status === 'rejected') {
608
+ console.warn(`[sync] 审批被拒绝 (${changeName}): ${result.reason || '无原因'}`);
609
+ }
610
+
611
+ return result;
612
+ }
613
+
614
+ /**
615
+ * 查看同步状态。
616
+ * 读取 local.yaml 中的 platform 配置,返回连接信息。
617
+ */
618
+ status() {
619
+ const config = readLocalYaml(this.cwd);
620
+ const platform = config.platform;
621
+ if (!platform) {
622
+ return { connected: false };
623
+ }
624
+ return {
625
+ connected: true,
626
+ url: platform.url,
627
+ lastSync: platform.last_connected || null,
628
+ };
629
+ }
630
+
631
+ /** 获取当前平台配置,未连接返回 null */
632
+ _getPlatform() {
633
+ const config = readLocalYaml(this.cwd);
634
+ return config.platform || null;
635
+ }
636
+
637
+ /**
638
+ * 写冲突文件 .runtime/sync-conflict-<change>.json(task-12 / D-002 / D-008 / D-010 / FR-05)。
639
+ * push 409 与 pull 本地脏度双向冲突命中时调用,强制提示用户走 platform resolve 三选一。
640
+ * 绝不字段级 auto-merge;文件在 .runtime 下不入版本控制(gitignore)。
641
+ * @param {string} changeName
642
+ * @param {{base_ts?: string|null, local_modified_ts?: string|null, platform_last_pushed_at?: string|null, platform_progress?: object|null}} info
643
+ * @returns {string|null} 冲突文件路径(specDir 不可达返回 null)
644
+ */
645
+ _writeConflictFile(changeName, info = {}) {
646
+ const specDir = safePlatformSpecDir(this.cwd);
647
+ if (!specDir) return null;
648
+ const runtimeDir = join(specDir, '.runtime');
649
+ if (!existsSync(runtimeDir)) mkdirSync(runtimeDir, { recursive: true });
650
+ const conflictPath = join(runtimeDir, `sync-conflict-${changeName}.json`);
651
+ const payload = {
652
+ change: changeName,
653
+ base_ts: info.base_ts ?? null,
654
+ local_modified_ts: info.local_modified_ts ?? null,
655
+ platform_last_pushed_at: info.platform_last_pushed_at ?? null,
656
+ created_at: new Date().toISOString(),
657
+ // 额外存平台最新 progress JSON,供 resolve --take-platform 直接 import(无需再 pull)
658
+ platform_progress: info.platform_progress ?? null,
659
+ };
660
+ writeFileSync(conflictPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
661
+ return conflictPath;
662
+ }
663
+
664
+ /**
665
+ * 读取冲突文件(task-13 resolve / task-14 status 用)。
666
+ * @returns {object|null} 冲突文件内容;无文件返回 null
667
+ */
668
+ readConflictFile(changeName) {
669
+ const specDir = safePlatformSpecDir(this.cwd);
670
+ if (!specDir) return null;
671
+ const conflictPath = join(specDir, '.runtime', `sync-conflict-${changeName}.json`);
672
+ if (!existsSync(conflictPath)) return null;
673
+ try {
674
+ return JSON.parse(readFileSync(conflictPath, 'utf8'));
675
+ } catch {
676
+ return null;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * 删除冲突文件(resolve 完成后清理,task-13 用)。
682
+ * @returns {boolean} 是否删除(文件不存在返回 false)
683
+ */
684
+ clearConflictFile(changeName) {
685
+ const specDir = safePlatformSpecDir(this.cwd);
686
+ if (!specDir) return false;
687
+ const conflictPath = join(specDir, '.runtime', `sync-conflict-${changeName}.json`);
688
+ if (!existsSync(conflictPath)) return false;
689
+ try { unlinkSync(conflictPath); return true; }
690
+ catch { return false; }
691
+ }
692
+
693
+ /**
694
+ * 两级 pull 第一级:轻量 change 列表(design §7 / D-001 / D-006 / task-06 / FR-01 / FR-03)。
695
+ * GET {url}/api/changes → [{ name, current_stage, last_pushed_at, last_pusher }](兼容 { changes: [...] } 包裹)。
696
+ * CLI 比对本地决定哪些 change 需更新,控制 pull 性能(不每次全量拉单 change JSON)。
697
+ * Best Effort:未连接 / 网络失败 console.warn 不抛,返回 PullListResult { ok, changes, reason? }。
698
+ * @returns {Promise<{ok: boolean, changes: Array<object>, reason?: string}>}
699
+ */
700
+ async pullList() {
701
+ const platform = this._getPlatform();
702
+ if (!platform) {
703
+ debugLog('[sync] 未连接平台(本地合法状态);pullList 跳过');
704
+ return { ok: false, changes: [], reason: '未连接平台' };
705
+ }
706
+
707
+ const listUrl = `${platform.url}/api/changes`;
708
+ const result = await fetchJson(listUrl, {
709
+ headers: { Authorization: `Bearer ${platform.token}` },
710
+ });
711
+
712
+ if (result === null) {
713
+ console.warn(`[sync] 拉取变更列表失败: ${listUrl}`);
714
+ return { ok: false, changes: [], reason: '拉取变更列表失败' };
715
+ }
716
+
717
+ // 兼容两种响应形态:裸数组 / { changes: [...] } 包裹(sillyhub 后端实际 API 而定)
718
+ const changes = Array.isArray(result) ? result : (Array.isArray(result.changes) ? result.changes : []);
719
+ return { ok: true, changes, reason: undefined };
720
+ }
721
+
722
+ /**
723
+ * 两级 pull 第二级:按需拉平台单 change 完整 JSON 并 import 重建本地 DB 行(design §7 / D-001 / D-006 / D-014 / task-07 / FR-01 / FR-03 / FR-09)。
724
+ * GET {url}/api/changes/{name}/progress → 平台权威 JSON(serializeForSync 六表 + 顶层 last_pushed_at)。
725
+ * 本地脏度比对:last_local_modified_ts > last_synced_platform_ts(本地有未同步改动)且 平台 last_pushed_at 更新 → 冲突,不 import 返回 conflict:true(task-12 负责写 sync-conflict 文件)。
726
+ * 无冲突 → import() 重建 DB 行;import 后本地 base_ts/脏度重置为 pushed_at(D-013)。
727
+ * Best Effort:未连接 / 网络失败 / 404 console.warn 不抛,返回 PullResult { ok, imported, conflict, reason? }。
728
+ * @param {string} changeName
729
+ * @param {{force?: boolean, skipIfLocalDirty?: boolean}} [opts] - force 跳过本地脏度冲突检测直接
730
+ * import(task-12 resolve --take-platform 用);skipIfLocalDirty 自动注入点(triggerPull/
731
+ * triggerPullActiveChange)专用保守守卫——本地有未同步改动时跳过 import(ql-20260818-008)
732
+ * @returns {Promise<{ok: boolean, imported: boolean, conflict: boolean, reason?: string}>}
733
+ */
734
+ async pull(changeName, opts = {}) {
735
+ const { force = false, skipIfLocalDirty = false } = opts;
736
+ const platform = this._getPlatform();
737
+ if (!platform) {
738
+ debugLog('[sync] 未连接平台(本地合法状态);pull 跳过');
739
+ return { ok: false, imported: false, conflict: false, reason: '未连接平台' };
740
+ }
741
+ if (!changeName) {
742
+ console.warn('[sync] pull 需要指定变更名称 (changeName)');
743
+ return { ok: false, imported: false, conflict: false, reason: '未指定变更名称' };
744
+ }
745
+
746
+ // 1. GET 平台完整 progress JSON
747
+ const progressUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/progress`;
748
+ const result = await fetchJson(progressUrl, {
749
+ headers: { Authorization: `Bearer ${platform.token}` },
750
+ });
751
+ if (result === null) {
752
+ // sillyhub 未就绪 / 404 / 网络失败均 fetchJson 返回 null → Best Effort 降级
753
+ console.warn(`[sync] 拉取变更进度失败: ${changeName}(sillyhub 未就绪或变更不存在)`);
754
+ return { ok: false, imported: false, conflict: false, reason: '拉取变更进度失败' };
755
+ }
756
+
757
+ // 平台响应:serializeForSync 六表 + 顶层 last_pushed_at(兼容 { progress: {...} } 包裹)
758
+ const platformProgress = (result && result.project && result.changes) ? result : (result.progress || result);
759
+ const platformPushedAt = (result && result.last_pushed_at) || null;
760
+
761
+ // 2. 本地脏度比对(非 force 时):本地脏 AND 平台更新 → 冲突
762
+ if (!force) {
763
+ let localLastModified = null;
764
+ let localLastSynced = null;
765
+ try {
766
+ const { ProgressManager } = await import('./progress.js');
767
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
768
+ const row = pm._ensureDB(this.cwd).getDb().prepare(
769
+ 'SELECT last_local_modified_ts, last_synced_platform_ts FROM changes WHERE name = ?'
770
+ ).get(changeName);
771
+ if (row) {
772
+ localLastModified = row.last_local_modified_ts;
773
+ localLastSynced = row.last_synced_platform_ts;
774
+ }
775
+ } catch (err) {
776
+ console.warn(`[sync] 读取本地脏度失败 (${changeName}): ${err.message}`);
777
+ }
778
+ let localDirty = localLastModified && localLastSynced && localLastModified > localLastSynced;
779
+ let platformNewer = platformPushedAt && (!localLastSynced || platformPushedAt > localLastSynced);
780
+ // 自竞态防御(坑 2026-08-19-platform-sync-base-ts-silent-conflict 改进点 3):
781
+ // 判冲突用的 localLastSynced 若落后于本进程刚完成的 push 回填(另一连接 3ms 前推完、
782
+ // 本 pull 在其回填写库前读了旧值),会把自己刚写的 platformPushedAt 误判为「平台有更新」
783
+ // → 卡死。重读一次 DB:期间 push 回填已落库则 base_ts 已推进(>= platformPushedAt),
784
+ // platformNewer 翻 false,冲突自愈。重读仍旧则真冲突,维持原判。
785
+ if (localDirty && platformNewer) {
786
+ try {
787
+ const { ProgressManager } = await import('./progress.js');
788
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
789
+ const fresh = pm._ensureDB(this.cwd).getDb().prepare(
790
+ 'SELECT last_synced_platform_ts FROM changes WHERE name = ?'
791
+ ).get(changeName);
792
+ if (fresh && fresh.last_synced_platform_ts) {
793
+ localLastSynced = fresh.last_synced_platform_ts;
794
+ platformNewer = platformPushedAt && platformPushedAt > localLastSynced;
795
+ if (!platformNewer) {
796
+ debugLog(`[sync] pull 自竞态解除: base_ts 已被本进程 push 回填推进到 ${localLastSynced},平台 ts ${platformPushedAt} 非更新`);
797
+ }
798
+ }
799
+ } catch { /* 重读失败维持原判(fail-closed 到真冲突分支) */ }
800
+ }
801
+ if (localDirty && platformNewer) {
802
+ console.warn('');
803
+ console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
804
+ console.warn(`⚠️ 平台同步冲突:变更「${changeName}」pull 判定冲突(本地有未同步改动且平台已更新,base_ts 过期)`);
805
+ console.warn('⚠️ 该变更的自动同步已卡死,不会自愈——需人工 resolve 才能恢复:');
806
+ console.warn(`⚠️ 1. sillyspec platform status # 查看未决冲突`);
807
+ console.warn(`⚠️ 2. sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
808
+ console.warn(`⚠️ 3. sillyspec platform sync --change ${changeName}`);
809
+ console.warn('⚠️ 冲突详情已落盘: .sillyspec/.runtime/sync-conflict-' + changeName + '.json');
810
+ console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
811
+ // 写冲突文件(task-12 / D-002):强制提示,绝不 auto-merge
812
+ const conflictPath = this._writeConflictFile(changeName, {
813
+ base_ts: localLastSynced,
814
+ local_modified_ts: localLastModified,
815
+ platform_last_pushed_at: platformPushedAt,
816
+ platform_progress: platformProgress,
817
+ });
818
+ return { ok: false, imported: false, conflict: true, reason: `冲突: ${changeName} 本地脏且平台更新`, conflictPath };
819
+ }
820
+ // ql-20260818-008:自动注入点(triggerPull/triggerPullActiveChange)保守守卫——本地有未同步
821
+ // 改动时跳过 import。原逻辑只拦「本地脏 且 平台更新」的真冲突,「本地领先」(本地脏、平台更旧)
822
+ // 仍会 import 平台旧快照覆盖本地新进度(数据丢失)。手动 platform pull 不传该 flag,语义不变。
823
+ if (skipIfLocalDirty && localDirty) {
824
+ debugLog(`[sync] pull 跳过(本地有未同步改动,防平台旧快照覆盖): ${changeName}`);
825
+ return { ok: false, imported: false, conflict: false, reason: '本地有未同步改动,自动 pull 跳过' };
826
+ }
827
+ }
828
+
829
+ // 3. 无冲突(或 force)→ import 平台 JSON 重建本地 DB 行
830
+ try {
831
+ const { ProgressManager } = await import('./progress.js');
832
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
833
+ const importInput = { ...platformProgress, pushed_at: platformPushedAt || new Date().toISOString() };
834
+ pm.import(this.cwd, importInput, changeName);
835
+ console.log(`[sync] 已拉取变更进度: ${changeName}`);
836
+ return { ok: true, imported: true, conflict: false, reason: undefined };
837
+ } catch (err) {
838
+ console.warn(`[sync] import 失败 (${changeName}): ${err.message}`);
839
+ return { ok: false, imported: false, conflict: false, reason: `import 失败: ${err.message}` };
840
+ }
841
+ }
842
+
843
+ /**
844
+ * 冲突解决三选一(task-13 / D-002 / D-010 / D-013 / FR-05)。
845
+ * 读 task-12 写的 sync-conflict-<change>.json,按 mode 处理后必清冲突文件防累积(R-04 / constraints)。
846
+ *
847
+ * 三种语义(design 生命周期契约表):
848
+ * - keep-local:本地 DB 不变,仅把 last_synced_platform_ts(base_ts)推进到平台 last_pushed_at
849
+ * (表示「已知平台最新,本地为准」),用户后续手动 platform sync push 本地。
850
+ * - take-platform:用冲突文件存的 platform_progress 调 import() 覆盖本地(保隔离状态,D-013 重置脏度)。
851
+ * - abort:本地 DB 与 base_ts 均不变,仅清冲突文件(放弃本次同步)。
852
+ *
853
+ * 无冲突文件 → ok:false resolved:false(index.js 据此提示「无可解决冲突」)。
854
+ * 绝不字段级 auto-merge(D-002)。
855
+ * @param {string} changeName
856
+ * @param {'keep-local'|'take-platform'|'abort'} mode
857
+ * @returns {Promise<{ok: boolean, resolved: boolean, mode?: string, reason: string}>}
858
+ */
859
+ async resolve(changeName, mode) {
860
+ const cf = this.readConflictFile(changeName);
861
+ if (!cf) {
862
+ return { ok: false, resolved: false, reason: `无可解决冲突: ${changeName}(无 sync-conflict 文件)` };
863
+ }
864
+ const platformPushedAt = cf.platform_last_pushed_at || null;
865
+
866
+ if (mode === 'keep-local') {
867
+ // base_ts 推进到平台最新 last_pushed_at;本地 DB 不 import(用户本地为准,后续手动 push)。
868
+ // MAX() 单调防回退(坑 2026-08-19-resolve-keep-local-base-ts-rollback):冲突文件是历史快照,
869
+ // 其 platform_last_pushed_at 可能早于 DB 已由后续成功 push 回填的 base_ts——无条件覆盖会把
870
+ // base_ts 拉回过去,下次 sync 立即撞 409 再落冲突文件(恢复时实测二轮才收敛)。
871
+ // COALESCE 防 NULL:SQLite 标量 MAX(x, NULL) 恒 NULL,首同步前 base_ts NULL 应直取平台 ts。
872
+ try {
873
+ const { ProgressManager } = await import('./progress.js');
874
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
875
+ 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 = ?')
877
+ .run(platformPushedAt, platformPushedAt, changeName);
878
+ } catch (err) {
879
+ return { ok: false, resolved: false, reason: `keep-local 更新 base_ts 失败: ${err.message}` };
880
+ }
881
+ this.clearConflictFile(changeName);
882
+ return { ok: true, resolved: true, mode: 'keep-local', reason: '保留本地,base_ts 已推进到平台最新(后续请 platform sync push 本地)' };
883
+ }
884
+
885
+ if (mode === 'take-platform') {
886
+ // 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)
887
+ if (!cf.platform_progress) {
888
+ return { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
889
+ }
890
+ try {
891
+ const { ProgressManager } = await import('./progress.js');
892
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
893
+ pm.import(this.cwd, { ...cf.platform_progress, pushed_at: platformPushedAt || new Date().toISOString() }, changeName);
894
+ } catch (err) {
895
+ return { ok: false, resolved: false, reason: `take-platform import 失败: ${err.message}` };
896
+ }
897
+ this.clearConflictFile(changeName);
898
+ return { ok: true, resolved: true, mode: 'take-platform', reason: '已用平台进度覆盖本地' };
899
+ }
900
+
901
+ if (mode === 'abort') {
902
+ // 本地 DB 与 base_ts 均不变,仅清冲突文件(放弃本次同步,下次 push/pull 会重新检测)
903
+ this.clearConflictFile(changeName);
904
+ return { ok: true, resolved: true, mode: 'abort', reason: '放弃本次同步,本地不变' };
905
+ }
906
+
907
+ return { ok: false, resolved: false, reason: `未知 resolve 模式: ${mode}(--keep-local / --take-platform / --abort)` };
908
+ }
909
+
910
+ /**
911
+ * 扫描 .runtime/sync-conflict-*.json 列出未决冲突(task-14 / D-002 / D-010 / FR-05)。
912
+ * 只读 + 容错:损坏文件跳过不崩(constraints)。供 collectStatus / platform status 展示。
913
+ * @returns {Array<{change: string, created_at: string|null, path: string}>}
914
+ */
915
+ listConflictFiles() {
916
+ const specDir = safePlatformSpecDir(this.cwd);
917
+ if (!specDir) return [];
918
+ const runtimeDir = join(specDir, '.runtime');
919
+ if (!existsSync(runtimeDir)) return [];
920
+ let files = [];
921
+ try { files = readdirSync(runtimeDir); }
922
+ catch { return []; }
923
+ const conflicts = [];
924
+ for (const f of files) {
925
+ if (!f.startsWith('sync-conflict-') || !f.endsWith('.json')) continue;
926
+ const filePath = join(runtimeDir, f);
927
+ try {
928
+ const cf = JSON.parse(readFileSync(filePath, 'utf8'));
929
+ conflicts.push({
930
+ change: cf.change || f.replace(/^sync-conflict-/, '').replace(/\.json$/, ''),
931
+ created_at: cf.created_at || null,
932
+ path: filePath,
933
+ });
934
+ } catch {
935
+ // 损坏文件跳过不崩(constraints:容错)
936
+ }
937
+ }
938
+ return conflicts;
939
+ }
940
+
941
+ /**
942
+ * 扩展 status(task-14 / FR-05):连接信息 + 落后标记 + 未决冲突列表。
943
+ * 只读展示,不修改任何进度(constraints)。未连接返回 base.status(connected:false,behind/conflicts 空)。
944
+ * 连接时调 pullList(轻量 GET)拿平台各 change last_pushed_at,比对本地 last_synced_platform_ts
945
+ * 标记本地可能落后;扫描 sync-conflict-*.json 列未决冲突。网络失败 listFailed=true 不崩。
946
+ * @returns {Promise<{connected: boolean, url?: string, lastSync?: string|null, behind: Array<object>, conflicts: Array<object>, listFailed: boolean}>}
947
+ */
948
+ async collectStatus() {
949
+ const base = this.status();
950
+ if (!base.connected) {
951
+ return { ...base, behind: [], conflicts: [], listFailed: false };
952
+ }
953
+ const behind = [];
954
+ let listFailed = false;
955
+ const list = await this.pullList();
956
+ if (list.ok) {
957
+ for (const ch of list.changes) {
958
+ const name = typeof ch === 'string' ? ch : ch.name;
959
+ if (!name) continue;
960
+ const platformPushedAt = (typeof ch === 'object' && (ch.last_pushed_at || ch.last_active)) || null;
961
+ if (!platformPushedAt) continue;
962
+ let localSynced = null;
963
+ try {
964
+ const { ProgressManager } = await import('./progress.js');
965
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
966
+ const row = pm._ensureDB(this.cwd).getDb().prepare('SELECT last_synced_platform_ts FROM changes WHERE name = ?').get(name);
967
+ if (row) localSynced = row.last_synced_platform_ts;
968
+ } catch {
969
+ // 本地无该 change 记录 / DB 不可达 → 不算落后,跳过
970
+ }
971
+ // 仅本地有同步基准(last_synced_platform_ts)且平台更新晚于本地 → 落后
972
+ if (localSynced && platformPushedAt > localSynced) {
973
+ behind.push({ name, local_synced: localSynced, platform_pushed_at: platformPushedAt });
974
+ }
975
+ }
976
+ } else {
977
+ listFailed = true;
978
+ }
979
+ const conflicts = this.listConflictFiles();
980
+ return { ...base, behind, conflicts, listFailed };
981
+ }
982
+ }
983
+
984
+ // ── CLI 入口函数 ──
985
+
986
+ /**
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 直接调用
1002
+ */
1003
+ export async function connect(url, token, cwd) {
1004
+ return new SyncManager(cwd).connect(url, token);
1005
+ }
1006
+
1007
+ export async function disconnect(cwd) {
1008
+ return new SyncManager(cwd).disconnect();
1009
+ }
1010
+
1011
+ export async function sync(changeName, cwd) {
1012
+ return new SyncManager(cwd).sync(changeName);
1013
+ }
1014
+
1015
+ // manual=true:本导出对应 CLI platform sync-docs 手动命令(index.js platform 分支唯一调用方),
1016
+ // 四件套缺失打 warn;sync() 内的自动顺带推走 SyncManager 实例方法默认 auto(ql-20260818-008)
1017
+ export async function syncDocuments(changeName, cwd) {
1018
+ return new SyncManager(cwd).syncDocuments(changeName, { manual: true });
1019
+ }
1020
+
1021
+ export async function checkApproval(changeName, cwd) {
1022
+ return new SyncManager(cwd).checkApproval(changeName);
1023
+ }
1024
+
1025
+ // task-11 / D-006 / D-009 / FR-03:手动 pull 便捷导出(index.js platform pull 子命令用,
1026
+ // 与自动 triggerPull 共用 SyncManager.pull 实例方法,行为一致)
1027
+ export async function pull(changeName, opts, cwd) {
1028
+ return new SyncManager(cwd).pull(changeName, opts);
1029
+ }
1030
+
1031
+ // 两级 pull 第一级(轻量 change 列表),platform pull 无 --change 时先拉列表再按需 pull
1032
+ export async function pullList(cwd) {
1033
+ return new SyncManager(cwd).pullList();
1034
+ }
1035
+
1036
+ // task-13 / D-002 / D-010 / D-013 / FR-05:冲突解决三选一(index.js platform resolve 子命令用)
1037
+ export async function resolve(changeName, mode, cwd) {
1038
+ return new SyncManager(cwd).resolve(changeName, mode);
1039
+ }
1040
+
1041
+ // task-14 / FR-05:扩展 status(连接信息 + 落后标记 + 未决冲突列表),index.js platform status 用
1042
+ export async function collectStatus(cwd) {
1043
+ return new SyncManager(cwd).collectStatus();
1044
+ }
1045
+
1046
+ // 未决冲突文件列表便捷导出(index.js platform resolve 参数解析/报错兜底用,只读)
1047
+ export function listConflictFiles(cwd) {
1048
+ return new SyncManager(cwd).listConflictFiles();
1049
+ }
1050
+
1051
+ // ql-20260818-011:quick 会话专用 spec 树同步入口。quick-<hex8> 会话按设计无
1052
+ // changes/<name>/ 实体目录,progress/四件套上行对它是孤儿数据(平台变更中心按磁盘
1053
+ // change_key join 永不命中),但 spec 树增量(QUICKLOG/模块文档的上行通道)以服务器
1054
+ // 清单为锚做全树 diff,与变更目录无关——triggerSync 对 quick 会话降级只调本函数,
1055
+ // 不走 sync()(后者第二道 existsSync 门会以「变更不存在」提前 return)。
1056
+ // 未连接平台 → {synced: 0} 静默(本地合法状态,与 syncSpecTree 内部口径一致)。
1057
+ export async function syncSpecTreeOnly(changeName, cwd) {
1058
+ const sm = new SyncManager(cwd);
1059
+ const platform = sm._getPlatform();
1060
+ if (!platform) return { synced: 0 };
1061
+ return syncSpecTree(join(cwd, '.sillyspec'), platform, changeName);
1062
+ }
1063
+
1064
+ // TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
1065
+ // 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
1066
+ /**
1067
+ * 向平台提交审批决定(approve/reject 共用)。
1068
+ *
1069
+ * 显式用户/daemon 动作:网络/平台失败必须可见(decisions.md D-006@v1)——
1070
+ * 与 best-effort 的 sync 不同,此处失败打 error 并置 process.exitCode = 1。
1071
+ *
1072
+ * @param {string} cwd - 工作目录
1073
+ * @param {string} changeName - 变更名称
1074
+ * @param {'approved'|'rejected'} decision - 审批决定
1075
+ * @param {string|null} reason - rejected 时的原因(approved 时忽略)
1076
+ * @returns {Promise<boolean>} 是否成功
1077
+ */
1078
+ async function _submitApproval(cwd, changeName, decision, reason = null) {
1079
+ const sm = new SyncManager(cwd);
1080
+ const platform = sm._getPlatform();
1081
+ if (!platform) {
1082
+ console.error('❌ 未连接平台,请先 sillyspec platform connect');
1083
+ process.exitCode = 1;
1084
+ return false;
1085
+ }
1086
+
1087
+ if (!changeName) {
1088
+ console.error('❌ 审批需要指定变更名称 (changeName)');
1089
+ process.exitCode = 1;
1090
+ return false;
1091
+ }
1092
+
1093
+ // TBD-hub-api: 端点路径/body 以 SillyHub 实际 API 为准,对齐时只改此处
1094
+ const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
1095
+ const body = decision === 'rejected'
1096
+ ? { decision: 'rejected', reason }
1097
+ : { decision: 'approved' };
1098
+
1099
+ const result = await fetchJson(approvalUrl, {
1100
+ method: 'POST',
1101
+ headers: {
1102
+ 'Content-Type': 'application/json',
1103
+ Authorization: `Bearer ${platform.token}`,
1104
+ },
1105
+ body: JSON.stringify(body),
1106
+ });
1107
+
1108
+ // fetchJson 在非 2xx / 网络错 / 超时 / 非 JSON 时返回 null
1109
+ if (result === null) {
1110
+ console.error(`❌ 审批请求失败 (${changeName} → ${decision}),详情见上方 [sync] 警告`);
1111
+ process.exitCode = 1;
1112
+ return false;
1113
+ }
1114
+
1115
+ // HTTP 成功后更新本地 approvals 表(HTTP 已成功是主要目标,落库失败只 warn 不阻断)
1116
+ try {
1117
+ const { ProgressManager } = await import('./progress.js');
1118
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(cwd) });
1119
+ pm._updateApprovalStatus(cwd, changeName, decision, reason);
1120
+ } catch (err) {
1121
+ console.warn(`[sync] 更新本地审批状态失败 (${changeName}): ${err.message}`);
1122
+ }
1123
+
1124
+ if (decision === 'approved') {
1125
+ console.log(`✅ 已批准变更 ${changeName}`);
1126
+ } else {
1127
+ console.log(`✅ 已拒绝变更 ${changeName}${reason ? `(原因: ${reason})` : ''}`);
1128
+ }
1129
+ return true;
1130
+ }
1131
+
1132
+ export async function approve(changeName, cwd) {
1133
+ return _submitApproval(cwd, changeName, 'approved');
1134
+ }
1135
+
1136
+ export async function reject(changeName, reason, cwd) {
1137
+ return _submitApproval(cwd, changeName, 'rejected', reason);
1138
+ }
1139
+
1140
+ export async function status(cwd) {
1141
+ const sm = new SyncManager(cwd);
1142
+ const st = sm.status();
1143
+ if (!st.connected) {
1144
+ console.log('平台: 未连接');
1145
+ } else {
1146
+ console.log(`平台: ${st.url}`);
1147
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
1148
+ }
1149
+ }
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
+ }