sillyspec 3.26.11 → 3.26.14

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 (62) 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 +200 -27
  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 +187 -30
  29. package/src/quicklog.js +47 -16
  30. package/src/run/command.js +88 -46
  31. package/src/run/complete-handlers.js +27 -15
  32. package/src/run/complete.js +1186 -1121
  33. package/src/run/concurrent-detect.js +3 -1
  34. package/src/run/gates.js +172 -25
  35. package/src/run/prompt.js +17 -9
  36. package/src/run/scan-profile.js +35 -23
  37. package/src/run/shared.js +164 -63
  38. package/src/run/stage.js +41 -1
  39. package/src/setup.js +8 -10
  40. package/src/sillyhub-mcp/client.js +74 -6
  41. package/src/spec-dir-typo.js +3 -24
  42. package/src/spec-sync.js +43 -8
  43. package/src/stage-contract.js +12 -38
  44. package/src/stage-review.js +40 -1
  45. package/src/stages/archive.js +4 -3
  46. package/src/stages/brainstorm-auto.js +1 -1
  47. package/src/stages/brainstorm.js +27 -13
  48. package/src/stages/doctor.js +29 -16
  49. package/src/stages/execute.js +170 -154
  50. package/src/stages/knowledge.js +18 -3
  51. package/src/stages/plan-postcheck.js +72 -37
  52. package/src/stages/plan.js +70 -48
  53. package/src/stages/quick.js +3 -1
  54. package/src/stages/verify.js +1 -1
  55. package/src/sync.js +339 -225
  56. package/src/task-review.js +281 -64
  57. package/src/taskcard.js +178 -0
  58. package/src/verify-postcheck.js +48 -35
  59. package/src/worktree-apply.js +58 -34
  60. package/src/worktree-deps.js +120 -20
  61. package/src/worktree.js +55 -9
  62. package/.npmrc-publish +0 -2
package/src/run/shared.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * 本模块仅 re-export,run/ 层现有调用方路径与行为不变。
12
12
  */
13
13
  import { basename, join, resolve, dirname, sep } from 'node:path'
