sillyspec 3.26.11 → 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 (58) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-knowledge/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-plan/SKILL.md +5 -5
  5. package/.claude/skills/sillyspec-quick/SKILL.md +5 -1
  6. package/README.md +7 -5
  7. package/SKILL.md +5 -4
  8. package/package.json +1 -1
  9. package/packages/dashboard/server/executor.js +10 -1
  10. package/packages/dashboard/server/index.js +108 -46
  11. package/src/config-schema.js +3 -0
  12. package/src/contract-matrix.js +22 -13
  13. package/src/db.js +24 -4
  14. package/src/dispatch/probe.js +99 -63
  15. package/src/docs-check.js +58 -13
  16. package/src/docs-debt.js +6 -2
  17. package/src/docs-gate.js +1 -0
  18. package/src/doctor-diagnostics.js +208 -21
  19. package/src/fs-atomic.js +3 -1
  20. package/src/git-helper.js +7 -4
  21. package/src/hooks/worktree-guard.js +8 -2
  22. package/src/index.js +161 -12
  23. package/src/init.js +3 -2
  24. package/src/local-detect.js +9 -1
  25. package/src/modules.js +7 -5
  26. package/src/progress/stage-machine.js +589 -578
  27. package/src/progress/step-store.js +1 -1
  28. package/src/progress.js +184 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +31 -8
  31. package/src/run/complete-handlers.js +21 -14
  32. package/src/run/complete.js +1181 -1121
  33. package/src/run/gates.js +148 -25
  34. package/src/run/prompt.js +9 -5
  35. package/src/run/shared.js +138 -58
  36. package/src/run/stage.js +41 -1
  37. package/src/setup.js +8 -10
  38. package/src/sillyhub-mcp/client.js +74 -6
  39. package/src/spec-dir-typo.js +3 -24
  40. package/src/spec-sync.js +43 -8
  41. package/src/stage-contract.js +12 -38
  42. package/src/stages/archive.js +4 -3
  43. package/src/stages/brainstorm-auto.js +1 -1
  44. package/src/stages/brainstorm.js +2 -1
  45. package/src/stages/doctor.js +28 -13
  46. package/src/stages/execute.js +164 -153
  47. package/src/stages/plan-postcheck.js +49 -28
  48. package/src/stages/plan.js +57 -40
  49. package/src/stages/quick.js +3 -1
  50. package/src/stages/verify.js +1 -1
  51. package/src/sync.js +323 -221
  52. package/src/task-review.js +246 -61
  53. package/src/taskcard.js +178 -0
  54. package/src/verify-postcheck.js +48 -35
  55. package/src/worktree-apply.js +58 -34
  56. package/src/worktree-deps.js +120 -20
  57. package/src/worktree.js +55 -9
  58. package/.npmrc-publish +0 -2
package/src/run/stage.js CHANGED
@@ -19,7 +19,7 @@
19
19
  import { join, dirname } from 'node:path'
20
20
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
21
21
  import { writeAtomicSync } from '../fs-atomic.js'
22
- import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
22
+ import { resolveSpecDir, resolveChangeDir, resolveRuntimeRoot, resolveQuickSessionsDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps, warnApprovalUnknown } from './shared.js'
23
23
  import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
24
24
  import { outputStep } from './prompt.js'
25
25
  import { allocateQuicklogEntry, deriveTitleFromLinkedChange, sanitizeDesc } from '../quicklog.js'
@@ -56,6 +56,11 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
56
56
  console.log(`⏳ 变更 ${changeName} 的执行审批待处理中...`)
57
57
  console.log(' 提示:使用 --skip-approval 跳过审批检查')
58
58
  }
59
+ // HUB-07:unknown(连接了平台但 404/断网/非 JSON)此前静默落空(fail-open 无提示)——
60
+ // 醒目警告 + 留痕,放行但可审计
61
+ if (approval.status === 'unknown') {
62
+ warnApprovalUnknown(cwd, changeName, approval.reason)
63
+ }
59
64
  }
60
65
  }
61
66
 
@@ -118,6 +123,10 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
118
123
  `请检查该路径是否为普通文件/只读,清理后重跑(sillyspec run execute --change ${changeName} --skip-approval)`)
119
124
  }
120
125
  writeFileSync(runIdFile, currentExecuteRunId + '\n')
126
+ // change 归属戳(坑 worktree-cleanup-marker-chain 根治):run 目录自带变更身份,
127
+ // marker 断裂(worktree cleanup / 归档清理 / 并行误删)后按戳归属,不再错配其他变更的 run
128
+ const { stampExecuteRunChange } = await import('../task-review.js')
129
+ stampExecuteRunChange(runtimeRoot, currentExecuteRunId, changeName)
121
130
  }
122
131
  }
123
132
 
