sillyspec 3.20.2 → 3.20.4

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 (133) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +21 -21
  2. package/.claude/skills/sillyspec-auto/SKILL.md +83 -83
  3. package/.claude/skills/sillyspec-brainstorm/SKILL.md +44 -44
  4. package/.claude/skills/sillyspec-commit/SKILL.md +106 -106
  5. package/.claude/skills/sillyspec-continue/SKILL.md +45 -45
  6. package/.claude/skills/sillyspec-doctor/SKILL.md +31 -31
  7. package/.claude/skills/sillyspec-execute/SKILL.md +30 -30
  8. package/.claude/skills/sillyspec-explore/SKILL.md +109 -109
  9. package/.claude/skills/sillyspec-knowledge/SKILL.md +269 -269
  10. package/.claude/skills/sillyspec-plan/SKILL.md +21 -21
  11. package/.claude/skills/sillyspec-propose/SKILL.md +21 -21
  12. package/.claude/skills/sillyspec-quick/SKILL.md +21 -21
  13. package/.claude/skills/sillyspec-resume/SKILL.md +68 -68
  14. package/.claude/skills/sillyspec-scan/SKILL.md +21 -21
  15. package/.claude/skills/sillyspec-state/SKILL.md +54 -54
  16. package/.claude/skills/sillyspec-status/SKILL.md +21 -21
  17. package/.claude/skills/sillyspec-verify/SKILL.md +21 -21
  18. package/.claude/skills/sillyspec-workspace/SKILL.md +157 -157
  19. package/.husky/pre-push +13 -13
  20. package/CLAUDE.md +18 -18
  21. package/README.md +198 -188
  22. package/SKILL.md +90 -91
  23. package/bin/sillyspec.js +2 -2
  24. package/docs/brainstorm-plan-contract.md +64 -64
  25. package/docs/plan-execute-contract.md +123 -123
  26. package/docs/platform-scan-protocol.md +298 -298
  27. package/docs/revision-mode.md +115 -115
  28. package/docs/sillyspec/file-lifecycle/known-implementation-gaps.md +99 -99
  29. package/docs/sillyspec/file-lifecycle/platform-workflows-sync.md +218 -218
  30. package/docs/sillyspec/file-lifecycle/stage-artifacts.md +167 -167
  31. package/docs/sillyspec/file-lifecycle/storage-and-state.md +148 -148
  32. package/docs/sillyspec/file-lifecycle/worktree-and-guard.md +211 -193
  33. package/docs/sillyspec/file-lifecycle.md +125 -125
  34. package/docs/workflow-contract-regression.md +106 -106
  35. package/docs/worktree-isolation.md +252 -252
  36. package/package.json +40 -40
  37. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  38. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  39. package/packages/dashboard/dist/index.html +16 -16
  40. package/packages/dashboard/index.html +15 -15
  41. package/packages/dashboard/package-lock.json +2384 -2384
  42. package/packages/dashboard/package.json +25 -25
  43. package/packages/dashboard/server/executor.js +86 -86
  44. package/packages/dashboard/server/index.js +588 -588
  45. package/packages/dashboard/server/parser.js +526 -526
  46. package/packages/dashboard/server/watcher.js +344 -344
  47. package/packages/dashboard/src/App.vue +558 -558
  48. package/packages/dashboard/src/components/ActionBar.vue +93 -93
  49. package/packages/dashboard/src/components/CommandPalette.vue +96 -96
  50. package/packages/dashboard/src/components/DetailPanel.vue +137 -137
  51. package/packages/dashboard/src/components/LogStream.vue +65 -65
  52. package/packages/dashboard/src/components/PipelineStage.vue +95 -95
  53. package/packages/dashboard/src/components/PipelineView.vue +156 -156
  54. package/packages/dashboard/src/components/ProjectList.vue +210 -210
  55. package/packages/dashboard/src/components/StageBadge.vue +67 -67
  56. package/packages/dashboard/src/components/StepCard.vue +94 -94
  57. package/packages/dashboard/src/components/detail/DocsDetail.vue +48 -48
  58. package/packages/dashboard/src/components/detail/GitDetail.vue +61 -61
  59. package/packages/dashboard/src/components/detail/TechDetail.vue +43 -43
  60. package/packages/dashboard/src/composables/useDashboard.js +170 -170
  61. package/packages/dashboard/src/composables/useKeyboard.js +119 -119
  62. package/packages/dashboard/src/composables/useWebSocket.js +129 -129
  63. package/packages/dashboard/src/main.js +8 -8
  64. package/packages/dashboard/src/style.css +132 -132
  65. package/packages/dashboard/vite.config.js +18 -18
  66. package/src/brainstorm-postcheck.js +158 -158
  67. package/src/change-list.js +52 -52
  68. package/src/change-risk-profile.js +352 -352
  69. package/src/classify-change.js +73 -73
  70. package/src/constants.js +70 -70
  71. package/src/contract-matrix.js +278 -278
  72. package/src/db.js +201 -201
  73. package/src/endpoint-extractor.js +315 -315
  74. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  75. package/src/hooks/worktree-guard.js +653 -653
  76. package/src/index.js +922 -900
  77. package/src/init.js +431 -431
  78. package/src/knowledge-match.js +130 -130
  79. package/src/migrate.js +117 -117
  80. package/src/modules.js +482 -482
  81. package/src/progress.js +1734 -1734
  82. package/src/run.js +3465 -3358
  83. package/src/scan-postcheck.js +387 -383
  84. package/src/setup.js +398 -398
  85. package/src/stage-contract.js +700 -700
  86. package/src/stages/archive.js +160 -160
  87. package/src/stages/brainstorm-auto.js +229 -229
  88. package/src/stages/brainstorm.js +645 -645
  89. package/src/stages/doctor.js +365 -365
  90. package/src/stages/execute.js +625 -625
  91. package/src/stages/explore.js +34 -34
  92. package/src/stages/index.js +29 -29
  93. package/src/stages/knowledge.js +498 -498
  94. package/src/stages/plan-postcheck.js +511 -513
  95. package/src/stages/plan.js +582 -582
  96. package/src/stages/propose.js +174 -174
  97. package/src/stages/quick.js +82 -82
  98. package/src/stages/scan.js +558 -558
  99. package/src/stages/status.js +65 -65
  100. package/src/stages/verify.js +322 -322
  101. package/src/sync.js +497 -497
  102. package/src/task-review.js +346 -346
  103. package/src/workflow.js +785 -785
  104. package/src/worktree-apply.js +549 -549
  105. package/src/worktree-deps.js +185 -0
  106. package/src/worktree.js +982 -932
  107. package/templates/workflows/archive-impact.yaml +79 -79
  108. package/templates/workflows/scan-docs.yaml +132 -132
  109. package/test/brainstorm-plan-contract.test.mjs +273 -273
  110. package/test/check-syntax.mjs +26 -26
  111. package/test/contract-artifacts.test.mjs +323 -323
  112. package/test/decision-supersede.test.mjs +277 -277
  113. package/test/knowledge-match.test.mjs +231 -231
  114. package/test/plan-execute-contract.test.mjs +330 -330
  115. package/test/plan-optimization.test.mjs +572 -572
  116. package/test/platform-artifacts.test.mjs +166 -166
  117. package/test/platform-failure-samples.test.mjs +199 -199
  118. package/test/platform-recovery-chain.test.mjs +167 -167
  119. package/test/platform-recovery.test.mjs +136 -136
  120. package/test/platform-scan-p0.test.mjs +168 -168
  121. package/test/revision-v1.test.mjs +1145 -1145
  122. package/test/run-scan-project-parse.test.mjs +200 -200
  123. package/test/run-tests.mjs +48 -48
  124. package/test/scan-knowledge.test.mjs +175 -175
  125. package/test/scan-paths.test.mjs +68 -68
  126. package/test/scan-postcheck.test.mjs +197 -197
  127. package/test/spec-dir.test.mjs +206 -206
  128. package/test/stage-contract.test.mjs +299 -299
  129. package/test/stage-definitions.test.mjs +39 -39
  130. package/test/wait-gates.test.mjs +496 -496
  131. package/test/worktree-deps-provision.test.mjs +148 -0
  132. package/test/worktree-guard.test.mjs +71 -71
  133. package/test/worktree-native-overlay.test.mjs +188 -188
