sillyspec 3.33.0 → 3.33.2

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 (72) hide show
  1. package/.claude/skills/sillyspec-flow/SKILL.md +18 -10
  2. package/.github/workflows/ci.yml +11 -3
  3. package/AGENTS.md +3 -3
  4. package/README.md +2 -2
  5. package/assets/command-cards/flow.md +9 -5
  6. package/package.json +1 -1
  7. package/src/archive-delta.js +4 -3
  8. package/src/change-delete.js +6 -5
  9. package/src/contract-matrix.js +2 -1
  10. package/src/cross-placement.js +5 -1
  11. package/src/cross-repo-reconcile.js +4 -1
  12. package/src/db.js +8 -2
  13. package/src/decision-distill.js +22 -8
  14. package/src/decision-values.js +34 -0
  15. package/src/decisions-io.js +5 -2
  16. package/src/deleted-ledger.js +2 -1
  17. package/src/doctor-diagnostics.js +8 -3
  18. package/src/flow-draft.js +17 -10
  19. package/src/flow-parity.js +93 -5
  20. package/src/flow-review-pack.js +183 -0
  21. package/src/flow-review.js +45 -3
  22. package/src/flow.js +365 -109
  23. package/src/friction-ledger.js +2 -1
  24. package/src/friction-tally.js +8 -4
  25. package/src/handoff.js +2 -1
  26. package/src/hooks/worktree-guard.js +5 -5
  27. package/src/index.js +62 -21
  28. package/src/init.js +7 -30
  29. package/src/knowledge-hits.js +2 -1
  30. package/src/knowledge-vector.js +2 -1
  31. package/src/progress/consistency-doctor.js +5 -2
  32. package/src/progress/stage-machine.js +6 -2
  33. package/src/progress.js +19 -8
  34. package/src/quicklog.js +194 -9
  35. package/src/review-dispatch.js +4 -3
  36. package/src/review-tier.js +2 -1
  37. package/src/route-hindsight.js +4 -3
  38. package/src/run/bg-sync.js +10 -3
  39. package/src/run/command.js +14 -12
  40. package/src/run/complete-handlers.js +40 -37
  41. package/src/run/complete.js +12 -8
  42. package/src/run/gate-snapshot-ledger.js +2 -1
  43. package/src/run/gate-snapshot.js +102 -23
  44. package/src/run/gates.js +32 -8
  45. package/src/run/prompt.js +6 -5
  46. package/src/run/scan-profile.js +2 -1
  47. package/src/run/shared.js +109 -5
  48. package/src/run/stage.js +5 -4
  49. package/src/run/test-ledger.js +2 -1
  50. package/src/run/verify-quality-scan.js +11 -5
  51. package/src/runtime-paths.js +189 -0
  52. package/src/scan-refresh.js +4 -3
  53. package/src/scope-audit.js +13 -7
  54. package/src/sentinel-assertions.js +37 -0
  55. package/src/spec-sync.js +38 -22
  56. package/src/stage-contract.js +24 -2
  57. package/src/stage-review.js +3 -2
  58. package/src/stage-templates.js +3 -2
  59. package/src/stages/brainstorm.js +6 -5
  60. package/src/sync-noise.js +3 -2
  61. package/src/sync.js +71 -15
  62. package/src/task-done.js +2 -1
  63. package/src/task-review.js +7 -5
  64. package/src/task-tick.js +45 -1
  65. package/src/validate-artifacts.js +2 -1
  66. package/src/verify-draft.js +2 -1
  67. package/src/verify-postcheck.js +11 -10
  68. package/src/verify-probes.js +157 -52
  69. package/src/watcher.js +15 -9
  70. package/src/worktree-apply.js +2 -1
  71. package/src/write-audit.js +2 -1
  72. package/templates/agents-instruction.md +3 -3
@@ -17,12 +17,13 @@
17
17
  * - node: join(path) + existsSync/readdirSync/readFileSync/mkdirSync/writeFileSync/appendFileSync/statSync(fs)
18
18
  */
19
19
  import { join, isAbsolute, resolve } from 'node:path'
20
+ import { runtimeStatePath } from '../runtime-paths.js'
20
21
  import { existsSync, readFileSync, mkdirSync, writeFileSync, appendFileSync } from 'node:fs'
21
22
  import { writeAtomicSync } from '../fs-atomic.js'
22
23
  import { gitQuiet } from '../git-helper.js'
23
24
  import { writeCloseTraceArtifacts, projectTraceFaceRows } from '../flow-parity.js'
24
25
  import { withFileLock } from '../quicklog.js'
25
- import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir, writePlatformDocsPointer, isRetiredAutoBrainstormTable, printRetiredAutoBrainstormRefusal } from './shared.js'
26
+ import { triggerSync, triggerSyncTerminalStrict, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveRuntimeRoot, getOrCreateMultiRepoContext, resolveChangeDir, writePlatformDocsPointer, isRetiredAutoBrainstormTable, printRetiredAutoBrainstormRefusal } from './shared.js'
26
27
  import { isExplicitReviewWrite, collectWorktreeChangedFiles } from '../task-review.js'
27
28
 
28
29
  // 退役自动道守门(2026-10-14-brainstorm-auto-retire):brainstorm-auto 4 步表已随自动道删除。
@@ -828,7 +829,10 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
828
829
 
829
830
  // gate 全过:persist completed(task-01 移后)。此处到 _write 之间若崩,DB 仍 in-progress(内存已 completed 但未落盘),下次进 CLI 读 DB 即 in-progress,不产生"假 completed"。
830
831
  pm._write(cwd, progress, changeName)
