sillyspec 3.26.10 → 3.26.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) 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 +115 -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 -83
  16. package/.claude/skills/sillyspec-workspace/SKILL.md +169 -169
  17. package/.gitattributes +2 -0
  18. package/.husky/pre-push +20 -20
  19. package/README.md +203 -201
  20. package/SKILL.md +91 -90
  21. package/package.json +39 -39
  22. package/packages/dashboard/dist/assets/index-Bq_Z2hne.js +7446 -7446
  23. package/packages/dashboard/dist/assets/index-O2W5RV4z.css +1 -1
  24. package/packages/dashboard/dist/index.html +16 -16
  25. package/packages/dashboard/package.json +25 -25
  26. package/packages/dashboard/server/executor.js +95 -86
  27. package/packages/dashboard/server/index.js +663 -601
  28. package/packages/dashboard/server/parser.js +526 -526
  29. package/src/change-risk-profile.js +254 -254
  30. package/src/classify-change.js +109 -109
  31. package/src/config-schema.js +313 -310
  32. package/src/contract-matrix.js +388 -379
  33. package/src/db.js +370 -350
  34. package/src/dispatch/backends/local-agent.js +155 -155
  35. package/src/dispatch/backends/sillyhub-mcp.js +245 -245
  36. package/src/dispatch/probe.js +246 -210
  37. package/src/dispatch/strategy.js +121 -121
  38. package/src/docs-check.js +188 -16
  39. package/src/docs-debt.js +6 -2
  40. package/src/docs-gate.js +1 -0
  41. package/src/doctor-diagnostics.js +898 -711
  42. package/src/endpoint-extractor.js +401 -401
  43. package/src/fs-atomic.js +71 -69
  44. package/src/git-helper.js +82 -79
  45. package/src/hooks/claude-pre-tool-use.cjs +125 -125
  46. package/src/hooks/worktree-guard.js +854 -848
  47. package/src/index.js +251 -20
  48. package/src/init.js +624 -623
  49. package/src/knowledge-match.js +130 -130
  50. package/src/local-detect.js +128 -120
  51. package/src/machine-interface.js +543 -543
  52. package/src/migrate.js +127 -127
  53. package/src/modules.js +7 -5
  54. package/src/progress/change-registry.js +297 -260
  55. package/src/progress/consistency-doctor.js +355 -355
  56. package/src/progress/shared.js +36 -36
  57. package/src/progress/stage-machine.js +589 -550
  58. package/src/progress/step-store.js +261 -261
  59. package/src/progress.js +1285 -1127
  60. package/src/quicklog.js +724 -693
  61. package/src/review-tier.js +77 -77
  62. package/src/run/command.js +1408 -1364
  63. package/src/run/complete-handlers.js +1450 -1399
  64. package/src/run/complete.js +1181 -920
  65. package/src/run/gates.js +878 -755
  66. package/src/run/prompt.js +765 -761
  67. package/src/run/quick-audit.js +155 -146
  68. package/src/run/shared.js +1348 -1215
  69. package/src/run/stage.js +527 -479
  70. package/src/scan-postcheck.js +371 -371
  71. package/src/setup.js +414 -416
  72. package/src/sillyhub-mcp/client.js +597 -529
  73. package/src/spec-dir-typo.js +3 -24
  74. package/src/spec-sync.js +358 -319
  75. package/src/stage-contract.js +871 -897
  76. package/src/stage-review.js +549 -549
  77. package/src/stages/archive.js +156 -155
  78. package/src/stages/brainstorm-auto.js +236 -236
  79. package/src/stages/brainstorm.js +597 -597
  80. package/src/stages/cmd-existence.js +145 -145
  81. package/src/stages/doctor.js +386 -371
  82. package/src/stages/execute.js +1014 -1003
  83. package/src/stages/explore.js +34 -34
  84. package/src/stages/index.js +29 -29
  85. package/src/stages/knowledge.js +498 -498
  86. package/src/stages/plan-postcheck.js +1323 -1302
  87. package/src/stages/plan.js +642 -623
  88. package/src/stages/quick.js +153 -151
  89. package/src/stages/scan.js +607 -607
  90. package/src/stages/status.js +64 -64
  91. package/src/stages/verify.js +264 -264
  92. package/src/sync.js +1325 -1135
  93. package/src/task-review.js +1204 -1019
  94. package/src/taskcard.js +178 -0
  95. package/src/verify-postcheck.js +1199 -1186
  96. package/src/workflow.js +600 -600
  97. package/src/worktree-apply.js +1237 -1136
  98. package/src/worktree-deps.js +453 -353
  99. package/src/worktree.js +1527 -1481
  100. package/templates/workflows/archive-impact.yaml +79 -79
  101. package/templates/workflows/scan-docs.yaml +132 -132