package/src/sync.js CHANGED
@@ -1,497 +1,497 @@
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, writeFileSync, unlinkSync } from 'fs';
12
- import { join } from 'path';
13
-
14
- const LOCAL_YAML = '.sillyspec/local.yaml';
15
- const CHANGES_DIR = '.sillyspec/changes';
16
- const REQUEST_TIMEOUT_MS = 10_000;
17
-
18
- /** 四件套文档文件名 */
19
- const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'];
20
-
21
- // ── YAML 辅助 ──
22
-
23
- /**
24
- * 简易 YAML 读写,只处理 project 段的扁平结构。
25
- * 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
26
- */
27
- function readLocalYaml(cwd) {
28
- const p = join(cwd, LOCAL_YAML);
29
- if (!existsSync(p)) return {};
30
- try {
31
- return parseSimpleYaml(readFileSync(p, 'utf8'));
32
- } catch {
33
- return {};
34
- }
35
- }
36
-
37
- function writeLocalYaml(cwd, obj) {
38
- const dir = join(cwd, '.sillyspec');
39
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
- const lines = [];
41
- const rootKeys = Object.keys(obj);
42
- for (const key of rootKeys) {
43
- const val = obj[key];
44
- if (val === null || val === undefined) continue;
45
- if (typeof val === 'object' && !Array.isArray(val)) {
46
- lines.push(`${key}:`);
47
- for (const [k, v] of Object.entries(val)) {
48
- if (typeof v === 'string') {
49
- lines.push(` ${k}: ${v}`);
50
- } else {
51
- lines.push(` ${k}: ${JSON.stringify(v)}`);
52
- }
53
- }
54
- } else {
55
- lines.push(`${key}: ${typeof val === 'string' ? val : JSON.stringify(val)}`);
56
- }
57
- }
58
- writeFileSync(join(cwd, LOCAL_YAML), lines.join('\n') + '\n', 'utf8');
59
- }
60
-
61
- function parseSimpleYaml(content) {
62
- const result = {};
63
- let currentSection = null;
64
- for (const line of content.split('\n')) {
65
- const trimmed = line.trim();
66
- if (!trimmed || trimmed.startsWith('#')) continue;
67
- if (!trimmed.startsWith(' ')) {
68
- const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
69
- if (m) {
70
- const key = m[1];
71
- const val = m[2].trim();
72
- if (val) {
73
- result[key] = val;
74
- currentSection = null;
75
- } else {
76
- result[key] = {};
77
- currentSection = key;
78
- }
79
- }
80
- } else if (currentSection) {
81
- const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
82
- if (m && result[currentSection] && typeof result[currentSection] === 'object') {
83
- result[currentSection][m[1]] = m[2].trim();
84
- }
85
- }
86
- }
87
- return result;
88
- }
89
-
90
- // ── HTTP 辅助 ──
91
-
92
- async function fetchJson(url, options = {}) {
93
- const controller = new AbortController();
94
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
95
- try {
96
- const res = await fetch(url, { ...options, signal: controller.signal });
97
- if (!res.ok) {
98
- const text = await res.text().catch(() => '');
99
- console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
100
- return null;
101
- }
102
- const ct = res.headers.get('content-type') || '';
103
- if (ct.includes('application/json')) {
104
- return res.json();
105
- }
106
- return null;
107
- } catch (err) {
108
- if (err.name === 'AbortError') {
109
- console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
110
- } else {
111
- console.warn(`[sync] ${url} 请求失败: ${err.message}`);
112
- }
113
- return null;
114
- } finally {
115
- clearTimeout(timer);
116
- }
117
- }
118
-
119
- // ── SyncManager ──
120
-
121
- export class SyncManager {
122
- constructor(cwd) {
123
- this.cwd = cwd;
124
- }
125
-
126
- /**
127
- * 连接 SillyHub 平台。
128
- * 保存配置到 .sillyspec/local.yaml,发送 ping 验证连接。
129
- */
130
- async connect(url, token) {
131
- // 验证连接
132
- const healthUrl = `${url.replace(/\/+$/, '')}/api/health`;
133
- const result = await fetchJson(healthUrl);
134
- if (result === null) {
135
- console.warn(`[sync] 平台连接验证失败: ${url}`);
136
- return;
137
- }
138
- console.log(`[sync] 平台连接成功: ${url}`);
139
-
140
- // 写入 local.yaml
141
- const config = readLocalYaml(this.cwd);
142
- config.platform = {
143
- url: url.replace(/\/+$/, ''),
144
- token,
145
- last_connected: new Date().toISOString(),
146
- };
147
- writeLocalYaml(this.cwd, config);
148
- }
149
-
150
- /**
151
- * 断开平台连接。
152
- * 从 local.yaml 删除 platform 配置段。
153
- */
154
- disconnect() {
155
- const p = join(this.cwd, LOCAL_YAML);
156
- if (!existsSync(p)) {
157
- console.log('[sync] 已断开连接(无配置文件)');
158
- return;
159
- }
160
- const config = readLocalYaml(this.cwd);
161
- if (!config.platform) {
162
- console.log('[sync] 已断开连接(未连接)');
163
- return;
164
- }
165
- delete config.platform;
166
- if (Object.keys(config).length === 0) {
167
- // 配置为空,删除整个文件
168
- try { unlinkSync(p); } catch { /* best effort */ }
169
- } else {
170
- writeLocalYaml(this.cwd, config);
171
- }
172
- console.log('[sync] 已断开连接');
173
- }
174
-
175
- /**
176
- * 增量同步变更的 progress 状态到平台。
177
- * 读取 ProgressManager.read() 的数据,POST 到平台。
178
- * 同步完成后更新 changes 表的 platform_last_sync 字段。
179
- */
180
- async sync(changeName) {
181
- const platform = this._getPlatform();
182
- if (!platform) {
183
- console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
184
- return { synced: 0, errors: ['未连接平台'] };
185
- }
186
-
187
- if (!changeName) {
188
- console.warn('[sync] sync 需要指定变更名称 (changeName)');
189
- return { synced: 0, errors: ['未指定变更名称'] };
190
- }
191
-
192
- // 检查变更是否存在
193
- const changeDir = join(this.cwd, CHANGES_DIR, changeName);
194
- if (!existsSync(changeDir)) {
195
- console.warn(`[sync] 变更不存在: ${changeName}`);
196
- return { synced: 0, errors: [`变更不存在: ${changeName}`] };
197
- }
198
-
199
- // 读取 progress 数据(通过导入 ProgressManager 动态调用)
200
- let progressData;
201
- try {
202
- const { ProgressManager } = await import('./progress.js');
203
- const pm = new ProgressManager();
204
- progressData = await pm.read(this.cwd, changeName);
205
- } catch (err) {
206
- console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
207
- return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
208
- }
209
-
210
- // POST 到平台
211
- const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
212
- const result = await fetchJson(syncUrl, {
213
- method: 'POST',
214
- headers: {
215
- 'Content-Type': 'application/json',
216
- Authorization: `Bearer ${platform.token}`,
217
- },
218
- body: JSON.stringify(progressData),
219
- });
220
-
221
- if (!result) {
222
- return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
223
- }
224
-
225
- // 更新 platform_last_sync
226
- try {
227
- const { ProgressManager } = await import('./progress.js');
228
- const pm = new ProgressManager();
229
- await pm._updatePlatformLastSync(this.cwd, changeName);
230
- } catch (err) {
231
- console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
232
- }
233
-
234
- console.log(`[sync] 已同步变更: ${changeName}`);
235
- return { synced: 1, errors: [] };
236
- }
237
-
238
- /**
239
- * 同步四件套文档到平台(全量同步)。
240
- * POST {url}/api/changes/{changeName}/documents
241
- */
242
- async syncDocuments(changeName) {
243
- const platform = this._getPlatform();
244
- if (!platform) {
245
- console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
246
- return { synced: 0, errors: ['未连接平台'] };
247
- }
248
-
249
- if (!changeName) {
250
- console.warn('[sync] syncDocuments 需要指定变更名称 (changeName)');
251
- return { synced: 0, errors: ['未指定变更名称'] };
252
- }
253
-
254
- const changeDir = join(this.cwd, CHANGES_DIR, changeName);
255
- if (!existsSync(changeDir)) {
256
- console.warn(`[sync] 变更不存在: ${changeName}`);
257
- return { synced: 0, errors: [`变更不存在: ${changeName}`] };
258
- }
259
-
260
- const documents = {};
261
- let syncedCount = 0;
262
- const errors = [];
263
-
264
- for (const docFile of DOCUMENT_FILES) {
265
- const docPath = join(changeDir, docFile);
266
- if (existsSync(docPath)) {
267
- try {
268
- documents[docFile] = readFileSync(docPath, 'utf8');
269
- syncedCount++;
270
- } catch (err) {
271
- errors.push(`读取 ${docFile} 失败: ${err.message}`);
272
- }
273
- }
274
- }
275
-
276
- if (syncedCount === 0) {
277
- console.warn(`[sync] 未找到可同步的文档: ${changeName}`);
278
- return { synced: 0, errors: [...errors, '无可用文档'] };
279
- }
280
-
281
- const docUrl = `${platform.url}/api/changes/${changeName}/documents`;
282
- const result = await fetchJson(docUrl, {
283
- method: 'POST',
284
- headers: {
285
- 'Content-Type': 'application/json',
286
- Authorization: `Bearer ${platform.token}`,
287
- },
288
- body: JSON.stringify(documents),
289
- });
290
-
291
- if (!result) {
292
- return { synced: 0, errors: [...errors, '文档同步请求失败'] };
293
- }
294
-
295
- console.log(`[sync] 已同步 ${syncedCount} 个文档: ${changeName}`);
296
- return { synced: syncedCount, errors };
297
- }
298
-
299
- /**
300
- * 检查变更的审批状态。
301
- * GET {url}/api/changes/{changeName}/approval
302
- * 返回 { status: 'pending'|'approved'|'rejected', reason?: string }
303
- */
304
- async checkApproval(changeName) {
305
- const platform = this._getPlatform();
306
- if (!platform) {
307
- console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
308
- return { status: 'pending', reason: '未连接平台' };
309
- }
310
-
311
- if (!changeName) {
312
- console.warn('[sync] checkApproval 需要指定变更名称 (changeName)');
313
- return { status: 'pending', reason: '未指定变更名称' };
314
- }
315
-
316
- const approvalUrl = `${platform.url}/api/changes/${changeName}/approval`;
317
- const result = await fetchJson(approvalUrl, {
318
- headers: { Authorization: `Bearer ${platform.token}` },
319
- });
320
-
321
- if (!result) {
322
- console.warn(`[sync] 检查审批状态失败: ${changeName}`);
323
- return { status: 'pending', reason: '请求失败' };
324
- }
325
-
326
- // 更新本地 approvals 表
327
- try {
328
- const { ProgressManager } = await import('./progress.js');
329
- const pm = new ProgressManager();
330
- await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
331
- } catch (err) {
332
- console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
333
- }
334
-
335
- if (result.status === 'rejected') {
336
- console.warn(`[sync] 审批被拒绝 (${changeName}): ${result.reason || '无原因'}`);
337
- }
338
-
339
- return result;
340
- }
341
-
342
- /**
343
- * 查看同步状态。
344
- * 读取 local.yaml 中的 platform 配置,返回连接信息。
345
- */
346
- status() {
347
- const config = readLocalYaml(this.cwd);
348
- const platform = config.platform;
349
- if (!platform) {
350
- return { connected: false };
351
- }
352
- return {
353
- connected: true,
354
- url: platform.url,
355
- lastSync: platform.last_connected || null,
356
- };
357
- }
358
-
359
- /** 获取当前平台配置,未连接返回 null */
360
- _getPlatform() {
361
- const config = readLocalYaml(this.cwd);
362
- return config.platform || null;
363
- }
364
- }
365
-
366
- // ── CLI 入口函数 ──
367
-
368
- /**
369
- * syncModule — sillyspec platform 子命令入口
370
- *
371
- * 用法:
372
- * sillyspec platform connect <url> <token>
373
- * sillyspec platform disconnect
374
- * sillyspec platform sync [changeName]
375
- * sillyspec platform sync-docs [changeName]
376
- * sillyspec platform approval <changeName>
377
- * sillyspec platform status
378
- *
379
- * @param {string[]} args — 子命令及参数
380
- * @param {string} cwd — 工作目录
381
- */
382
- /**
383
- * 便捷函数导出 — 供 index.js 和 run.js 直接调用
384
- */
385
- export async function connect(url, token, cwd) {
386
- return new SyncManager(cwd).connect(url, token);
387
- }
388
-
389
- export async function disconnect(cwd) {
390
- return new SyncManager(cwd).disconnect();
391
- }
392
-
393
- export async function sync(changeName, cwd) {
394
- return new SyncManager(cwd).sync(changeName);
395
- }
396
-
397
- export async function syncDocuments(changeName, cwd) {
398
- return new SyncManager(cwd).syncDocuments(changeName);
399
- }
400
-
401
- export async function checkApproval(changeName, cwd) {
402
- return new SyncManager(cwd).checkApproval(changeName);
403
- }
404
-
405
- export async function approve(changeName, cwd) {
406
- // TODO: SillyHub 平台侧实现后启用
407
- console.warn(`[sync] approve 尚未实现 (${changeName})`);
408
- }
409
-
410
- export async function reject(changeName, reason, cwd) {
411
- // TODO: SillyHub 平台侧实现后启用
412
- console.warn(`[sync] reject 尚未实现 (${changeName})`);
413
- }
414
-
415
- export async function status(cwd) {
416
- const sm = new SyncManager(cwd);
417
- const st = sm.status();
418
- if (!st.connected) {
419
- console.log('平台: 未连接');
420
- } else {
421
- console.log(`平台: ${st.url}`);
422
- console.log(`上次连接: ${st.lastSync || '未知'}`);
423
- }
424
- }
425
-
426
- /**
427
- * syncModule — sillyspec platform 子命令入口
428
- */
429
- export async function syncModule(args, cwd) {
430
- const sm = new SyncManager(cwd);
431
-
432
- const sub = args[0];
433
-
434
- switch (sub) {
435
- case 'connect': {
436
- const url = args[1];
437
- const token = args[2];
438
- if (!url || !token) {
439
- console.error('用法: sillyspec platform connect <url> <token>');
440
- process.exit(1);
441
- }
442
- await sm.connect(url, token);
443
- break;
444
- }
445
-
446
- case 'disconnect':
447
- sm.disconnect();
448
- break;
449
-
450
- case 'sync': {
451
- const changeName = args[1];
452
- const result = await sm.sync(changeName);
453
- if (result.errors.length > 0) {
454
- console.log(`同步完成,${result.errors.length} 个错误`);
455
- }
456
- break;
457
- }
458
-
459
- case 'sync-docs':
460
- case 'sync-documents': {
461
- const changeName = args[1];
462
- const result = await sm.syncDocuments(changeName);
463
- if (result.errors.length > 0) {
464
- console.log(`文档同步完成,${result.errors.length} 个错误`);
465
- }
466
- break;
467
- }
468
-
469
- case 'approval':
470
- case 'check-approval': {
471
- const changeName = args[1];
472
- if (!changeName) {
473
- console.error('用法: sillyspec platform approval <changeName>');
474
- process.exit(1);
475
- }
476
- const approval = await sm.checkApproval(changeName);
477
- console.log(`审批状态: ${approval.status}${approval.reason ? ` (${approval.reason})` : ''}`);
478
- break;
479
- }
480
-
481
- case 'status': {
482
- const st = sm.status();
483
- if (!st.connected) {
484
- console.log('平台: 未连接');
485
- } else {
486
- console.log(`平台: ${st.url}`);
487
- console.log(`上次连接: ${st.lastSync || '未知'}`);
488
- }
489
- break;
490
- }
491
-
492
- default:
493
- console.error(`未知子命令: ${sub || '(无)'}`);
494
- console.error('可用命令: connect, disconnect, sync, sync-docs, approval, status');
495
- process.exit(1);
496
- }
497
- }
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, writeFileSync, unlinkSync } from 'fs';
12
+ import { join } from 'path';
13
+
14
+ const LOCAL_YAML = '.sillyspec/local.yaml';
15
+ const CHANGES_DIR = '.sillyspec/changes';
16
+ const REQUEST_TIMEOUT_MS = 10_000;
17
+
18
+ /** 四件套文档文件名 */
19
+ const DOCUMENT_FILES = ['proposal.md', 'design.md', 'requirements.md', 'tasks.md'];
20
+
21
+ // ── YAML 辅助 ──
22
+
23
+ /**
24
+ * 简易 YAML 读写,只处理 project 段的扁平结构。
25
+ * 与 worktree-guard.js 的 parseSimpleYaml 保持一致的轻量风格。
26
+ */
27
+ function readLocalYaml(cwd) {
28
+ const p = join(cwd, LOCAL_YAML);
29
+ if (!existsSync(p)) return {};
30
+ try {
31
+ return parseSimpleYaml(readFileSync(p, 'utf8'));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function writeLocalYaml(cwd, obj) {
38
+ const dir = join(cwd, '.sillyspec');
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
+ const lines = [];
41
+ const rootKeys = Object.keys(obj);
42
+ for (const key of rootKeys) {
43
+ const val = obj[key];
44
+ if (val === null || val === undefined) continue;
45
+ if (typeof val === 'object' && !Array.isArray(val)) {
46
+ lines.push(`${key}:`);
47
+ for (const [k, v] of Object.entries(val)) {
48
+ if (typeof v === 'string') {
49
+ lines.push(` ${k}: ${v}`);
50
+ } else {
51
+ lines.push(` ${k}: ${JSON.stringify(v)}`);
52
+ }
53
+ }
54
+ } else {
55
+ lines.push(`${key}: ${typeof val === 'string' ? val : JSON.stringify(val)}`);
56
+ }
57
+ }
58
+ writeFileSync(join(cwd, LOCAL_YAML), lines.join('\n') + '\n', 'utf8');
59
+ }
60
+
61
+ function parseSimpleYaml(content) {
62
+ const result = {};
63
+ let currentSection = null;
64
+ for (const line of content.split('\n')) {
65
+ const trimmed = line.trim();
66
+ if (!trimmed || trimmed.startsWith('#')) continue;
67
+ if (!trimmed.startsWith(' ')) {
68
+ const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
69
+ if (m) {
70
+ const key = m[1];
71
+ const val = m[2].trim();
72
+ if (val) {
73
+ result[key] = val;
74
+ currentSection = null;
75
+ } else {
76
+ result[key] = {};
77
+ currentSection = key;
78
+ }
79
+ }
80
+ } else if (currentSection) {
81
+ const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
82
+ if (m && result[currentSection] && typeof result[currentSection] === 'object') {
83
+ result[currentSection][m[1]] = m[2].trim();
84
+ }
85
+ }
86
+ }
87
+ return result;
88
+ }
89
+
90
+ // ── HTTP 辅助 ──
91
+
92
+ async function fetchJson(url, options = {}) {
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
95
+ try {
96
+ const res = await fetch(url, { ...options, signal: controller.signal });
97
+ if (!res.ok) {
98
+ const text = await res.text().catch(() => '');
99
+ console.warn(`[sync] ${options.method || 'GET'} ${url} → ${res.status} ${text.slice(0, 200)}`);
100
+ return null;
101
+ }
102
+ const ct = res.headers.get('content-type') || '';
103
+ if (ct.includes('application/json')) {
104
+ return res.json();
105
+ }
106
+ return null;
107
+ } catch (err) {
108
+ if (err.name === 'AbortError') {
109
+ console.warn(`[sync] ${url} 请求超时 (${REQUEST_TIMEOUT_MS}ms)`);
110
+ } else {
111
+ console.warn(`[sync] ${url} 请求失败: ${err.message}`);
112
+ }
113
+ return null;
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+
119
+ // ── SyncManager ──
120
+
121
+ export class SyncManager {
122
+ constructor(cwd) {
123
+ this.cwd = cwd;
124
+ }
125
+
126
+ /**
127
+ * 连接 SillyHub 平台。
128
+ * 保存配置到 .sillyspec/local.yaml,发送 ping 验证连接。
129
+ */
130
+ async connect(url, token) {
131
+ // 验证连接
132
+ const healthUrl = `${url.replace(/\/+$/, '')}/api/health`;
133
+ const result = await fetchJson(healthUrl);
134
+ if (result === null) {
135
+ console.warn(`[sync] 平台连接验证失败: ${url}`);
136
+ return;
137
+ }
138
+ console.log(`[sync] 平台连接成功: ${url}`);
139
+
140
+ // 写入 local.yaml
141
+ const config = readLocalYaml(this.cwd);
142
+ config.platform = {
143
+ url: url.replace(/\/+$/, ''),
144
+ token,
145
+ last_connected: new Date().toISOString(),
146
+ };
147
+ writeLocalYaml(this.cwd, config);
148
+ }
149
+
150
+ /**
151
+ * 断开平台连接。
152
+ * 从 local.yaml 删除 platform 配置段。
153
+ */
154
+ disconnect() {
155
+ const p = join(this.cwd, LOCAL_YAML);
156
+ if (!existsSync(p)) {
157
+ console.log('[sync] 已断开连接(无配置文件)');
158
+ return;
159
+ }
160
+ const config = readLocalYaml(this.cwd);
161
+ if (!config.platform) {
162
+ console.log('[sync] 已断开连接(未连接)');
163
+ return;
164
+ }
165
+ delete config.platform;
166
+ if (Object.keys(config).length === 0) {
167
+ // 配置为空,删除整个文件
168
+ try { unlinkSync(p); } catch { /* best effort */ }
169
+ } else {
170
+ writeLocalYaml(this.cwd, config);
171
+ }
172
+ console.log('[sync] 已断开连接');
173
+ }
174
+
175
+ /**
176
+ * 增量同步变更的 progress 状态到平台。
177
+ * 读取 ProgressManager.read() 的数据,POST 到平台。
178
+ * 同步完成后更新 changes 表的 platform_last_sync 字段。
179
+ */
180
+ async sync(changeName) {
181
+ const platform = this._getPlatform();
182
+ if (!platform) {
183
+ console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
184
+ return { synced: 0, errors: ['未连接平台'] };
185
+ }
186
+
187
+ if (!changeName) {
188
+ console.warn('[sync] sync 需要指定变更名称 (changeName)');
189
+ return { synced: 0, errors: ['未指定变更名称'] };
190
+ }
191
+
192
+ // 检查变更是否存在
193
+ const changeDir = join(this.cwd, CHANGES_DIR, changeName);
194
+ if (!existsSync(changeDir)) {
195
+ console.warn(`[sync] 变更不存在: ${changeName}`);
196
+ return { synced: 0, errors: [`变更不存在: ${changeName}`] };
197
+ }
198
+
199
+ // 读取 progress 数据(通过导入 ProgressManager 动态调用)
200
+ let progressData;
201
+ try {
202
+ const { ProgressManager } = await import('./progress.js');
203
+ const pm = new ProgressManager();
204
+ progressData = await pm.read(this.cwd, changeName);
205
+ } catch (err) {
206
+ console.warn(`[sync] 读取 progress 失败 (${changeName}): ${err.message}`);
207
+ return { synced: 0, errors: [`读取 progress 失败: ${err.message}`] };
208
+ }
209
+
210
+ // POST 到平台
211
+ const syncUrl = `${platform.url}/api/changes/${changeName}/progress`;
212
+ const result = await fetchJson(syncUrl, {
213
+ method: 'POST',
214
+ headers: {
215
+ 'Content-Type': 'application/json',
216
+ Authorization: `Bearer ${platform.token}`,
217
+ },
218
+ body: JSON.stringify(progressData),
219
+ });
220
+
221
+ if (!result) {
222
+ return { synced: 0, errors: [`同步请求失败: ${changeName}`] };
223
+ }
224
+
225
+ // 更新 platform_last_sync
226
+ try {
227
+ const { ProgressManager } = await import('./progress.js');
228
+ const pm = new ProgressManager();
229
+ await pm._updatePlatformLastSync(this.cwd, changeName);
230
+ } catch (err) {
231
+ console.warn(`[sync] 更新 platform_last_sync 失败: ${err.message}`);
232
+ }
233
+
234
+ console.log(`[sync] 已同步变更: ${changeName}`);
235
+ return { synced: 1, errors: [] };
236
+ }
237
+
238
+ /**
239
+ * 同步四件套文档到平台(全量同步)。
240
+ * POST {url}/api/changes/{changeName}/documents
241
+ */
242
+ async syncDocuments(changeName) {
243
+ const platform = this._getPlatform();
244
+ if (!platform) {
245
+ console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
246
+ return { synced: 0, errors: ['未连接平台'] };
247
+ }
248
+
249
+ if (!changeName) {
250
+ console.warn('[sync] syncDocuments 需要指定变更名称 (changeName)');
251
+ return { synced: 0, errors: ['未指定变更名称'] };
252
+ }
253
+
254
+ const changeDir = join(this.cwd, CHANGES_DIR, changeName);
255
+ if (!existsSync(changeDir)) {
256
+ console.warn(`[sync] 变更不存在: ${changeName}`);
257
+ return { synced: 0, errors: [`变更不存在: ${changeName}`] };
258
+ }
259
+
260
+ const documents = {};
261
+ let syncedCount = 0;
262
+ const errors = [];
263
+
264
+ for (const docFile of DOCUMENT_FILES) {
265
+ const docPath = join(changeDir, docFile);
266
+ if (existsSync(docPath)) {
267
+ try {
268
+ documents[docFile] = readFileSync(docPath, 'utf8');
269
+ syncedCount++;
270
+ } catch (err) {
271
+ errors.push(`读取 ${docFile} 失败: ${err.message}`);
272
+ }
273
+ }
274
+ }
275
+
276
+ if (syncedCount === 0) {
277
+ console.warn(`[sync] 未找到可同步的文档: ${changeName}`);
278
+ return { synced: 0, errors: [...errors, '无可用文档'] };
279
+ }
280
+
281
+ const docUrl = `${platform.url}/api/changes/${changeName}/documents`;
282
+ const result = await fetchJson(docUrl, {
283
+ method: 'POST',
284
+ headers: {
285
+ 'Content-Type': 'application/json',
286
+ Authorization: `Bearer ${platform.token}`,
287
+ },
288
+ body: JSON.stringify(documents),
289
+ });
290
+
291
+ if (!result) {
292
+ return { synced: 0, errors: [...errors, '文档同步请求失败'] };
293
+ }
294
+
295
+ console.log(`[sync] 已同步 ${syncedCount} 个文档: ${changeName}`);
296
+ return { synced: syncedCount, errors };
297
+ }
298
+
299
+ /**
300
+ * 检查变更的审批状态。
301
+ * GET {url}/api/changes/{changeName}/approval
302
+ * 返回 { status: 'pending'|'approved'|'rejected', reason?: string }
303
+ */
304
+ async checkApproval(changeName) {
305
+ const platform = this._getPlatform();
306
+ if (!platform) {
307
+ console.warn('[sync] 未连接平台,请先 sillyspec platform connect');
308
+ return { status: 'pending', reason: '未连接平台' };
309
+ }
310
+
311
+ if (!changeName) {
312
+ console.warn('[sync] checkApproval 需要指定变更名称 (changeName)');
313
+ return { status: 'pending', reason: '未指定变更名称' };
314
+ }
315
+
316
+ const approvalUrl = `${platform.url}/api/changes/${changeName}/approval`;
317
+ const result = await fetchJson(approvalUrl, {
318
+ headers: { Authorization: `Bearer ${platform.token}` },
319
+ });
320
+
321
+ if (!result) {
322
+ console.warn(`[sync] 检查审批状态失败: ${changeName}`);
323
+ return { status: 'pending', reason: '请求失败' };
324
+ }
325
+
326
+ // 更新本地 approvals 表
327
+ try {
328
+ const { ProgressManager } = await import('./progress.js');
329
+ const pm = new ProgressManager();
330
+ await pm._updateApprovalStatus(this.cwd, changeName, result.status, result.reason);
331
+ } catch (err) {
332
+ console.warn(`[sync] 更新本地审批状态失败: ${err.message}`);
333
+ }
334
+
335
+ if (result.status === 'rejected') {
336
+ console.warn(`[sync] 审批被拒绝 (${changeName}): ${result.reason || '无原因'}`);
337
+ }
338
+
339
+ return result;
340
+ }
341
+
342
+ /**
343
+ * 查看同步状态。
344
+ * 读取 local.yaml 中的 platform 配置,返回连接信息。
345
+ */
346
+ status() {
347
+ const config = readLocalYaml(this.cwd);
348
+ const platform = config.platform;
349
+ if (!platform) {
350
+ return { connected: false };
351
+ }
352
+ return {
353
+ connected: true,
354
+ url: platform.url,
355
+ lastSync: platform.last_connected || null,
356
+ };
357
+ }
358
+
359
+ /** 获取当前平台配置,未连接返回 null */
360
+ _getPlatform() {
361
+ const config = readLocalYaml(this.cwd);
362
+ return config.platform || null;
363
+ }
364
+ }
365
+
366
+ // ── CLI 入口函数 ──
367
+
368
+ /**
369
+ * syncModule — sillyspec platform 子命令入口
370
+ *
371
+ * 用法:
372
+ * sillyspec platform connect <url> <token>
373
+ * sillyspec platform disconnect
374
+ * sillyspec platform sync [changeName]
375
+ * sillyspec platform sync-docs [changeName]
376
+ * sillyspec platform approval <changeName>
377
+ * sillyspec platform status
378
+ *
379
+ * @param {string[]} args — 子命令及参数
380
+ * @param {string} cwd — 工作目录
381
+ */
382
+ /**
383
+ * 便捷函数导出 — 供 index.js 和 run.js 直接调用
384
+ */
385
+ export async function connect(url, token, cwd) {
386
+ return new SyncManager(cwd).connect(url, token);
387
+ }
388
+
389
+ export async function disconnect(cwd) {
390
+ return new SyncManager(cwd).disconnect();
391
+ }
392
+
393
+ export async function sync(changeName, cwd) {
394
+ return new SyncManager(cwd).sync(changeName);
395
+ }
396
+
397
+ export async function syncDocuments(changeName, cwd) {
398
+ return new SyncManager(cwd).syncDocuments(changeName);
399
+ }
400
+
401
+ export async function checkApproval(changeName, cwd) {
402
+ return new SyncManager(cwd).checkApproval(changeName);
403
+ }
404
+
405
+ export async function approve(changeName, cwd) {
406
+ // TODO: SillyHub 平台侧实现后启用
407
+ console.warn(`[sync] approve 尚未实现 (${changeName})`);
408
+ }
409
+
410
+ export async function reject(changeName, reason, cwd) {
411
+ // TODO: SillyHub 平台侧实现后启用
412
+ console.warn(`[sync] reject 尚未实现 (${changeName})`);
413
+ }
414
+
415
+ export async function status(cwd) {
416
+ const sm = new SyncManager(cwd);
417
+ const st = sm.status();
418
+ if (!st.connected) {
419
+ console.log('平台: 未连接');
420
+ } else {
421
+ console.log(`平台: ${st.url}`);
422
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * syncModule — sillyspec platform 子命令入口
428
+ */
429
+ export async function syncModule(args, cwd) {
430
+ const sm = new SyncManager(cwd);
431
+
432
+ const sub = args[0];
433
+
434
+ switch (sub) {
435
+ case 'connect': {
436
+ const url = args[1];
437
+ const token = args[2];
438
+ if (!url || !token) {
439
+ console.error('用法: sillyspec platform connect <url> <token>');
440
+ process.exit(1);
441
+ }
442
+ await sm.connect(url, token);
443
+ break;
444
+ }
445
+
446
+ case 'disconnect':
447
+ sm.disconnect();
448
+ break;
449
+
450
+ case 'sync': {
451
+ const changeName = args[1];
452
+ const result = await sm.sync(changeName);
453
+ if (result.errors.length > 0) {
454
+ console.log(`同步完成,${result.errors.length} 个错误`);
455
+ }
456
+ break;
457
+ }
458
+
459
+ case 'sync-docs':
460
+ case 'sync-documents': {
461
+ const changeName = args[1];
462
+ const result = await sm.syncDocuments(changeName);
463
+ if (result.errors.length > 0) {
464
+ console.log(`文档同步完成,${result.errors.length} 个错误`);
465
+ }
466
+ break;
467
+ }
468
+
469
+ case 'approval':
470
+ case 'check-approval': {
471
+ const changeName = args[1];
472
+ if (!changeName) {
473
+ console.error('用法: sillyspec platform approval <changeName>');
474
+ process.exit(1);
475
+ }
476
+ const approval = await sm.checkApproval(changeName);
477
+ console.log(`审批状态: ${approval.status}${approval.reason ? ` (${approval.reason})` : ''}`);
478
+ break;
479
+ }
480
+
481
+ case 'status': {
482
+ const st = sm.status();
483
+ if (!st.connected) {
484
+ console.log('平台: 未连接');
485
+ } else {
486
+ console.log(`平台: ${st.url}`);
487
+ console.log(`上次连接: ${st.lastSync || '未知'}`);
488
+ }
489
+ break;
490
+ }
491
+
492
+ default:
493
+ console.error(`未知子命令: ${sub || '(无)'}`);
494
+ console.error('可用命令: connect, disconnect, sync, sync-docs, approval, status');
495
+ process.exit(1);
496
+ }
497
+ }