831
- triggerSync(cwd, changeName, platformOpts, { completion: true })
832
+ // 终态强校验(2026-10-17-archive-half-state-location-stuck-active):状态机驱动——仅
833
+ // DB status 已达 archived/deleted(archive 阶段完成即此态)走内联强校验,其余阶段
834
+ // 原样委托 triggerSync(后台化不变,非终态零行为变化)。见 run/shared.js 契约注释。
835
+ await triggerSyncTerminalStrict(cwd, changeName, platformOpts, { completion: true })
832
836
 
833
837
  const total = steps.length
834
838
  console.log(`✅ ${stageName} 阶段已完成(${total}/${total} 步)`)
@@ -843,7 +847,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
843
847
  let stageLedger = null
844
848
  let crossStage = false
845
849
  try {
846
- const ledgerPath = join(specBase, '.runtime', `stage-session-ledger-${changeName}.json`)
850
+ const ledgerPath = runtimeStatePath(join(specBase, '.runtime'), `stage-session-ledger-${changeName}.json`)
847
851
  try { stageLedger = JSON.parse(readFileSync(ledgerPath, 'utf8')) } catch { /* 首阶段 */ }
848
852
  const r = updateStageSessionLedger(stageLedger, process.env.SILLYSPEC_SESSION_ID, stageName)
849
853
  stageLedger = r.ledger
@@ -1018,7 +1022,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
1018
1022
  if (stageName === 'execute' && changeName && defSteps && defSteps[currentIdx] && defSteps[nextPendingIdx] &&
1019
1023
  /Wave \d+ 执行/.test(defSteps[currentIdx].name || '') && /Wave \d+ 执行/.test(defSteps[nextPendingIdx].name || '')) {
1020
1024
  try {
1021
- const ledgerPath = join(specBase, '.runtime', `wave-session-ledger-${changeName}.json`)
1025
+ const ledgerPath = runtimeStatePath(join(specBase, '.runtime'), `wave-session-ledger-${changeName}.json`)
1022
1026
  let ledger = null
1023
1027
  try { ledger = JSON.parse(readFileSync(ledgerPath, 'utf8')) } catch { /* 首 Wave */ }
1024
1028
  const { ledger: nextLedger, crossWaveAdvisory } = updateWaveSessionLedger(ledger, process.env.SILLYSPEC_SESSION_ID, defSteps[currentIdx].name)
@@ -1141,7 +1145,7 @@ async function printVerifyScopeDrift({ cwd, changeName, specBase, platformOpts,
1141
1145
  snapshot = JSON.parse(readFileSync(join(specBase, 'changes', changeName, 'scope-audit.json'), 'utf8'))
1142
1146
  } catch {
1143
1147
  try {
1144
- snapshot = JSON.parse(readFileSync(join(resolveRuntimeRoot(platformOpts, specBase), `scope-audit-${changeName}.json`), 'utf8'))
1148
+ snapshot = JSON.parse(readFileSync(runtimeStatePath(resolveRuntimeRoot(platformOpts, specBase), `scope-audit-${changeName}.json`), 'utf8'))
1145
1149
  } catch { /* 快照缺失/损坏 → 跳过对比只打当前 totals */ }
1146
1150
  }
1147
1151
  }
@@ -1559,7 +1563,7 @@ export async function autoTickTasksFromCommitSubjects({ changeName, cwd, specBas
1559
1563
  try {
1560
1564
  const _tasksMdPath = join(specBase, 'changes', changeName, 'tasks.md')
1561
1565
  if (!existsSync(_tasksMdPath)) return { ticked: 0, skippedNoAnchor: 0 }
1562
- const _runIdFile = join(resolveRuntimeRoot(platformOpts, specBase), `current-execute-run-id-${changeName}`)
1566
+ const _runIdFile = runtimeStatePath(resolveRuntimeRoot(platformOpts, specBase), `current-execute-run-id-${changeName}`)
1563
1567
  let _tickLog = ''
1564
1568
  try {
1565
1569
  if (existsSync(_runIdFile)) {
@@ -1640,7 +1644,7 @@ export async function autoCheckPlanFromReviews({ stageName, changeName, cwd, pla
1640
1644
  // plan.md Wave 引用行无 checkbox,无处可勾)。tasks.md 存在性作前置。
1641
1645
  const tasksMdPath = join(changeDir, 'tasks.md')
1642
1646
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBaseLc)
1643
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1647
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
1644
1648
  if (!existsSync(tasksMdPath) || !existsSync(runIdFile)) {
1645
1649
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
1646
1650
  }
@@ -1798,7 +1802,7 @@ export async function detectExecuteBatchFinish({ pm, stageName, changeName, cwd,
1798
1802
  // 平台模式 runtimeRoot / worktree 漂移 specDriftAnchor 不透传时 marker 恒读不到
1799
1803
  // → 每个 task 进 blockedTasks → 批量完成静默失效
1800
1804
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
1801
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1805
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
1802
1806
  // ── 缺席草稿自动补齐(坑 task-review-retired-archive-mismatch,2026-10-15 归档门口实证)──
1803
1807
  // Task Review 层退役(2026-09-26)后 execute 批量完成不再产 per-task review.json,但本函数
1804
1808
  // 下方复核循环与 archive step2 完成度判定(真相源=review.json verdict)仍以它在场为前提——
@@ -16,6 +16,7 @@
16
16
  * 异常面:全部 fail-open 吞掉(账本/回收任一异常零阻断,退现状残留)。
17
17
  */
18
18
  import { execFileSync } from 'node:child_process'
19
+ import { runtimeStatePath } from '../runtime-paths.js'
19
20
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
20
21
  import { basename, dirname, join, resolve, sep } from 'node:path'
21
22
  import { tmpdir } from 'node:os'
@@ -28,7 +29,7 @@ const STALE_HOURS_DEFAULT = 24
28
29
 
29
30
  /** 账本文件路径(runtime 域:.sillyspec/.runtime/active-gate-snapshots.json) */
30
31
  export function gateSnapshotLedgerPath(runtimeRoot) {
31
- return join(runtimeRoot, LEDGER_FILE)
32
+ return runtimeStatePath(runtimeRoot, LEDGER_FILE)
32
33
  }
33
34
 
34
35
  /** 读账本(损坏/缺失/非数组 → 空数组,fail-open) */
@@ -494,7 +494,12 @@ export function applyGateSnapshotCopy(cwd, snapshotRoot) {
494
494
  * @param {{ cwd: string, files: string[] }} opts cwd=主仓根;files=本会话变更文件(仓库根相对 POSIX)
495
495
  * @returns {{ snapshotRoot: string, cleanup: () => void, reason?: string }|null} 失败返回 null(调用方回退主仓)
496
496
  */
497
- export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSmoke = false, mergeBase = null, runtimeRoot = null }) {
497
+ export function createGateSnapshot({ cwd, files, carriedFiles = [], sourceRoot = null, skipImportSmoke = false, mergeBase = null, runtimeRoot = null, failRef = null }) {
498
+ // failRef 可选出参(坑 snapshot-fallback-reason-blind,2026-10-16 下游实证):所有失败分支
499
+ // 把结构化原因写进 failRef.reason——调用方(verify --done 实测门 / noAI 质量扫描)消费它
500
+ // 替换硬编码笼统文案,降级原因可直读(pnpm 布局拒建 ≠ 文件集为空 ≠ junction 失败)。
501
+ // 返回契约不变(失败恒 null);不传 failRef 行为与既有逐字节一致。
502
+ const setFail = (reason) => { if (failRef && typeof failRef === 'object') failRef.reason = reason }
498
503
  let snapshotRoot = null
499
504
  try {
500
505
  // 前置:主仓须是 git 仓且有 HEAD(无 git 环境回退主仓现行为)
@@ -519,6 +524,7 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
519
524
  // 与其让沙箱报无关错误逼 advisory,不如布局命中即作废快照回退主仓(宁可主仓口径)。
520
525
  const layoutHit = detectSymlinkStoreLayout(cwd)
521
526
  if (layoutHit) {
527
+ setFail(`symlink-store 布局拒建(${layoutHit}):node_modules 符号链接 store 经 junction 跨根失效,实测必假败——设计性回退主仓实测`)
522
528
  console.warn(`⚠️ 门禁快照对 ${layoutHit} 布局不可靠(node_modules 符号链接 store 经 junction 跨根失效,实测必假败)——跳过快照回退主仓实测;主仓失败再做污染归属鉴定`)
523
529
  return null
524
530
  }
@@ -537,7 +543,6 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
537
543
  // 是共享运行时面:.runtime/(sqlite 锁/在途 marker)与 quicklog/(跨会话共享账本);
538
544
  // local.yaml 由下方 cfg 段显式复制(单一来源,不经 overlay)。
539
545
  const overlayRoot = sourceRoot || cwd
540
- let overlaid = 0
541
546
  // 双写一致性(2026-09-20 红线机检变更实证:主 agent 在主仓直写而 execute 已建 worktree 时,
542
547
  // overlay 源=worktree 拿陈旧内容盖掉快照 HEAD 的新版——快照内 import 找不到新导出→门禁假红)。
543
548
  // 祖先判据(fr-index-l2 二次实证升级):基线优先取 merge-base 的每文件祖先内容
@@ -562,9 +567,12 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
562
567
  }
563
568
  try { return cmpText(readFileSync(join(snapshotRoot, rel))) } catch { return null }
564
569
  }
565
- for (const f of files) {
570
+ // 单文件 overlay(判定面 files 与携带面 carriedFiles 共用,2026-10-20-gate-snapshot-
571
+ // carried-deps 提取):源=overlayRoot;worktree 模式经三态判定(陈旧取主仓/正常取
572
+ // worktree/分叉取 worktree 血统 M2);源侧不存在返回 false(已删/不在场不拷)。
573
+ const overlayOne = (f) => {
566
574
  const isSillyspecRuntime = typeof f === 'string' && (f.startsWith('.sillyspec/.runtime/') || f.startsWith('.sillyspec/quicklog/') || f === '.sillyspec/local.yaml' || f === '.sillyspec/.sillyspec-platform.json')
567
- if (typeof f !== 'string' || f.includes('..') || f.startsWith('/') || isSillyspecRuntime) continue
575
+ if (typeof f !== 'string' || f.includes('..') || f.startsWith('/') || isSillyspecRuntime) return false
568
576
  let src = join(overlayRoot, f)
569
577
  if (sourceRoot && sourceRoot !== cwd) {
570
578
  const wtPath = join(sourceRoot, f), cwdPath = join(cwd, f)
@@ -589,11 +597,26 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
589
597
  }
590
598
  }
591
599
  }
592
- if (!existsSync(src)) continue
600
+ if (!existsSync(src)) return false
593
601
  const dst = join(snapshotRoot, f)
594
602
  mkdirSync(dirname(dst), { recursive: true })
595
603
  copyFileSync(src, dst)
596
- overlaid++
604
+ return true
605
+ }
606
+ let overlaid = 0
607
+ for (const f of files) { if (overlayOne(f)) overlaid++ }
608
+
609
+ // 携带面 overlay(2026-10-20-gate-snapshot-carried-deps,坑 verify-gate-…-three-defects
610
+ // 缺陷二):他者声明文件从判定面剔除但物理携带进快照——快照复现本变更实测时真实的依赖
611
+ // 环境(worktree 模式≈worktree 自洽态;in-place≈主仓混合树)。坑文场景:本变更文件 import
612
+ // 他者未提交导出(quick 的 stripPathPrefix)→ 原快照装 HEAD 旧版必 TypeError 假红,携带后
613
+ // 自洽。判定面(files/overlaid)不含携带件——归属语义不变,假红可经控制台清单归因到他者。
614
+ const carriedList = []
615
+ if (Array.isArray(carriedFiles) && carriedFiles.length > 0) {
616
+ for (const f of carriedFiles) {
617
+ if (files.includes(f)) continue // 判定面已含(own 声明优先级高于他者)——不重复拷
618
+ if (overlayOne(f)) carriedList.push(f)
619
+ }
597
620
  }
598
621
 
599
622
  // 环境目录链接(坑 gate-snapshot-env-mismatch,2026-09-12 驾驭第十三批③,用户实证:
@@ -625,6 +648,7 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
625
648
  // 回退主仓现行为(主仓口径可能被并行脏文件污染,但环境真实;两害取轻 + 回退原因可见)。
626
649
  const envMissing = envDirsLinked(cwd, snapshotRoot)
627
650
  if (envMissing.length > 0) {
651
+ setFail(`环境目录链接不完整(${envMissing.join('、')} 在主仓存在、快照内缺失)——快照作废回退主仓实测`)
628
652
  console.warn(`⚠️ 门禁快照环境不完整(${envMissing.join('、')} 在主仓存在、快照内缺失)——快照作废回退主仓实测(宁可主仓口径不可假沙箱硬挂;回退后失败先做污染归属鉴定)`)
629
653
  // 与正常 cleanup/失败 catch 同一清理体:双清确认才销账(该早退曾残留旧式 --quiet
630
654
  // 清理与无重试 rmSync——独立审查 P1-1 根因未除尽的漏网面)
@@ -699,7 +723,7 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
699
723
  // HEAD 健版保证 import 图闭合。新增文件(HEAD 无版)无法回退 → 醒目 warn 保留。
700
724
  // 全链 fail-open:找不到解释器/基建异常只 warn 不作废快照(主场景是 Python 项目,
701
725
  // JS/TS 的 vitest load 冒烟成本高留后续)。
702
- const overlaidPyFiles = files.filter(f => typeof f === 'string' && f.endsWith('.py')
726
+ const overlaidPyFiles = [...files, ...carriedList].filter(f => typeof f === 'string' && f.endsWith('.py')
703
727
  && !f.startsWith('.sillyspec/')
704
728
  && existsSync(join(snapshotRoot, f)))
705
729
  if (overlaidPyFiles.length > 0 && !skipImportSmoke) {
@@ -720,10 +744,13 @@ export function createGateSnapshot({ cwd, files, sourceRoot = null, skipImportSm
720
744
  if (dirRemoved && worktreeCleaned) unregisterGateSnapshot({ runtimeRoot, snapshotRoot })
721
745
  } catch { /* 双失败已由 cleanupSnapshot 吞掉;条目保留 */ }
722
746
  }
723
- return { snapshotRoot, cleanup, overlaid, pinnedCacheLinks }
747
+ return { snapshotRoot, cleanup, overlaid, pinnedCacheLinks, carriedFiles: carriedList, carried: carriedList.length }
724
748
  } catch (e) {
725
749
  // 基建失败(worktree add 拒绝/磁盘满/…):尽力清理后回退主仓;销账同走双清确认
726
- // (D-004@v2:清不掉的条目保留待回收——它不是悬空条目,是待回收记录)
750
+ // (D-004@v2:清不掉的条目保留待回收——它不是悬空条目,是待回收记录)。
751
+ // 异常细节不再静默(坑 snapshot-fallback-reason-blind):failRef 带原因 + warn 落日志。
752
+ setFail(`快照基建异常: ${e && e.message ? e.message : e}`)
753
+ try { console.warn(`⚠️ 门禁快照基建失败(回退主仓实测): ${e && e.message ? e.message : e}`) } catch { /* 输出失败不影响回退 */ }
727
754
  if (snapshotRoot) {
728
755
  try {
729
756
  const { dirRemoved, worktreeCleaned } = cleanupSnapshot({ snapshotRoot, cwd })
@@ -856,8 +883,15 @@ export function envDirsLinked(cwd, snapshotRoot) {
856
883
  */
857
884
  export function printSnapshotFailureHint(snapInfo, { offEnv = 'SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF' } = {}) {
858
885
  if (!snapInfo || !snapInfo.snapshotRoot) return
859
- console.error(` 🔬 本次实测执行于隔离快照:${snapInfo.snapshotRoot}(HEAD + 本变更 ${snapInfo.changeFileCount ?? '?'} 个文件${snapInfo.sourceRoot ? ',overlay 自 worktree' : ''})`)
860
- console.error(` 失败疑点排查顺序:① 本变更文件自身的真实失败(最常见)→ 修代码;② 快照环境差异(monorepo 依赖布局/环境目录链接缺失)→ 设 ${offEnv}=1 回退主仓复跑对照——主仓过而快照挂即环境问题;③ 并行污染已被快照隔离,不在疑点内。`)
886
+ console.error(` 🔬 本次实测执行于隔离快照:${snapInfo.snapshotRoot}(HEAD + 本变更 ${snapInfo.changeFileCount ?? '?'} 个文件${snapInfo.sourceRoot ? ',overlay 自 worktree' : ''}${snapInfo.carriedFiles && snapInfo.carriedFiles.length > 0 ? `,携带 ${snapInfo.carriedFiles.length} 个他者声明文件(依赖自洽面)` : ''})`)
887
+ // 2026-10-20-gate-snapshot-carried-deps 评审 F3 清偿:携带面在场时他者文件**在**疑点内
888
+ // (原「并行污染不在疑点内」只对未声明脏文件成立——携带件是声明过的他者内容)。
889
+ if (snapInfo.carriedFiles && snapInfo.carriedFiles.length > 0) {
890
+ const preview = snapInfo.carriedFiles.slice(0, 5).join('、')
891
+ console.error(` 失败疑点排查顺序:① 本变更文件自身的真实失败(最常见)→ 修代码;② 携带的他者声明文件坏半态(${snapInfo.carriedFiles.length > 5 ? `${preview} 等` : preview}——他者变更在途内容,非本变更面)→ 核对他者变更状态或 SNAPSHOT_OFF 对照;③ 快照环境差异(monorepo 依赖布局/环境目录链接缺失)→ 设 ${offEnv}=1 回退主仓复跑对照——主仓过而快照挂即环境问题。`)
892
+ } else {
893
+ console.error(` 失败疑点排查顺序:① 本变更文件自身的真实失败(最常见)→ 修代码;② 快照环境差异(monorepo 依赖布局/环境目录链接缺失)→ 设 ${offEnv}=1 回退主仓复跑对照——主仓过而快照挂即环境问题;③ 并行污染已被快照隔离,不在疑点内。`)
894
+ }
861
895
  }
862
896
 
863
897
  /**
@@ -865,12 +899,14 @@ export function printSnapshotFailureHint(snapInfo, { offEnv = 'SILLYSPEC_VERIFY_
865
899
  * main 工作区,多会话并发任何人的 WIP 都能弄红别人的门)。等价「--worktree 定向跑」:
866
900
  * overlay 文件集 = resolveVerifyChangedFiles(worktree-aware:含 merge-base 补齐与
867
901
  * working-tree),overlay 源 = 本变更 worktree 根(meta 感知;in-place 退主仓 cwd)——
868
- * 并行会话的在途文件物理不进快照。变更文档(changes/<name>/**,test_strategy 的
869
- * module-impact.md 等消费)从主仓 specBase 随快照复制。
902
+ * 并行会话**未声明**的在途脏文件物理不进快照(隔离核心);他者**显式声明**的文件以携带面
903
+ * 物理进快照(2026-10-20-gate-snapshot-carried-deps:依赖自洽——判定/归因面仍剔除)。
904
+ * 变更文档(changes/<name>/**,test_strategy 的 module-impact.md 等消费)从主仓 specBase 随快照复制。
870
905
  * @param {{ cwd: string, changeName: string, specBase: string, platformOpts?: object }} opts
871
906
  * @returns {Promise<{snapshotRoot: string, cleanup: () => void, overlaid: number, changeFileCount: number}|null>}
872
907
  */
873
- export async function createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts = {} }) {
908
+ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts = {}, failRef = null }) {
909
+ const setFail = (reason) => { if (failRef && typeof failRef === 'object') failRef.reason = reason }
874
910
  try {
875
911
  const { resolveVerifyChangedFiles } = await import('../verify-postcheck.js')
876
912
  const { resolveRuntimeRoot } = await import('./shared.js')
@@ -879,19 +915,35 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
879
915
  let verifyRuntimeRoot = null
880
916
  try { verifyRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase) } catch { verifyRuntimeRoot = null }
881
917
  const { splitOwnVsForeignDiffFiles } = await import('../foreign-declared.js')
882
- let changeFiles = resolveVerifyChangedFiles(cwd, changeName, null, {
918
+ // 变更文件集为空三分成因区分(坑 snapshot-fallback-reason-blind):null=git 不可用/解析
919
+ // 失败;[]=HEAD 无 diff;他者声明过滤清空在下方单独计——三者处置同为回退主仓,但排查
920
+ // 出路完全不同,笼统「变更文件集为空」曾把 pnpm 布局拒建也误标成它。
921
+ const rawChangeFiles = resolveVerifyChangedFiles(cwd, changeName, null, {
883
922
  specBase, includeWorkingTree: true,
884
- }) || []
885
- if (changeFiles.length === 0) return null // 无变更文件集 → 无从定向,回退主仓
923
+ })
924
+ let changeFiles = rawChangeFiles || []
925
+ if (changeFiles.length === 0) {
926
+ setFail(rawChangeFiles === null
927
+ ? '变更文件集为空(git 不可用/解析失败——非 git 仓或 git 异常,无从定向 overlay)'
928
+ : '变更文件集为空(HEAD 无 diff——改动可能已全部提交,无可隔离面)')
929
+ return null // 无变更文件集 → 无从定向,回退主仓
930
+ }
886
931
  // 他者声明归属过滤(坑 verify-reconcile-foreign-wip 同款):in-place 模式 working-tree
887
932
  // 并入后主仓全部在途文件进场——他者活跃变更显式声明的文件(quick --files / 他者 design
888
- // 清单)剔除,只留本变更相关面(无主文件保留,fail-closed 口径与 probe6 一致)。
933
+ // 清单)从**判定面**剔除,只留本变更相关面(无主文件保留,fail-closed 口径与 probe6 一致)。
934
+ // 2026-10-20-gate-snapshot-carried-deps(坑 verify-gate-…-three-defects 缺陷二):被剔
935
+ // 文件改以「携带面」物理进快照(carriedFiles)——快照依赖自洽,判定/归属语义不变。
936
+ let carriedForeignFiles = []
889
937
  try {
890
938
  const { foreign } = splitOwnVsForeignDiffFiles(cwd, changeName, changeFiles, { specBase, runtimeRoot: verifyRuntimeRoot })
891
939
  if (foreign.length > 0) {
892
940
  const foreignSet = new Set(foreign.map(x => x.file))
941
+ carriedForeignFiles = changeFiles.filter(f => foreignSet.has(f))
893
942
  changeFiles = changeFiles.filter(f => !foreignSet.has(f))
894
- if (changeFiles.length === 0) return null
943
+ if (changeFiles.length === 0) {
944
+ setFail(`变更文件集为空(${foreign.length} 个 diff 文件全部被他者声明过滤——并行会话声明面覆盖了本变更全部在途文件)`)
945
+ return null
946
+ }
895
947
  }
896
948
  } catch { /* 过滤失败退全量(fail-closed:宁可多 overlay 不漏本变更文件) */ }
897
949
 
@@ -920,8 +972,16 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
920
972
  }
921
973
  } catch { /* meta 读取失败 → 主仓 cwd 源(files 已含本变更 working-tree 改动) */ }
922
974
 
923
- const snap = createGateSnapshot({ cwd, files: changeFiles, sourceRoot, mergeBase: snapMergeBase, runtimeRoot: verifyRuntimeRoot })
924
- if (!snap) return null
975
+ const snap = createGateSnapshot({ cwd, files: changeFiles, carriedFiles: carriedForeignFiles, sourceRoot, mergeBase: snapMergeBase, runtimeRoot: verifyRuntimeRoot, failRef })
976
+ if (!snap) {
977
+ // createGateSnapshot 已把具体原因写进同一 failRef;无 reason(旧形态兜底)补笼统口径
978
+ if (failRef && typeof failRef === 'object' && !failRef.reason) setFail('快照基建不可用(createGateSnapshot 返回 null,无更细原因)')
979
+ return null
980
+ }
981
+ if (snap.carriedFiles && snap.carriedFiles.length > 0) {
982
+ const preview = snap.carriedFiles.slice(0, 5).join('、')
983
+ console.log(`📦 快照携带 ${snap.carriedFiles.length} 个他者声明文件(依赖自洽面,不进判定/归因面;假红先核对他者变更)${snap.carriedFiles.length > 5 ? `:${preview} 等` : `:${preview}`}`)
984
+ }
925
985
 
926
986
  // 变更文档随快照:module-impact.md(test_strategy=evidence-auto 消费)/tasks.md 等
927
987
  // 在 .sillyspec(gitignore)不进 HEAD,从主仓 specBase 复制 changes/<name>/**
@@ -940,7 +1000,26 @@ export async function createVerifyGateSnapshot({ cwd, changeName, specBase, plat
940
1000
  } catch { /* 文档复制失败不连坐快照(test_strategy 降级 module 集仍可跑) */ }
941
1001
 
942
1002
  return { ...snap, changeFileCount: changeFiles.length, sourceRoot }
943
- } catch {
944
- return null // 任一链路异常 → 回退主仓现行为
1003
+ } catch (e) {
1004
+ // 任一链路异常 → 回退主仓现行为。异常细节不再静默(坑 snapshot-fallback-reason-blind):
1005
+ // failRef 带原因 + warn 落日志,排查不再靠 stdout 考古。
1006
+ setFail(`快照链路异常: ${e && e.message ? e.message : e}`)
1007
+ try { console.warn(`⚠️ 门禁快照链路异常(回退主仓实测): ${e && e.stack ? e.stack.split('\n')[0] : e}`) } catch { /* 输出失败不影响回退 */ }
1008
+ return null
945
1009
  }
946
1010
  }
1011
+
1012
+ /**
1013
+ * 快照口径观测字段(坑 snapshot-fallback-reason-blind,FR-04 落盘面):--done 实测门把
1014
+ * used_snapshot 与降级原因经 runVerifyTestCheck 的 observability 通道带进 test-result.json
1015
+ * extra(对齐 2026-10-09 复用观测先例)。快照在场时记录随快照销毁(无可降级事实);
1016
+ * 回退主仓时记录恰好落在主仓 .runtime——降级证据存活在主仓,与用途一致。
1017
+ * @param {boolean} used 快照是否真实在场
1018
+ * @param {{ reason?: string }|null} failRef createVerifyGateSnapshot 的出参(可 null)
1019
+ * @returns {{ used_snapshot: boolean, snapshot_fallback_reason?: string }}
1020
+ */
1021
+ export function snapshotObservability(used, failRef) {
1022
+ if (used) return { used_snapshot: true }
1023
+ const reason = failRef && failRef.reason ? String(failRef.reason) : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)'
1024
+ return { used_snapshot: false, snapshot_fallback_reason: reason }
1025
+ }
package/src/run/gates.js CHANGED
@@ -28,6 +28,7 @@
28
28
  * 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
29
29
  */
30
30
  import { basename, join, relative, dirname } from 'node:path'
31
+ import { runtimeStatePath } from '../runtime-paths.js'
31
32
  import { createHash } from 'node:crypto'
32
33
  import { execFileSync } from 'node:child_process'
33
34
  import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -444,8 +445,8 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
444
445
  if (worktreeGone && changeName) {
445
446
  try {
446
447
  const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
447
- const legitimatelyEnded = existsSync(join(runtimeRoot, `apply-pathspec-${changeName}.txt`))
448
- || existsSync(join(runtimeRoot, `execute-cleanup-${changeName}.json`))
448
+ const legitimatelyEnded = existsSync(runtimeStatePath(runtimeRoot, `apply-pathspec-${changeName}.txt`))
449
+ || existsSync(runtimeStatePath(runtimeRoot, `execute-cleanup-${changeName}.json`))
449
450
  if (legitimatelyEnded) {
450
451
  console.log('ℹ️ worktree 已在 apply/execute 收尾时正当清理(凭据在 .runtime),deps 门放行')
451
452
  return true
@@ -549,7 +550,7 @@ function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx,
549
550
 
550
551
  /** 档位状态文件路径(runtimeRoot 下 per-change;命名对齐 apply-pathspec-<change>.txt 先例) */
551
552
  function ceremonyTierFilePath(runtimeRoot, changeName) {
552
- return join(runtimeRoot, `ceremony-tier-${changeName}.json`)
553
+ return runtimeStatePath(runtimeRoot, `ceremony-tier-${changeName}.json`)
553
554
  }
554
555
 
555
556
  /**
@@ -1100,17 +1101,24 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1100
1101
  // 变更文档随快照(test_strategy 消费)。并行会话在途文件物理不进快照、不参与本门判定。
1101
1102
  // 快照基建失败 → 回退主仓现行为(失败路径沿用 renderVerifyTestAttribution 归因提示)。
1102
1103
  let verifyGateSnap = null
1104
+ let snapshotFailRef = null // createVerifyGateSnapshot 出参:结构化失败原因(FR-04,替代硬编码笼统文案)
1103
1105
  if (!process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF) {
1104
1106
  try {
1105
1107
  const { createVerifyGateSnapshot } = await import('./gate-snapshot.js')
1106
- verifyGateSnap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts })
1108
+ snapshotFailRef = {}
1109
+ verifyGateSnap = await createVerifyGateSnapshot({ cwd, changeName, specBase, platformOpts, failRef: snapshotFailRef })
1107
1110
  if (verifyGateSnap) {
1108
- console.log(`🧪 Verify 实测隔离快照(根:${verifyGateSnap.snapshotRoot}):HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}(并行会话脏文件不参与门判定)`)
1111
+ console.log(`🧪 Verify 实测隔离快照(根:${verifyGateSnap.snapshotRoot}):HEAD + 本变更 ${verifyGateSnap.changeFileCount} 个文件${verifyGateSnap.sourceRoot ? '(overlay 自 worktree——本变更分支内容定向跑)' : ''}${verifyGateSnap.carriedFiles && verifyGateSnap.carriedFiles.length > 0 ? ` + 携带 ${verifyGateSnap.carriedFiles.length} 个他者声明文件(依赖自洽,不进判定面)` : ''}(并行会话未声明脏文件不参与门判定)`)
1109
1112
  } else {
1110
- // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账
1113
+ // FR-02(2026-10-09-verify-reuse-friction):null 回退与异常回退同等可见 + 记账;
1114
+ // FR-04(2026-10-16-fix-downstream-tool-defects):reason 消费 failRef 结构化原因
1115
+ // (pnpm 布局拒建 / 文件集为空三分成因 / junction 失败 / 基建异常详情),不再笼统
1111
1116
  try {
1112
1117
  const { reportGateSnapshotFallback } = await import('./gate-snapshot.js')
1113
- if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'verify --done 实测门', reason: 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)' })
1118
+ const fbReason = snapshotFailRef && snapshotFailRef.reason
1119
+ ? `createVerifyGateSnapshot 返回 null:${snapshotFailRef.reason}`
1120
+ : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)'
1121
+ if (typeof reportGateSnapshotFallback === 'function') await reportGateSnapshotFallback({ cwd, changeName, platformOpts, where: 'verify --done 实测门', reason: fbReason })
1114
1122
  } catch { /* 报告失败不改变回退行为(含测试 mock 模块缺导出的形态) */ }
1115
1123
  }
1116
1124
  } catch (e) {
@@ -1120,6 +1128,22 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1120
1128
  } catch { /* 报告失败不改变回退行为(含测试 mock 模块缺导出的形态) */ }
1121
1129
  }
1122
1130
  }
1131
+ // FR-04 降级事实落盘:used_snapshot 与失败原因随 test-result.json extra 持久化(快照在场
1132
+ // 时记录随快照销毁;回退主仓时恰好落在主仓 .runtime——降级证据存活面与用途一致)。
1133
+ // 评审 P3-2 整改:OFF 开关是主动关闸,不得记成「基建故障/文件集为空」的笼统回退原因。
1134
+ let snapObsFields
1135
+ if (process.env.SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF) {
1136
+ snapObsFields = { used_snapshot: false, snapshot_fallback_reason: 'SILLYSPEC_VERIFY_GATE_SNAPSHOT_OFF=1 主动关闸(快照未尝试,非基建故障)' }
1137
+ } else if (verifyGateSnap) {
1138
+ snapObsFields = { used_snapshot: true }
1139
+ } else {
1140
+ try {
1141
+ const { snapshotObservability } = await import('./gate-snapshot.js')
1142
+ snapObsFields = snapshotObservability(false, snapshotFailRef)
1143
+ } catch {
1144
+ snapObsFields = { used_snapshot: false, snapshot_fallback_reason: snapshotFailRef && snapshotFailRef.reason ? String(snapshotFailRef.reason) : 'createVerifyGateSnapshot 返回 null(变更文件集为空 / 快照基建不可用)' }
1145
+ }
1146
+ }
1123
1147
  const gateCwd = verifyGateSnap ? verifyGateSnap.snapshotRoot : cwd
1124
1148
  const gateSpecBase = verifyGateSnap ? join(verifyGateSnap.snapshotRoot, '.sillyspec') : specBase
1125
1149
  let testCheck
@@ -1220,7 +1244,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
1220
1244
  `质量扫描: ${reusableScan ? '命中(上层未消费)' : '指纹失配/无记录'}`,
1221
1245
  `green-cache: ${greenHit ? '命中(上层未消费)' : '指纹失配/过期/无记录'}`,
1222
1246
  ].join(';')
1223
- testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName, ctx, observability: { fingerprint: _obsFingerprint, reuse_decision: { layer: 'real-run', hit: false, reason: _missReason } }, ...(Array.isArray(_restrict) && _restrict.length > 0 ? { restrictFiles: _restrict } : {}) })
1247
+ testCheck = runVerifyTestCheck({ cwd: gateCwd, specBase: gateSpecBase, changeName, ctx, observability: { fingerprint: _obsFingerprint, reuse_decision: { layer: 'real-run', hit: false, reason: _missReason }, ...snapObsFields }, ...(Array.isArray(_restrict) && _restrict.length > 0 ? { restrictFiles: _restrict } : {}) })
1224
1248
  printVerifyTestCheck(testCheck)
1225
1249
  // 自动绑定补全(full-autopilot-parity,R21 thin 迁移):测试已跑、test-result.json 已落盘,
1226
1250
  // verify --done 收口时从测试结果自动补全 requirements.md 的空绑定槽——与 thin 同逻辑(逐行扫描)。
package/src/run/prompt.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * - loadModuleContextIndex/buildModuleContextInjection 内 require('fs'/'path') 改顶部静态 import
20
20
  */
21
21
  import { basename, dirname, join } from 'node:path'
22
+ import { runtimeStatePath } from '../runtime-paths.js'
22
23
  import { existsSync, readFileSync, mkdirSync, readdirSync, writeFileSync, rmSync } from 'node:fs'
23
24
  import { createHash } from 'node:crypto'
24
25
  import jsYaml from 'js-yaml'
@@ -868,7 +869,7 @@ export async function renderPreflightFailures({ stageName, stepName, cwd, specBa
868
869
  export function shouldInjectFullContext({ changeName, stageName, runtimeRoot } = {}) {
869
870
  try {
870
871
  if (!runtimeRoot || !changeName || !stageName) return { full: true }
871
- const ledgerPath = join(runtimeRoot, `prompt-inject-${changeName}.json`)
872
+ const ledgerPath = runtimeStatePath(runtimeRoot, `prompt-inject-${changeName}.json`)
872
873
  if (!existsSync(ledgerPath)) return { full: true }
873
874
  const ledger = JSON.parse(readFileSync(ledgerPath, 'utf8'))
874
875
  const rec = (ledger && ledger.stages && typeof ledger.stages === 'object') ? ledger.stages[stageName] : null
@@ -906,7 +907,7 @@ function renderPromptInjectSummaryLine({ firstStep, digest, specBase, projectNam
906
907
  * @param {{ runtimeRoot: string, changeName: string, stageName: string, firstStep: number, digest: string }} args
907
908
  */
908
909
  async function recordPromptInjectLedger({ runtimeRoot, changeName, stageName, firstStep, digest }) {
909
- const ledgerPath = join(runtimeRoot, `prompt-inject-${changeName}.json`)
910
+ const ledgerPath = runtimeStatePath(runtimeRoot, `prompt-inject-${changeName}.json`)
910
911
  await withFileLock(ledgerPath + '.lock', () => {
911
912
  let ledger = null
912
913
  try { ledger = JSON.parse(readFileSync(ledgerPath, 'utf8')) } catch { ledger = null }
@@ -1428,7 +1429,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1428
1429
  // 注入结果同步落 runtime JSON(与既有 KNOWLEDGE_HIT_REPORT 落盘口径一致)
1429
1430
  const decRuntimeDir = join(decSpecBase, '.runtime')
1430
1431
  mkdirSync(decRuntimeDir, { recursive: true })
1431
- writeAtomicSync(join(decRuntimeDir, 'decision-hits.json'), JSON.stringify({ matched: decResult.matched, decisionHits: decResult.decisionHits || [] }, null, 2) + '\n')
1432
+ writeAtomicSync(runtimeStatePath(decRuntimeDir, 'decision-hits.json'), JSON.stringify({ matched: decResult.matched, decisionHits: decResult.decisionHits || [] }, null, 2) + '\n')
1432
1433
  } catch (e) {
1433
1434
  substitute(/\{DECISION_HITS\}/g, `[decisions] 决策命中检测异常(${e.message}),跳过`)
1434
1435
  }
@@ -1495,7 +1496,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1495
1496
  )
1496
1497
  // 写入 runtime JSON(既有机制照旧,新旧遥测共存)
1497
1498
  mkdirSync(runtimeDir, { recursive: true })
1498
- writeAtomicSync(join(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeInjection.json, null, 2) + '\n')
1499
+ writeAtomicSync(runtimeStatePath(runtimeDir, 'knowledge-hit-report.json'), JSON.stringify(knowledgeInjection.json, null, 2) + '\n')
1499
1500
  } catch (e) {
1500
1501
  substitute(/\{KNOWLEDGE_HIT_REPORT\}/g, 'Status: no matches (error: ' + e.message + ')')
1501
1502
  }
@@ -1552,7 +1553,7 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1552
1553
  let runId = ''
1553
1554
  const execSpecBase = resolvePromptSpecBase(platformOpts, cwd)
1554
1555
  const runtimeRoot = resolveRuntimeRoot(platformOpts, execSpecBase)
1555
- const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
1556
+ const runIdFile = runtimeStatePath(runtimeRoot, `current-execute-run-id-${changeName}`)
1556
1557
  try {
1557
1558
  if (existsSync(runIdFile)) {
1558
1559
  const c = readFileSync(runIdFile, 'utf8').trim()
@@ -9,6 +9,7 @@
9
9
  * - executeScanPostcheck 原冗余动态 import('fs'/'path'/'child_process') 删除,改顶部静态(execSync 实际未直接用,纯遗留)
10
10
  */
11
11
  import { join, basename, extname } from 'node:path'
12
+ import { runtimeStatePath } from '../runtime-paths.js'
12
13
  import { existsSync, readdirSync, statSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs'
13
14
  import { safeGit, resolveRuntimeRoot } from './shared.js'
14
15
  import { SCAN_REQUIRED_DOCS } from '../constants.js'
@@ -332,7 +333,7 @@ export async function executeScanResumeCheck(cwd, platformOpts) {
332
333
  // .runtime/last-delta.json(归档/delta 补跑写入的最近变更快照),14 天窗口内提示本轮
333
334
  // scan 优先核对其涉及模块——advisory 不阻断不改变步骤结构;缺失/过期/解析失败静默。
334
335
  try {
335
- const sidecarPath = join(resolveRuntimeRoot(platformOpts, specBase), 'last-delta.json')
336
+ const sidecarPath = runtimeStatePath(resolveRuntimeRoot(platformOpts, specBase), 'last-delta.json')
336
337
  if (existsSync(sidecarPath)) {
337
338
  const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8'))
338
339
  const ageMs = Date.now() - Date.parse(sidecar.updatedAt || '')