14
- import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
14
+ import { existsSync, readdirSync, readFileSync, statSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
15
15
  import { fileURLToPath } from 'node:url'
16
16
  import os from 'node:os'
17
17
 
@@ -336,12 +336,26 @@ export const PLATFORM_MANAGED_FILENAME = '.sillyspec-platform-managed'
336
336
 
337
337
  export function writePlatformPointer(cwd, platformOpts, extra = {}) {
338
338
  if (!platformOpts || (!platformOpts.specRoot && !platformOpts.runtimeRoot)) return false
339
+ // HUB-05:合并保留既有生命周期字段。scan 完成时指针被写入 status/completedAt/scanStatus
340
+ //(complete-handlers),但下一次任何平台模式 run(含只读 --status)都会重写指针——
341
+ // 恢复链只回填 specRoot 等四字段,不保留 status → 指针永远回 active、isPointerStale
342
+ // 恒 false、`platform pointer --cleanup` 的 STALE 分支不可达。extra 显式传值仍可覆盖。
343
+ let preserved = {}
344
+ try {
345
+ const existing = JSON.parse(readFileSync(join(cwd, '.sillyspec-platform.json'), 'utf8'))
346
+ if (existing && typeof existing === 'object') {
347
+ for (const k of ['status', 'completedAt', 'scanStatus']) {
348
+ if (existing[k] !== undefined && extra[k] === undefined) preserved[k] = existing[k]
349
+ }
350
+ }
351
+ } catch { /* 无既有指针/损坏 → 全新写入 */ }
339
352
  const payload = {
340
353
  specRoot: platformOpts.specRoot || null,
341
354
  runtimeRoot: platformOpts.runtimeRoot || null,
342
355
  workspaceId: platformOpts.workspaceId || null,
343
356
  scanRunId: platformOpts.scanRunId || null,
344
357
  savedAt: new Date().toISOString(),
358
+ ...preserved,
345
359
  ...extra,
346
360
  }
347
361
  // 声明文件字段集独立于指针 payload(D-E@v2 四字段:无 status/savedAt/scanRunId——
@@ -353,8 +367,14 @@ export function writePlatformPointer(cwd, platformOpts, extra = {}) {
353
367
  declaredAt: new Date().toISOString(),
354
368
  }
355
369
  try {
356
- mkdirSync(join(platformOpts.specRoot, '.runtime'), { recursive: true })
357
- writeFileSync(join(platformOpts.specRoot, '.runtime', 'platform-scan.json'), JSON.stringify(payload, null, 2) + '\n')
370
+ // platform-scan.json 只在 specRoot 存在时可写(坑 pointer-runtime-root-only-null-join):
371
+ // runtimeRoot-only 平台组合(command.js 明确允许)下 specRoot 为 null,此前 join(null,…)
372
+ // 抛 ERR_INVALID_ARG_TYPE 落入总 catch → 指针与接管声明也一并静默未写,双入口 fail-closed
373
+ // 对该组合整体失效。分步写:specRoot 缺失只跳过 platform-scan.json,指针/声明照写。
374
+ if (platformOpts.specRoot) {
375
+ mkdirSync(join(platformOpts.specRoot, '.runtime'), { recursive: true })
376
+ writeFileSync(join(platformOpts.specRoot, '.runtime', 'platform-scan.json'), JSON.stringify(payload, null, 2) + '\n')
377
+ }
358
378
  writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify(payload, null, 2) + '\n')
359
379
  writeFileSync(join(cwd, PLATFORM_MANAGED_FILENAME), JSON.stringify(declaration, null, 2) + '\n')
360
380
  return true
@@ -415,54 +435,90 @@ export function resolveChangeDir(cwd, progress, specDir = null) {
415
435
  // sync 总超时熔断:sync.js 是 best-effort 后台回传(每请求已有 10s 超时),但 sync() 可能串行多次
416
436
  // fetchJson 累积等待、阻塞 --done。给 8s 总超时,超时放弃(best-effort,失败不影响正确性,下次 --done 重试)。
417
437
  // 历史痛点:--done 在 sync 慢时体感 hang,用户被迫用外部 timeout 兜底。
438
+ // HUB-09:熔断超时时必须 abort 底层在飞请求(opts.signal 一路传到 fetch)——否则熔断后请求
439
+ // 仍在跑,平台可能已接受推送而客户端当作超时放弃(spec 树推送无 base_ts 自愈兜底)。
418
440
  const SYNC_TOTAL_TIMEOUT_MS = 8_000
419
441
 
442
+ /**
443
+ * 熔断 race + 确定性取消:超时触发 controller.abort() 并 resolve,在飞 fetch 收到
444
+ * AbortError 后自然失败(各调用方 catch 返回降级值,进程不挂起)。
445
+ * @param {() => Promise} op 接收 { signal } 的异步操作
446
+ * @param {number} timeoutMs 熔断时长(测试可缩短)
447
+ */
448
+ async function raceWithAbort(op, timeoutMs = SYNC_TOTAL_TIMEOUT_MS) {
449
+ const controller = new AbortController()
450
+ let timer
451
+ try {
452
+ await Promise.race([
453
+ op({ signal: controller.signal }),
454
+ new Promise((resolve) => { timer = setTimeout(() => { controller.abort(); resolve(null) }, timeoutMs) }),
455
+ ])
456
+ } finally {
457
+ clearTimeout(timer)
458
+ }
459
+ }
460
+
420
461
  // quick 会话 ID 形态(quick-<hex8>)。与 run/command.js QUICK_SID_RE、progress.js
421
462
  // initChange 的跳过实体目录判断同源;shared.js 不反向 import command.js(重模块),
422
463
  // 局部复制正则保持轻载——改形态时三处同改。
423
464
  const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
424
465
 
425
- export async function triggerSync(cwd, changeName, platformOpts = {}) {
466
+ export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
426
467
  // 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
427
468
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
428
469
  try {
429
- if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) {
430
- // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
431
- // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
432
- // 模块文档的上行通道)以服务器清单为锚,与变更目录无关——降级只推 spec 树。
433
- // 其余形态(真实变更名拼错)维持静默 return,防噪音混入 spec 树通道。
434
- if (QUICK_SID_RE.test(changeName)) {
435
- // shared.js 在 src/run/,sync.js 在 src/ → 退一层
436
- const syncMod = await import('../sync.js')
437
- let timer
438
- try {
439
- await Promise.race([
440
- syncMod.syncSpecTreeOnly(changeName, cwd),
441
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
442
- ])
443
- } finally {
444
- clearTimeout(timer)
445
- }
446
- }
470
+ // ql-20260818-011:quick 会话按设计无实体变更目录(progress.js initChange 同款
471
+ // 跳过建目录),progress/四件套上行对它是孤儿数据;但 spec 树增量(QUICKLOG/
472
+ // 模块文档的上行通道)以服务器清单为锚,与变更目录无关——降级只推 spec 树。
473
+ if (changeName && QUICK_SID_RE.test(changeName) && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) {
474
+ // shared.js 在 src/run/,sync.js 在 src/ → 退一层
475
+ const syncMod = await import('../sync.js')
476
+ await raceWithAbort((sig) => syncMod.syncSpecTreeOnly(changeName, cwd, sig), opts.timeoutMs)
447
477
  return
448
478
  }
479
+ // 目录缺失的非 quick 变更不再静默 return(archive-final-state-sync):归档把
480
+ // changes/<name>/ 移到 changes/archive/<name>/ 并注销 DB 行后,第 4/5 步收尾的
481
+ // triggerSync 仍会触发——DB 里的终态(steps 完成态 + status='archived')与文件树
482
+ // 移动必须推平台,否则平台进度永远停在移动前的最后一步。sync() 内部对目录缺失
483
+ // 本就是 warn-and-continue(serializeForSync 从 DB 读不依赖目录);真实拼错名时
484
+ // 无 DB 行,sync() 在发出任何 HTTP 前即 return,仅多一行 warn(未连接平台则完全静默)。
449
485
  // shared.js 在 src/run/,sync.js 在 src/ → 退一层
450
486
  const syncMod = await import('../sync.js')
451
- let timer
452
- try {
453
- await Promise.race([
454
- syncMod.sync(changeName, cwd),
455
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
456
- ])
457
- } finally {
458
- clearTimeout(timer) // sync 先完成则清掉未触发的 timer,避免泄漏
459
- }
487
+ await raceWithAbort((sig) => syncMod.sync(changeName, cwd, sig), opts.timeoutMs)
460
488
  } catch (e) {
461
489
  // sync.js 不存在或同步失败,静默跳过
462
490
  console.warn('⚠️ 同步失败:', e.message)
463
491
  }
464
492
  }
465
493
 
494
+ /**
495
+ * 自动 pull 节流(体检 PERF-02):triggerPull/triggerPullActiveChange 在每条 stage 命令
496
+ * 启动时注入,agent 命令往往是秒级连发——每次都发 HTTP GET(daemon 挂/慢时单命令最多
497
+ * 阻塞 8s)。跨进程 marker 节流:10s 内已有自动 pull 则跳过(手动 `platform pull` 不走
498
+ * 此门,语义不变)。错过窗口的平台更新由下一条 >10s 的命令或 push 409 自愈兜底。
499
+ */
500
+ const AUTO_PULL_THROTTLE_MS = 10_000
501
+
502
+ function _autoPullThrottlePath(cwd) {
503
+ return join(cwd, '.sillyspec', '.runtime', 'auto-pull-throttle.json')
504
+ }
505
+
506
+ function _autoPullRecently(cwd) {
507
+ try {
508
+ const t = JSON.parse(readFileSync(_autoPullThrottlePath(cwd), 'utf8'))
509
+ return typeof t.at === 'number' && Date.now() - t.at < AUTO_PULL_THROTTLE_MS
510
+ } catch {
511
+ return false
512
+ }
513
+ }
514
+
515
+ function _stampAutoPull(cwd) {
516
+ try {
517
+ mkdirSync(dirname(_autoPullThrottlePath(cwd)), { recursive: true })
518
+ writeFileSync(_autoPullThrottlePath(cwd), JSON.stringify({ at: Date.now() }) + '\n', 'utf8')
519
+ } catch { /* best-effort:节流失效仅退化为逐次 pull */ }
520
+ }
521
+
466
522
  /**
467
523
  * 触发 pull(下行同步,task-10 / D-009 / FR-04 / FR-06)。
468
524
  * 复用 triggerSync 的 8s 熔断与 Best Effort 语义;未连接平台静默跳过(与现状一致)。
@@ -474,23 +530,18 @@ export async function triggerSync(cwd, changeName, platformOpts = {}) {
474
530
  * @param {string} changeName - 当前活跃变更(多变更时传 null 跳过,避免误拉)
475
531
  * @param {object} [platformOpts] - 平台模式 opts(specRoot/runtimeRoot 存在则跳过,走平台自有链路)
476
532
  */
477
- export async function triggerPull(cwd, changeName, platformOpts = {}) {
533
+ export async function triggerPull(cwd, changeName, platformOpts = {}, opts = {}) {
478
534
  // 平台模式(SillyHub)走自己的链路,跳过
479
535
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
480
536
  try {
481
537
  const syncMod = await import('../sync.js')
482
538
  const sm = new syncMod.SyncManager(cwd)
483
- // 未连接平台静默跳过(本地独立用户合法状态,不噪音)
539
+ // 未连接平台静默跳过(本地独立用户合法状态,不噪音)——节流 stamp 必须在此之后:
540
+ // 否则未连接的项目每次 run 都写 .sillyspec/.runtime/ 标记(凭空造目录)
484
541
  if (!sm._getPlatform()) return
485
- let timer
486
- try {
487
- await Promise.race([
488
- sm.pull(changeName, { skipIfLocalDirty: true }),
489
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
490
- ])
491
- } finally {
492
- clearTimeout(timer)
493
- }
542
+ if (_autoPullRecently(cwd)) return
543
+ _stampAutoPull(cwd)
544
+ await raceWithAbort((sig) => sm.pull(changeName, { skipIfLocalDirty: true, signal: sig.signal }), opts.timeoutMs)
494
545
  } catch (e) {
495
546
  // pull 失败静默跳过(Best Effort,失败不影响正确性)
496
547
  console.warn('⚠️ 拉取失败:', e.message)
@@ -513,6 +564,10 @@ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
513
564
  } catch {
514
565
  return
515
566
  }
567
+ // PERF-02 节流(连接确认后才 stamp——未连接项目不得凭空写 .sillyspec/.runtime/):
568
+ // 与 triggerPull 共用同一 marker(本函数与它在同一命令启动链上互斥出现)
569
+ if (_autoPullRecently(cwd)) return
570
+ _stampAutoPull(cwd)
516
571
  let cn = null
517
572
  try {
518
573
  const { ProgressManager } = await import('../progress.js')
@@ -523,29 +578,48 @@ export async function triggerPullActiveChange(cwd, platformOpts = {}) {
523
578
  // progress 不可达则跳过(Best Effort)
524
579
  }
525
580
  if (!cn) return
526
- // 已确认连接 + 单活跃变更,调 pull(复用 triggerPull 的 8s 熔断;skipIfLocalDirty 保守守卫
581
+ // 已确认连接 + 单活跃变更,调 pull(复用 8s 熔断 + abort(HUB-09);skipIfLocalDirty 保守守卫
527
582
  // 同 triggerPull——本地脏时跳过 import,防平台旧快照覆盖本地领先进度,ql-20260818-008)
528
583
  try {
529
- let timer
530
- try {
531
- await Promise.race([
532
- sm.pull(cn, { skipIfLocalDirty: true }),
533
- new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
534
- ])
535
- } finally {
536
- clearTimeout(timer)
537
- }
584
+ await raceWithAbort((sig) => sm.pull(cn, { skipIfLocalDirty: true, signal: sig.signal }))
538
585
  } catch (e) {
539
586
  console.warn('⚠️ 拉取失败:', e.message)
540
587
  }
541
588
  }
542
589
 
590
+ /**
591
+ * 审批状态 unknown(体检 HUB-07)的统一处置:醒目多行警告 + 落 .runtime/approval-unknown.log 留痕。
592
+ *
593
+ * 语义边界:unknown = 已连接平台但 404/断网/超时/非 JSON——**不是审批通过**。放行是
594
+ * fail-open 的既定取舍(网络故障不应硬阻断每一条 execute),但静默放行会让团队审批
595
+ * 形同虚设且无迹可查;至少要(a)让操作者当场看见(b)留下可审计记录。
596
+ * 留痕选 .runtime 日志而非 QUICKLOG:QUICKLOG 是 quick 会话的任务记录,run 流程的
597
+ * 审批事件写进去会污染 quick 语义与边界审计。
598
+ * @param {string} cwd
599
+ * @param {string} changeName
600
+ * @param {string} reason checkApproval 返回的 reason
601
+ */
602
+ export function warnApprovalUnknown(cwd, changeName, reason) {
603
+ console.warn('')
604
+ console.warn('⚠️⚠️⚠️══════════════════════════════════════════════')
605
+ console.warn(`⚠️ 审批状态未知:变更「${changeName}」无法核实审批(${reason || '请求失败'})`)
606
+ console.warn('⚠️ 本次 execute 按 fail-open 放行——这不是审批通过;平台恢复后请到平台复核审批记录')
607
+ console.warn('⚠️ 排查:平台服务是否可达(sillyspec platform status)/ 网络代理;确认无需审批可 --skip-approval 显式跳过')
608
+ console.warn('⚠️ 本次放行已记录:.sillyspec/.runtime/approval-unknown.log')
609
+ console.warn('⚠️⚠️══════════════════════════════════════════════')
610
+ try {
611
+ const logDir = join(cwd, '.sillyspec', '.runtime')
612
+ mkdirSync(logDir, { recursive: true })
613
+ appendFileSync(join(logDir, 'approval-unknown.log'),
614
+ `${new Date().toISOString()} | change=${changeName} | reason=${reason || 'unknown'}\n`, 'utf8')
615
+ } catch { /* 留痕失败不阻断流程(警告横幅已输出) */ }
616
+ }
617
+
543
618
  /**
544
619
  * 审批检查:execute 阶段启动前检查(W6 Step8a 从 run.js 搬入,runStage + runAutoMode 共用)。
545
620
  * 平台模式走自己的链路,跳过;否则 await import sync.js。
546
621
  * @returns {{ status: string, reason?: string } | null}
547
- */
548
- export async function checkApproval(cwd, changeName, platformOpts = {}) {
622
+ */export async function checkApproval(cwd, changeName, platformOpts = {}) {
549
623
  // 平台模式不需要 CLI 内置审批检查
550
624
  if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return null
551
625
  try {
@@ -774,7 +848,11 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
774
848
  ]
775
849
 
776
850
  for (const entry of currentEntries) {
777
- const status = entry.slice(0, 2).trim()
851
+ // porcelain 两字符状态码(X=暂存区 Y=工作区)不能先 trim:' D'.trim()==='D' 使第二分支恒假、
852
+ // 'DD'/'AD' 等 trim 后不等于 'D' 而漏检——--allow-delete 门对此类删除静默放行(体检 BUG-04)。
853
+ // 用原始两字符判定任一侧为 D 即删除。
854
+ const rawStatus = entry.slice(0, 2)
855
+ const status = rawStatus.trim()
778
856
  const file = parsePorcelainPath(entry) // 已去引号/处理 rename/归一化,修正首行丢首字符
779
857
  if (!file) continue
780
858
 
@@ -786,7 +864,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
786
864
  if (isBaselineFile(file)) continue
787
865
 
788
866
  result.changedFiles.push(file)
789
- if (status === 'D' || status === ' D') result.deletedFiles.push(file)
867
+ if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.push(file)
790
868
  if (status === '??') result.newFiles.push(file)
791
869
 
792
870
  // 检查是否命中 baseline protected files
@@ -841,13 +919,17 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
841
919
  // 致 flag 对 status 失效。)
842
920
  if ((!forceBaseline && result.baselineHit.length > 0) || (!allowDelete && result.deletedFiles.length > 0) || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
843
921
  result.status = 'blocked'
844
- } else if ((!allowNew && result.newFiles.length > 0) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
922
+ } else if ((!allowNew && result.newFiles.some(f => !isQuickMetadata(f, guard.linkedChanges))) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
923
+ // newFiles 按元数据过滤后判定(坑 quick-metadata-warning-no-reason):.sillyspec/ 被 git
924
+ // 跟踪的仓库里 quick 新建的 quicklog/*.md 是合法元数据,不过滤会 status=warning 而
925
+ // reasons 为空(reasons 侧已过滤),打印空原因的 WARNING 噪声。
845
926
  result.status = 'warning'
846
927
  }
847
928
 
848
- // quicklog 存在性检查
929
+ // quicklog 存在性检查(specBase 感知:平台模式 quicklog 写在 specRoot 下,stage.js
930
+ // allocateQuicklogEntry 同源;硬编码 cwd/.sillyspec 会恒误报「目录不存在」抬 warning)
849
931
  try {
850
- const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
932
+ const quicklogDir = join(specBase || join(cwd, '.sillyspec'), 'quicklog')
851
933
  if (existsSync(quicklogDir)) {
852
934
  const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
853
935
  if (qlFiles.length === 0) {
@@ -885,8 +967,12 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
885
967
  const mdChanged = result.changedFiles.filter((f) => f.endsWith('.md') && !deletedSet.has(f) && !isQuickMetadata(f, guard.linkedChanges))
886
968
  if (mdChanged.length > 0) {
887
969
  try {
888
- const { runDocsCheck } = await import('../docs-check.js')
889
- const dc = runDocsCheck({ projectRoot: cwd, docs: mdChanged })
970
+ const { runDocsCheck, readDocsCheckConfig } = await import('../docs-check.js')
971
+ // HUB-06:补传 crossRepoRoots(与 docs gate / index.js docs check 同口径)——
972
+ // 否则 repo:// 跨仓引用在这条链路恒跳过;无 local.yaml 配置时 undefined = 不启用
973
+ let quickCfg = {}
974
+ try { quickCfg = readDocsCheckConfig(cwd) || {} } catch { quickCfg = {} }
975
+ const dc = runDocsCheck({ projectRoot: cwd, docs: mdChanged, crossRepoRoots: quickCfg.crossRepoRoots })
890
976
  if (!dc.ok) {
891
977
  result.reasons.push(`本次改动文档含 ${dc.invalid.length} 处失效 file:line 引用(sillyspec docs check 可复现)`)
892
978
  result.docsCheckHint = { invalid: dc.invalid.length, total: dc.total }
@@ -906,7 +992,11 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
906
992
  const srcChangedFiles = result.changedFiles.filter((f) => f.startsWith('src/') && !deletedSet.has(f))
907
993
  if (srcChangedFiles.length > 0) {
908
994
  try {
909
- const { collectDocRefs, runDocsCheck } = await import('../docs-check.js')
995
+ const { collectDocRefs, runDocsCheck, readDocsCheckConfig } = await import('../docs-check.js')
996
+ // HUB-06:活文档(platform-interface-map.md 等最可能写 repo:// 引用的文档)同样补传
997
+ // crossRepoRoots;配置读一次提到循环外(per-cwd 恒同)
998
+ let livingCfg = {}
999
+ try { livingCfg = readDocsCheckConfig(cwd) || {} } catch { livingCfg = {} }
910
1000
  const livingDocs = await resolveLivingDocs(cwd)
911
1001
  const files = []
912
1002
  const docs = []
@@ -918,7 +1008,7 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
918
1008
  if (hit.length === 0) continue
919
1009
  files.push(...hit)
920
1010
  docs.push(docRel)
921
- const r = runDocsCheck({ projectRoot: cwd, docs: [docRel], keywordAssert: true })
1011
+ const r = runDocsCheck({ projectRoot: cwd, docs: [docRel], keywordAssert: true, crossRepoRoots: livingCfg.crossRepoRoots })
922
1012
  for (const x of r.invalid) invalidAll.push({ ...x, doc: docRel })
923
1013
  }
924
1014
  const invalid = matchInvalidRefsToChanged(invalidAll, srcChangedFiles)
@@ -1262,6 +1352,17 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
1262
1352
  const changeDir = resolveChangeDir(cwd, progress, specDir)
1263
1353
  return buildPlanSteps(changeDir)
1264
1354
  }
1355
+ if (stageName === 'scan') {
1356
+ // quick 档感知(坑 scan-quick-profile-step-mismatch):scan 的 scanProfile 在首进程
1357
+ // runStage 里算出并持久化(stageData.scanProfile),quick 档把 11 步注册表裁成 3 步。
1358
+ // 此处恒返回注册表会让后续进程 ensureStageSteps 见 3 vs 11 触发漂移重播种(quick 已完成
1359
+ // 步丢失、从未执行的注册表步骤被 --done),同进程 outputStep 也用错位 defSteps 渲染。
1360
+ // quick 档 → 与 applyScanProfileSteps 同源返回 3 步表;standard/deep 与注册表同长度零影响。
1361
+ if (progress?.stages?.scan?.scanProfile?.mode === 'quick') {
1362
+ const { buildQuickScanSteps } = await import('./scan-profile.js')
1363
+ return buildQuickScanSteps()
1364
+ }
1365
+ }
1265
1366
  const def = stageRegistry[stageName]
1266
1367
  return def ? def.steps : null
1267
1368
  }
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 目录拼写变体。