package/src/spec-sync.js CHANGED
@@ -1,319 +1,358 @@
1
- /**
2
- * CLI 直跑 spec 文件增量同步模块。
3
- *
4
- * Change 2026-08-17-spec-file-incremental-sync:daemon↔后端已有增量同步协议,但 CLI
5
- * 直跑场景(本地 agent 直接跑 sillyspec 无 daemon)只有四件套直推到 documents 列。
6
- * 本模块补齐 CLI 直跑的文件树增量同步:以服务器清单为锚,walk/hash/diff 后只推送
7
- * 变化文件(add/update/delete/rename),无差异时短路不发请求。
8
- *
9
- * 复用 daemon 已验证的排除口径(UPLOAD_EXCLUDE_TOP_BASE / UPLOAD_PRUNE_NAMES_BASE),
10
- * 与 daemon 共用同一套清单语义(base_version 乐观锁、conflict 提示但不阻塞)。
11
- */
12
-
13
- import { createHash } from 'crypto';
14
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
15
- import { join, relative, sep } from 'path';
16
-
17
- // 与 sillyhub-daemon/src/spec-sync.ts 共用排除口径(task-07 / FR-06 / D-008@v2):
18
- // .runtime(有点)/ runtime(无点)/ projects(本地环境文件)在顶层排除;
19
- // worktrees 在任意深度按 basename 剪枝(可能嵌在 .runtime/worktrees 下)。
20
- const UPLOAD_EXCLUDE_TOP_BASE = new Set(['.runtime', 'runtime', 'projects']);
21
- const UPLOAD_PRUNE_NAMES_BASE = new Set(['worktrees']);
22
- // ql-20260818-002:local.yaml 是本机连接配置(platform/mcp 段含 shpsync_ token),
23
- // 不上传——与服务器侧过滤(backend spec_workspace SERVER_EXCLUDED_FILENAMES)双侧
24
- // 对齐。服务器清单残留 local.yaml 行时,本地 walk 不含它 → 生成 delete op,
25
- // 服务器放行 delete 清存量(token 不落 landing 树、不随 bundle 跨机分发)。
26
- const UPLOAD_EXCLUDE_FILENAMES = new Set(['local.yaml']);
27
-
28
- function isUploadExcludedPath(relPath) {
29
- const segs = relPath.split('/');
30
- if (UPLOAD_EXCLUDE_TOP_BASE.has(segs[0] ?? '')) return true;
31
- if (UPLOAD_EXCLUDE_FILENAMES.has(segs[segs.length - 1] ?? '')) return true;
32
- return segs.some((s) => UPLOAD_PRUNE_NAMES_BASE.has(s));
33
- }
34
-
35
- function toPosix(p) {
36
- return p.replace(/\\/g, '/');
37
- }
38
-
39
- function debugLog(msg) {
40
- if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg);
41
- }
42
-
43
- function _hashBuffer(buf) {
44
- return createHash('sha256').update(buf).digest('hex');
45
- }
46
-
47
- /**
48
- * 单遍 walk 本地 .sillyspec 树。
49
- *
50
- * 返回 { path, absPath, mtimeMs } 列表;path 已统一为 POSIX。
51
- * 不收集目录项(只关心文件);命中排除规则的路径直接跳过不递归。
52
- */
53
- export function walkSpecTree(specRoot) {
54
- const out = [];
55
- if (!existsSync(specRoot)) return out;
56
-
57
- function recurse(dir) {
58
- let names;
59
- try {
60
- names = readdirSync(dir);
61
- } catch {
62
- return;
63
- }
64
- for (const name of names) {
65
- const abs = join(dir, name);
66
- let st;
67
- try {
68
- st = statSync(abs);
69
- } catch {
70
- continue;
71
- }
72
- const relToRoot = toPosix(relative(specRoot, abs));
73
- if (isUploadExcludedPath(relToRoot)) continue;
74
- // pruneNames:任意深度按 basename 命中即不收集/不递归
75
- if (UPLOAD_PRUNE_NAMES_BASE.has(name)) continue;
76
-
77
- if (st.isDirectory()) {
78
- recurse(abs);
79
- } else if (st.isFile()) {
80
- out.push({ path: relToRoot, absPath: abs, mtimeMs: st.mtimeMs });
81
- }
82
- // symlink / 其他:跳过
83
- }
84
- }
85
-
86
- recurse(specRoot);
87
- return out;
88
- }
89
-
90
- /**
91
- * 计算本地文件哈希。
92
- *
93
- * 返回 { path, hash, mtime, absPath }(mtime 为 Unix 秒,供 os.utime;absPath
94
- * 保留给 computeSpecOps 读 content 用,避免按相对路径回退错位)。
95
- */
96
- export function hashFiles(entries) {
97
- return entries.map((e) => {
98
- const buf = readFileSync(e.absPath);
99
- return {
100
- path: e.path,
101
- hash: _hashBuffer(buf),
102
- mtime: Math.floor(e.mtimeMs / 1000),
103
- absPath: e.absPath,
104
- };
105
- });
106
- }
107
-
108
- /**
109
- * 根据服务器清单与本地文件差异生成 FileOp[]。
110
- *
111
- * 服务器清单格式:{ [path]: { hash, version, exists } }
112
- * 本地文件格式:hashFiles 结果 { path, hash, mtime }
113
- *
114
- * 规则(与 daemon computeIncrementalOps 对齐):
115
- * - 本地有、服务器无 → add(base_version=0)
116
- * - 服务器有 exists=true、本地无 → delete(base_version=服务器 version)
117
- * - 服务器有 exists=false、本地无 → 不生成 delete(已软删)
118
- * - 同路径 hash 不同 → update(base_version=服务器 version)
119
- * - 服务器旧路径有、本地新路径有且 hash 相同 → rename(base_version=服务器旧路径 version)
120
- */
121
- export function computeSpecOps(serverManifest, localFiles) {
122
- const server = serverManifest || {};
123
- const localMap = new Map(localFiles.map((f) => [f.path, f]));
124
- const localSet = new Set(localMap.keys());
125
-
126
- // 只拿服务器 exists=true 的行参与 diff;exists=false 视为已删除,不生成 delete
127
- const serverPaths = Object.keys(server).filter((p) => server[p]?.exists !== false);
128
- const serverSet = new Set(serverPaths);
129
-
130
- // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
131
- const renames = [];
132
- const consumedNew = new Set();
133
- for (const oldPath of serverPaths) {
134
- if (localSet.has(oldPath)) continue;
135
- const cachedEntry = server[oldPath];
136
- for (const newPath of localMap.keys()) {
137
- if (serverSet.has(newPath)) continue;
138
- if (consumedNew.has(newPath)) continue;
139
- if (localMap.get(newPath).hash === cachedEntry.hash) {
140
- renames.push({ oldPath, newPath });
141
- consumedNew.add(newPath);
142
- break;
143
- }
144
- }
145
- }
146
- const renamedOld = new Set(renames.map((r) => r.oldPath));
147
- const renamedNew = new Set(renames.map((r) => r.newPath));
148
-
149
- const ops = [];
150
-
151
- for (const r of renames) {
152
- ops.push({
153
- op: 'rename',
154
- path: r.oldPath,
155
- new_path: r.newPath,
156
- base_version: server[r.oldPath].version,
157
- });
158
- }
159
-
160
- for (const p of serverPaths) {
161
- if (renamedOld.has(p)) continue;
162
- if (localSet.has(p)) {
163
- const localEntry = localMap.get(p);
164
- if (localEntry.hash !== server[p].hash) {
165
- const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
166
- ops.push({
167
- op: 'update',
168
- path: p,
169
- hash: localEntry.hash,
170
- content,
171
- base_version: server[p].version,
172
- mtime: localEntry.mtime,
173
- });
174
- }
175
- } else {
176
- ops.push({
177
- op: 'delete',
178
- path: p,
179
- base_version: server[p].version,
180
- });
181
- }
182
- }
183
-
184
- for (const p of localMap.keys()) {
185
- if (renamedNew.has(p)) continue;
186
- if (!serverSet.has(p)) {
187
- const localEntry = localMap.get(p);
188
- const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
189
- ops.push({
190
- op: 'add',
191
- path: p,
192
- hash: localEntry.hash,
193
- content,
194
- base_version: 0,
195
- mtime: localEntry.mtime,
196
- });
197
- }
198
- }
199
-
200
- return ops;
201
- }
202
-
203
- /**
204
- * 从 ops 提取本次涉及的变更目录名集合(change_dirs 标注,D-005@v1)。
205
- *
206
- * 对每个 op 的 path(rename 含新旧 path),取 `changes/<name>/` 或
207
- * `changes/archive/<name>/` 前缀分组的 key(去掉前缀、取第一段目录名),去重。
208
- * 非 changes 前缀路径不进结果。best-effort,失败降级 []。
209
- */
210
- export function extractChangeDirs(ops) {
211
- const dirs = new Set();
212
- for (const op of ops) {
213
- collectChangeDir(dirs, op.path);
214
- if (op.new_path) collectChangeDir(dirs, op.new_path);
215
- }
216
- return [...dirs];
217
- }
218
-
219
- function collectChangeDir(dirs, p) {
220
- const norm = p.split(/[\\/]/).join('/');
221
- let rest = null;
222
- if (norm.startsWith('changes/')) {
223
- rest = norm.slice('changes/'.length);
224
- } else if (norm.startsWith('changes\\')) {
225
- rest = norm.slice('changes\\'.length).replace(/\\/g, '/');
226
- }
227
- if (!rest) return;
228
-
229
- if (rest.startsWith('archive/')) {
230
- rest = rest.slice('archive/'.length);
231
- }
232
- const first = rest.split('/', 1)[0];
233
- if (first && first !== 'archive' && !first.includes('/')) {
234
- dirs.add(first);
235
- }
236
- }
237
-
238
- /**
239
- * 同步本地 .sillyspec 树到平台(CLI 直跑增量同步入口)。
240
- *
241
- * @param {string} specRoot - 本地 .sillyspec 目录绝对路径
242
- * @param {object} platform - { url, token }
243
- * @param {string} changeName - 当前变更名
244
- * @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
245
- */
246
- export async function syncSpecTree(specRoot, platform, changeName) {
247
- if (!platform || !platform.url || !platform.token) {
248
- debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
249
- return { synced: 0 };
250
- }
251
-
252
- const manifestUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-manifest`;
253
- const syncUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-sync`;
254
-
255
- // 1. GET 服务器清单
256
- let serverManifest = {};
257
- try {
258
- const res = await fetch(manifestUrl, {
259
- headers: { Authorization: `Bearer ${platform.token}` },
260
- signal: AbortSignal.timeout(10000),
261
- });
262
- if (!res.ok) {
263
- debugLog(`[spec-sync] 拉取清单失败 ${res.status}: ${changeName}`);
264
- return { synced: 0 };
265
- }
266
- const body = await res.json().catch(() => ({}));
267
- serverManifest = body.files || {};
268
- } catch (err) {
269
- debugLog(`[spec-sync] 拉取清单异常(不影响进度): ${changeName}: ${err.message}`);
270
- return { synced: 0 };
271
- }
272
-
273
- // 2. walk/hash/diff
274
- const entries = walkSpecTree(specRoot);
275
- const localFiles = hashFiles(entries);
276
- const ops = computeSpecOps(serverManifest, localFiles);
277
-
278
- if (ops.length === 0) {
279
- debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
280
- return { synced: 0 };
281
- }
282
-
283
- // 3. POST ops
284
- try {
285
- const res = await fetch(syncUrl, {
286
- method: 'POST',
287
- headers: {
288
- 'Content-Type': 'application/json',
289
- Authorization: `Bearer ${platform.token}`,
290
- },
291
- body: JSON.stringify({ ops }),
292
- signal: AbortSignal.timeout(30000),
293
- });
294
- if (!res.ok) {
295
- debugLog(`[spec-sync] 同步请求失败 ${res.status}: ${changeName}`);
296
- return { synced: 0 };
297
- }
298
- const body = await res.json().catch(() => ({}));
299
- if (body.conflict) {
300
- console.warn(
301
- `[spec-sync] 检测到冲突,请人工拍板。服务器版本: ${JSON.stringify(body.server_versions || {})}`
302
- );
303
- return { synced: 0, conflict: true, serverVersions: body.server_versions };
304
- }
305
- console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
306
- return { synced: ops.length };
307
- } catch (err) {
308
- debugLog(`[spec-sync] 同步异常(不影响进度): ${changeName}: ${err.message}`);
309
- return { synced: 0 };
310
- }
311
- }
312
-
313
- export default {
314
- walkSpecTree,
315
- hashFiles,
316
- computeSpecOps,
317
- extractChangeDirs,
318
- syncSpecTree,
319
- };
1
+ /**
2
+ * CLI 直跑 spec 文件增量同步模块。
3
+ *
4
+ * Change 2026-08-17-spec-file-incremental-sync:daemon↔后端已有增量同步协议,但 CLI
5
+ * 直跑场景(本地 agent 直接跑 sillyspec 无 daemon)只有四件套直推到 documents 列。
6
+ * 本模块补齐 CLI 直跑的文件树增量同步:以服务器清单为锚,walk/hash/diff 后只推送
7
+ * 变化文件(add/update/delete/rename),无差异时短路不发请求。
8
+ *
9
+ * 复用 daemon 已验证的排除口径(UPLOAD_EXCLUDE_TOP_BASE / UPLOAD_PRUNE_NAMES_BASE),
10
+ * 与 daemon 共用同一套清单语义(base_version 乐观锁、conflict 提示但不阻塞)。
11
+ */
12
+
13
+ import { createHash } from 'crypto';
14
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
15
+ import { join, relative, sep } from 'path';
16
+
17
+ // 与 sillyhub-daemon/src/spec-sync.ts 共用排除口径(task-07 / FR-06 / D-008@v2):
18
+ // .runtime(有点)/ runtime(无点)/ projects(本地环境文件)在顶层排除;
19
+ // worktrees 在任意深度按 basename 剪枝(可能嵌在 .runtime/worktrees 下)。
20
+ const UPLOAD_EXCLUDE_TOP_BASE = new Set(['.runtime', 'runtime', 'projects']);
21
+ const UPLOAD_PRUNE_NAMES_BASE = new Set(['worktrees']);
22
+ // ql-20260818-002:local.yaml 是本机连接配置(platform/mcp 段含 shpsync_ token),
23
+ // 不上传——与服务器侧过滤(backend spec_workspace SERVER_EXCLUDED_FILENAMES)双侧
24
+ // 对齐。服务器清单残留 local.yaml 行时,本地 walk 不含它 → 生成 delete op,
25
+ // 服务器放行 delete 清存量(token 不落 landing 树、不随 bundle 跨机分发)。
26
+ const UPLOAD_EXCLUDE_FILENAMES = new Set(['local.yaml']);
27
+
28
+ function isUploadExcludedPath(relPath) {
29
+ const segs = relPath.split('/');
30
+ if (UPLOAD_EXCLUDE_TOP_BASE.has(segs[0] ?? '')) return true;
31
+ if (UPLOAD_EXCLUDE_FILENAMES.has(segs[segs.length - 1] ?? '')) return true;
32
+ return segs.some((s) => UPLOAD_PRUNE_NAMES_BASE.has(s));
33
+ }
34
+
35
+ function toPosix(p) {
36
+ return p.replace(/\\/g, '/');
37
+ }
38
+
39
+ function debugLog(msg) {
40
+ if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg);
41
+ }
42
+
43
+ function _hashBuffer(buf) {
44
+ return createHash('sha256').update(buf).digest('hex');
45
+ }
46
+
47
+ /**
48
+ * 单遍 walk 本地 .sillyspec 树。
49
+ *
50
+ * 返回 { path, absPath, mtimeMs } 列表;path 已统一为 POSIX。
51
+ * 不收集目录项(只关心文件);命中排除规则的路径直接跳过不递归。
52
+ */
53
+ export function walkSpecTree(specRoot) {
54
+ const out = [];
55
+ if (!existsSync(specRoot)) return out;
56
+
57
+ function recurse(dir) {
58
+ let names;
59
+ try {
60
+ names = readdirSync(dir);
61
+ } catch {
62
+ return;
63
+ }
64
+ for (const name of names) {
65
+ const abs = join(dir, name);
66
+ let st;
67
+ try {
68
+ st = statSync(abs);
69
+ } catch {
70
+ continue;
71
+ }
72
+ const relToRoot = toPosix(relative(specRoot, abs));
73
+ if (isUploadExcludedPath(relToRoot)) continue;
74
+ // pruneNames:任意深度按 basename 命中即不收集/不递归
75
+ if (UPLOAD_PRUNE_NAMES_BASE.has(name)) continue;
76
+
77
+ if (st.isDirectory()) {
78
+ recurse(abs);
79
+ } else if (st.isFile()) {
80
+ out.push({ path: relToRoot, absPath: abs, mtimeMs: st.mtimeMs });
81
+ }
82
+ // symlink / 其他:跳过
83
+ }
84
+ }
85
+
86
+ recurse(specRoot);
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * 计算本地文件哈希。
92
+ *
93
+ * 返回 { path, hash, mtime, absPath }(mtime 为 Unix 秒,供 os.utime;absPath
94
+ * 保留给 computeSpecOps 读 content 用,避免按相对路径回退错位)。
95
+ */
96
+ export function hashFiles(entries) {
97
+ return entries.map((e) => {
98
+ const buf = readFileSync(e.absPath);
99
+ return {
100
+ path: e.path,
101
+ hash: _hashBuffer(buf),
102
+ mtime: Math.floor(e.mtimeMs / 1000),
103
+ absPath: e.absPath,
104
+ };
105
+ });
106
+ }
107
+
108
+ /**
109
+ * 根据服务器清单与本地文件差异生成 FileOp[]。
110
+ *
111
+ * 服务器清单格式:{ [path]: { hash, version, exists } }
112
+ * 本地文件格式:hashFiles 结果 { path, hash, mtime }
113
+ *
114
+ * 规则(与 daemon computeIncrementalOps 对齐):
115
+ * - 本地有、服务器无 → add(base_version=0)
116
+ * - 服务器有 exists=true、本地无 → delete(base_version=服务器 version)
117
+ * - 服务器有 exists=false、本地无 → 不生成 delete(已软删)
118
+ * - 同路径 hash 不同 → update(base_version=服务器 version)
119
+ * - 服务器旧路径有、本地新路径有且 hash 相同 → rename(base_version=服务器旧路径 version)
120
+ */
121
+ export function computeSpecOps(serverManifest, localFiles) {
122
+ const server = serverManifest || {};
123
+ const localMap = new Map(localFiles.map((f) => [f.path, f]));
124
+ const localSet = new Set(localMap.keys());
125
+
126
+ // 只拿服务器 exists=true 的行参与 diff;exists=false 视为已删除,不生成 delete
127
+ const serverPaths = Object.keys(server).filter((p) => server[p]?.exists !== false);
128
+ const serverSet = new Set(serverPaths);
129
+
130
+ // fail-closed 护栏(2026-08-20 全量体检 BUG-01):本地树为空而服务器非空时,
131
+ // 全量 delete 几乎必然是「锚错了目录」(如平台模式 cwd/.sillyspec 只剩 local.yaml
132
+ // 被 walk 排除),而不是用户真的删光了所有文件——一旦发出就是服务器整树清空。
133
+ // 唯一放行:服务器只剩 local.yaml 存量(顶层单文件),保留「delete 清 token 存量」
134
+ // 的既定语义(见 UPLOAD_EXCLUDE_FILENAMES 注释)。真实清空需求需本地树非空逐轮收敛。
135
+ const nonTrivialServer = serverPaths.filter((p) => p !== 'local.yaml');
136
+ if (localFiles.length === 0 && nonTrivialServer.length > 0) {
137
+ console.warn(
138
+ `[spec-sync] 本地 spec 树为空但服务器有 ${serverPaths.length} 个文件,跳过同步(防误清空;请检查 spec 目录锚点)`
139
+ );
140
+ return [];
141
+ }
142
+
143
+ // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
144
+ const renames = [];
145
+ const consumedNew = new Set();
146
+ for (const oldPath of serverPaths) {
147
+ if (localSet.has(oldPath)) continue;
148
+ const cachedEntry = server[oldPath];
149
+ for (const newPath of localMap.keys()) {
150
+ if (serverSet.has(newPath)) continue;
151
+ if (consumedNew.has(newPath)) continue;
152
+ if (localMap.get(newPath).hash === cachedEntry.hash) {
153
+ renames.push({ oldPath, newPath });
154
+ consumedNew.add(newPath);
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ const renamedOld = new Set(renames.map((r) => r.oldPath));
160
+ const renamedNew = new Set(renames.map((r) => r.newPath));
161
+
162
+ const ops = [];
163
+
164
+ for (const r of renames) {
165
+ ops.push({
166
+ op: 'rename',
167
+ path: r.oldPath,
168
+ new_path: r.newPath,
169
+ base_version: server[r.oldPath].version,
170
+ });
171
+ }
172
+
173
+ for (const p of serverPaths) {
174
+ if (renamedOld.has(p)) continue;
175
+ if (localSet.has(p)) {
176
+ const localEntry = localMap.get(p);
177
+ if (localEntry.hash !== server[p].hash) {
178
+ const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
179
+ ops.push({
180
+ op: 'update',
181
+ path: p,
182
+ hash: localEntry.hash,
183
+ content,
184
+ base_version: server[p].version,
185
+ mtime: localEntry.mtime,
186
+ });
187
+ }
188
+ } else {
189
+ ops.push({
190
+ op: 'delete',
191
+ path: p,
192
+ base_version: server[p].version,
193
+ });
194
+ }
195
+ }
196
+
197
+ for (const p of localMap.keys()) {
198
+ if (renamedNew.has(p)) continue;
199
+ if (!serverSet.has(p)) {
200
+ const localEntry = localMap.get(p);
201
+ const content = readFileSync(localEntry.absPath ?? join('.', p)).toString('base64');
202
+ ops.push({
203
+ op: 'add',
204
+ path: p,
205
+ hash: localEntry.hash,
206
+ content,
207
+ base_version: 0,
208
+ mtime: localEntry.mtime,
209
+ });
210
+ }
211
+ }
212
+
213
+ return ops;
214
+ }
215
+
216
+ /**
217
+ * 从 ops 提取本次涉及的变更目录名集合(change_dirs 标注,D-005@v1)。
218
+ *
219
+ * 对每个 op 的 path(rename 含新旧 path),取 `changes/<name>/` 或
220
+ * `changes/archive/<name>/` 前缀分组的 key(去掉前缀、取第一段目录名),去重。
221
+ * 非 changes 前缀路径不进结果。best-effort,失败降级 []。
222
+ */
223
+ export function extractChangeDirs(ops) {
224
+ const dirs = new Set();
225
+ for (const op of ops) {
226
+ collectChangeDir(dirs, op.path);
227
+ if (op.new_path) collectChangeDir(dirs, op.new_path);
228
+ }
229
+ return [...dirs];
230
+ }
231
+
232
+ function collectChangeDir(dirs, p) {
233
+ const norm = p.split(/[\\/]/).join('/');
234
+ let rest = null;
235
+ if (norm.startsWith('changes/')) {
236
+ rest = norm.slice('changes/'.length);
237
+ } else if (norm.startsWith('changes\\')) {
238
+ rest = norm.slice('changes\\'.length).replace(/\\/g, '/');
239
+ }
240
+ if (!rest) return;
241
+
242
+ if (rest.startsWith('archive/')) {
243
+ rest = rest.slice('archive/'.length);
244
+ }
245
+ const first = rest.split('/', 1)[0];
246
+ if (first && first !== 'archive' && !first.includes('/')) {
247
+ dirs.add(first);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * 同步本地 .sillyspec 树到平台(CLI 直跑增量同步入口)。
253
+ *
254
+ * @param {string} specRoot - 本地 .sillyspec 目录绝对路径
255
+ * @param {object} platform - { url, token }
256
+ * @param {string} changeName - 当前变更名
257
+ * @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
258
+ */
259
+ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
260
+ if (!platform || !platform.url || !platform.token) {
261
+ debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
262
+ return { synced: 0 };
263
+ }
264
+ // HUB-09:单请求超时与外部熔断 signal 合并——trigger* 熔断时在飞请求被真实取消
265
+ const withSignal = (timeoutMs) => (opts.signal ? AbortSignal.any([AbortSignal.timeout(timeoutMs), opts.signal]) : AbortSignal.timeout(timeoutMs));
266
+
267
+ const manifestUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-manifest`;
268
+ const syncUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-sync`;
269
+
270
+ // 1. GET 服务器清单
271
+ let serverManifest = {};
272
+ try {
273
+ const res = await fetch(manifestUrl, {
274
+ headers: { Authorization: `Bearer ${platform.token}` },
275
+ signal: withSignal(10000),
276
+ });
277
+ if (!res.ok) {
278
+ // ql-20260818-008:debugLog → console.warn。树同步失败原本完全静默(要
279
+ // SILLYSPEC_DEBUG_SYNC=1 才可见),文件迟到平台且无任何线索(multi-agent-platform
280
+ // 2026-08-18-workspace-file-browser 实证:design/decisions 迟到 27 分钟、plan.md 迟到
281
+ // 8 分钟才被后续步骤的同步补上)。失败可见、成功不打扰。
282
+ console.warn(`[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`);
283
+ return { synced: 0 };
284
+ }
285
+ const body = await res.json().catch(() => ({}));
286
+ serverManifest = body.files || {};
287
+ } catch (err) {
288
+ console.warn(`[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${changeName}: ${err.message}`);
289
+ return { synced: 0 };
290
+ }
291
+
292
+ // 2. walk/hash/diff
293
+ const entries = walkSpecTree(specRoot);
294
+ const localFiles = hashFiles(entries);
295
+ const ops = computeSpecOps(serverManifest, localFiles);
296
+
297
+ if (ops.length === 0) {
298
+ debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
299
+ return { synced: 0 };
300
+ }
301
+
302
+ // 3. POST ops
303
+ try {
304
+ const res = await fetch(syncUrl, {
305
+ method: 'POST',
306
+ headers: {
307
+ 'Content-Type': 'application/json',
308
+ Authorization: `Bearer ${platform.token}`,
309
+ },
310
+ body: JSON.stringify({ ops }),
311
+ signal: withSignal(30000),
312
+ });
313
+ if (!res.ok) {
314
+ console.warn(`[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`);
315
+ return { synced: 0 };
316
+ }
317
+ const body = await res.json().catch(() => ({}));
318
+ if (body.conflict) {
319
+ // HUB-08 冲突闭环:落 spec-sync-conflict-<change>.json(与进度 sync-conflict-* 同目录),
320
+ // 供 platform status 列出 + platform resolve 三态处置。此前只 warn 返回,下次 sync 用
321
+ // 同一 base_version 继续冲突循环,无人工裁决入口。
322
+ const serverVersions = body.server_versions || {};
323
+ let conflictPath = null;
324
+ try {
325
+ const runtimeDir = join(specRoot, '.runtime');
326
+ mkdirSync(runtimeDir, { recursive: true });
327
+ conflictPath = join(runtimeDir, `spec-sync-conflict-${changeName}.json`);
328
+ writeFileSync(conflictPath, JSON.stringify({
329
+ change: changeName,
330
+ kind: 'spec-tree',
331
+ created_at: new Date().toISOString(),
332
+ server_versions: serverVersions,
333
+ conflicting_paths: Object.keys(serverVersions),
334
+ note: 'spec 树文件冲突(服务器版本领先于本地 diff 基线)。resolve --keep-local 以本地为准重推;--take-platform 暂不支持(平台无文件下载端点)',
335
+ }, null, 2) + '\n', 'utf8');
336
+ } catch (e) {
337
+ console.warn(`[spec-sync] 冲突文件写入失败(冲突信息仅打印): ${e.message}`);
338
+ }
339
+ console.warn('');
340
+ console.warn(`⚠️ [spec-sync] 检测到 spec 树冲突(${Object.keys(serverVersions).length} 个文件,服务器版本: ${JSON.stringify(serverVersions)})`);
341
+ console.warn(`⚠️ 处置:sillyspec platform resolve ${changeName} --keep-local | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
342
+ return { synced: 0, conflict: true, serverVersions, conflictPath };
343
+ }
344
+ console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
345
+ return { synced: ops.length };
346
+ } catch (err) {
347
+ console.warn(`[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${err.message}`);
348
+ return { synced: 0 };
349
+ }
350
+ }
351
+
352
+ export default {
353
+ walkSpecTree,
354
+ hashFiles,
355
+ computeSpecOps,
356
+ extractChangeDirs,
357
+ syncSpecTree,
358
+ };