sillyspec 3.26.8 → 3.26.10

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 (55) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -2
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -2
  3. package/.claude/skills/sillyspec-execute/SKILL.md +11 -3
  4. package/.claude/skills/sillyspec-explore/SKILL.md +0 -1
  5. package/.claude/skills/sillyspec-plan/SKILL.md +1 -2
  6. package/.claude/skills/sillyspec-quick/SKILL.md +4 -5
  7. package/.claude/skills/sillyspec-scan/SKILL.md +2 -2
  8. package/.claude/skills/sillyspec-status/SKILL.md +0 -1
  9. package/.claude/skills/sillyspec-verify/SKILL.md +4 -3
  10. package/README.md +5 -9
  11. package/package.json +2 -2
  12. package/packages/dashboard/server/index.js +14 -8
  13. package/src/change-risk-profile.js +5 -207
  14. package/src/config-schema.js +6 -1
  15. package/src/constants.js +97 -106
  16. package/src/db-engine.js +1 -1
  17. package/src/docs-check.js +34 -5
  18. package/src/git-helper.js +2 -2
  19. package/src/hooks/worktree-guard.js +1 -1
  20. package/src/index.js +96 -13
  21. package/src/init.js +14 -3
  22. package/src/machine-interface.js +7 -4
  23. package/src/progress/stage-machine.js +3 -0
  24. package/src/quicklog.js +203 -4
  25. package/src/run/command.js +101 -3
  26. package/src/run/complete-handlers.js +1399 -1254
  27. package/src/run/complete.js +9 -1
  28. package/src/run/concurrent-detect.js +22 -0
  29. package/src/run/gates.js +67 -1
  30. package/src/run/prompt.js +761 -713
  31. package/src/run/quick-audit.js +146 -137
  32. package/src/run/shared.js +1215 -1109
  33. package/src/run/stage.js +17 -1
  34. package/src/scan-diff.js +388 -0
  35. package/src/scan-staleness.js +18 -13
  36. package/src/spec-sync.js +319 -0
  37. package/src/stage-contract-spec.js +1 -1
  38. package/src/stage-review.js +1 -1
  39. package/src/stages/archive.js +2 -1
  40. package/src/stages/brainstorm.js +2 -2
  41. package/src/stages/execute.js +1003 -985
  42. package/src/stages/plan-postcheck.js +22 -1
  43. package/src/stages/plan.js +74 -40
  44. package/src/stages/quick.js +13 -5
  45. package/src/stages/scan.js +4 -3
  46. package/src/stages/verify.js +14 -12
  47. package/src/sync.js +22 -0
  48. package/src/task-review.js +12 -4
  49. package/src/workflow.js +8 -185
  50. package/src/worktree-apply.js +122 -0
  51. package/src/worktree.js +50 -6
  52. package/templates/claude-instruction.md +1 -1
  53. package/templates/prompts/taskcard-rules.md +1 -0
  54. package/templates/prompts/verify-probes.md +2 -2
  55. package/templates/skills/sillyspec-onboard/SKILL.md +0 -92
@@ -0,0 +1,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 } 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
+ };
@@ -39,7 +39,7 @@ import { hasAllSections, hasNoPlaceholderLine, lineCount, meetsMinLines, missing
39
39
  import { SCAN_REQUIRED_DOCS } from './constants.js'
40
40
 
41
41
  // custom kind 集合:判定算法留在各 validator(复杂条件),引擎跳过不计入 errors/warnings。
42
- export const CUSTOM_KINDS = new Set([
42
+ const CUSTOM_KINDS = new Set([
43
43
  'lifecycle-exemption', // design.md 生命周期关键词触发 + 多层豁免(validateBrainstormOutputs)
44
44
  'entry-point-wiring', // design.md 入口实例化 → task allowed_paths 覆盖对账(validatePlanOutputs)
45
45
  'id-traceability', // FR/D 引用追踪(extractIds / warnMissingIds)
@@ -21,7 +21,7 @@ import { detectSpecDirTypo } from './spec-dir-typo.js'
21
21
  import { resolveRuntimeRoot } from './run/shared.js'
22
22
 
23
23
  // 文档型 stage review 的合法 reviewType
24
- export const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
24
+ const STAGE_REVIEW_TYPES = ['design', 'plan', 'proposal', 'code', 'acceptance']
25
25
 
26
26
  // checklist 每项 result 的合法值
27
27
  const CHECKLIST_RESULTS = ['pass', 'gap', 'fail']
@@ -81,6 +81,7 @@ export const definition = {
81
81
  3. 如果标记缺失或重复 → 在 _module-map.yaml 中标记 needs_review: true
82
82
 
83
83
  #### 新建模块卡片模板
84
+ 一级标题格式 \`# <中文名>(<module-id>)\`——中文名从模块职责提炼 2-8 字,括号内保留 module-id 原样(sillyhub 平台解析识别用,与 scan 生成约定一致)。
84
85
  \`\`\`markdown
85
86
  ---
86
87
  schema_version: 1
@@ -88,7 +89,7 @@ doc_type: module-card
88
89
  module_id: <module-id>
89
90
  ---
90
91
 
91
- # <module-id>
92
+ # <中文名>(<module-id>)
92
93
 
93
94
  ## 定位
94
95
 
@@ -330,8 +330,8 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
330
330
  - 长期术语只在 archive/scan 时再提升到 \`{SPEC_ROOT}/docs/<project>/glossary.md\`
331
331
  4. 格式自检(只查章节齐全;语义一致性/可行性/YAGNI 不在本步查,交给下一步 Design Grill 独立审查):
332
332
  - design.md 含全部必填章节(背景/设计目标/非目标/总体方案/文件变更清单/接口定义/风险登记)
333
- - design.md 头部 YAML frontmatter 字段齐全(author/created_at/scale 等)——step8 完成契约硬要求,审查前就补齐避免后续 docHash 漂移
334
- - design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——step8 完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
333
+ - design.md 头部 YAML frontmatter 字段齐全(author/created_at/scale 等)——「生成规范文件」步完成契约硬要求,审查前就补齐避免后续 docHash 漂移
334
+ - design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——「生成规范文件」步完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
335
335
  - 如存在 decisions.md,design.md 是否引用所有当前版本 D-xxx@vN
336
336
  - 涉及 session/lease/agent_run/daemon/lifecycle 等关键词时,是否含「生命周期契约表」
337
337
  5. 缺章节 → 补齐后重检;章节齐全 → 进入下一步(Design Grill 做语义层交叉审查)