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
@@ -0,0 +1,323 @@
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
+ // ql-20260818-008:debugLog → console.warn。树同步失败原本完全静默(要
264
+ // SILLYSPEC_DEBUG_SYNC=1 才可见),文件迟到平台且无任何线索(multi-agent-platform
265
+ // 2026-08-18-workspace-file-browser 实证:design/decisions 迟到 27 分钟、plan.md 迟到
266
+ // 8 分钟才被后续步骤的同步补上)。失败可见、成功不打扰。
267
+ console.warn(`[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`);
268
+ return { synced: 0 };
269
+ }
270
+ const body = await res.json().catch(() => ({}));
271
+ serverManifest = body.files || {};
272
+ } catch (err) {
273
+ console.warn(`[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${changeName}: ${err.message}`);
274
+ return { synced: 0 };
275
+ }
276
+
277
+ // 2. walk/hash/diff
278
+ const entries = walkSpecTree(specRoot);
279
+ const localFiles = hashFiles(entries);
280
+ const ops = computeSpecOps(serverManifest, localFiles);
281
+
282
+ if (ops.length === 0) {
283
+ debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
284
+ return { synced: 0 };
285
+ }
286
+
287
+ // 3. POST ops
288
+ try {
289
+ const res = await fetch(syncUrl, {
290
+ method: 'POST',
291
+ headers: {
292
+ 'Content-Type': 'application/json',
293
+ Authorization: `Bearer ${platform.token}`,
294
+ },
295
+ body: JSON.stringify({ ops }),
296
+ signal: AbortSignal.timeout(30000),
297
+ });
298
+ if (!res.ok) {
299
+ console.warn(`[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`);
300
+ return { synced: 0 };
301
+ }
302
+ const body = await res.json().catch(() => ({}));
303
+ if (body.conflict) {
304
+ console.warn(
305
+ `[spec-sync] 检测到冲突,请人工拍板。服务器版本: ${JSON.stringify(body.server_versions || {})}`
306
+ );
307
+ return { synced: 0, conflict: true, serverVersions: body.server_versions };
308
+ }
309
+ console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
310
+ return { synced: ops.length };
311
+ } catch (err) {
312
+ console.warn(`[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${err.message}`);
313
+ return { synced: 0 };
314
+ }
315
+ }
316
+
317
+ export default {
318
+ walkSpecTree,
319
+ hashFiles,
320
+ computeSpecOps,
321
+ extractChangeDirs,
322
+ syncSpecTree,
323
+ };