@@ -244,6 +253,25 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
244
253
  if (existingGuard) {
245
254
  // 跨进程重入:复用已分配的 ql-ID,跳过 baseline 重捕与分配(幂等)
246
255
  progress.quickGuard = existingGuard
256
+ // 中途追加边界:会话中途发现要改启动声明外的文件,此前 resume 会把 --files 静默丢弃、
257
+ // 边界冻结在启动时刻,只能靠事后归属/审计行兜底。恢复时带 --files 即并入 allowedFiles
258
+ // (追加不替换、去重保序),同时点录 allowedFilesHash 供同文件并发检测(文件不存在跳过,
259
+ // 与启动同语义)。--done 审计直读 guard.json(complete-handlers §4.6),追加即被归属消费。
260
+ const resumeFiles = Array.isArray(quickOpts?.quickFiles) ? quickOpts.quickFiles.filter(Boolean) : []
261
+ if (resumeFiles.length > 0) {
262
+ const known = new Set(progress.quickGuard.allowedFiles || [])
263
+ const added = resumeFiles.filter(f => !known.has(f))
264
+ if (added.length > 0) {
265
+ progress.quickGuard.allowedFiles = [...(progress.quickGuard.allowedFiles || []), ...added]
266
+ progress.quickGuard.allowedFilesHash = { ...(progress.quickGuard.allowedFilesHash || {}) }
267
+ for (const f of added) {
268
+ try { progress.quickGuard.allowedFilesHash[f] = createHash('sha256').update(readFileSync(join(cwd, f))).digest('hex') }
269
+ catch { /* 预声明将新建的文件,与启动时 allowedFilesHash 同语义:不存在跳过 */ }
270
+ }
271
+ writeAtomicSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
272
+ console.log(`🛡️ quick 边界已追加: ${added.length} 个文件(累计 ${progress.quickGuard.allowedFiles.length} 个): ${added.join(', ')}`)
273
+ }
274
+ }
247
275
  } else {
248
276
  // baseline 采集用 safeGit(带 -c safe.directory,避免 linked worktree/容器异 uid/Windows 挂载点
249
277
  // 下裸 `git status` 抛错)。safeGit 已消除 safe.directory 类失败;若仍失败(真非 git 目录等),
@@ -317,6 +345,16 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
317
345
  if (allowDelete) parts.push('允许删除文件')
318
346
  console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
319
347
  console.log(`📝 QUICKLOG 条目已创建: ${qlId}`)
348
+ // 缺 --input 且关联变更无可提取标题 → 条目落「(quick 任务)」占位标题,平台「快速修复」列表
349
+ // 默认隐藏进行中占位(task-06 口径),长会话全程不可见、语义标题要到最终 --done 才回填。
350
+ // 此刻会话刚起步零沉没成本,提示放弃重启带 --input 是最便宜的自愈点。
351
+ if (!quickDesc) {
352
+ console.warn('')
353
+ console.warn('⚠️ 本次 quick 未带 --input:QUICKLOG 条目将以占位标题「(quick 任务)」落盘')
354
+ console.warn(' 平台「快速修复」列表默认隐藏进行中的占位条目,语义标题要到最终 --done 才回填。')
355
+ console.warn(' 长任务建议放弃本会话、带一句话任务描述重启(旧会话 run quick --reset --change <sessionId> 重置):')
356
+ console.warn(' sillyspec run quick --input "<一句话任务描述>" [--linked-changes <a,b>] [--files <...>]')
357
+ }
320
358
  // 回填 DB changes 行的 title + quicklog_id,让 quick-<hex> 可读、DB↔QUICKLOG 可对账。
321
359
  // title 用 quickDesc(任务描述或关联变更标题)经 sanitizeDesc 压一行限长,与 QUICKLOG 条目标题同源。
322
360
  try {
@@ -380,6 +418,8 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
380
418
  await executeScanPostcheck(cwd, platformOpts, scanProfile)
381
419
  } else if (cliAction === 'planPostcheck') {
382
420
  await executePlanPostcheck(cwd, platformOpts, progress)
421
+ } else {
422
+ throw new Error(`noAI 步骤 ${stepName} 的未知 _cliAction: ${cliAction}——请在 stage.js 注册对应分支`)
383
423
  }
384
424
  stageData.steps[currentIdx].status = 'completed'
385
425
  stageData.steps[currentIdx].completedAt = new Date().toLocaleString('zh-CN', { hour12: false })
package/src/setup.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
- import { execSync, execFileSync } from 'child_process';
3
+ import { execSync } from 'child_process';
4
+ import { gitQuiet } from './git-helper.js';
4
5
  import chalk from 'chalk';
5
6
  import ora from 'ora';
6
7
  import { checkbox, input } from '@inquirer/prompts';
@@ -359,16 +360,13 @@ export async function cmdSetup(dir, options = {}) {
359
360
  // 凭据提交风险提示(sec-c):.claude/mcp.json / .cursor/mcp.json 是项目共享文件、通常随 git
360
361
  // 提交,而 DB MCP 的 env 含明文连接串/密码。检测 git 跟踪状态,已跟踪/未忽略时显式警告。
361
362
  if (selectedDb.length > 0) {
362
- try {
363
- execFileSync('git', ['-C', dir, 'ls-files', '--error-unmatch', path], { stdio: 'pipe' });
363
+ // QUAL-01 收口:裸 execFileSync → git-helper(-C 由 helper 统一注入)。
364
+ // 语义映射:ls-files --error-unmatch 命中(exit 0)= 已跟踪 → 警告;
365
+ // check-ignore -q 命中(exit 0)= 已忽略 → 不警告
366
+ if (gitQuiet(dir, ['ls-files', '--error-unmatch', path]) !== null) {
364
367
  console.warn(chalk.yellow(` ⚠️ ${path} 已被 git 跟踪——其中数据库连接串/密码会随提交进入仓库,建议加入 .gitignore 或改用环境变量`));
365
- } catch {
366
- // 未被跟踪:再查是否被 ignore;都不满足(未忽略未跟踪)时提示防止将来误提交
367
- try {
368
- execFileSync('git', ['-C', dir, 'check-ignore', '-q', path], { stdio: 'pipe' });
369
- } catch {
370
- console.warn(chalk.yellow(` ⚠️ ${path} 含数据库连接串/密码且未被 .gitignore 忽略——提交前请先加入 .gitignore 或改用环境变量`));
371
- }
368
+ } else if (gitQuiet(dir, ['check-ignore', '-q', path]) === null) {
369
+ console.warn(chalk.yellow(` ⚠️ ${path} 含数据库连接串/密码且未被 .gitignore 忽略——提交前请先加入 .gitignore 或改用环境变量`));
372
370
  }
373
371
  }
374
372
  }
@@ -49,7 +49,6 @@ export class SillyHubMcpClient {
49
49
  if (this._url && !/^https:\/\//i.test(this._url) && !/^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(this._url)) {
50
50
  console.warn(`[sillyhub-mcp] mcp.url 非 https(${this._url.slice(0, 60)}),Bearer token 将明文传输——请确认为受控环境`);
51
51
  }
52
- this._token = typeof t === 'string' ? t : '';
53
52
  this._timeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0
54
53
  ? timeoutMs
55
54
  : DEFAULT_TIMEOUT_MS;
@@ -206,6 +205,13 @@ export class SillyHubMcpClient {
206
205
  }
207
206
 
208
207
  if (rpc.error) {
208
+ // HUB-11:session 过期第二种形态——HTTP 200 + JSON-RPC error -32600「Missing session」
209
+ // (api-reference 记载的形态;FastMCP 实测走 HTTP 400,两种都认)。落哨兵触发
210
+ // _sendRpc 重置重连;其余 error 维持 warn+null(如 -32602 Invalid params 是调用侧问题,
211
+ // 重连救不回,不误触发)
212
+ if (rpc.error.code === -32600 && /missing session/i.test(String(rpc.error.message || ''))) {
213
+ return SESSION_EXPIRED;
214
+ }
209
215
  if (!quiet) console.warn(`[sillyhub-mcp] ${tag} JSON-RPC error: ${JSON.stringify(rpc.error)}`);
210
216
  return null;
211
217
  }
@@ -228,11 +234,8 @@ export class SillyHubMcpClient {
228
234
 
229
235
  /**
230
236
  * MCP initialize 握手:POST initialize → 从响应 header(或 body _meta)读 mcp-session-id。
231
- * 成功存 this._sessionId;失败返回 false(best-effort,warn 不抛)。
232
- *
233
- * 注:协议要求 initialize 后发 notifications/initialized,本客户端只做探测
234
- * (tools/list / 只读 tool),实测 FastMCP 不强制该通知即可用(2026-08-14 验证),
235
- * 为省一次 RPC 不发;若未来 server 强校验再补。
237
+ * 成功存 this._sessionId 并补发 notifications/initialized(协议要求;FastMCP 实测不强制,
238
+ * best-effort 失败仅 warn);失败返回 false(best-effort,warn 不抛)。
236
239
  * @returns {Promise<boolean>}
237
240
  */
238
241
  async _initialize(quiet = false) {
@@ -299,6 +302,28 @@ export class SillyHubMcpClient {
299
302
  return false;
300
303
  }
301
304
  this._sessionId = sid;
305
+ // MCP 2025-11-25 协议要求 initialize 后发 notifications/initialized(无 id 通知,server 不回包)。
306
+ // 2026-08-14 实测 FastMCP v1.29 不强制;补发是为防未来 server 强校验拒掉所有 tools/call。
307
+ // best-effort:失败仅 warn,不阻断(session 已建,宽松 server 仍可用)。
308
+ try {
309
+ const noteController = new AbortController();
310
+ const noteTimer = setTimeout(() => noteController.abort(), this._timeoutMs);
311
+ await fetch(this._endpoint, {
312
+ method: 'POST',
313
+ headers: {
314
+ Authorization: `Bearer ${this._token}`,
315
+ 'Content-Type': 'application/json',
316
+ Accept: 'application/json, text/event-stream',
317
+ 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
318
+ 'Mcp-Session-Id': this._sessionId,
319
+ },
320
+ body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
321
+ signal: noteController.signal,
322
+ }).catch(() => { /* best-effort */ });
323
+ clearTimeout(noteTimer);
324
+ } catch (err) {
325
+ if (!quiet) console.warn(`[sillyhub-mcp] notifications/initialized 发送失败(忽略): ${err && err.message ? err.message : err}`);
326
+ }
302
327
  return true;
303
328
  }
304
329
 
@@ -387,6 +412,49 @@ export class SillyHubMcpClient {
387
412
  return null;
388
413
  }
389
414
 
415
+ /**
416
+ * HUB-12a:tools/list 的完整 result(一次请求同时喂路径A schema 预热与 root_path 越界
417
+ * 校验——此前 listTools + getRootPath 各发一次同 method 请求,probe 链路重复 RPC)。
418
+ * best-effort:未配置/失败 → null 不抛。形态:{tools:[...], root_path?} 或裸数组。
419
+ */
420
+ async listToolsWithMeta({ quiet = true } = {}) {
421
+ if (!this._configured) return null;
422
+ return this._sendRpc('tools/list', {}, { quiet, label: 'tools/list' });
423
+ }
424
+
425
+ /**
426
+ * HUB-12b:关闭 MCP session(streamable HTTP DELETE,2025-11-25 协议规定的会话终止方式)。
427
+ * CLI 短进程原本把 session 留给 server TTL 清理;长驻场景(未来 daemon 化)会积累半开
428
+ * session。best-effort:网络失败/无 session 返回 false 不抛;成功后清本地 session 态,
429
+ * 后续调用自动重新 initialize。
430
+ * @returns {Promise<boolean>} 是否发出了有效的 DELETE
431
+ */
432
+ async close() {
433
+ if (!this._configured || !this._sessionId) return false;
434
+ const sid = this._sessionId;
435
+ this._sessionId = null; // 先清本地态:DELETE 失败也不留「以为还活着」的 session
436
+ try {
437
+ const controller = new AbortController();
438
+ const timer = setTimeout(() => controller.abort(), this._timeoutMs);
439
+ try {
440
+ const res = await fetch(this._endpoint, {
441
+ method: 'DELETE',
442
+ headers: {
443
+ Authorization: `Bearer ${this._token}`,
444
+ 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION,
445
+ 'Mcp-Session-Id': sid,
446
+ },
447
+ signal: controller.signal,
448
+ });
449
+ return res.ok;
450
+ } finally {
451
+ clearTimeout(timer);
452
+ }
453
+ } catch {
454
+ return false;
455
+ }
456
+ }
457
+
390
458
  /**
391
459
  * best-effort 从 daemon 拿 workspace root_path(task-12 路径A 越界校验用)。
392
460
  *
@@ -13,30 +13,9 @@
13
13
 
14
14
  import { dirname, basename, join } from 'path'
15
15
  import { readdirSync } from 'fs'
16
-
17
- /**
18
- * 计算两字符串的 Levenshtein 编辑距离(大小写不敏感比较字符)。
19
- * @param {string} a
20
- * @param {string} b
21
- * @returns {number}
22
- */
23
- function levenshtein(a, b) {
24
- const m = a.length
25
- const n = b.length
26
- if (m === 0) return n
27
- if (n === 0) return m
28
- let prev = Array.from({ length: n + 1 }, (_, j) => j)
29
- let curr = new Array(n + 1)
30
- for (let i = 1; i <= m; i++) {
31
- curr[0] = i
32
- for (let j = 1; j <= n; j++) {
33
- const cost = a[i - 1].toLowerCase() === b[j - 1].toLowerCase() ? 0 : 1
34
- curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
35
- }
36
- ;[prev, curr] = [curr, prev]
37
- }
38
- return prev[n]
39
- }
16
+ // levenshtein 单一实现收敛到 run/shared.js(原本地副本与 shared.js 逐行相同);
17
+ // shared.js 不静态 import review 族,此处引入不成环
18
+ import { levenshtein } from './run/shared.js'
40
19
 
41
20
  /**
42
21
  * 检测 spec 目录拼写变体。
package/src/spec-sync.js CHANGED
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { createHash } from 'crypto';
14
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
14
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'fs';
15
15
  import { join, relative, sep } from 'path';
16
16
 
17
17
  // 与 sillyhub-daemon/src/spec-sync.ts 共用排除口径(task-07 / FR-06 / D-008@v2):
@@ -127,6 +127,19 @@ export function computeSpecOps(serverManifest, localFiles) {
127
127
  const serverPaths = Object.keys(server).filter((p) => server[p]?.exists !== false);
128
128
  const serverSet = new Set(serverPaths);
129
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
+
130
143
  // rename 检测:旧路径(服务器有、本地无)↔ 新路径(本地有、服务器无)hash 相同
131
144
  const renames = [];
132
145
  const consumedNew = new Set();
@@ -243,11 +256,13 @@ function collectChangeDir(dirs, p) {
243
256
  * @param {string} changeName - 当前变更名
244
257
  * @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
245
258
  */
246
- export async function syncSpecTree(specRoot, platform, changeName) {
259
+ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
247
260
  if (!platform || !platform.url || !platform.token) {
248
261
  debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
249
262
  return { synced: 0 };
250
263
  }
264
+ // HUB-09:单请求超时与外部熔断 signal 合并——trigger* 熔断时在飞请求被真实取消
265
+ const withSignal = (timeoutMs) => (opts.signal ? AbortSignal.any([AbortSignal.timeout(timeoutMs), opts.signal]) : AbortSignal.timeout(timeoutMs));
251
266
 
252
267
  const manifestUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-manifest`;
253
268
  const syncUrl = `${platform.url.replace(/\/$/, '')}/api/changes/-/spec-sync`;
@@ -257,7 +272,7 @@ export async function syncSpecTree(specRoot, platform, changeName) {
257
272
  try {
258
273
  const res = await fetch(manifestUrl, {
259
274
  headers: { Authorization: `Bearer ${platform.token}` },
260
- signal: AbortSignal.timeout(10000),
275
+ signal: withSignal(10000),
261
276
  });
262
277
  if (!res.ok) {
263
278
  // ql-20260818-008:debugLog → console.warn。树同步失败原本完全静默(要
@@ -293,7 +308,7 @@ export async function syncSpecTree(specRoot, platform, changeName) {
293
308
  Authorization: `Bearer ${platform.token}`,
294
309
  },
295
310
  body: JSON.stringify({ ops }),
296
- signal: AbortSignal.timeout(30000),
311
+ signal: withSignal(30000),
297
312
  });
298
313
  if (!res.ok) {
299
314
  console.warn(`[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`);
@@ -301,10 +316,30 @@ export async function syncSpecTree(specRoot, platform, changeName) {
301
316
  }
302
317
  const body = await res.json().catch(() => ({}));
303
318
  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 };
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 };
308
343
  }
309
344
  console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
310
345
  return { synced: ops.length };
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { existsSync, readdirSync, readFileSync } from 'fs'
9
9
  import { join, basename } from 'path'
10
- import { execFileSync } from 'child_process'
10
+ import { safeGit } from './git-helper.js'
11
11
  import { detectChangeRisk, checkIntegrationEvidence, VERIFICATION_NEEDS, RISK_LEVEL_CAUSES } from './change-risk-profile.js'
12
12
  import { SCAN_REQUIRED_DOCS, AUXILIARY_STAGES } from './constants.js'
13
13
  import { evaluateRules } from './stage-contract-engine.js'
@@ -61,22 +61,12 @@ export function validateChangeExists(specBase, stageName, changeName) {
61
61
  if (/^quick-[0-9a-f]{8}$/.test(changeName)) return null
62
62
  if (existsSync(join(specBase, 'changes', changeName))) return null
63
63
 
64
- // archive 特例:step4 --confirm 已把变更从 changes/<name>/ 移到 changes/archive/<date>-<name>/。
64
+ // archive 特例:step4 --confirm 已把变更从 changes/<name>/ 移到 changes/archive/<name>/。
65
65
  // step5(更新路线图/提交)--change <name> 不应因 changes/<name>/ 已移走而被前置校验误拦。
66
- // 精确匹配归档目录名(YYYY-MM-DD-<changeName>),避免后缀子串误匹配(auth ≠ deep-auth)。
67
66
  if (stageName === 'archive') {
68
67
  const archiveRoot = join(specBase, 'changes', 'archive')
69
- if (existsSync(archiveRoot)) {
70
- // 归档目录名恒为 <归档日>-<纯描述>(archiveDestDirName 写入时已 strip 源名前导日期)。
71
- // 校验侧同样 strip changeName 前导日期再比 —— 否则「写 strip、读拿完整源名」语义不一致,
72
- // 会让带日期前缀的源名(brainstorm 约定,如 2026-07-29-sidebar-menu-restructure)在 step5
73
- // 被前置校验误拦(坑 archive-step5-leading-date-change-name)。
74
- const stripped = String(changeName).replace(/^\d{4}-\d{2}-\d{2}-/, '')
75
- const archived = readdirSync(archiveRoot).find(d => {
76
- const m = d.match(/^\d{4}-\d{2}-\d{2}-(.+)$/)
77
- return m && m[1] === stripped
78
- })
79
- if (archived) return null
68
+ if (existsSync(archiveRoot) && existsSync(join(archiveRoot, changeName))) {
69
+ return null
80
70
  }
81
71
  }
82
72
  return {
@@ -527,19 +517,10 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
527
517
  }
528
518
 
529
519
  /**
530
- * 计算归档目标目录名:保证 archive/ 下目录有且仅有一个日期前缀。
531
- *
532
- * 变更名创建时常已带 YYYY-MM-DD- 前缀(brainstorm 强制约定 + CLI 自动占位名),
533
- * 若归档时再无条件拼一次 date- 前缀,会产生 2026-07-28-2026-07-28-<desc> 双日期。
534
- * 这里先剥离源名前导日期,再统一拼归档当天日期 → 归档目录名恒为 <归档日期>-<纯描述>。
535
- * 不带日期的源名(如 quick-<hash>)strip 无效,正常补日期,行为不变。
536
- *
537
- * 写(archiveChangeDirectory)与校验(validateArchiveOutputs)共用此函数,
538
- * 避免「写和校验各自硬编码同公式」的漂移(曾因此导致双日期 bug 写校验都自洽通过)。
520
+ * 计算归档目标目录名:保持原变更名不变,直接移入 archive/。
539
521
  */
540
522
  export function archiveDestDirName(date, changeName) {
541
- const stripped = String(changeName).replace(/^\d{4}-\d{2}-\d{2}-/, '')
542
- return `${date}-${stripped}`
523
+ return String(changeName)
543
524
  }
544
525
 
545
526
  /**
@@ -547,8 +528,7 @@ export function archiveDestDirName(date, changeName) {
547
528
  */
548
529
  function validateArchiveOutputs(cwd, changeName) {
549
530
  const archiveDir = join(cwd, '.sillyspec', 'changes', 'archive')
550
- const date = new Date().toISOString().slice(0, 10)
551
- const destDir = join(archiveDir, archiveDestDirName(date, changeName))
531
+ const destDir = join(archiveDir, changeName)
552
532
 
553
533
  // 归档目录不存在 early return(引擎在存在时才跑)
554
534
  if (!existsSync(destDir)) {
@@ -583,18 +563,12 @@ function validateChangeClosed(cwd, changeName) {
583
563
 
584
564
  // ============ Execute 代码变更客观核验 ============
585
565
 
566
+ // QUAL-01 收口:本地 execFileSync 裸调(无 safe.directory)→ git-helper safeGit 统一入口,
567
+ // {ok,out,error} 适配语义与 timeout 15000 不变
586
568
  function gitTry(dir, args) {
587
- try {
588
- const out = execFileSync('git', args, {
589
- cwd: dir,
590
- encoding: 'utf8',
591
- timeout: 15000,
592
- stdio: ['ignore', 'pipe', 'pipe'],
593
- }).trim()
594
- return { ok: true, out }
595
- } catch (e) {
596
- return { ok: false, out: '', error: e.message?.split('\n')[0] || String(e) }
597
- }
569
+ const r = safeGit(dir, args, { timeout: 15000 })
570
+ if (r.value !== null) return { ok: true, out: r.value }
571
+ return { ok: false, out: '', error: r.error || 'git 执行失败' }
598
572
  }
599
573
 
600
574
  /**
@@ -12,8 +12,9 @@ export const definition = {
12
12
 
13
13
  ### 判定规则
14
14
  - **真相源 = review.json verdict**:上方报告由 CLI 从 .runtime/execute-runs/<runId>/tasks/task-NN/review.json 的 specVerdict + qualityVerdict 算出。
15
- - plan.md 的 - [x] checkbox **仅作显示态参考**:它依赖自动回填,runId marker / review 缺失时会停在未勾态,与客观 verdict 不一致时**以本报告为准**,不要被未勾 checkbox 误导。
15
+ - tasks.md 的 - [x] checkbox **仅作显示态参考**:它依赖自动回填,runId marker / review 缺失时会停在未勾态,与客观 verdict 不一致时**以本报告为准**,不要被未勾 checkbox 误导。
16
16
  - 报告「未通过 / 缺失」= 0 且 source 为 review.json → 完成度合格,进入下一步。
17
+ - 报告含「cannot_verify 草稿(未真正复核)」计数 > 0 → 这些 task 只有 cannot_verify 草稿兜底、未被真正复核:先确认 verify 阶段已兑现其 requiredEvidence(verify-result.md 有对应证据),缺证据的派独立子代理对照 task brief + git diff 补真实复核(升级为 pass/fail),勿静默放行。
17
18
  - 报告 source 为 plan-checkbox-fallback(客观源不可用)→ 完成度无法客观确认,**必须暂停**让用户交叉核对,不要直接放行。
18
19
  - 存在未完成任务 → **必须暂停等待用户决定**,不要自行判断"可以归档"。
19
20
  - 调用:sillyspec run archive --wait --reason "存在未完成任务,是否继续归档" --options "继续归档,回到execute完成剩余任务" --output "未完成任务列表"
@@ -126,10 +127,10 @@ module_id: <module-id>
126
127
 
127
128
  ### 操作
128
129
  1. 展示:变更目录名、包含的文件列表(含 module-impact.md)、生成总结
129
- 2. 确保所有 checkbox 都已勾选
130
+ 2. 确保任务清单(tasks.md)所有 task checkbox 都已勾选
130
131
  3. 让用户确认后,用 \`--confirm\` 完成本步骤:
131
132
  \`sillyspec run archive --done --confirm --output "确认归档"\`
132
- 4. CLI 会创建 \`.sillyspec/changes/archive/\`,并将变更目录移动到 \`.sillyspec/changes/archive/<归档日期>-<纯描述>(源名前导日期去重,避免双日期)/\`
133
+ 4. CLI 会创建 \`.sillyspec/changes/archive/\`,并将变更目录移动到 \`.sillyspec/changes/archive/<原变更名>/\`
133
134
 
134
135
  ### 输出
135
136
  归档完成 + archive 目录路径`,
@@ -217,7 +217,7 @@ AC-011: 不涉及业务规则/产品范围/默认行为/用户可见行为变更
217
217
  1. 基于变更根目录下的设计产物(design.md / decisions.md / gaps.md / assumptions.md / next-action.json),生成规范文件(直接写入变更根目录):
218
218
  - **proposal.md**:动机、关键问题、变更范围、不在范围内、成功标准
219
219
  - **requirements.md**:角色表 + FR 编号需求 + Given/When/Then + 非功能需求
220
- - **tasks.md**:任务列表(只列名称,细节在 plan 阶段展开)
220
+ - **tasks.md**:任务列表骨架(只列名称;任务清单唯一真相,plan 阶段展开细节并写回本文件——execute 勾选与 verify 对照都在本文件)
221
221
  2. 所有规范文件头部包含 YAML frontmatter
222
222
  3. \`git add {SPEC_ROOT}/changes/<change-name>/\` — 暂存本变更的规范文件(勿用 .sillyspec/ 整目录——会裹挟其他活跃变更;不要 commit)
223
223
 
@@ -537,7 +537,7 @@ Then 期望结果
537
537
  | D-001@v1 | FR-01 | ... |
538
538
  \`\`\`
539
539
 
540
- ### tasks.md 格式要求(scale=large 时生成骨架,plan 阶段展开)
540
+ ### tasks.md 格式要求(scale=large 时生成骨架;任务清单唯一真相——plan 阶段展开细节并写回本文件)
541
541
  \`\`\`markdown
542
542
  ---
543
543
  author: <git-user>
@@ -548,6 +548,7 @@ created_at: <now-datetime>
548
548
  - [ ] task-01: <任务名>
549
549
  - [ ] task-02: <任务名>
550
550
  \`\`\`
551
+ > 骨架只列任务名。plan 阶段会把展开后的清单**写回本文件**(checkbox 行带一句话名,可附 [model:xxx]/(depends_on: …) 标注;保留 frontmatter/标题/ql-xxx 等非 task-XX 行);execute 勾选与 verify 对照都在本文件。
551
552
 
552
553
  ### decisions.md 格式要求(仅在有 Grill/重大决策时生成)
553
554
  \`\`\`markdown
@@ -12,7 +12,7 @@ export const definition = {
12
12
  name: 'SillySpec 内部检查',
13
13
  prompt: `运行 SillySpec 内部检查。逐项执行以下命令并汇总结果:
14
14
 
15
- > ⚠️ Windows 兼容:下列 sqlite3 查询依赖外部 sqlite3 CLI(Windows 默认没有)。若某条 sqlite3 失败,改用 sillyspec 自身命令替代——sillyspec progress show 查看当前变更与各阶段状态(等价于查 changes/stages 表),sillyspec doctor --json 查看 db 健康与活跃变更列表。
15
+ > ✅ Windows 兼容(SS-1,2026-08-20):db 查询已全部改走 node:sqlite(node≥22.13 内置),不再依赖外部 sqlite3 CLI。若 node 脚本失败,改用 sillyspec 自身命令替代——sillyspec progress show 查看当前变更与各阶段状态,sillyspec doctor --json 查看 db 健康与活跃变更列表。
16
16
 
17
17
  ### 1. 目录结构完整性
18
18
  \`\`\`bash
@@ -44,16 +44,22 @@ done
44
44
 
45
45
  ### 3. 进度数据一致性
46
46
  \`\`\`bash
47
- # 从 sillyspec.db 读取活跃变更并检查目录存在性
47
+ # 从 sillyspec.db 读取活跃变更并检查目录存在性(node:sqlite 主路径,Windows 无 sqlite3 CLI)
48
48
  DB_FILE='.sillyspec/.runtime/sillyspec.db'
49
49
  if [ ! -f "$DB_FILE" ]; then echo "⚠️ sillyspec.db 不存在"; exit 0; fi
50
- ACTIVE_CHANGE=$(sqlite3 "$DB_FILE" "SELECT name FROM changes WHERE status='active' ORDER BY last_active DESC LIMIT 1" 2>/dev/null)
51
- if [ -z "$ACTIVE_CHANGE" ]; then echo "ℹ️ 无当前变更"; exit 0; fi
52
- echo "当前变更: $ACTIVE_CHANGE"
53
- DIR=".sillyspec/changes/$ACTIVE_CHANGE"
54
- [ -d "$DIR" ] && echo "✅ 变更目录存在: $ACTIVE_CHANGE" || echo "❌ 变更目录不存在: $ACTIVE_CHANGE"
55
- # 检查各阶段状态
56
- sqlite3 -header -column "$DB_FILE" "SELECT stage, status FROM stages s JOIN changes c ON s.change_id=c.id WHERE c.name='$ACTIVE_CHANGE' ORDER BY s.stage" 2>/dev/null || echo "⚠️ 无法查询阶段数据"
50
+ node --input-type=module -e "
51
+ import { DatabaseSync } from 'node:sqlite';
52
+ import { existsSync } from 'node:fs';
53
+ const db = new DatabaseSync(process.argv[1], { readOnly: true });
54
+ const row = db.prepare(\\"SELECT name FROM changes WHERE status='active' ORDER BY last_active DESC LIMIT 1\\").get();
55
+ if (!row) { console.log('ℹ️ 无当前变更'); process.exit(0); }
56
+ console.log('当前变更: ' + row.name);
57
+ const dir = '.sillyspec/changes/' + row.name;
58
+ console.log(existsSync(dir) ? '✅ 变更目录存在: ' + row.name : '❌ 变更目录不存在: ' + row.name);
59
+ const stages = db.prepare('SELECT s.stage AS stage, s.status AS status FROM stages s JOIN changes c ON s.change_id=c.id WHERE c.name=? ORDER BY s.stage').all(row.name);
60
+ if (stages.length === 0) console.log('⚠️ 无阶段数据'); else for (const s of stages) console.log(' ' + s.stage + ': ' + s.status);
61
+ db.close();
62
+ " "$DB_FILE" 2>/dev/null || echo "⚠️ 无法查询阶段数据"
57
63
  \`\`\`
58
64
 
59
65
  ### 4. 孤儿文件检查
@@ -62,11 +68,14 @@ DB_FILE='.sillyspec/.runtime/sillyspec.db'
62
68
  if [ ! -f "$DB_FILE" ]; then echo "⚠️ sillyspec.db 不存在"; exit 0; fi
63
69
  node -e "
64
70
  const fs = require('fs');
71
+ const { DatabaseSync } = require('node:sqlite');
65
72
  const dir = '.sillyspec/changes';
66
- const subs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => { try { return fs.statSync(dir+'/'+f).isDirectory(); } catch { return false; } }) : [];
67
- const { execSync } = require('child_process');
73
+ const subs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => { try { return fs.statSync(dir+'/'+f).isDirectory() && f !== 'archive'; } catch { return false; } }) : [];
68
74
  let known;
69
- try { const rows = execSync('sqlite3 -json \".sillyspec/.runtime/sillyspec.db\" \"SELECT name FROM changes WHERE status=\\\"active\\\"\"', {encoding:'utf8'}).trim(); known = new Set(JSON.parse(rows).map(r => r.name)); } catch { known = new Set(); }
75
+ try { const db = new DatabaseSync('.sillyspec/.runtime/sillyspec.db', { readOnly: true }); known = new Set(db.prepare(\"SELECT name FROM changes WHERE status='active'\").all().map(r => r.name)); db.close(); } catch (e) { console.log('⚠️ db 读取失败: ' + e.message + '(跳过孤儿判定,不做任何清理建议)'); known = null; }
76
+ if (known) subs.forEach(s => {
77
+ console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(先 dump 取证再手工归位,勿直接 rm)');
78
+ });
70
79
  subs.forEach(s => {
71
80
  console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(可清理)');
72
81
  });
@@ -132,7 +141,13 @@ DB_FILE='.sillyspec/.runtime/sillyspec.db'
132
141
  if [ -f "$DB_FILE" ]; then
133
142
  echo ""
134
143
  echo "isolation 状态(来自 sillyspec.db):"
135
- sqlite3 -header -column "$DB_FILE" "SELECT name, isolation_status AS status, isolation_mode AS mode, isolation_reason AS reason FROM changes WHERE status='active'" 2>/dev/null || echo "⚠️ 查询 isolation 失败"
144
+ node -e "
145
+ const { DatabaseSync } = require('node:sqlite');
146
+ const db = new DatabaseSync(process.argv[1], { readOnly: true });
147
+ const rows = db.prepare(\"SELECT name, isolation_status AS status, isolation_mode AS mode, isolation_reason AS reason FROM changes WHERE status='active'\").all();
148
+ for (const r of rows) console.log(' ' + r.name + ' | ' + r.status + ' | ' + r.mode + ' | ' + (r.reason || ''));
149
+ db.close();
150
+ " "$DB_FILE" 2>/dev/null || echo "⚠️ 查询 isolation 失败"
136
151
  else
137
152
  echo ""
138
153
  echo "ℹ️ sillyspec.db 不存在(尚未初始化)"