spexcode 0.6.1 → 0.6.5
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.
- package/README.md +99 -149
- package/bin/spex.mjs +4 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +76 -0
- package/node_modules/@spexcode/spec-cli/dist/attach.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/attach.js +48 -0
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +33 -0
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +307 -0
- package/node_modules/@spexcode/spec-cli/dist/cli.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/cli.js +1859 -0
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +111 -0
- package/node_modules/@spexcode/spec-cli/dist/client.js +476 -0
- package/node_modules/@spexcode/spec-cli/dist/cockpit.d.ts +7 -0
- package/node_modules/@spexcode/spec-cli/dist/cockpit.js +26 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-headless.d.ts +1 -0
- package/{spec-cli/src/codex-headless.ts → node_modules/@spexcode/spec-cli/dist/codex-headless.js} +3 -9
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +53 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +686 -0
- package/node_modules/@spexcode/spec-cli/dist/commit-surgery.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/commit-surgery.js +90 -0
- package/node_modules/@spexcode/spec-cli/dist/contract-filter.d.ts +14 -0
- package/node_modules/@spexcode/spec-cli/dist/contract-filter.js +219 -0
- package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/dashboard-assets.js +50 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-queue.d.ts +23 -0
- package/node_modules/@spexcode/spec-cli/dist/delivery-queue.js +179 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +547 -0
- package/node_modules/@spexcode/spec-cli/dist/eval-host.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/eval-host.js +19 -0
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +26 -0
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +475 -0
- package/node_modules/@spexcode/spec-cli/dist/file-write.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/file-write.js +26 -0
- package/node_modules/@spexcode/spec-cli/dist/flat.d.ts +89 -0
- package/node_modules/@spexcode/spec-cli/dist/flat.js +1076 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-auth.d.ts +56 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-auth.js +168 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.d.ts +28 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +361 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +37 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +503 -0
- package/node_modules/@spexcode/spec-cli/dist/graphCache.d.ts +27 -0
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +716 -0
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.d.ts +14 -0
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +35 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.d.ts +85 -0
- package/node_modules/@spexcode/spec-cli/dist/graphStream.js +1195 -0
- package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.d.ts +64 -0
- package/node_modules/@spexcode/spec-cli/dist/guidance-catalog.js +166 -0
- package/node_modules/@spexcode/spec-cli/dist/guide.d.ts +8 -0
- package/{spec-cli/src/guide.ts → node_modules/@spexcode/spec-cli/dist/guide.js} +115 -22
- package/node_modules/@spexcode/spec-cli/dist/harness-select.d.ts +18 -0
- package/node_modules/@spexcode/spec-cli/dist/harness-select.js +62 -0
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +325 -0
- package/node_modules/@spexcode/spec-cli/dist/harness.js +3026 -0
- package/node_modules/@spexcode/spec-cli/dist/headless-controller.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/headless-controller.js +47 -0
- package/node_modules/@spexcode/spec-cli/dist/help.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/help.js +574 -0
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.d.ts +18 -0
- package/node_modules/@spexcode/spec-cli/dist/hook-prompts.js +122 -0
- package/node_modules/@spexcode/spec-cli/dist/hooks.d.ts +1 -0
- package/{spec-cli/src/hooks.ts → node_modules/@spexcode/spec-cli/dist/hooks.js} +15 -15
- package/node_modules/@spexcode/spec-cli/dist/host-resources.d.ts +97 -0
- package/node_modules/@spexcode/spec-cli/dist/host-resources.js +795 -0
- package/node_modules/@spexcode/spec-cli/dist/host.d.ts +88 -0
- package/node_modules/@spexcode/spec-cli/dist/host.js +663 -0
- package/node_modules/@spexcode/spec-cli/dist/index.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/index.js +899 -0
- package/node_modules/@spexcode/spec-cli/dist/init.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/init.js +284 -0
- package/node_modules/@spexcode/spec-cli/dist/issues-cli.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/issues-cli.js +334 -0
- package/node_modules/@spexcode/spec-cli/dist/issues.d.ts +69 -0
- package/node_modules/@spexcode/spec-cli/dist/issues.js +189 -0
- package/node_modules/@spexcode/spec-cli/dist/lint.d.ts +34 -0
- package/node_modules/@spexcode/spec-cli/dist/lint.js +476 -0
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +7 -0
- package/{spec-cli/src/listen.ts → node_modules/@spexcode/spec-cli/dist/listen.js} +13 -17
- package/node_modules/@spexcode/spec-cli/dist/localIssues.d.ts +65 -0
- package/node_modules/@spexcode/spec-cli/dist/localIssues.js +582 -0
- package/node_modules/@spexcode/spec-cli/dist/login-page.d.ts +5 -0
- package/{spec-cli/src/login-page.ts → node_modules/@spexcode/spec-cli/dist/login-page.js} +7 -7
- package/node_modules/@spexcode/spec-cli/dist/loop-in.d.ts +23 -0
- package/{spec-cli/src/loop-in.ts → node_modules/@spexcode/spec-cli/dist/loop-in.js} +35 -45
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +69 -0
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +603 -0
- package/node_modules/@spexcode/spec-cli/dist/materialize.d.ts +18 -0
- package/node_modules/@spexcode/spec-cli/dist/materialize.js +468 -0
- package/node_modules/@spexcode/spec-cli/dist/mentions.d.ts +50 -0
- package/node_modules/@spexcode/spec-cli/dist/mentions.js +117 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +4 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +208 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode.d.ts +3 -0
- package/{spec-cli/src/opencode.ts → node_modules/@spexcode/spec-cli/dist/opencode.js} +9 -13
- package/node_modules/@spexcode/spec-cli/dist/pi-harness.d.ts +3 -0
- package/{spec-cli/src/pi-harness.ts → node_modules/@spexcode/spec-cli/dist/pi-harness.js} +45 -40
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +25 -0
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +196 -0
- package/node_modules/@spexcode/spec-cli/dist/plugin-harness.d.ts +21 -0
- package/node_modules/@spexcode/spec-cli/dist/plugin-harness.js +146 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-bridge.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-bridge.js +404 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-helper.d.mts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-helper.mjs +101 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.d.mts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/pty-native-helper.mjs +23 -0
- package/node_modules/@spexcode/spec-cli/dist/public-graph.d.ts +46 -0
- package/node_modules/@spexcode/spec-cli/dist/public-graph.js +56 -0
- package/node_modules/@spexcode/spec-cli/dist/ranker.d.ts +15 -0
- package/node_modules/@spexcode/spec-cli/dist/ranker.js +188 -0
- package/node_modules/@spexcode/spec-cli/dist/reaper.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/reaper.js +81 -0
- package/node_modules/@spexcode/spec-cli/dist/reviews.d.ts +189 -0
- package/node_modules/@spexcode/spec-cli/dist/reviews.js +350 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-guard.d.ts +6 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-guard.js +37 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.d.ts +21 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-ownership.js +84 -0
- package/node_modules/@spexcode/spec-cli/dist/search.bench.d.mts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/search.bench.mjs +75 -0
- package/node_modules/@spexcode/spec-cli/dist/search.d.ts +20 -0
- package/node_modules/@spexcode/spec-cli/dist/search.js +61 -0
- package/node_modules/@spexcode/spec-cli/dist/session-cursors.d.ts +14 -0
- package/node_modules/@spexcode/spec-cli/dist/session-cursors.js +82 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.d.ts +13 -0
- package/node_modules/@spexcode/spec-cli/dist/session-declarations.js +126 -0
- package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/session-execution.js +70 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.d.ts +28 -0
- package/node_modules/@spexcode/spec-cli/dist/session-files.js +130 -0
- package/node_modules/@spexcode/spec-cli/dist/session-follow.d.ts +31 -0
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +179 -0
- package/node_modules/@spexcode/spec-cli/dist/session-reparent.d.ts +10 -0
- package/node_modules/@spexcode/spec-cli/dist/session-reparent.js +33 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +51 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +233 -0
- package/node_modules/@spexcode/spec-cli/dist/session-web.d.ts +24 -0
- package/node_modules/@spexcode/spec-cli/dist/session-web.js +141 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +412 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +4239 -0
- package/node_modules/@spexcode/spec-cli/dist/sh.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/sh.js +3 -0
- package/node_modules/@spexcode/spec-cli/dist/shim-runtime.d.ts +1 -0
- package/{spec-cli/src/shim-runtime.ts → node_modules/@spexcode/spec-cli/dist/shim-runtime.js} +3 -4
- package/node_modules/@spexcode/spec-cli/dist/slash-commands.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/slash-commands.js +307 -0
- package/node_modules/@spexcode/spec-cli/dist/source-files.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/source-files.js +113 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +308 -0
- package/node_modules/@spexcode/spec-cli/dist/tree.d.ts +25 -0
- package/node_modules/@spexcode/spec-cli/dist/tree.js +98 -0
- package/node_modules/@spexcode/spec-cli/dist/tsx-bin.d.ts +3 -0
- package/node_modules/@spexcode/spec-cli/dist/tsx-bin.js +41 -0
- package/node_modules/@spexcode/spec-cli/dist/uninstall.d.ts +3 -0
- package/node_modules/@spexcode/spec-cli/dist/uninstall.js +177 -0
- package/node_modules/@spexcode/spec-cli/dist/uploads.d.ts +36 -0
- package/node_modules/@spexcode/spec-cli/dist/uploads.js +243 -0
- package/node_modules/@spexcode/spec-cli/dist/worktree-sources.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/worktree-sources.js +47 -0
- package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/dispatch.sh +5 -5
- package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/harness.sh +27 -7
- package/node_modules/@spexcode/spec-cli/package.json +47 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec-first.sh +1 -1
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +1 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/core/spec.md +22 -0
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/spec.md +2 -2
- package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +31 -29
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
- package/node_modules/@spexcode/spec-core/dist/anchors.d.ts +94 -0
- package/node_modules/@spexcode/spec-core/dist/anchors.js +730 -0
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +166 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +2736 -0
- package/node_modules/@spexcode/spec-core/dist/graph-delta.d.ts +44 -0
- package/node_modules/@spexcode/spec-core/dist/graph-delta.js +72 -0
- package/node_modules/@spexcode/spec-core/dist/graph.d.ts +34 -0
- package/node_modules/@spexcode/spec-core/dist/graph.js +237 -0
- package/node_modules/@spexcode/spec-core/dist/graphDelta.d.ts +3 -0
- package/node_modules/@spexcode/spec-core/dist/graphDelta.js +13 -0
- package/node_modules/@spexcode/spec-core/dist/harness-identity.d.ts +31 -0
- package/node_modules/@spexcode/spec-core/dist/harness-identity.js +20 -0
- package/node_modules/@spexcode/spec-core/dist/identity-presets.d.ts +152 -0
- package/node_modules/@spexcode/spec-core/dist/identity-presets.js +132 -0
- package/node_modules/@spexcode/spec-core/dist/index.d.ts +45 -0
- package/node_modules/@spexcode/spec-core/dist/index.js +19 -0
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +183 -0
- package/node_modules/@spexcode/spec-core/dist/layout.js +548 -0
- package/node_modules/@spexcode/spec-core/dist/process-identity.d.ts +37 -0
- package/node_modules/@spexcode/spec-core/dist/process-identity.js +214 -0
- package/node_modules/@spexcode/spec-core/dist/project-identity.d.ts +12 -0
- package/node_modules/@spexcode/spec-core/dist/project-identity.js +71 -0
- package/node_modules/@spexcode/spec-core/dist/project-store.d.ts +3 -0
- package/node_modules/@spexcode/spec-core/dist/project-store.js +14 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.d.ts +2 -0
- package/node_modules/@spexcode/spec-core/dist/resilience.js +40 -0
- package/node_modules/@spexcode/spec-core/dist/review/index.d.ts +3 -0
- package/node_modules/@spexcode/spec-core/dist/review/index.js +5 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.d.ts +77 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewFilters.js +308 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.d.ts +66 -0
- package/node_modules/@spexcode/spec-core/dist/review/reviewQuery.js +180 -0
- package/node_modules/@spexcode/spec-core/dist/review/session.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/review/session.js +8 -0
- package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.d.ts +15 -0
- package/node_modules/@spexcode/spec-core/dist/reviewSnapshot.js +12 -0
- package/node_modules/@spexcode/spec-core/dist/root-lru.d.ts +4 -0
- package/{spec-cli/src/root-lru.ts → node_modules/@spexcode/spec-core/dist/root-lru.js} +26 -30
- package/node_modules/@spexcode/spec-core/dist/specs.d.ts +117 -0
- package/node_modules/@spexcode/spec-core/dist/specs.js +489 -0
- package/node_modules/@spexcode/spec-core/package.json +28 -0
- package/node_modules/@spexcode/spec-eval/dist/cache.d.ts +10 -0
- package/node_modules/@spexcode/spec-eval/dist/cache.js +50 -0
- package/node_modules/@spexcode/spec-eval/dist/cli.d.ts +22 -0
- package/node_modules/@spexcode/spec-eval/dist/cli.js +981 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.d.ts +98 -0
- package/node_modules/@spexcode/spec-eval/dist/evaltab.js +176 -0
- package/node_modules/@spexcode/spec-eval/dist/filing.d.ts +16 -0
- package/node_modules/@spexcode/spec-eval/dist/filing.js +36 -0
- package/node_modules/@spexcode/spec-eval/dist/freshness.d.ts +48 -0
- package/node_modules/@spexcode/spec-eval/dist/freshness.js +799 -0
- package/node_modules/@spexcode/spec-eval/dist/host.d.ts +46 -0
- package/node_modules/@spexcode/spec-eval/dist/host.js +53 -0
- package/node_modules/@spexcode/spec-eval/dist/humanok.d.ts +11 -0
- package/node_modules/@spexcode/spec-eval/dist/humanok.js +27 -0
- package/node_modules/@spexcode/spec-eval/dist/index.d.ts +4 -0
- package/node_modules/@spexcode/spec-eval/dist/index.js +4 -0
- package/node_modules/@spexcode/spec-eval/dist/remarks.d.ts +31 -0
- package/node_modules/@spexcode/spec-eval/dist/remarks.js +1 -0
- package/node_modules/@spexcode/spec-eval/dist/scenariofresh.d.ts +13 -0
- package/node_modules/@spexcode/spec-eval/dist/scenariofresh.js +303 -0
- package/node_modules/@spexcode/spec-eval/dist/scenarios.d.ts +99 -0
- package/node_modules/@spexcode/spec-eval/dist/scenarios.js +650 -0
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.d.ts +307 -0
- package/node_modules/@spexcode/spec-eval/dist/sessioneval.js +1883 -0
- package/node_modules/@spexcode/spec-eval/dist/sidecar.d.ts +55 -0
- package/node_modules/@spexcode/spec-eval/dist/sidecar.js +82 -0
- package/node_modules/@spexcode/spec-eval/dist/timeline.d.ts +25 -0
- package/node_modules/@spexcode/spec-eval/dist/timeline.js +65 -0
- package/node_modules/@spexcode/spec-eval/dist/ui-path.d.ts +1 -0
- package/node_modules/@spexcode/spec-eval/dist/ui-path.js +2 -0
- package/node_modules/@spexcode/spec-eval/package.json +36 -0
- package/node_modules/@spexcode/spec-forge/dist/cache.d.ts +29 -0
- package/node_modules/@spexcode/spec-forge/dist/cache.js +59 -0
- package/node_modules/@spexcode/spec-forge/dist/cli.d.ts +1 -0
- package/node_modules/@spexcode/spec-forge/dist/cli.js +100 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/github.d.ts +2 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/github.js +116 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.d.ts +7 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers/gitlab.js +144 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers.d.ts +10 -0
- package/node_modules/@spexcode/spec-forge/dist/drivers.js +83 -0
- package/node_modules/@spexcode/spec-forge/dist/index.d.ts +4 -0
- package/node_modules/@spexcode/spec-forge/dist/index.js +4 -0
- package/node_modules/@spexcode/spec-forge/dist/links.d.ts +15 -0
- package/node_modules/@spexcode/spec-forge/dist/links.js +65 -0
- package/node_modules/@spexcode/spec-forge/dist/needs-eval.d.ts +10 -0
- package/node_modules/@spexcode/spec-forge/dist/needs-eval.js +20 -0
- package/node_modules/@spexcode/spec-forge/dist/port.d.ts +53 -0
- package/node_modules/@spexcode/spec-forge/dist/port.js +1 -0
- package/node_modules/@spexcode/spec-forge/dist/resident.d.ts +7 -0
- package/node_modules/@spexcode/spec-forge/dist/resident.js +43 -0
- package/node_modules/@spexcode/spec-forge/package.json +32 -0
- package/package.json +33 -19
- package/spec-cli/bin/spex.mjs +0 -69
- package/spec-cli/src/anchors.ts +0 -706
- package/spec-cli/src/attach.ts +0 -43
- package/spec-cli/src/claude-headless.ts +0 -321
- package/spec-cli/src/cli.ts +0 -1115
- package/spec-cli/src/client.ts +0 -315
- package/spec-cli/src/cockpit.ts +0 -43
- package/spec-cli/src/codex-runtime-generations.ts +0 -646
- package/spec-cli/src/commit-surgery.ts +0 -68
- package/spec-cli/src/contract-filter.ts +0 -202
- package/spec-cli/src/delivery-queue.ts +0 -107
- package/spec-cli/src/doctor.ts +0 -516
- package/spec-cli/src/file-write.ts +0 -22
- package/spec-cli/src/gateway-auth.ts +0 -181
- package/spec-cli/src/gateway-hub.ts +0 -356
- package/spec-cli/src/gateway.ts +0 -368
- package/spec-cli/src/git.ts +0 -2180
- package/spec-cli/src/graph.ts +0 -221
- package/spec-cli/src/graphCache.ts +0 -641
- package/spec-cli/src/graphDelta.ts +0 -77
- package/spec-cli/src/graphStream.ts +0 -1019
- package/spec-cli/src/harness-select.ts +0 -77
- package/spec-cli/src/harness.ts +0 -2747
- package/spec-cli/src/headless-controller.ts +0 -63
- package/spec-cli/src/help.ts +0 -480
- package/spec-cli/src/host-resources.ts +0 -707
- package/spec-cli/src/host.ts +0 -611
- package/spec-cli/src/identity-presets.d.ts +0 -13
- package/spec-cli/src/identity-presets.js +0 -128
- package/spec-cli/src/index.ts +0 -730
- package/spec-cli/src/init.ts +0 -258
- package/spec-cli/src/issues-cli.ts +0 -276
- package/spec-cli/src/issues.ts +0 -237
- package/spec-cli/src/layout.ts +0 -557
- package/spec-cli/src/lint.ts +0 -458
- package/spec-cli/src/localIssues.ts +0 -538
- package/spec-cli/src/materialize.ts +0 -412
- package/spec-cli/src/mentions.ts +0 -81
- package/spec-cli/src/opencode-headless.ts +0 -205
- package/spec-cli/src/pi-headless.ts +0 -153
- package/spec-cli/src/plugin-harness.ts +0 -157
- package/spec-cli/src/process-identity.ts +0 -207
- package/spec-cli/src/project-identity.ts +0 -73
- package/spec-cli/src/project-store.ts +0 -17
- package/spec-cli/src/pty-bridge.ts +0 -396
- package/spec-cli/src/pty-helper.mjs +0 -96
- package/spec-cli/src/pty-native-helper.mjs +0 -22
- package/spec-cli/src/ranker.ts +0 -191
- package/spec-cli/src/reaper.ts +0 -125
- package/spec-cli/src/resilience.ts +0 -41
- package/spec-cli/src/reviewSnapshot.ts +0 -28
- package/spec-cli/src/reviews.ts +0 -405
- package/spec-cli/src/runtime-guard.ts +0 -35
- package/spec-cli/src/runtime-ownership.ts +0 -88
- package/spec-cli/src/search.bench.mjs +0 -80
- package/spec-cli/src/search.ts +0 -63
- package/spec-cli/src/session-cursors.ts +0 -91
- package/spec-cli/src/session-follow.ts +0 -182
- package/spec-cli/src/session-timeline.ts +0 -98
- package/spec-cli/src/sessions.ts +0 -3403
- package/spec-cli/src/sh.ts +0 -3
- package/spec-cli/src/slash-commands.ts +0 -318
- package/spec-cli/src/source-files.ts +0 -104
- package/spec-cli/src/specs.ts +0 -498
- package/spec-cli/src/supervise.ts +0 -245
- package/spec-cli/src/tree.ts +0 -118
- package/spec-cli/src/tsx-bin.ts +0 -18
- package/spec-cli/src/uninstall.ts +0 -171
- package/spec-cli/src/uploads.ts +0 -253
- package/spec-cli/src/worktree-sources.ts +0 -42
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +0 -28
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +0 -23
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +0 -32
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +0 -9
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +0 -15
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +0 -18
- package/spec-dashboard/dist/assets/App-F9uaAVcH.js +0 -2
- package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +0 -1
- package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-CuKLFhH3.js +0 -1
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
- package/spec-dashboard/dist/assets/Modal-CQgYymmr.js +0 -1
- package/spec-dashboard/dist/assets/PageScroll-hT7UTLvD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-CtXxakF9.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +0 -32
- package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-CixDi4PI.js +0 -1
- package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +0 -1
- package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
- package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
- package/spec-dashboard/dist/assets/index-DrVao0Ep.js +0 -41
- package/spec-dashboard/dist/assets/launch-BBH02b1v.js +0 -276
- package/spec-dashboard/dist/assets/launch-Cp579UoJ.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-dashboard/dist/index.html +0 -29
- package/spec-dashboard/src/reviewFilters.js +0 -322
- package/spec-dashboard/src/reviewQuery.js +0 -174
- package/spec-dashboard/src/session.js +0 -199
- package/spec-eval/src/cache.ts +0 -57
- package/spec-eval/src/cli.ts +0 -889
- package/spec-eval/src/evaltab.ts +0 -262
- package/spec-eval/src/filing.ts +0 -39
- package/spec-eval/src/freshness.ts +0 -557
- package/spec-eval/src/humanok.ts +0 -28
- package/spec-eval/src/scenariofresh.ts +0 -244
- package/spec-eval/src/scenarios.ts +0 -672
- package/spec-eval/src/sessioneval.ts +0 -2226
- package/spec-eval/src/sidecar.ts +0 -93
- package/spec-eval/src/timeline.ts +0 -54
- package/spec-forge/src/__fixtures__/github-forge.json +0 -63
- package/spec-forge/src/cache.ts +0 -66
- package/spec-forge/src/cli.ts +0 -99
- package/spec-forge/src/drivers/github.ts +0 -132
- package/spec-forge/src/drivers/gitlab.ts +0 -153
- package/spec-forge/src/drivers.ts +0 -73
- package/spec-forge/src/links.ts +0 -72
- package/spec-forge/src/needs-eval.ts +0 -29
- package/spec-forge/src/port.ts +0 -36
- package/spec-forge/src/resident.ts +0 -46
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/README.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/hooks/compat/mark-active-sed-v0.fixture +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/commit-msg +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-checkout +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/post-merge +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/pre-commit +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/prepare-commit-msg +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/hooks/reference-transaction +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/extract/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/regroup/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/rename/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/commands/tidy/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/idle.sh +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/idle/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/mark-active.sh +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/mark-active/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/fail.sh +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/session-fail/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-first/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/core/spec-of-file/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/digest.mjs +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/distill/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/skills/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-core}/templates/spexcode.json +0 -0
|
@@ -0,0 +1,1883 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
5
|
+
import { batchBlobTexts, batchRevisionOids, git, gitA, gitTry, pathRangeEvents, repoRoot, driftIndex, historyIndex, treeTextFiles, withEventLedgerBuild, withEventLedgerDemand, } from '@spexcode/spec-core';
|
|
6
|
+
import { touchRoot } from '@spexcode/spec-core';
|
|
7
|
+
import { loadSpecs } from '@spexcode/spec-core';
|
|
8
|
+
import { mainBranch } from '@spexcode/spec-core';
|
|
9
|
+
import { anchorHitCommits, diffHunkRanges, extOf, extractorFor, extractors, parseRelation, relationClaimsPath, resolveAnchor, selectorsHitRanges, } from '@spexcode/spec-core';
|
|
10
|
+
import { evalTimelines, evalContext, readBlobByHash } from './evaltab.js';
|
|
11
|
+
import { isUiPath } from './ui-path.js';
|
|
12
|
+
import { readReadings } from './sidecar.js';
|
|
13
|
+
import { parseScenarios, scenarioCodeAxis, scenarioHash } from './scenarios.js';
|
|
14
|
+
import { evalRemarkSourceFingerprint, evalRemarkTracks } from './host.js';
|
|
15
|
+
let sessionEvalHostValue = null;
|
|
16
|
+
export function setSessionEvalHost(next) {
|
|
17
|
+
if (typeof next?.reviewIdentity !== 'function')
|
|
18
|
+
throw new SessionEvalUnavailableError('spec-eval host is not configured: reviewIdentity');
|
|
19
|
+
if (typeof next?.reviewPayload !== 'function')
|
|
20
|
+
throw new SessionEvalUnavailableError('spec-eval host is not configured: reviewPayload');
|
|
21
|
+
sessionEvalHostValue = next;
|
|
22
|
+
}
|
|
23
|
+
export function sessionEvalHost() {
|
|
24
|
+
if (!sessionEvalHostValue)
|
|
25
|
+
throw new SessionEvalUnavailableError('spec-eval host is not configured');
|
|
26
|
+
return sessionEvalHostValue;
|
|
27
|
+
}
|
|
28
|
+
export class SessionEvalUnavailableError extends Error {
|
|
29
|
+
name = 'SessionEvalUnavailableError';
|
|
30
|
+
}
|
|
31
|
+
export class SessionImpactUnavailableError extends SessionEvalUnavailableError {
|
|
32
|
+
name = 'SessionImpactUnavailableError';
|
|
33
|
+
}
|
|
34
|
+
export class SessionImpactRevisionMovedError extends SessionImpactUnavailableError {
|
|
35
|
+
name = 'SessionImpactRevisionMovedError';
|
|
36
|
+
}
|
|
37
|
+
async function impactGit(root, args, operation) {
|
|
38
|
+
const result = await gitTry(['-C', root, ...args]);
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure';
|
|
41
|
+
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`);
|
|
42
|
+
}
|
|
43
|
+
return result.stdout;
|
|
44
|
+
}
|
|
45
|
+
async function impactIndexGit(root, indexFile, args, operation) {
|
|
46
|
+
const result = await gitTry(['-C', root, ...args], { indexFile });
|
|
47
|
+
if (!result.ok) {
|
|
48
|
+
const detail = result.failure ? `${result.failure} failure${result.stderr.trim() ? `: ${result.stderr.trim()}` : ''}` : result.stderr.trim() || 'git failure';
|
|
49
|
+
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`);
|
|
50
|
+
}
|
|
51
|
+
return result.stdout;
|
|
52
|
+
}
|
|
53
|
+
async function impactCommit(root, selector) {
|
|
54
|
+
const out = await impactGit(root, ['rev-parse', '--verify', `${selector}^{commit}`], `resolve revision '${selector}'`);
|
|
55
|
+
const oid = out.trim();
|
|
56
|
+
if (!oid)
|
|
57
|
+
throw new SessionImpactUnavailableError(`session impact revision '${selector}' resolved to no commit`);
|
|
58
|
+
return oid;
|
|
59
|
+
}
|
|
60
|
+
function parseNameStatusChanges(out) {
|
|
61
|
+
const records = out.split('\0');
|
|
62
|
+
const changes = [];
|
|
63
|
+
for (let index = 0; index < records.length;) {
|
|
64
|
+
const status = records[index++];
|
|
65
|
+
if (!status)
|
|
66
|
+
continue;
|
|
67
|
+
const first = records[index++];
|
|
68
|
+
if (!first)
|
|
69
|
+
throw new SessionImpactUnavailableError(`session impact diff emitted '${status}' without a path`);
|
|
70
|
+
if (/^[RC]/.test(status)) {
|
|
71
|
+
const second = records[index++];
|
|
72
|
+
if (!second)
|
|
73
|
+
throw new SessionImpactUnavailableError(`session impact diff emitted '${status}' without both rename paths`);
|
|
74
|
+
changes.push({ status, before: first, after: second });
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
changes.push({ status, before: first, after: first });
|
|
78
|
+
}
|
|
79
|
+
return changes;
|
|
80
|
+
}
|
|
81
|
+
async function revisionFile(root, revision, path) {
|
|
82
|
+
const listed = await impactGit(root, ['-c', 'core.quotePath=false', 'ls-tree', '-z', '--name-only', revision, '--', path], `read ${revision}:${path}`);
|
|
83
|
+
if (!listed.split('\0').includes(path))
|
|
84
|
+
return null;
|
|
85
|
+
return impactGit(root, ['show', `${revision}:${path}`], `read ${revision}:${path}`);
|
|
86
|
+
}
|
|
87
|
+
// the snapshot already carries parsed entries; a caller wanting one relation just picks it. This used to mint
|
|
88
|
+
// `path#selector` STRINGS from the split path/scoped pair and hand them back to the parser to be turned into
|
|
89
|
+
// the entries the loader had all along — a serialize/reparse round-trip through a form nobody stored.
|
|
90
|
+
function loadedRelationEntries(spec, relation) {
|
|
91
|
+
if (!spec)
|
|
92
|
+
return [];
|
|
93
|
+
return relation === 'code' ? spec.codeEntries : spec.relatedEntries;
|
|
94
|
+
}
|
|
95
|
+
function scenarioMetadata(scenario, effectiveCode) {
|
|
96
|
+
return JSON.stringify({
|
|
97
|
+
tags: scenario.tags ?? [],
|
|
98
|
+
test: scenario.test ?? null,
|
|
99
|
+
effectiveCode,
|
|
100
|
+
related: scenario.related ?? [],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function selectorSource(context, revision, path, sourceView) {
|
|
104
|
+
if (sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path))
|
|
105
|
+
return context.overlay.files[path];
|
|
106
|
+
const key = `${revision}\0${path}`;
|
|
107
|
+
let pending = context.sources.get(key);
|
|
108
|
+
if (!pending) {
|
|
109
|
+
pending = revisionFile(context.root, revision, path);
|
|
110
|
+
context.sources.set(key, pending);
|
|
111
|
+
}
|
|
112
|
+
return pending;
|
|
113
|
+
}
|
|
114
|
+
async function validateSelectorEntry(context, revision, entry, sourceView) {
|
|
115
|
+
const x = extractorFor(context.regs, extOf(entry.path));
|
|
116
|
+
if (!x) {
|
|
117
|
+
throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors[0]}' is unavailable: no designated extractor for '.${extOf(entry.path)}'`);
|
|
118
|
+
}
|
|
119
|
+
const ready = x.ready();
|
|
120
|
+
if (ready !== true) {
|
|
121
|
+
throw new SessionImpactUnavailableError(`session impact selectors on '${entry.path}' are unavailable: ${ready}`);
|
|
122
|
+
}
|
|
123
|
+
const sourceIdentity = sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, entry.path)
|
|
124
|
+
? `overlay:${context.overlay.revision}`
|
|
125
|
+
: 'exact';
|
|
126
|
+
const key = `${revision}\0${sourceIdentity}\0${entry.path}\0${x.id}\0${x.memoKey(entry.path)}`;
|
|
127
|
+
let extracted = context.units.get(key);
|
|
128
|
+
if (!extracted) {
|
|
129
|
+
extracted = (async () => {
|
|
130
|
+
const source = await selectorSource(context, revision, entry.path, sourceView);
|
|
131
|
+
if (source === null) {
|
|
132
|
+
throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors[0]}' is dead at ${revision.slice(0, 8)}: the file does not exist`);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return { extractor: x, units: x.extract(source, entry.path) };
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
throw new SessionImpactUnavailableError(`session impact selectors on '${entry.path}' are unextractable at ${revision.slice(0, 8)}: ${error?.message ?? String(error)}`);
|
|
139
|
+
}
|
|
140
|
+
})();
|
|
141
|
+
context.units.set(key, extracted);
|
|
142
|
+
}
|
|
143
|
+
const { extractor, units } = await extracted;
|
|
144
|
+
for (const selector of entry.selectors) {
|
|
145
|
+
const resolved = resolveAnchor(units, selector);
|
|
146
|
+
if ('dead' in resolved) {
|
|
147
|
+
throw new SessionImpactUnavailableError(`dead session impact selector '${entry.path}#${selector}' at ${revision.slice(0, 8)}: follow the rename or repair the code relation`);
|
|
148
|
+
}
|
|
149
|
+
if ('ambiguous' in resolved) {
|
|
150
|
+
throw new SessionImpactUnavailableError(`ambiguous session impact selector '${entry.path}#${selector}' at ${revision.slice(0, 8)}: ${resolved.ambiguous} units share that name`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return { extractor, units };
|
|
154
|
+
}
|
|
155
|
+
function selectorEntriesForSnapshot(specs, scenariosById) {
|
|
156
|
+
const entries = [];
|
|
157
|
+
for (const spec of specs) {
|
|
158
|
+
entries.push(...loadedRelationEntries(spec, 'code'));
|
|
159
|
+
entries.push(...loadedRelationEntries(spec, 'related'));
|
|
160
|
+
for (const scenario of scenariosById.get(spec.id) ?? []) {
|
|
161
|
+
entries.push(...parsedRelation(scenario.code ?? [], 'code', `scenario '${spec.id} · ${scenario.name}'`));
|
|
162
|
+
entries.push(...parsedRelation(scenario.related ?? [], 'related', `scenario '${spec.id} · ${scenario.name}'`));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const unique = new Map();
|
|
166
|
+
for (const entry of entries)
|
|
167
|
+
if (entry.selectors.length)
|
|
168
|
+
unique.set(`${entry.path}\0${entry.selectors.join('\x1e')}`, entry);
|
|
169
|
+
return [...unique.values()];
|
|
170
|
+
}
|
|
171
|
+
async function primeSelectorSources(context, revision, paths, sourceView) {
|
|
172
|
+
const exactPaths = [...new Set(paths)].filter((path) => (!context.sources.has(`${revision}\0${path}`)
|
|
173
|
+
&& !(sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path)))).sort();
|
|
174
|
+
if (!exactPaths.length)
|
|
175
|
+
return;
|
|
176
|
+
try {
|
|
177
|
+
const oids = await batchRevisionOids(context.root, exactPaths.map((path) => `${revision}:${path}`));
|
|
178
|
+
const blobs = await batchBlobTexts(context.root, oids.filter((oid) => !!oid));
|
|
179
|
+
for (let index = 0; index < exactPaths.length; index++) {
|
|
180
|
+
const path = exactPaths[index];
|
|
181
|
+
const oid = oids[index];
|
|
182
|
+
if (oid && !blobs.has(oid))
|
|
183
|
+
throw new Error(`cat-file returned no bytes for ${revision.slice(0, 8)}:${path}`);
|
|
184
|
+
context.sources.set(`${revision}\0${path}`, Promise.resolve(oid ? blobs.get(oid) : null));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
throw new SessionImpactUnavailableError(`session impact cannot batch-read selector sources at ${revision.slice(0, 8)}: ${error?.message ?? String(error)}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function impactForEntry(context, entry, options = {}) {
|
|
192
|
+
const revision = options.validationRevision ?? context.head;
|
|
193
|
+
const sourceView = options.validationSide ?? 'head';
|
|
194
|
+
const key = `${context.base}\0${context.head}\0${context.overlay?.revision ?? ''}\0${sourceView}\0${revision}\0${entry.path}\0${entry.selectors.join('\x1e')}\0${options.validateUnchanged ? 1 : 0}`;
|
|
195
|
+
let pending = context.entryImpacts.get(key);
|
|
196
|
+
if (pending)
|
|
197
|
+
return pending;
|
|
198
|
+
pending = (async () => {
|
|
199
|
+
// Reuse sessioneval's existing relation claim matcher over the ONE exact base..head changed set. The
|
|
200
|
+
// projector adds no impact-local glob/path grammar; selector entries then narrow that file result.
|
|
201
|
+
const changed = [...context.changedPaths].filter((path) => codeClaims([entry.path], path));
|
|
202
|
+
if (!entry.selectors.length)
|
|
203
|
+
return { paths: changed.sort(), selectorHits: [] };
|
|
204
|
+
// Resolution is a validity claim about the selected HEAD, independent of whether the file moved.
|
|
205
|
+
// Validating first prevents a dead selector from becoming a vacuous no-hit/fresh answer.
|
|
206
|
+
if (!changed.length && !options.validateUnchanged)
|
|
207
|
+
return { paths: [], selectorHits: [] };
|
|
208
|
+
const { units } = await validateSelectorEntry(context, revision, entry, sourceView);
|
|
209
|
+
if (!changed.length)
|
|
210
|
+
return { paths: [], selectorHits: [] };
|
|
211
|
+
const selectorHits = [];
|
|
212
|
+
const committed = [...context.committedPaths].some((path) => codeClaims([entry.path], path));
|
|
213
|
+
if (committed) {
|
|
214
|
+
if (!context.didx)
|
|
215
|
+
throw new SessionImpactUnavailableError('session impact has committed paths but no exact event index');
|
|
216
|
+
const windowKey = `${revision}\0${entry.path}`;
|
|
217
|
+
let window = context.windows.get(windowKey);
|
|
218
|
+
if (!window) {
|
|
219
|
+
const projected = pathRangeEvents(context.didx, context.base, entry.path, revision);
|
|
220
|
+
if (!projected)
|
|
221
|
+
throw new SessionImpactUnavailableError(`session impact cannot place base ${context.base.slice(0, 8)} in the exact event topology`);
|
|
222
|
+
window = projected;
|
|
223
|
+
context.windows.set(windowKey, window);
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
const hits = await anchorHitCommits(context.root, window, [...entry.selectors], context.regs);
|
|
227
|
+
for (const hit of hits)
|
|
228
|
+
selectorHits.push({ path: entry.path, ...hit });
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors.join(',#')}' is unavailable: ${error?.message ?? String(error)}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (context.overlay && context.overlay.paths.some((path) => codeClaims([entry.path], path))) {
|
|
235
|
+
const ranges = sourceView === 'base' ? context.overlay.baseRanges : context.overlay.ranges;
|
|
236
|
+
const touched = selectorsHitRanges(units, entry.selectors, ranges[entry.path] ?? []);
|
|
237
|
+
if (touched.length)
|
|
238
|
+
selectorHits.push({
|
|
239
|
+
path: entry.path,
|
|
240
|
+
commit: `worktree:${context.overlay.revision.slice(0, 12)}`,
|
|
241
|
+
selectors: touched,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
return { paths: selectorHits.length ? changed.sort() : [], selectorHits };
|
|
245
|
+
})();
|
|
246
|
+
context.entryImpacts.set(key, pending);
|
|
247
|
+
return pending;
|
|
248
|
+
}
|
|
249
|
+
async function impactForEntries(context, entries, options = {}) {
|
|
250
|
+
const results = await Promise.all(entries.map((entry) => impactForEntry(context, entry, options)));
|
|
251
|
+
return {
|
|
252
|
+
paths: [...new Set(results.flatMap((result) => result.paths))].sort(),
|
|
253
|
+
selectorHits: results.flatMap((result) => result.selectorHits),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function pushNodeCause(causes, kind, paths = [], scenarios = [], selectorHits = []) {
|
|
257
|
+
let cause = causes.find((candidate) => candidate.kind === kind);
|
|
258
|
+
if (!cause) {
|
|
259
|
+
cause = { kind, paths: [], scenarios: [], selectorHits: [] };
|
|
260
|
+
causes.push(cause);
|
|
261
|
+
}
|
|
262
|
+
cause.paths = [...new Set([...cause.paths, ...paths])].sort();
|
|
263
|
+
cause.scenarios = [...new Set([...cause.scenarios, ...scenarios])].sort();
|
|
264
|
+
const seen = new Set(cause.selectorHits.map((hit) => JSON.stringify(hit)));
|
|
265
|
+
for (const hit of selectorHits) {
|
|
266
|
+
const key = JSON.stringify(hit);
|
|
267
|
+
if (!seen.has(key)) {
|
|
268
|
+
seen.add(key);
|
|
269
|
+
cause.selectorHits.push(hit);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function parsedRelation(raw, relation, owner) {
|
|
274
|
+
const parsed = parseRelation([...raw], relation);
|
|
275
|
+
if (parsed.problems.length) {
|
|
276
|
+
throw new SessionImpactUnavailableError(`session impact ${owner} has invalid ${relation}: ${parsed.problems.join('; ')}`);
|
|
277
|
+
}
|
|
278
|
+
return parsed.entries;
|
|
279
|
+
}
|
|
280
|
+
// Public, exact-revision impact projection. It reads both spec trees and all scenario declarations from
|
|
281
|
+
// immutable Git objects, reuses the canonical relation/anchor engine, then re-resolves the caller's selectors
|
|
282
|
+
// before publication. Callers may pass branch names, but never receive a projection spanning two ref states.
|
|
283
|
+
// @@@ the projection is the PERMANENT half ([[taste]] 19) - what commits between two immutable trees changed
|
|
284
|
+
// which paths, and what each side declared, are facts about those commits: they cannot change, so a repeat
|
|
285
|
+
// projection over the same inputs is pure recomputation. It used to be deliberately build-local, which was
|
|
286
|
+
// right while it was cheap; on a branch 840 commits from its base it is 2.4s, and a detail open pays it
|
|
287
|
+
// EVERY time because a focused build deposits no cut. Every input that can move the result is in the key —
|
|
288
|
+
// the two resolved commit oids, the live overlay's own content revision, and the measurement axis — so a hit
|
|
289
|
+
// answers the identical question. The promise is cached, not the value, so concurrent openers join one
|
|
290
|
+
// projection; a rejection is never retained, keeping an unavailable selector loud on every read.
|
|
291
|
+
const impactSlots = Math.max(4, Number(process.env.SPEXCODE_IMPACT_SLOTS || 32));
|
|
292
|
+
const impactRoots = new Map();
|
|
293
|
+
const impactMemo = new Map();
|
|
294
|
+
function impactKey(root, base, head, options) {
|
|
295
|
+
const measurements = Object.entries(options.measurements ?? {})
|
|
296
|
+
.map(([node, names]) => `${node}:${[...names].sort().join(',')}`).sort().join('\u001f');
|
|
297
|
+
return [resolvePath(root), base, head, options.overlay?.revision ?? '', createHash('sha256').update(measurements).digest('hex')].join('\0');
|
|
298
|
+
}
|
|
299
|
+
export async function projectSessionImpact(root, options) {
|
|
300
|
+
const [base, head] = await Promise.all([
|
|
301
|
+
impactCommit(root, options.base),
|
|
302
|
+
impactCommit(root, options.head),
|
|
303
|
+
]);
|
|
304
|
+
const memoKey = impactKey(root, base, head, options);
|
|
305
|
+
const memoHit = impactMemo.get(memoKey);
|
|
306
|
+
if (memoHit)
|
|
307
|
+
return memoHit;
|
|
308
|
+
const flight = projectSessionImpactUncached(root, options, base, head);
|
|
309
|
+
impactMemo.set(memoKey, flight);
|
|
310
|
+
flight.catch(() => { if (impactMemo.get(memoKey) === flight)
|
|
311
|
+
impactMemo.delete(memoKey); });
|
|
312
|
+
touchRoot(impactRoots, impactMemo, resolvePath(root), memoKey, impactSlots);
|
|
313
|
+
return flight;
|
|
314
|
+
}
|
|
315
|
+
// the slow, obviously-correct recompute — kept whole as the specification the cached path must equal
|
|
316
|
+
export async function projectSessionImpactUncached(root, options, base, head) {
|
|
317
|
+
const ancestry = await gitTry(['-C', root, 'merge-base', '--is-ancestor', base, head]);
|
|
318
|
+
if (!ancestry.ok) {
|
|
319
|
+
if (ancestry.failure === 'exit') {
|
|
320
|
+
throw new SessionImpactUnavailableError(`session impact base ${base.slice(0, 8)} is not an ancestor of head ${head.slice(0, 8)}; use the session merge-base as base`);
|
|
321
|
+
}
|
|
322
|
+
throw new SessionImpactUnavailableError(`session impact cannot verify base/head ancestry: ${ancestry.stderr.trim() || ancestry.failure}`);
|
|
323
|
+
}
|
|
324
|
+
// One immutable .spec tree read per distinct revision supplies BOTH spec relations and eval declarations.
|
|
325
|
+
// Build-local by design: the memo above owns reuse ACROSS builds, this map owns it within one.
|
|
326
|
+
const treeByRevision = new Map();
|
|
327
|
+
const readTree = (revision) => {
|
|
328
|
+
const cached = treeByRevision.get(revision);
|
|
329
|
+
if (cached)
|
|
330
|
+
return cached;
|
|
331
|
+
try {
|
|
332
|
+
const tree = treeTextFiles(root, revision, '.spec');
|
|
333
|
+
treeByRevision.set(revision, tree);
|
|
334
|
+
return tree;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
throw new SessionImpactUnavailableError(`session impact cannot read ${revision.slice(0, 8)} declarations: ${error?.message ?? String(error)}`);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const baseTree = readTree(base);
|
|
341
|
+
const headTree = options.overlay ? null : readTree(head);
|
|
342
|
+
const [baseSpecs, headSpecs] = await Promise.all([
|
|
343
|
+
loadSpecs(root, { tip: base, history: null, drift: null, snapshot: { tip: base, files: baseTree } }),
|
|
344
|
+
options.overlay
|
|
345
|
+
? Promise.resolve(options.overlay.specs)
|
|
346
|
+
: loadSpecs(root, { tip: head, history: null, drift: null, snapshot: { tip: head, files: headTree } }),
|
|
347
|
+
]);
|
|
348
|
+
const committedChanges = parseNameStatusChanges(await impactGit(root, ['-c', 'core.quotePath=false', 'diff', '--name-status', '-z', '-M', base, head], `diff ${base.slice(0, 8)}..${head.slice(0, 8)}`));
|
|
349
|
+
const committedPaths = new Set(committedChanges.flatMap((change) => [change.before, change.after]));
|
|
350
|
+
let didx = null;
|
|
351
|
+
if (committedPaths.size) {
|
|
352
|
+
try {
|
|
353
|
+
didx = await driftIndex(root, head);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
throw new SessionImpactUnavailableError(`session impact cannot derive the exact ${base.slice(0, 8)}..${head.slice(0, 8)} event window: ${error?.message ?? String(error)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const changedPaths = new Set([...committedPaths, ...(options.overlay?.paths ?? [])]);
|
|
360
|
+
const context = {
|
|
361
|
+
root,
|
|
362
|
+
base,
|
|
363
|
+
head,
|
|
364
|
+
committedPaths,
|
|
365
|
+
changedPaths,
|
|
366
|
+
overlay: options.overlay,
|
|
367
|
+
regs: extractors(root),
|
|
368
|
+
didx,
|
|
369
|
+
entryImpacts: new Map(),
|
|
370
|
+
windows: new Map(),
|
|
371
|
+
sources: new Map(),
|
|
372
|
+
units: new Map(),
|
|
373
|
+
};
|
|
374
|
+
const baseById = new Map(baseSpecs.map((spec) => [spec.id, spec]));
|
|
375
|
+
const headById = new Map(headSpecs.map((spec) => [spec.id, spec]));
|
|
376
|
+
const nodeIds = [...new Set([...baseById.keys(), ...headById.keys()])].sort();
|
|
377
|
+
if (options.overlay) {
|
|
378
|
+
for (const path of options.overlay.paths) {
|
|
379
|
+
if (!Object.hasOwn(options.overlay.files, path)
|
|
380
|
+
|| !Object.hasOwn(options.overlay.baseRanges, path)
|
|
381
|
+
|| !Object.hasOwn(options.overlay.ranges, path)) {
|
|
382
|
+
throw new SessionImpactUnavailableError(`session impact overlay '${options.overlay.revision}' is incomplete for '${path}'`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const scenariosAt = (side, spec) => {
|
|
387
|
+
const evalPath = join(dirname(spec.path), 'eval.md');
|
|
388
|
+
let source;
|
|
389
|
+
if (side === 'base')
|
|
390
|
+
source = baseTree.get(evalPath) ?? null;
|
|
391
|
+
else if (options.overlay) {
|
|
392
|
+
if (!Object.hasOwn(options.overlay.evalSources, evalPath)) {
|
|
393
|
+
throw new SessionImpactUnavailableError(`session impact overlay '${options.overlay.revision}' has no declaration snapshot for '${evalPath}'`);
|
|
394
|
+
}
|
|
395
|
+
source = options.overlay.evalSources[evalPath];
|
|
396
|
+
}
|
|
397
|
+
else
|
|
398
|
+
source = headTree.get(evalPath) ?? null;
|
|
399
|
+
if (source === null)
|
|
400
|
+
return [];
|
|
401
|
+
try {
|
|
402
|
+
return parseScenarios(source);
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
throw new SessionImpactUnavailableError(`session impact cannot parse ${side} declarations '${evalPath}': ${error?.message ?? String(error)}`);
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
const baseScenariosById = new Map(baseSpecs.map((spec) => [spec.id, scenariosAt('base', spec)]));
|
|
409
|
+
const headScenariosById = new Map(headSpecs.map((spec) => [spec.id, scenariosAt('head', spec)]));
|
|
410
|
+
const baseSelectorEntries = selectorEntriesForSnapshot(baseSpecs, baseScenariosById);
|
|
411
|
+
const headSelectorEntries = selectorEntriesForSnapshot(headSpecs, headScenariosById);
|
|
412
|
+
await primeSelectorSources(context, base, baseSelectorEntries.map((entry) => entry.path), 'base');
|
|
413
|
+
await primeSelectorSources(context, head, headSelectorEntries.map((entry) => entry.path), 'head');
|
|
414
|
+
await Promise.all([
|
|
415
|
+
...baseSelectorEntries.map((entry) => validateSelectorEntry(context, base, entry, 'base')),
|
|
416
|
+
...headSelectorEntries.map((entry) => validateSelectorEntry(context, head, entry, 'head')),
|
|
417
|
+
]);
|
|
418
|
+
const nodes = [];
|
|
419
|
+
for (const id of nodeIds) {
|
|
420
|
+
const baseSpec = baseById.get(id);
|
|
421
|
+
const headSpec = headById.get(id);
|
|
422
|
+
const causes = [];
|
|
423
|
+
const baseSpecPath = baseSpec?.path ?? '';
|
|
424
|
+
const headSpecPath = headSpec?.path ?? '';
|
|
425
|
+
const specPaths = [...new Set([baseSpecPath, headSpecPath].filter(Boolean))];
|
|
426
|
+
const specChanged = [...changedPaths].filter((path) => specPaths.some((claim) => codeClaims([claim], path)));
|
|
427
|
+
if (specChanged.length)
|
|
428
|
+
pushNodeCause(causes, 'spec', specChanged);
|
|
429
|
+
if (baseSpec?.relationProblems.length || headSpec?.relationProblems.length) {
|
|
430
|
+
throw new SessionImpactUnavailableError(`session impact node '${id}' has invalid relations: ${[
|
|
431
|
+
...(baseSpec?.relationProblems ?? []), ...(headSpec?.relationProblems ?? []),
|
|
432
|
+
].join('; ')}`);
|
|
433
|
+
}
|
|
434
|
+
const baseEvalPath = baseSpec ? join(dirname(baseSpec.path), 'eval.md') : '';
|
|
435
|
+
const headEvalPath = headSpec ? join(dirname(headSpec.path), 'eval.md') : '';
|
|
436
|
+
const baseScenarios = baseScenariosById.get(id) ?? [];
|
|
437
|
+
const headScenarios = headScenariosById.get(id) ?? [];
|
|
438
|
+
const evalPaths = [...new Set([baseEvalPath, headEvalPath].filter(Boolean))];
|
|
439
|
+
const evalChanged = [...changedPaths].filter((path) => evalPaths.some((claim) => codeClaims([claim], path)));
|
|
440
|
+
if (evalChanged.length)
|
|
441
|
+
pushNodeCause(causes, 'eval', evalChanged);
|
|
442
|
+
// no second validity gate here: relationProblems above already threw for either side, and it is the
|
|
443
|
+
// SAME parse — a re-parse of rows minted from those entries cannot surface a problem the loader did not.
|
|
444
|
+
const baseNodeCodeEntries = loadedRelationEntries(baseSpec, 'code');
|
|
445
|
+
const headNodeCodeEntries = loadedRelationEntries(headSpec, 'code');
|
|
446
|
+
const nodeCodeMoved = !!baseSpec && !!headSpec
|
|
447
|
+
&& JSON.stringify(baseNodeCodeEntries) !== JSON.stringify(headNodeCodeEntries);
|
|
448
|
+
const nodeCodeReads = nodeCodeMoved
|
|
449
|
+
? await Promise.all([
|
|
450
|
+
impactForEntries(context, baseNodeCodeEntries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
|
|
451
|
+
impactForEntries(context, headNodeCodeEntries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
|
|
452
|
+
])
|
|
453
|
+
: [await impactForEntries(context, headSpec ? headNodeCodeEntries : baseNodeCodeEntries, {
|
|
454
|
+
validateUnchanged: !baseSpec || !headSpec,
|
|
455
|
+
validationRevision: headSpec ? head : base,
|
|
456
|
+
validationSide: headSpec ? 'head' : 'base',
|
|
457
|
+
})];
|
|
458
|
+
const nodeCodeImpact = {
|
|
459
|
+
paths: [...new Set(nodeCodeReads.flatMap((read) => read.paths))].sort(),
|
|
460
|
+
selectorHits: nodeCodeReads.flatMap((read) => read.selectorHits),
|
|
461
|
+
};
|
|
462
|
+
if (nodeCodeImpact.paths.length)
|
|
463
|
+
pushNodeCause(causes, 'code', nodeCodeImpact.paths, [], nodeCodeImpact.selectorHits);
|
|
464
|
+
// Related declarations belong to their own owners. Combine their parsed DATA entries after validation;
|
|
465
|
+
// parsing one synthetic concatenated relation would turn two legitimate owners naming the same path into
|
|
466
|
+
// a fake duplicate/mixed-form schema error.
|
|
467
|
+
const relatedFor = (spec, scenarios) => [
|
|
468
|
+
...loadedRelationEntries(spec, 'related'),
|
|
469
|
+
...scenarios.flatMap((scenario) => (parsedRelation(scenario.related ?? [], 'related', `scenario '${id} · ${scenario.name}'`))),
|
|
470
|
+
];
|
|
471
|
+
const baseRelatedEntries = relatedFor(baseSpec, baseScenarios);
|
|
472
|
+
const headRelatedEntries = relatedFor(headSpec, headScenarios);
|
|
473
|
+
const relatedMoved = !!baseSpec && !!headSpec
|
|
474
|
+
&& JSON.stringify(baseRelatedEntries) !== JSON.stringify(headRelatedEntries);
|
|
475
|
+
const relatedReads = relatedMoved
|
|
476
|
+
? await Promise.all([
|
|
477
|
+
impactForEntries(context, baseRelatedEntries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
|
|
478
|
+
impactForEntries(context, headRelatedEntries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
|
|
479
|
+
])
|
|
480
|
+
: [await impactForEntries(context, headSpec ? headRelatedEntries : baseRelatedEntries, {
|
|
481
|
+
validateUnchanged: !baseSpec || !headSpec,
|
|
482
|
+
validationRevision: headSpec ? head : base,
|
|
483
|
+
validationSide: headSpec ? 'head' : 'base',
|
|
484
|
+
})];
|
|
485
|
+
const relatedImpact = {
|
|
486
|
+
paths: [...new Set(relatedReads.flatMap((read) => read.paths))].sort(),
|
|
487
|
+
selectorHits: relatedReads.flatMap((read) => read.selectorHits),
|
|
488
|
+
};
|
|
489
|
+
if (relatedImpact.paths.length)
|
|
490
|
+
pushNodeCause(causes, 'related', relatedImpact.paths, [], relatedImpact.selectorHits);
|
|
491
|
+
const baseByName = new Map(baseScenarios.map((scenario) => [scenario.name, scenario]));
|
|
492
|
+
const headByName = new Map(headScenarios.map((scenario) => [scenario.name, scenario]));
|
|
493
|
+
const scenarioNames = [...new Set([...baseByName.keys(), ...headByName.keys()])].sort();
|
|
494
|
+
const measured = new Set(options.measurements?.[id] ?? []);
|
|
495
|
+
const scenarios = [];
|
|
496
|
+
for (const name of scenarioNames) {
|
|
497
|
+
const before = baseByName.get(name);
|
|
498
|
+
const after = headByName.get(name);
|
|
499
|
+
const state = !before ? 'added' : !after ? 'removed' : 'present';
|
|
500
|
+
const baseScenarioHash = before ? scenarioHash(before) : null;
|
|
501
|
+
const headScenarioHash = after ? scenarioHash(after) : null;
|
|
502
|
+
const semantic = !before || !after || baseScenarioHash !== headScenarioHash;
|
|
503
|
+
const baseAxis = before ? scenarioCodeAxis(before.code, loadedRelationEntries(baseSpec, 'code')) : { entries: [], paths: [], problems: [] };
|
|
504
|
+
const headAxis = after ? scenarioCodeAxis(after.code, loadedRelationEntries(headSpec, 'code')) : { entries: [], paths: [], problems: [] };
|
|
505
|
+
if (baseAxis.problems.length || headAxis.problems.length) {
|
|
506
|
+
throw new SessionImpactUnavailableError(`session impact scenario '${id} · ${name}' has invalid code: ${[
|
|
507
|
+
...baseAxis.problems, ...headAxis.problems,
|
|
508
|
+
].join('; ')}`);
|
|
509
|
+
}
|
|
510
|
+
// Metadata is the RESOLVED measurement pointer, not only bytes authored in this scenario block. A
|
|
511
|
+
// declaration without own code inherits the node relation at each revision, so node #alpha -> #beta is
|
|
512
|
+
// a metadata move for that scenario even though its eval.md block did not change.
|
|
513
|
+
const metadata = !!before && !!after
|
|
514
|
+
&& scenarioMetadata(before, baseAxis.entries) !== scenarioMetadata(after, headAxis.entries);
|
|
515
|
+
const effectiveCodeMoved = JSON.stringify(baseAxis.entries) !== JSON.stringify(headAxis.entries);
|
|
516
|
+
const kind = !before ? 'added' : !after ? 'removed'
|
|
517
|
+
: semantic || metadata ? 'modified' : 'unchanged';
|
|
518
|
+
const codeReads = before && after && effectiveCodeMoved
|
|
519
|
+
? await Promise.all([
|
|
520
|
+
impactForEntries(context, baseAxis.entries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
|
|
521
|
+
impactForEntries(context, headAxis.entries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
|
|
522
|
+
])
|
|
523
|
+
: [await impactForEntries(context, (after ? headAxis : baseAxis).entries, {
|
|
524
|
+
validateUnchanged: kind === 'added' || kind === 'removed',
|
|
525
|
+
validationRevision: after ? head : base,
|
|
526
|
+
validationSide: after ? 'head' : 'base',
|
|
527
|
+
})];
|
|
528
|
+
const code = {
|
|
529
|
+
paths: [...new Set(codeReads.flatMap((read) => read.paths))].sort(),
|
|
530
|
+
selectorHits: codeReads.flatMap((read) => read.selectorHits),
|
|
531
|
+
};
|
|
532
|
+
const impact = [];
|
|
533
|
+
if (code.paths.length)
|
|
534
|
+
impact.push('code');
|
|
535
|
+
if (semantic)
|
|
536
|
+
impact.push('contract');
|
|
537
|
+
if (measured.has(name))
|
|
538
|
+
impact.push('measurement');
|
|
539
|
+
if (code.paths.length)
|
|
540
|
+
pushNodeCause(causes, 'code', code.paths, [name], code.selectorHits);
|
|
541
|
+
if (semantic)
|
|
542
|
+
pushNodeCause(causes, 'contract', evalChanged, [name]);
|
|
543
|
+
if (measured.has(name))
|
|
544
|
+
pushNodeCause(causes, 'measurement', [], [name]);
|
|
545
|
+
scenarios.push({
|
|
546
|
+
name,
|
|
547
|
+
state,
|
|
548
|
+
impact,
|
|
549
|
+
baseEffectiveCode: baseAxis.entries,
|
|
550
|
+
headEffectiveCode: headAxis.entries,
|
|
551
|
+
selectorHits: code.selectorHits,
|
|
552
|
+
baseScenarioHash,
|
|
553
|
+
headScenarioHash,
|
|
554
|
+
delta: { kind, semantic, metadata, metadataOnly: metadata && !semantic },
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
if (causes.length || scenarios.some((scenario) => scenario.delta.kind !== 'unchanged' || scenario.impact.length)) {
|
|
558
|
+
nodes.push({ id, causes, scenarios });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const [baseAfter, headAfter] = await Promise.all([
|
|
562
|
+
impactCommit(root, options.base),
|
|
563
|
+
impactCommit(root, options.head),
|
|
564
|
+
]);
|
|
565
|
+
if (baseAfter !== base || headAfter !== head) {
|
|
566
|
+
throw new SessionImpactRevisionMovedError(`session impact revisions moved while projecting: base ${base.slice(0, 8)} -> ${baseAfter.slice(0, 8)}, head ${head.slice(0, 8)} -> ${headAfter.slice(0, 8)}; retry the projection`);
|
|
567
|
+
}
|
|
568
|
+
const revision = createHash('sha256').update(JSON.stringify({ base, head, overlay: options.overlay?.revision ?? null, nodes })).digest('hex');
|
|
569
|
+
return { base, head, revision, nodes };
|
|
570
|
+
}
|
|
571
|
+
export function scopeSessionScenarioRows(current, scenarioInfo, evals, projected) {
|
|
572
|
+
const impactByName = new Map(projected
|
|
573
|
+
.filter((scenario) => scenario.state !== 'removed' && scenario.impact.length)
|
|
574
|
+
.map((scenario) => [scenario.name, scenario.impact]));
|
|
575
|
+
const infoByName = new Map(scenarioInfo.map((scenario) => [scenario.name, scenario]));
|
|
576
|
+
const scenarios = current.filter((scenario) => impactByName.has(scenario.name)).map((scenario) => ({
|
|
577
|
+
...(infoByName.get(scenario.name) ?? {
|
|
578
|
+
name: scenario.name, expected: scenario.expected,
|
|
579
|
+
...(scenario.tags?.length ? { tags: scenario.tags } : {}),
|
|
580
|
+
...(scenario.test ? { test: scenario.test } : {}),
|
|
581
|
+
...(scenario.code?.length ? { code: scenario.code } : {}),
|
|
582
|
+
}),
|
|
583
|
+
impact: impactByName.get(scenario.name),
|
|
584
|
+
}));
|
|
585
|
+
const names = new Set(scenarios.map((scenario) => scenario.name));
|
|
586
|
+
return { scenarios, evals: evals.filter((reading) => names.has(reading.scenario)) };
|
|
587
|
+
}
|
|
588
|
+
export function completeExportNodeIds(changedNodeIds, scopedNodeIds) {
|
|
589
|
+
return [...new Set([...changedNodeIds, ...scopedNodeIds])];
|
|
590
|
+
}
|
|
591
|
+
// null when no session has that id (route → 404).
|
|
592
|
+
export async function buildExportModel(id) {
|
|
593
|
+
const identity = sessionEvalHost().reviewIdentity(id);
|
|
594
|
+
if (!identity)
|
|
595
|
+
return null;
|
|
596
|
+
// root EVERYTHING at the SESSION's worktree — readings, freshness, AND the spec tree itself. The
|
|
597
|
+
// worktree's .spec is the branch's pending proposal ([[source-of-truth]]): a node the branch ADDED
|
|
598
|
+
// exists only there, so a trunk-rooted loadSpecs would silently drop it from the model (the 0fca
|
|
599
|
+
// family's node-existence layer). No worktree → the backend checkout, unchanged.
|
|
600
|
+
const wtPath = worktreePathForBranch(identity.branch);
|
|
601
|
+
const ctxRoot = wtPath ?? repoRoot();
|
|
602
|
+
return withEventLedgerDemand(ctxRoot, async () => {
|
|
603
|
+
const payload = await sessionEvalHost().reviewPayload(id);
|
|
604
|
+
return payload
|
|
605
|
+
? withEventLedgerBuild(ctxRoot, () => buildExportModelInLedger(id, payload, wtPath, ctxRoot))
|
|
606
|
+
: null;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
async function buildExportModelInLedger(id, payload, wtPath, ctxRoot) {
|
|
610
|
+
const specs = await loadSpecs(ctxRoot);
|
|
611
|
+
const specById = new Map(specs.map((s) => [s.id, s]));
|
|
612
|
+
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)]);
|
|
613
|
+
const ctx = await evalContext(ctxRoot, specs, didx, hidx);
|
|
614
|
+
// enrich each changed file with its unified diff + full before/after content (derived from the session
|
|
615
|
+
// worktree at the merge-base ↔ HEAD), so the proof can drill summary → diff → whole-file comparison with no
|
|
616
|
+
// extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
|
|
617
|
+
// row but say so (omitted), never silently blank.
|
|
618
|
+
const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath);
|
|
619
|
+
const base = impact.base;
|
|
620
|
+
const scopedNodes = await sessionScopeNodes(id, ctx, impact, shas);
|
|
621
|
+
const enriched = new Map();
|
|
622
|
+
let budget = MAX_ENRICHED_FILES;
|
|
623
|
+
for (const f of payload.diff) {
|
|
624
|
+
if (wtPath && base && budget > 0) {
|
|
625
|
+
enriched.set(f.path, await enrichFile(wtPath, base, f));
|
|
626
|
+
budget--;
|
|
627
|
+
}
|
|
628
|
+
else
|
|
629
|
+
enriched.set(f.path, { ...f, patch: '', oldText: null, newText: null, truncated: false, omitted: !!(wtPath && base) });
|
|
630
|
+
}
|
|
631
|
+
// group the session's real changes (merge-base diff) by the spec node that owns each file.
|
|
632
|
+
const byNode = new Map();
|
|
633
|
+
const otherFiles = [];
|
|
634
|
+
for (const f of payload.diff) {
|
|
635
|
+
const nid = nodeForFile(f.path, specs, payload.node);
|
|
636
|
+
const pf = enriched.get(f.path);
|
|
637
|
+
if (nid) {
|
|
638
|
+
const arr = byNode.get(nid) ?? [];
|
|
639
|
+
arr.push(pf);
|
|
640
|
+
byNode.set(nid, arr);
|
|
641
|
+
}
|
|
642
|
+
else
|
|
643
|
+
otherFiles.push(pf);
|
|
644
|
+
}
|
|
645
|
+
const nodes = [];
|
|
646
|
+
let passed = 0, total = 0, fresh = 0;
|
|
647
|
+
const scopedById = new Map(scopedNodes.map((node) => [node.id, node]));
|
|
648
|
+
const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]));
|
|
649
|
+
const nodeIds = completeExportNodeIds(byNode.keys(), scopedById.keys());
|
|
650
|
+
for (const id of nodeIds) {
|
|
651
|
+
const scoped = scopedById.get(id);
|
|
652
|
+
const spec = specById.get(id);
|
|
653
|
+
const files = byNode.get(id) ?? [];
|
|
654
|
+
const projection = scoped ? scopedScenarioReadings(scoped.scenarios, scoped.evals) : { latest: [], unmeasured: [] };
|
|
655
|
+
const impactByName = new Map(scoped?.scenarios.map((scenario) => [scenario.name, scenario.impact]) ?? []);
|
|
656
|
+
const readings = await Promise.all(projection.latest.map((reading) => toExportReading(reading, impactByName.get(reading.scenario) ?? [])));
|
|
657
|
+
const unmeasured = projection.unmeasured.map((scenario) => ({
|
|
658
|
+
scenario: scenario.name,
|
|
659
|
+
expected: scenario.expected,
|
|
660
|
+
impact: scenario.impact,
|
|
661
|
+
}));
|
|
662
|
+
total += scoped?.scenarios.length ?? 0;
|
|
663
|
+
for (const r of projection.latest) {
|
|
664
|
+
if (r.fresh)
|
|
665
|
+
fresh++;
|
|
666
|
+
if (r.fresh && r.verdict?.status === 'pass')
|
|
667
|
+
passed++;
|
|
668
|
+
}
|
|
669
|
+
nodes.push({
|
|
670
|
+
id,
|
|
671
|
+
title: scoped?.title ?? spec.title,
|
|
672
|
+
hue: scoped?.hue ?? spec.hue,
|
|
673
|
+
desc: scoped?.desc ?? spec.desc,
|
|
674
|
+
files,
|
|
675
|
+
additions: files.reduce((a, f) => a + f.additions, 0),
|
|
676
|
+
deletions: files.reduce((a, f) => a + f.deletions, 0),
|
|
677
|
+
hasEvalFile: scoped?.hasEvalFile ?? evalById.has(id),
|
|
678
|
+
uncoveredFrontend: scoped?.uncoveredFrontend ?? false,
|
|
679
|
+
affectedScenarios: scoped?.scenarios.length ?? 0,
|
|
680
|
+
score: nodeScore(scoped?.hasEvalFile ?? evalById.has(id), projection.latest, scoped?.scenarios.length ?? 0),
|
|
681
|
+
readings,
|
|
682
|
+
unmeasured,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
// affected scenarios first, then by amount changed — review work leads while every changed file remains.
|
|
686
|
+
nodes.sort((a, b) => (b.affectedScenarios - a.affectedScenarios) || ((b.additions + b.deletions) - (a.additions + a.deletions)));
|
|
687
|
+
// the headline is DERIVED — the node the session is on, else its branch, else the id. No agent claim.
|
|
688
|
+
const primary = payload.node && specById.has(payload.node) ? specById.get(payload.node).title : null;
|
|
689
|
+
const title = primary || payload.node || payload.branch || id.slice(0, 8);
|
|
690
|
+
return {
|
|
691
|
+
id,
|
|
692
|
+
node: payload.node,
|
|
693
|
+
branch: payload.branch,
|
|
694
|
+
title,
|
|
695
|
+
generatedAt: new Date().toISOString(),
|
|
696
|
+
ahead: payload.ahead,
|
|
697
|
+
dirtyNonRuntime: payload.dirtyNonRuntime,
|
|
698
|
+
gates: gateRows(payload),
|
|
699
|
+
score: { passed, total, fresh },
|
|
700
|
+
impact,
|
|
701
|
+
nodes,
|
|
702
|
+
otherFiles,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
// the gate checklist, derived from the cockpit payload's gates (the SAME numbers `spex session review` prints).
|
|
706
|
+
function gateRows(p) {
|
|
707
|
+
const g = p.gates;
|
|
708
|
+
return [
|
|
709
|
+
{ label: 'lint', ok: g.lint.errorCount === 0, detail: `${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)` },
|
|
710
|
+
{ label: 'merge', ok: !g.conflictsWithMain, detail: g.conflictsWithMain ? 'conflicts with main' : 'no conflict' },
|
|
711
|
+
{ label: 'ahead', ok: p.ahead > 0, detail: `${p.ahead} commit(s) ahead of main` },
|
|
712
|
+
{ label: 'committed', ok: p.dirtyNonRuntime === 0, detail: p.dirtyNonRuntime === 0 ? 'nothing uncommitted' : `${p.dirtyNonRuntime} uncommitted file(s)` },
|
|
713
|
+
];
|
|
714
|
+
}
|
|
715
|
+
// gitA returns '' for a missing path — exactly the added/deleted (and best-effort rename) side; each side capped to MAX_FILE_BYTES
|
|
716
|
+
const MAX_ENRICHED_FILES = 60;
|
|
717
|
+
const MAX_FILE_BYTES = 200_000;
|
|
718
|
+
async function enrichFile(wtPath, base, f) {
|
|
719
|
+
const run = (args) => gitA(['-C', wtPath, '-c', 'core.quotePath=false', ...args]);
|
|
720
|
+
const paths = f.oldPath ? [f.oldPath, f.path] : [f.path];
|
|
721
|
+
const [patchRaw, oldRaw, newRaw] = await Promise.all([
|
|
722
|
+
run(['diff', '-M', `${base}..HEAD`, '--', ...paths]),
|
|
723
|
+
f.status === 'added' ? Promise.resolve('') : run(['show', `${base}:${f.oldPath ?? f.path}`]),
|
|
724
|
+
f.status === 'deleted' ? Promise.resolve('') : run(['show', `HEAD:${f.path}`]),
|
|
725
|
+
]);
|
|
726
|
+
const cap = (s) => (s.length > MAX_FILE_BYTES ? { text: s.slice(0, MAX_FILE_BYTES), cut: true } : { text: s, cut: false });
|
|
727
|
+
const p = cap(patchRaw), o = cap(oldRaw), nw = cap(newRaw);
|
|
728
|
+
return {
|
|
729
|
+
...f,
|
|
730
|
+
patch: p.text,
|
|
731
|
+
oldText: f.status === 'added' ? null : o.text,
|
|
732
|
+
newText: f.status === 'deleted' ? null : nw.text,
|
|
733
|
+
truncated: p.cut || o.cut || nw.cut,
|
|
734
|
+
omitted: false,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
// resolve a reading's evidence to inline bytes so the proof is a self-contained file: an image → a base64
|
|
738
|
+
// data-URI; a transcript → its text; the cache miss / no-capture states pass through. the eval cache owns the bytes
|
|
739
|
+
// (the content-addressed cache) — the proof only asks for them by hash.
|
|
740
|
+
async function toExportReading(r, impact) {
|
|
741
|
+
const base = {
|
|
742
|
+
scenario: r.scenario, expected: r.expected, impact, verdict: r.verdict, fresh: r.fresh,
|
|
743
|
+
staleAxes: r.staleAxes, score: readingScore(r), evaluator: r.evaluator, ts: r.ts,
|
|
744
|
+
};
|
|
745
|
+
if (r.blobState !== 'present' || !r.blob)
|
|
746
|
+
return { ...base, evidence: { kind: r.blobState === 'miss' ? 'miss' : 'none' } };
|
|
747
|
+
const blob = readBlobByHash(r.blob);
|
|
748
|
+
if (!blob.ok)
|
|
749
|
+
return { ...base, evidence: { kind: 'miss' } };
|
|
750
|
+
if (blob.mime.startsWith('image/'))
|
|
751
|
+
return { ...base, evidence: { kind: 'image', dataUri: `data:${blob.mime};base64,${blob.bytes.toString('base64')}` } };
|
|
752
|
+
if (blob.mime.startsWith('video/'))
|
|
753
|
+
return { ...base, evidence: { kind: 'video', dataUri: `data:${blob.mime};base64,${blob.bytes.toString('base64')}` } };
|
|
754
|
+
// structured data ([[evidence-kind-taxonomy]]): pretty-print so the self-contained proof shows a
|
|
755
|
+
// validatable data block, not one flat line; invalid JSON falls back to the raw bytes rather than hiding.
|
|
756
|
+
if (blob.mime.startsWith('application/json')) {
|
|
757
|
+
let text = blob.bytes.toString('utf8');
|
|
758
|
+
try {
|
|
759
|
+
text = JSON.stringify(JSON.parse(text), null, 2);
|
|
760
|
+
}
|
|
761
|
+
catch { /* keep raw — invalid JSON still shows */ }
|
|
762
|
+
return { ...base, evidence: { kind: 'data', text } };
|
|
763
|
+
}
|
|
764
|
+
return { ...base, evidence: { kind: 'transcript', text: blob.bytes.toString('utf8') } };
|
|
765
|
+
}
|
|
766
|
+
// the latest reading per scenario from a newest-first timeline (first seen wins) — the eval tab / score
|
|
767
|
+
// badge convention, so the proof shows each scenario's CURRENT loss, not its whole history.
|
|
768
|
+
function latestPerScenario(readings) {
|
|
769
|
+
const seen = new Set();
|
|
770
|
+
const out = [];
|
|
771
|
+
for (const r of readings)
|
|
772
|
+
if (!seen.has(r.scenario)) {
|
|
773
|
+
seen.add(r.scenario);
|
|
774
|
+
out.push(r);
|
|
775
|
+
}
|
|
776
|
+
return out;
|
|
777
|
+
}
|
|
778
|
+
export function scopedScenarioReadings(scenarios, readings) {
|
|
779
|
+
const latestByName = new Map(latestPerScenario(readings).map((reading) => [reading.scenario, reading]));
|
|
780
|
+
const latest = [];
|
|
781
|
+
const unmeasured = [];
|
|
782
|
+
for (const scenario of scenarios) {
|
|
783
|
+
const reading = latestByName.get(scenario.name);
|
|
784
|
+
if (reading)
|
|
785
|
+
latest.push(reading);
|
|
786
|
+
else
|
|
787
|
+
unmeasured.push(scenario);
|
|
788
|
+
}
|
|
789
|
+
return { latest, unmeasured };
|
|
790
|
+
}
|
|
791
|
+
// the DECLARED scenarios' latest reading — the SAME declared-bounded computation every other eval face reads
|
|
792
|
+
// (score.jsx's scenarioStates for the node badge and the eval tab). A reading whose scenario is no longer in
|
|
793
|
+
// eval.md is residual: the append-only sidecar still carries it, but it is not current loss, so it must not
|
|
794
|
+
// become a proof reading card, a passed/total tick, or a node score. The proof was the one face driven by the
|
|
795
|
+
// readings that happen to exist rather than the scenarios that are declared — this bounds it like the rest, so
|
|
796
|
+
// a retired scenario's stale reading can't make the proof disagree with the dashboard (phantom card, off ribbon).
|
|
797
|
+
export function declaredLatest(tl) {
|
|
798
|
+
const declared = new Set(tl.scenarios.map((s) => s.name));
|
|
799
|
+
return latestPerScenario(tl.readings).filter((r) => declared.has(r.scenario));
|
|
800
|
+
}
|
|
801
|
+
// ---- scoring (mirrors the dashboard's score.jsx vocabulary, on the EvalEntry shape) ----
|
|
802
|
+
const verdictMark = (r) => r.verdict?.status === 'pass' ? 'check' : r.verdict?.status === 'fail' ? 'cross' : null;
|
|
803
|
+
function readingScore(r) {
|
|
804
|
+
const m = verdictMark(r);
|
|
805
|
+
if (!m)
|
|
806
|
+
return 'empty';
|
|
807
|
+
if (!r.fresh)
|
|
808
|
+
return m === 'cross' ? 'staleFail' : 'stalePass';
|
|
809
|
+
return m === 'cross' ? 'fail' : 'pass';
|
|
810
|
+
}
|
|
811
|
+
// worst-first aggregate over the latest reading per scenario: any fresh fail → fail; else any stale → grey
|
|
812
|
+
// (✗ if any stale last-failed, else ✓); else any unscored scenario → empty; else every scenario fresh-passes.
|
|
813
|
+
export function nodeScore(hasEvalFile, latest, affectedScenarios = latest.length) {
|
|
814
|
+
if (!hasEvalFile)
|
|
815
|
+
return null;
|
|
816
|
+
if (!affectedScenarios || !latest.length)
|
|
817
|
+
return 'empty';
|
|
818
|
+
if (latest.some((r) => r.fresh && verdictMark(r) === 'cross'))
|
|
819
|
+
return 'fail';
|
|
820
|
+
const stale = latest.filter((r) => !r.fresh && verdictMark(r));
|
|
821
|
+
if (stale.length)
|
|
822
|
+
return stale.some((r) => verdictMark(r) === 'cross') ? 'staleFail' : 'stalePass';
|
|
823
|
+
if (latest.length < affectedScenarios || latest.some((r) => !verdictMark(r)))
|
|
824
|
+
return 'empty';
|
|
825
|
+
return 'pass';
|
|
826
|
+
}
|
|
827
|
+
// ---- file → node mapping ----
|
|
828
|
+
// which spec node owns a changed file: a file inside a node's directory (its spec.md / eval.md / sidecar)
|
|
829
|
+
// belongs to the NEAREST such node; otherwise the node whose governed `code:` claims it (exact path,
|
|
830
|
+
// directory prefix, or `*` glob — the same matching `spex eval lint --changed` uses). A shared file is
|
|
831
|
+
// governed by MANY nodes (ordinary composition); when the session has a primary node that also governs it,
|
|
832
|
+
// attribute it THERE so a node/<id> session's stake in cli.ts/index.ts groups under its own node, not
|
|
833
|
+
// whichever sibling sorts first.
|
|
834
|
+
// null = unclaimed.
|
|
835
|
+
function nodeForFile(file, specs, primary) {
|
|
836
|
+
let best = null, bestLen = -1;
|
|
837
|
+
for (const s of specs) {
|
|
838
|
+
const dir = dirname(s.path);
|
|
839
|
+
if ((file === dir || file.startsWith(dir + '/')) && dir.length > bestLen) {
|
|
840
|
+
best = s.id;
|
|
841
|
+
bestLen = dir.length;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (best)
|
|
845
|
+
return best;
|
|
846
|
+
if (primary) {
|
|
847
|
+
const ps = specs.find((s) => s.id === primary);
|
|
848
|
+
if (ps && codeClaims(ps.code, file))
|
|
849
|
+
return primary;
|
|
850
|
+
}
|
|
851
|
+
for (const s of specs)
|
|
852
|
+
if (codeClaims(s.code, file))
|
|
853
|
+
return s.id;
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
function codeClaims(code, file) {
|
|
857
|
+
return code.some((claim) => relationClaimsPath(claim, file));
|
|
858
|
+
}
|
|
859
|
+
// ---- worktree resolution (no sessions.ts edit: read git's own worktree list) ----
|
|
860
|
+
// the worktree path whose checked-out branch matches — so the eval context is rooted at the SESSION's
|
|
861
|
+
// worktree (its readings + git freshness), not the backend's checkout. `git worktree list --porcelain` emits
|
|
862
|
+
// `worktree <path>` then `branch refs/heads/<b>`.
|
|
863
|
+
function worktreePathForBranch(branch) {
|
|
864
|
+
if (!branch)
|
|
865
|
+
return null;
|
|
866
|
+
let out = '';
|
|
867
|
+
try {
|
|
868
|
+
out = git(['worktree', 'list', '--porcelain']);
|
|
869
|
+
}
|
|
870
|
+
catch {
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
let curPath = null;
|
|
874
|
+
for (const line of out.split('\n')) {
|
|
875
|
+
if (line.startsWith('worktree '))
|
|
876
|
+
curPath = line.slice('worktree '.length);
|
|
877
|
+
else if (line.startsWith('branch ') && line.slice('branch '.length) === `refs/heads/${branch}`)
|
|
878
|
+
return curPath;
|
|
879
|
+
}
|
|
880
|
+
return null;
|
|
881
|
+
}
|
|
882
|
+
export function parsePorcelainPaths(out) {
|
|
883
|
+
const paths = new Set();
|
|
884
|
+
const records = out.split('\0');
|
|
885
|
+
for (let i = 0; i < records.length; i++) {
|
|
886
|
+
const record = records[i];
|
|
887
|
+
if (!record)
|
|
888
|
+
continue;
|
|
889
|
+
const status = record.slice(0, 2);
|
|
890
|
+
const path = record.slice(3);
|
|
891
|
+
if (path)
|
|
892
|
+
paths.add(path);
|
|
893
|
+
if ((status.includes('R') || status.includes('C')) && records[i + 1])
|
|
894
|
+
paths.add(records[++i]);
|
|
895
|
+
}
|
|
896
|
+
return paths;
|
|
897
|
+
}
|
|
898
|
+
async function worktreeDirtyState(wtPath) {
|
|
899
|
+
const out = await impactGit(wtPath, ['-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--untracked-files=all'], 'snapshot worktree status');
|
|
900
|
+
return { paths: parsePorcelainPaths(out), status: out };
|
|
901
|
+
}
|
|
902
|
+
// Capture one immutable description of the live worktree overlay. One rename-aware change set supplies both
|
|
903
|
+
// path images; untracked files are wholly new result ranges. The canonical anchor parser
|
|
904
|
+
// turns those patches into ranges, and projectSessionImpact consumes the snapshot beside base..HEAD. The
|
|
905
|
+
// outer session content double-read fences concurrent edits before this result can publish.
|
|
906
|
+
export async function sessionImpactOverlay(wtPath, dirty) {
|
|
907
|
+
if (!dirty.paths.size)
|
|
908
|
+
return undefined;
|
|
909
|
+
const untracked = new Set(untrackedPaths(dirty.status));
|
|
910
|
+
const paths = [...dirty.paths].sort();
|
|
911
|
+
const baseRanges = {};
|
|
912
|
+
const ranges = {};
|
|
913
|
+
const files = {};
|
|
914
|
+
const fingerprints = [];
|
|
915
|
+
const bytesByPath = new Map();
|
|
916
|
+
for (const path of paths) {
|
|
917
|
+
let bytes = null;
|
|
918
|
+
try {
|
|
919
|
+
bytes = await readFile(join(wtPath, path));
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
if (error?.code !== 'ENOENT')
|
|
923
|
+
throw new SessionImpactUnavailableError(`session impact cannot snapshot '${path}': ${error?.message ?? String(error)}`);
|
|
924
|
+
}
|
|
925
|
+
bytesByPath.set(path, bytes);
|
|
926
|
+
files[path] = bytes?.toString('utf8') ?? null;
|
|
927
|
+
baseRanges[path] = [];
|
|
928
|
+
ranges[path] = [];
|
|
929
|
+
fingerprints.push(`${path}\0${bytes ? createHash('sha256').update(bytes).digest('hex') : '<gone>'}`);
|
|
930
|
+
}
|
|
931
|
+
const indexDir = await mkdtemp(join(tmpdir(), 'spex-impact-index-'));
|
|
932
|
+
const indexFile = join(indexDir, 'index');
|
|
933
|
+
try {
|
|
934
|
+
await impactIndexGit(wtPath, indexFile, ['read-tree', 'HEAD'], 'seed the isolated worktree index');
|
|
935
|
+
await impactIndexGit(wtPath, indexFile, ['add', '-A', '--', ...paths], 'snapshot dirty paths in the isolated worktree index');
|
|
936
|
+
const trackedChanges = parseNameStatusChanges(await impactIndexGit(wtPath, indexFile, ['-c', 'core.quotePath=false', 'diff', '--cached', 'HEAD', '--name-status', '-z', '-M'], 'snapshot worktree change identities'));
|
|
937
|
+
for (const change of trackedChanges) {
|
|
938
|
+
if (!Object.hasOwn(baseRanges, change.before) || !Object.hasOwn(ranges, change.after))
|
|
939
|
+
throw new SessionImpactUnavailableError(`session impact dirty snapshot omitted '${change.before}' -> '${change.after}'`);
|
|
940
|
+
const patch = await impactIndexGit(wtPath, indexFile, ['-c', 'core.quotePath=false', 'diff', '--cached', 'HEAD', '--unified=0', '--no-ext-diff', '-M', '--', ...new Set([change.before, change.after])], `snapshot worktree patch '${change.before}' -> '${change.after}'`);
|
|
941
|
+
baseRanges[change.before] = diffHunkRanges(patch, 'old');
|
|
942
|
+
ranges[change.after] = diffHunkRanges(patch, 'new');
|
|
943
|
+
fingerprints.push(`${change.status}\0${change.before}\0${change.after}\0${createHash('sha256').update(patch).digest('hex')}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
finally {
|
|
947
|
+
await rm(indexDir, { recursive: true, force: true });
|
|
948
|
+
}
|
|
949
|
+
for (const path of untracked)
|
|
950
|
+
fingerprints.push(`untracked\0${path}\0${bytesByPath.get(path)?.length ?? 0}`);
|
|
951
|
+
const loaded = await loadSpecs(wtPath, { history: null, drift: null });
|
|
952
|
+
const specs = loaded.map((spec) => ({
|
|
953
|
+
id: spec.id,
|
|
954
|
+
path: spec.path,
|
|
955
|
+
code: spec.code,
|
|
956
|
+
codeEntries: spec.codeEntries,
|
|
957
|
+
codeScoped: spec.codeScoped,
|
|
958
|
+
related: spec.related,
|
|
959
|
+
relatedEntries: spec.relatedEntries,
|
|
960
|
+
relatedScoped: spec.relatedScoped,
|
|
961
|
+
relationProblems: spec.relationProblems,
|
|
962
|
+
}));
|
|
963
|
+
const evalSources = {};
|
|
964
|
+
for (const spec of specs) {
|
|
965
|
+
const evalPath = join(dirname(spec.path), 'eval.md');
|
|
966
|
+
try {
|
|
967
|
+
evalSources[evalPath] = await readFile(join(wtPath, evalPath), 'utf8');
|
|
968
|
+
}
|
|
969
|
+
catch (error) {
|
|
970
|
+
if (error?.code === 'ENOENT')
|
|
971
|
+
evalSources[evalPath] = null;
|
|
972
|
+
else
|
|
973
|
+
throw new SessionImpactUnavailableError(`session impact cannot snapshot '${evalPath}': ${error?.message ?? String(error)}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const revision = createHash('sha256')
|
|
977
|
+
.update([dirty.status, ...fingerprints, JSON.stringify(specs), JSON.stringify(evalSources)].join('\0'))
|
|
978
|
+
.digest('hex');
|
|
979
|
+
return { revision, paths, baseRanges, ranges, specs, evalSources, files };
|
|
980
|
+
}
|
|
981
|
+
// ---- the renderer ----
|
|
982
|
+
// escape interpolated text for HTML (the proof inlines derived data — file paths, scenarios, expected — so
|
|
983
|
+
// every value is escaped; there is no agent-authored markdown to render).
|
|
984
|
+
function esc(s) {
|
|
985
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
986
|
+
}
|
|
987
|
+
const SCORE_GLYPH = { pass: '✓', fail: '✗', stalePass: '✓', staleFail: '✗', empty: '' };
|
|
988
|
+
function scoreBadge(state, title) {
|
|
989
|
+
if (!state)
|
|
990
|
+
return '';
|
|
991
|
+
return `<span class="score ${state}" title="${esc(title ?? state)}">${SCORE_GLYPH[state] ?? ''}</span>`;
|
|
992
|
+
}
|
|
993
|
+
function verdictBadge(v) {
|
|
994
|
+
if (!v)
|
|
995
|
+
return `<span class="verdict legacy">legacy</span>`;
|
|
996
|
+
if (v.status === 'pass')
|
|
997
|
+
return `<span class="verdict pass">✓ pass</span>`;
|
|
998
|
+
if (v.status === 'fail')
|
|
999
|
+
return `<span class="verdict fail">✗ fail</span>`;
|
|
1000
|
+
return `<span class="verdict note" title="${esc(v.note ?? '')}">≈ note</span>`;
|
|
1001
|
+
}
|
|
1002
|
+
function renderReading(r) {
|
|
1003
|
+
const ev = r.evidence;
|
|
1004
|
+
const body = ev.kind === 'image' ? `<img class="shot" src="${ev.dataUri}" alt="${esc(r.scenario)}">`
|
|
1005
|
+
: ev.kind === 'video' ? `<video class="shot" src="${ev.dataUri}" controls preload="metadata"></video>`
|
|
1006
|
+
: ev.kind === 'transcript' ? `<pre class="transcript">${esc(ev.text)}</pre>`
|
|
1007
|
+
: ev.kind === 'data' ? `<pre class="transcript data">${esc(ev.text)}</pre>`
|
|
1008
|
+
: ev.kind === 'miss' ? `<div class="noev">⌀ miss original file — the evidence bytes were pruned</div>`
|
|
1009
|
+
: `<div class="noev">attested without a capture</div>`;
|
|
1010
|
+
const stale = r.fresh ? '' : `<span class="stale" title="${esc(r.staleAxes.join(', '))} changed since this eval">stale</span>`;
|
|
1011
|
+
const note = r.verdict?.note ? `<div class="rnote"><b>note</b> ${esc(r.verdict.note)}</div>` : '';
|
|
1012
|
+
return `<div class="eval-entry">
|
|
1013
|
+
<div class="rhead">
|
|
1014
|
+
${scoreBadge(r.score, r.fresh ? undefined : `stale: ${r.staleAxes.join(', ')}`)}
|
|
1015
|
+
<span class="scenario">${esc(r.scenario)}</span>
|
|
1016
|
+
<span class="impact">${esc(r.impact.join(' + '))}</span>
|
|
1017
|
+
${verdictBadge(r.verdict)}
|
|
1018
|
+
${stale}
|
|
1019
|
+
<span class="rmeta">${r.evaluator ? `${esc(r.evaluator)} · ` : ''}${esc(r.ts)}</span>
|
|
1020
|
+
</div>
|
|
1021
|
+
${r.expected ? `<div class="expected"><b>expected</b> ${esc(r.expected)}</div>` : ''}
|
|
1022
|
+
${note}
|
|
1023
|
+
<figure class="evidence">${body}</figure>
|
|
1024
|
+
</div>`;
|
|
1025
|
+
}
|
|
1026
|
+
function renderUnmeasured(scenario) {
|
|
1027
|
+
return `<div class="eval-entry unmeasured">
|
|
1028
|
+
<div class="rhead">
|
|
1029
|
+
${scoreBadge('empty', 'unmeasured')}
|
|
1030
|
+
<span class="scenario">${esc(scenario.scenario)}</span>
|
|
1031
|
+
<span class="impact">${esc(scenario.impact.join(' + '))}</span>
|
|
1032
|
+
<span class="verdict legacy">unmeasured</span>
|
|
1033
|
+
</div>
|
|
1034
|
+
${scenario.expected ? `<div class="expected"><b>expected</b> ${esc(scenario.expected)}</div>` : ''}
|
|
1035
|
+
</div>`;
|
|
1036
|
+
}
|
|
1037
|
+
// each diff line is its own block so a long diff scrolls inside its box
|
|
1038
|
+
function renderPatch(patch) {
|
|
1039
|
+
return patch.split('\n').map((ln) => {
|
|
1040
|
+
const cls = /^@@/.test(ln) ? 'h'
|
|
1041
|
+
: /^(diff |index |--- |\+\+\+ |rename |similarity |new file|deleted file|old mode|new mode|Binary )/.test(ln) ? 'm'
|
|
1042
|
+
: ln[0] === '+' ? 'a' : ln[0] === '-' ? 'd' : '';
|
|
1043
|
+
return `<span class="dl ${cls}">${esc(ln) || ' '}</span>`;
|
|
1044
|
+
}).join('');
|
|
1045
|
+
}
|
|
1046
|
+
function renderFile(f) {
|
|
1047
|
+
const row = `<span class="fstatus ${esc(f.status)}">${esc(f.status)}</span> <span class="fpath">${esc(f.path)}</span> <span class="fnum"><span class="add">+${f.additions}</span> <span class="del">−${f.deletions}</span></span>`;
|
|
1048
|
+
if (f.omitted)
|
|
1049
|
+
return `<div class="file flat"><div class="frow">${row}<span class="note">diff omitted — large changeset</span></div></div>`;
|
|
1050
|
+
if (!f.patch && f.oldText == null && f.newText == null)
|
|
1051
|
+
return `<div class="file flat"><div class="frow">${row}</div></div>`;
|
|
1052
|
+
const side = (label, text, absent) => `<div class="side"><div class="side-h">${label}</div><pre>${text == null ? `<span class="abs">${absent}</span>` : (esc(text) || '<span class="abs">(empty)</span>')}</pre></div>`;
|
|
1053
|
+
const cmp = (f.oldText != null || f.newText != null)
|
|
1054
|
+
? `<details class="fullfile"><summary>full file · original ↔ new</summary><div class="cmp">${side('original (merge-base)', f.oldText, '— added —')}${side('new (HEAD)', f.newText, '— deleted —')}</div></details>`
|
|
1055
|
+
: '';
|
|
1056
|
+
return `<details class="file"><summary>${row}${f.truncated ? '<span class="note">truncated</span>' : ''}</summary>
|
|
1057
|
+
<div class="file-body">${f.patch ? `<div class="patch">${renderPatch(f.patch)}</div>` : '<div class="note">no textual diff</div>'}${cmp}</div></details>`;
|
|
1058
|
+
}
|
|
1059
|
+
function renderNode(n) {
|
|
1060
|
+
const stat = `<span class="diffstat"><span class="add">+${n.additions}</span> <span class="del">−${n.deletions}</span> · ${n.files.length} file(s)</span>`;
|
|
1061
|
+
const fileList = n.files.length ? `<div class="files">${n.files.map(renderFile).join('')}</div>` : '';
|
|
1062
|
+
let proof;
|
|
1063
|
+
if (n.affectedScenarios)
|
|
1064
|
+
proof = [...n.unmeasured.map(renderUnmeasured), ...n.readings.map(renderReading)].join('');
|
|
1065
|
+
else if (n.uncoveredFrontend)
|
|
1066
|
+
proof = `<div class="blindspot">⚠ a frontend node with no eval.md — its loss is unmeasured. Give it a scenario so this change can be verified.</div>`;
|
|
1067
|
+
else if (n.hasEvalFile)
|
|
1068
|
+
proof = `<div class="noev">no declared scenario is affected by this worktree</div>`;
|
|
1069
|
+
else
|
|
1070
|
+
proof = `<div class="noev">no measurable surface (no eval.md)</div>`;
|
|
1071
|
+
return `<article class="node" style="--hue:${n.hue}">
|
|
1072
|
+
<div class="nhead">
|
|
1073
|
+
<span class="huedot"></span>
|
|
1074
|
+
<span class="ntitle">${esc(n.title)}</span>
|
|
1075
|
+
<span class="nid">${esc(n.id)}</span>
|
|
1076
|
+
${scoreBadge(n.score, n.score ?? undefined)}
|
|
1077
|
+
${stat}
|
|
1078
|
+
</div>
|
|
1079
|
+
${n.desc ? `<div class="ndesc">${esc(n.desc)}</div>` : ''}
|
|
1080
|
+
${fileList}
|
|
1081
|
+
<div class="eval-list">${proof}</div>
|
|
1082
|
+
</article>`;
|
|
1083
|
+
}
|
|
1084
|
+
export function renderExportHtml(m) {
|
|
1085
|
+
const idShort = m.id.slice(0, 8);
|
|
1086
|
+
const ribbon = [
|
|
1087
|
+
...m.gates.map((g) => `<span class="chip ${g.ok ? 'ok' : 'bad'}" title="${esc(g.detail)}">${g.ok ? '✓' : '✗'} ${esc(g.label)}</span>`),
|
|
1088
|
+
m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="affected scenarios fresh-passing; ${m.score.fresh}/${m.score.total} have a fresh measurement">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no scenario is affected by this worktree">★ no affected scenarios</span>`,
|
|
1089
|
+
].join('');
|
|
1090
|
+
const gates = m.gates.map((g) => `<li class="${g.ok ? 'ok' : 'bad'}"><span class="gmark">${g.ok ? '✓' : '✗'}</span><span class="glabel">${esc(g.label)}</span><span class="gdetail">${esc(g.detail)}</span></li>`).join('');
|
|
1091
|
+
const otherBlock = m.otherFiles.length
|
|
1092
|
+
? `<article class="node other"><div class="nhead"><span class="ntitle">other files</span><span class="nid">unclaimed by any node</span></div>
|
|
1093
|
+
<div class="files">${m.otherFiles.map(renderFile).join('')}</div></article>`
|
|
1094
|
+
: '';
|
|
1095
|
+
return `<!doctype html>
|
|
1096
|
+
<html lang="en"><head>
|
|
1097
|
+
<meta charset="utf-8">
|
|
1098
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1099
|
+
<title>evals · ${esc(m.title)}</title>
|
|
1100
|
+
<style>${STYLE}</style>
|
|
1101
|
+
</head><body>
|
|
1102
|
+
<main class="evals">
|
|
1103
|
+
<header class="masthead">
|
|
1104
|
+
<div class="eyebrow">SpexCode · session eval export</div>
|
|
1105
|
+
<h1 class="claim">${esc(m.title)}</h1>
|
|
1106
|
+
<div class="meta">session <code>${esc(idShort)}</code>${m.branch ? ` · <code>${esc(m.branch)}</code>` : ''}${m.node ? ` · node <code>${esc(m.node)}</code>` : ''} · ${m.ahead} commit(s) · ${m.nodes.length} node(s) · <span class="ts">${esc(m.generatedAt)}</span></div>
|
|
1107
|
+
<div class="ribbon">${ribbon}</div>
|
|
1108
|
+
</header>
|
|
1109
|
+
<section class="evidence-section">
|
|
1110
|
+
<h2>Evidence — measured loss</h2>
|
|
1111
|
+
${m.nodes.map(renderNode).join('')}
|
|
1112
|
+
${otherBlock}
|
|
1113
|
+
</section>
|
|
1114
|
+
<section class="gates-section">
|
|
1115
|
+
<h2>Merge gates</h2>
|
|
1116
|
+
<ul class="gates">${gates}</ul>
|
|
1117
|
+
</section>
|
|
1118
|
+
<footer>Generated by <code>spex eval ls --session ${esc(idShort)} --export</code> — the optimizer's measured loss, presented for the merge decision. A spec is the loss; commits are the optimizer; eval keeps the score.</footer>
|
|
1119
|
+
</main>
|
|
1120
|
+
</body></html>`;
|
|
1121
|
+
}
|
|
1122
|
+
// the document's inline stylesheet — dark, matching the board's palette and the eval score colours, so the
|
|
1123
|
+
// proof reads as one surface with the dashboard. Self-contained (no external font/asset).
|
|
1124
|
+
const STYLE = `
|
|
1125
|
+
:root{--bg:#0b0e14;--panel:#11161f;--panel2:#0e131b;--ink:#c9d4e3;--dim:#7c899c;--line:#1e2733;--accent:#4cc2a0;--green:#3fb950;--red:#f85149;--grey:#6e7b8c;--amber:#d29922}
|
|
1126
|
+
*{box-sizing:border-box}
|
|
1127
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
|
1128
|
+
code,pre,.mono{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace}
|
|
1129
|
+
code{background:#0006;padding:.05em .35em;border-radius:4px;font-size:.88em;color:#aee1d2}
|
|
1130
|
+
.evals{max-width:920px;margin:0 auto;padding:40px 24px 80px}
|
|
1131
|
+
.masthead{padding:30px 30px 26px;border:1px solid var(--line);border-radius:16px;background:radial-gradient(1200px 240px at 0% 0%,#16352c66,transparent),linear-gradient(160deg,#141b26,#0d121a);box-shadow:0 20px 60px -30px #000}
|
|
1132
|
+
.eyebrow{font:600 11px/1 ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--accent)}
|
|
1133
|
+
.claim{margin:14px 0 10px;font-size:30px;line-height:1.2;font-weight:700;color:#eef3fa;letter-spacing:-.01em}
|
|
1134
|
+
.meta{color:var(--dim);font-size:13px}
|
|
1135
|
+
.meta code{background:#0008;color:#9fb3c8}
|
|
1136
|
+
.ribbon{margin-top:18px;display:flex;flex-wrap:wrap;gap:8px}
|
|
1137
|
+
.chip{display:inline-flex;align-items:center;gap:.4em;font:600 12px/1 ui-monospace,monospace;padding:6px 10px;border-radius:999px;border:1px solid var(--line);background:#0c1118}
|
|
1138
|
+
.chip.ok{color:var(--green);border-color:#1c3a26}
|
|
1139
|
+
.chip.bad{color:var(--red);border-color:#3a1f1f}
|
|
1140
|
+
.chip.warn{color:var(--amber);border-color:#3a3320}
|
|
1141
|
+
h2{margin:42px 0 16px;font-size:14px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);font-weight:700}
|
|
1142
|
+
.node{margin:14px 0;padding:18px 20px;border:1px solid var(--line);border-radius:12px;background:var(--panel);border-left:3px solid hsl(var(--hue,210),55%,55%)}
|
|
1143
|
+
.node.other{border-left-color:var(--grey)}
|
|
1144
|
+
.nhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
1145
|
+
.huedot{width:11px;height:11px;border-radius:3px;background:hsl(var(--hue,210),60%,55%);flex:none}
|
|
1146
|
+
.ntitle{font-weight:700;color:#e7eef7;font-size:16px}
|
|
1147
|
+
.nid{font:12px/1 ui-monospace,monospace;color:var(--dim)}
|
|
1148
|
+
.ndesc{margin:8px 0 4px;color:var(--dim);font-size:13px}
|
|
1149
|
+
.diffstat{margin-left:auto;font:12px/1 ui-monospace,monospace;color:var(--dim)}
|
|
1150
|
+
.add{color:var(--green)}.del{color:var(--red)}
|
|
1151
|
+
.files{margin:12px 0 0;border-top:1px solid var(--line)}
|
|
1152
|
+
.file{border-bottom:1px solid #0c1117}
|
|
1153
|
+
.file>summary,.frow{display:flex;align-items:center;gap:10px;padding:6px 2px;font:12px/1.4 ui-monospace,monospace}
|
|
1154
|
+
.file>summary{cursor:pointer;list-style:none}
|
|
1155
|
+
.file>summary::-webkit-details-marker{display:none}
|
|
1156
|
+
.file>summary::before{content:'▸ ';color:var(--dim)}
|
|
1157
|
+
.file[open]>summary::before{content:'▾ '}
|
|
1158
|
+
.file.flat .frow{padding-left:14px}
|
|
1159
|
+
.fstatus{flex:none;width:84px;color:var(--dim);text-transform:uppercase;font-size:10px;letter-spacing:.06em}
|
|
1160
|
+
.fstatus.added{color:var(--green)}.fstatus.deleted{color:var(--red)}.fstatus.modified{color:var(--amber)}
|
|
1161
|
+
.fpath{color:#aebccd;overflow:hidden;text-overflow:ellipsis}.fnum{margin-left:auto;flex:none}
|
|
1162
|
+
.note{color:var(--dim);font-style:italic;font-size:11px;margin-left:8px}
|
|
1163
|
+
.file-body{padding:2px 0 12px 14px}
|
|
1164
|
+
.patch{font:12px/1.5 ui-monospace,monospace;background:#05080d;border:1px solid var(--line);border-radius:8px;overflow:auto;max-height:480px;white-space:pre}
|
|
1165
|
+
.dl{display:block;padding:0 10px;min-height:1.5em}
|
|
1166
|
+
.dl.a{background:#11331f;color:#86e2ad}.dl.d{background:#3a1616;color:#f3a3a0}.dl.h{color:#74a6f7;background:#0c1830}.dl.m{color:var(--dim)}
|
|
1167
|
+
.fullfile{margin-top:10px}
|
|
1168
|
+
.fullfile>summary{cursor:pointer;color:var(--accent);font:600 11px/1 ui-monospace,monospace;padding:6px 0}
|
|
1169
|
+
.cmp{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px}
|
|
1170
|
+
.side{flex:1;min-width:300px}
|
|
1171
|
+
.side-h{font:600 10px/1 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em;color:var(--dim);padding:0 0 6px}
|
|
1172
|
+
.side pre{margin:0;padding:10px 12px;background:#05080d;border:1px solid var(--line);border-radius:8px;max-height:440px;overflow:auto;font:11px/1.5 ui-monospace,monospace;color:#aebccd;white-space:pre}
|
|
1173
|
+
.abs{color:var(--dim);font-style:italic}
|
|
1174
|
+
.eval-list{margin-top:8px}
|
|
1175
|
+
.eval-entry{margin-top:14px;padding:14px 16px;border:1px solid var(--line);border-radius:10px;background:var(--panel2)}
|
|
1176
|
+
.rhead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
1177
|
+
.scenario{font-weight:600;color:#dde7f1}
|
|
1178
|
+
.impact{font:600 10px/1 ui-monospace,monospace;color:var(--accent)}
|
|
1179
|
+
.rmeta{margin-left:auto;font:11px/1 ui-monospace,monospace;color:var(--dim)}
|
|
1180
|
+
.verdict{font:600 11px/1 ui-monospace,monospace;padding:3px 8px;border-radius:6px;border:1px solid var(--line)}
|
|
1181
|
+
.verdict.pass{color:var(--green);border-color:#1c3a26}.verdict.fail{color:var(--red);border-color:#3a1f1f}
|
|
1182
|
+
.verdict.note{color:var(--amber)}.verdict.legacy{color:var(--grey)}
|
|
1183
|
+
.stale{font:600 10px/1 ui-monospace,monospace;color:var(--amber);border:1px solid #3a3320;border-radius:6px;padding:3px 7px}
|
|
1184
|
+
.score{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:50%;border:2px solid var(--grey);font-size:11px;font-weight:700;flex:none}
|
|
1185
|
+
.score.pass{border-color:var(--green);color:var(--green)}
|
|
1186
|
+
.score.fail{border-color:var(--red);color:var(--red)}
|
|
1187
|
+
.score.stalePass,.score.staleFail{border-color:var(--grey);color:var(--grey)}
|
|
1188
|
+
.score.empty{border-style:dashed}
|
|
1189
|
+
.expected{margin:10px 0 0;color:var(--dim);font-size:13px}.expected b{color:#9fb3c8;font-weight:600}
|
|
1190
|
+
.rnote{margin:8px 0 0;color:var(--amber);font-size:13px}.rnote b{font-weight:600}
|
|
1191
|
+
.evidence{margin:12px 0 0}
|
|
1192
|
+
.shot{max-width:100%;border:1px solid var(--line);border-radius:8px;box-shadow:0 14px 40px -24px #000;display:block}
|
|
1193
|
+
.transcript{max-height:420px;overflow:auto;margin:0;padding:12px 14px;background:#05080d;border:1px solid var(--line);border-radius:8px;font-size:12px;color:#9fdcc6;white-space:pre-wrap;word-break:break-word}
|
|
1194
|
+
.transcript.data{color:#cbb6ff;border-left:3px solid #6b46c1}
|
|
1195
|
+
.noev,.blindspot{margin-top:10px;padding:10px 12px;border-radius:8px;font-size:13px;color:var(--dim);background:#0a0e15;border:1px dashed var(--line)}
|
|
1196
|
+
.blindspot{color:var(--amber);border-color:#3a3320}
|
|
1197
|
+
.gates{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
|
1198
|
+
.gates li{display:flex;align-items:center;gap:14px;padding:12px 18px;border-bottom:1px solid var(--line);background:var(--panel)}
|
|
1199
|
+
.gates li:last-child{border-bottom:0}
|
|
1200
|
+
.gmark{font-weight:700}.gates li.ok .gmark{color:var(--green)}.gates li.bad .gmark{color:var(--red)}
|
|
1201
|
+
.glabel{font-weight:600;width:120px;color:#dbe6f2}.gdetail{color:var(--dim);font:12px/1 ui-monospace,monospace}
|
|
1202
|
+
footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--line);color:var(--dim);font-size:12px;text-align:center}
|
|
1203
|
+
`;
|
|
1204
|
+
// The one count projection over the already-affected rows. This is the backend source both the graph
|
|
1205
|
+
// glance and the demand full model carry; consumers never repeat impact selection or score classification.
|
|
1206
|
+
export function sessionEvalSummary(nodes) {
|
|
1207
|
+
let total = 0, measured = 0, pass = 0, fail = 0, unknown = 0;
|
|
1208
|
+
for (const node of nodes) {
|
|
1209
|
+
total += node.scenarios.length;
|
|
1210
|
+
unknown += node.unknownCoverage.length;
|
|
1211
|
+
const latest = new Map(latestPerScenario(node.evals).map((reading) => [reading.scenario, reading]));
|
|
1212
|
+
for (const scenario of node.scenarios) {
|
|
1213
|
+
const reading = latest.get(scenario.name);
|
|
1214
|
+
if (!reading)
|
|
1215
|
+
continue;
|
|
1216
|
+
measured++;
|
|
1217
|
+
if (reading.fresh && reading.verdict?.status === 'pass')
|
|
1218
|
+
pass++;
|
|
1219
|
+
else if (reading.fresh && reading.verdict?.status === 'fail')
|
|
1220
|
+
fail++;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return {
|
|
1224
|
+
measured,
|
|
1225
|
+
total,
|
|
1226
|
+
pass,
|
|
1227
|
+
fail,
|
|
1228
|
+
review: measured - pass - fail,
|
|
1229
|
+
blind: Math.max(0, total - measured),
|
|
1230
|
+
unknown,
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
async function sessionScopeNodes(id, ctx, impact, shas, freshness = {}) {
|
|
1234
|
+
const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]));
|
|
1235
|
+
const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]));
|
|
1236
|
+
const nodes = [];
|
|
1237
|
+
// @@@one prime pass for the whole scope - evalTimelines unions the off-history content probes and the
|
|
1238
|
+
// anchor probes across every id it is given, so asking it once per scope issues one child per probe kind
|
|
1239
|
+
// instead of one per node. Reading the timelines inside the loop instead cost 74% of a warm open.
|
|
1240
|
+
const timelineIds = impact.nodes
|
|
1241
|
+
.filter((projected) => specById.has(projected.id) && evalById.has(projected.id))
|
|
1242
|
+
.filter((projected) => !freshness.only || freshness.only.has(projected.id))
|
|
1243
|
+
.map((projected) => projected.id);
|
|
1244
|
+
const timelineById = new Map((await evalTimelines(timelineIds, ctx, { order: freshness.order }))
|
|
1245
|
+
.map((timeline, i) => [timelineIds[i], timeline]));
|
|
1246
|
+
for (const projected of impact.nodes) {
|
|
1247
|
+
if (freshness.only && !freshness.only.has(projected.id))
|
|
1248
|
+
continue;
|
|
1249
|
+
const spec = specById.get(projected.id);
|
|
1250
|
+
if (!spec)
|
|
1251
|
+
continue; // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
|
|
1252
|
+
const evalNode = evalById.get(spec.id);
|
|
1253
|
+
const current = evalNode?.scenarios ?? [];
|
|
1254
|
+
// Unknown means the node has no measurement contract at all. A node that has eval.md is known even
|
|
1255
|
+
// when individual scenarios narrow their code axes; partial scenario ownership is not a synthetic gap.
|
|
1256
|
+
const unknownCoverage = evalNode ? [] : [...new Set(projected.causes
|
|
1257
|
+
.filter((cause) => cause.kind === 'code')
|
|
1258
|
+
.flatMap((cause) => cause.paths)
|
|
1259
|
+
.filter(isUiPath))];
|
|
1260
|
+
if (!evalNode) {
|
|
1261
|
+
if (unknownCoverage.length) {
|
|
1262
|
+
nodes.push({
|
|
1263
|
+
id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
|
|
1264
|
+
hasEvalFile: false, uncoveredFrontend: true, unknownCoverage,
|
|
1265
|
+
causes: projected.causes,
|
|
1266
|
+
scenarios: [], evals: [],
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
const timeline = timelineById.get(spec.id);
|
|
1272
|
+
// A reading is this session's own when the session filed it OR its anchor is a branch commit. This is
|
|
1273
|
+
// the same marker the UI and CLI render; measurement impact consumes that marker instead of inventing
|
|
1274
|
+
// another attribution rule.
|
|
1275
|
+
const evals = timeline.readings.map((reading) => ({
|
|
1276
|
+
...reading,
|
|
1277
|
+
inSession: readingInSession(reading, id, shas),
|
|
1278
|
+
}));
|
|
1279
|
+
const scoped = scopeSessionScenarioRows(current, timeline.scenarios, evals, projected.scenarios);
|
|
1280
|
+
if (!scoped.scenarios.length && !unknownCoverage.length && !projected.causes.length)
|
|
1281
|
+
continue;
|
|
1282
|
+
nodes.push({
|
|
1283
|
+
id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
|
|
1284
|
+
hasEvalFile: timeline.hasEvalFile,
|
|
1285
|
+
uncoveredFrontend: !timeline.hasEvalFile && unknownCoverage.length > 0,
|
|
1286
|
+
unknownCoverage,
|
|
1287
|
+
causes: projected.causes,
|
|
1288
|
+
scenarios: scoped.scenarios,
|
|
1289
|
+
// Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
|
|
1290
|
+
// honest downstream states; impact selection never removes a row because its reading is stale.
|
|
1291
|
+
evals: scoped.evals,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
return nodes;
|
|
1295
|
+
}
|
|
1296
|
+
function readingInSession(reading, id, shas) {
|
|
1297
|
+
return reading.by === id || shas.has(reading.codeSha);
|
|
1298
|
+
}
|
|
1299
|
+
function sessionMeasurements(id, ctx, shas) {
|
|
1300
|
+
const measurements = {};
|
|
1301
|
+
for (const node of ctx.ynodes) {
|
|
1302
|
+
const names = [...new Set(readReadings(node.sidecarPath)
|
|
1303
|
+
.filter((reading) => readingInSession(reading, id, shas))
|
|
1304
|
+
.map((reading) => reading.scenario))];
|
|
1305
|
+
if (names.length)
|
|
1306
|
+
measurements[node.id] = names;
|
|
1307
|
+
}
|
|
1308
|
+
return measurements;
|
|
1309
|
+
}
|
|
1310
|
+
async function sessionImpactForContext(id, ctx, wtPath) {
|
|
1311
|
+
const root = wtPath ?? ctx.root;
|
|
1312
|
+
if (!wtPath) {
|
|
1313
|
+
const head = await impactCommit(root, 'HEAD');
|
|
1314
|
+
const impact = await projectSessionImpact(root, {
|
|
1315
|
+
base: head,
|
|
1316
|
+
head,
|
|
1317
|
+
measurements: sessionMeasurements(id, ctx, new Set()),
|
|
1318
|
+
});
|
|
1319
|
+
return { impact, shas: new Set() };
|
|
1320
|
+
}
|
|
1321
|
+
const [base, head, shaRows, dirty] = await Promise.all([
|
|
1322
|
+
impactGit(wtPath, ['merge-base', mainBranch(), 'HEAD'], 'resolve session merge-base').then((out) => out.trim()),
|
|
1323
|
+
impactCommit(wtPath, 'HEAD'),
|
|
1324
|
+
impactGit(wtPath, ['rev-list', `${mainBranch()}..HEAD`], 'read session commits'),
|
|
1325
|
+
worktreeDirtyState(wtPath),
|
|
1326
|
+
]);
|
|
1327
|
+
const shas = new Set(shaRows.split('\n').filter(Boolean));
|
|
1328
|
+
const overlay = await sessionImpactOverlay(wtPath, dirty);
|
|
1329
|
+
const impact = await projectSessionImpact(wtPath, {
|
|
1330
|
+
base,
|
|
1331
|
+
head,
|
|
1332
|
+
overlay,
|
|
1333
|
+
measurements: sessionMeasurements(id, ctx, shas),
|
|
1334
|
+
});
|
|
1335
|
+
return { impact, shas };
|
|
1336
|
+
}
|
|
1337
|
+
async function buildSessionEvalModel(id, identity, wtPath, pick) {
|
|
1338
|
+
// spec tree from the session worktree, same root as readings/indexes — a branch-NEW node must exist
|
|
1339
|
+
// in this model or the Eval tab/deep link can never reach its readings (see buildExportModel above).
|
|
1340
|
+
const ctxRoot = wtPath ?? repoRoot();
|
|
1341
|
+
return withEventLedgerBuild(ctxRoot, () => buildSessionEvalModelInLedger(id, identity, wtPath, pick, ctxRoot));
|
|
1342
|
+
}
|
|
1343
|
+
async function buildSessionEvalModelInLedger(id, identity, wtPath, pick, ctxRoot) {
|
|
1344
|
+
const specs = await loadSpecs(ctxRoot);
|
|
1345
|
+
const specById = new Map(specs.map((s) => [s.id, s]));
|
|
1346
|
+
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)]);
|
|
1347
|
+
const ctx = await evalContext(ctxRoot, specs, didx, hidx);
|
|
1348
|
+
const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath);
|
|
1349
|
+
// @@@ TWO passes, ONE context - a detail open renders one scenario and at most five neighbours, but it
|
|
1350
|
+
// still owes the full population's sequence (its `index` and `total`). Sequence is freshness-free, so the
|
|
1351
|
+
// first pass reads every node's rows with no probes at all (~0.4s of the 25s), the caller names the few
|
|
1352
|
+
// nodes whose verdicts will actually be published, and only those pay the freshness pass. The context and
|
|
1353
|
+
// the impact projection are built once and shared, so the cheap pass adds no second projection.
|
|
1354
|
+
let order;
|
|
1355
|
+
let only;
|
|
1356
|
+
if (pick) {
|
|
1357
|
+
const draft = await sessionScopeNodes(id, ctx, impact, shas, { order: true });
|
|
1358
|
+
order = orderRowsOf(draft);
|
|
1359
|
+
only = new Set(pick(order));
|
|
1360
|
+
}
|
|
1361
|
+
const nodes = await sessionScopeNodes(id, ctx, impact, shas, only ? { only } : {});
|
|
1362
|
+
// nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
|
|
1363
|
+
nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
|
|
1364
|
+
|| (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length));
|
|
1365
|
+
const primary = identity.node && specById.has(identity.node) ? specById.get(identity.node).title : null;
|
|
1366
|
+
return {
|
|
1367
|
+
id,
|
|
1368
|
+
node: identity.node,
|
|
1369
|
+
branch: identity.branch,
|
|
1370
|
+
title: primary || identity.node || identity.branch || id.slice(0, 8),
|
|
1371
|
+
nodes,
|
|
1372
|
+
impact,
|
|
1373
|
+
...(order ? { order } : {}),
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
export function orderRowsOf(nodes) {
|
|
1377
|
+
const rows = [];
|
|
1378
|
+
for (const node of nodes) {
|
|
1379
|
+
const latest = new Map();
|
|
1380
|
+
for (const reading of node.evals)
|
|
1381
|
+
if (!latest.has(reading.scenario))
|
|
1382
|
+
latest.set(reading.scenario, reading.ts);
|
|
1383
|
+
for (const scenario of node.scenarios)
|
|
1384
|
+
rows.push({ node: node.id, scenario: scenario.name, ts: latest.get(scenario.name) ?? null });
|
|
1385
|
+
}
|
|
1386
|
+
return rows;
|
|
1387
|
+
}
|
|
1388
|
+
function untrackedPaths(status) {
|
|
1389
|
+
const out = [];
|
|
1390
|
+
const records = status.split('\0');
|
|
1391
|
+
for (let i = 0; i < records.length; i++) {
|
|
1392
|
+
const record = records[i];
|
|
1393
|
+
if (!record)
|
|
1394
|
+
continue;
|
|
1395
|
+
const code = record.slice(0, 2);
|
|
1396
|
+
const path = record.slice(3);
|
|
1397
|
+
if (code === '??' && path)
|
|
1398
|
+
out.push(path);
|
|
1399
|
+
if ((code.includes('R') || code.includes('C')) && records[i + 1])
|
|
1400
|
+
i++;
|
|
1401
|
+
}
|
|
1402
|
+
return out.sort();
|
|
1403
|
+
}
|
|
1404
|
+
// One content fingerprint over every axis that can alter the scoped summary. Committed declarations,
|
|
1405
|
+
// sidecars and governed code are covered by HEAD; the moving comparison base by the base ref;
|
|
1406
|
+
// index/worktree/rename content by the HEAD-relative binary diff; untracked bytes are folded explicitly.
|
|
1407
|
+
// Remark tracks are folded directly as well as through main: the disposable plain-file issue store used by
|
|
1408
|
+
// controlled runs has no ref move, but it is still the same freshness input and must obey the same fence.
|
|
1409
|
+
export async function sessionEvalContentRevision(wtPath) {
|
|
1410
|
+
const base = mainBranch();
|
|
1411
|
+
const [mainSha, headSha, mergeBase, status, dirtyDiff] = await Promise.all([
|
|
1412
|
+
gitA(['-C', wtPath, 'rev-parse', base]).then((out) => out.trim()),
|
|
1413
|
+
gitA(['-C', wtPath, 'rev-parse', 'HEAD']).then((out) => out.trim()),
|
|
1414
|
+
gitA(['-C', wtPath, 'merge-base', base, 'HEAD']).then((out) => out.trim()),
|
|
1415
|
+
gitA(['-C', wtPath, '-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--untracked-files=all']),
|
|
1416
|
+
gitA(['-C', wtPath, 'diff', 'HEAD', '--binary', '--no-ext-diff', '--']),
|
|
1417
|
+
]);
|
|
1418
|
+
const untracked = await Promise.all(untrackedPaths(status).map(async (path) => {
|
|
1419
|
+
try {
|
|
1420
|
+
const bytes = await readFile(join(wtPath, path));
|
|
1421
|
+
return `${path}\0${createHash('sha256').update(bytes).digest('hex')}`;
|
|
1422
|
+
}
|
|
1423
|
+
catch {
|
|
1424
|
+
return `${path}\0<gone>`;
|
|
1425
|
+
}
|
|
1426
|
+
}));
|
|
1427
|
+
const remarks = [...evalRemarkTracks()]
|
|
1428
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
1429
|
+
.map(([key, track]) => [key, track.thread]);
|
|
1430
|
+
return createHash('sha256')
|
|
1431
|
+
.update([mainSha, headSha, mergeBase, status, dirtyDiff, ...untracked, JSON.stringify(remarks), evalRemarkSourceFingerprint()].join('\0'))
|
|
1432
|
+
.digest('hex');
|
|
1433
|
+
}
|
|
1434
|
+
// Pure generation coordinator around an injected stable builder. Sessions have independent evaluation cuts,
|
|
1435
|
+
// so an entry launches and publishes independently; generation/revision fences remain per entry.
|
|
1436
|
+
export class SessionEvalProjectionCache {
|
|
1437
|
+
build;
|
|
1438
|
+
epoch;
|
|
1439
|
+
entries = new Map();
|
|
1440
|
+
observerHolds = new Map();
|
|
1441
|
+
observerWaiters = new Set();
|
|
1442
|
+
flights = new Set();
|
|
1443
|
+
notify;
|
|
1444
|
+
precompute;
|
|
1445
|
+
demands = new Map();
|
|
1446
|
+
constructor(build, notify = () => { }, epoch = randomUUID(), precompute = true) {
|
|
1447
|
+
this.build = build;
|
|
1448
|
+
this.notify = notify;
|
|
1449
|
+
this.epoch = epoch;
|
|
1450
|
+
this.precompute = precompute;
|
|
1451
|
+
}
|
|
1452
|
+
setNotify(notify) { this.notify = notify; }
|
|
1453
|
+
setPrecompute(enabled) {
|
|
1454
|
+
if (this.precompute === enabled)
|
|
1455
|
+
return;
|
|
1456
|
+
this.precompute = enabled;
|
|
1457
|
+
for (const entry of this.entries.values()) {
|
|
1458
|
+
if (!enabled) {
|
|
1459
|
+
// A running build is allowed to settle; dropping only queued work prevents a last subscriber's
|
|
1460
|
+
// departure from starting another historical walk after the era has ended.
|
|
1461
|
+
if (entry.running == null)
|
|
1462
|
+
entry.scheduled = null;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
this.authorize(entry);
|
|
1466
|
+
}
|
|
1467
|
+
if (enabled)
|
|
1468
|
+
queueMicrotask(() => this.startScheduled());
|
|
1469
|
+
}
|
|
1470
|
+
authorize(entry) {
|
|
1471
|
+
if (!this.precompute || entry.liveness === 'offline' || entry.observerHolds.size)
|
|
1472
|
+
return;
|
|
1473
|
+
if (entry.demandCancelledGeneration === entry.generation)
|
|
1474
|
+
return;
|
|
1475
|
+
if ((entry.phase === 'loading' || entry.phase === 'updating')
|
|
1476
|
+
&& entry.running !== entry.generation && entry.scheduled !== entry.generation)
|
|
1477
|
+
entry.scheduled = entry.generation;
|
|
1478
|
+
}
|
|
1479
|
+
ensureEntry(id, path) {
|
|
1480
|
+
let entry = this.entries.get(id);
|
|
1481
|
+
if (entry) {
|
|
1482
|
+
entry.path = path || entry.path;
|
|
1483
|
+
return entry;
|
|
1484
|
+
}
|
|
1485
|
+
entry = {
|
|
1486
|
+
id,
|
|
1487
|
+
path,
|
|
1488
|
+
liveness: 'offline',
|
|
1489
|
+
generation: 0,
|
|
1490
|
+
phase: 'loading',
|
|
1491
|
+
scheduled: null,
|
|
1492
|
+
running: null,
|
|
1493
|
+
flight: null,
|
|
1494
|
+
demandCancelledGeneration: null,
|
|
1495
|
+
observerHolds: new Set(),
|
|
1496
|
+
};
|
|
1497
|
+
this.entries.set(id, entry);
|
|
1498
|
+
return entry;
|
|
1499
|
+
}
|
|
1500
|
+
snapshot(sessions) {
|
|
1501
|
+
const live = new Set(sessions.map((session) => session.id));
|
|
1502
|
+
for (const id of this.entries.keys())
|
|
1503
|
+
if (!live.has(id))
|
|
1504
|
+
this.entries.delete(id);
|
|
1505
|
+
const out = new Map();
|
|
1506
|
+
for (const session of sessions) {
|
|
1507
|
+
let entry = this.entries.get(session.id);
|
|
1508
|
+
if (!entry) {
|
|
1509
|
+
entry = {
|
|
1510
|
+
id: session.id,
|
|
1511
|
+
path: session.path,
|
|
1512
|
+
// Unit tests and direct callers omit liveness; those callers retain the historical eager default.
|
|
1513
|
+
liveness: session.liveness ?? 'online',
|
|
1514
|
+
generation: 0,
|
|
1515
|
+
phase: 'loading',
|
|
1516
|
+
scheduled: null,
|
|
1517
|
+
running: null,
|
|
1518
|
+
flight: null,
|
|
1519
|
+
demandCancelledGeneration: null,
|
|
1520
|
+
observerHolds: new Set(),
|
|
1521
|
+
};
|
|
1522
|
+
this.entries.set(session.id, entry);
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
entry.path = session.path;
|
|
1526
|
+
entry.liveness = session.liveness ?? 'online';
|
|
1527
|
+
}
|
|
1528
|
+
entry.observerHolds = new Set([...this.observerHolds]
|
|
1529
|
+
.filter(([, target]) => this.matches(entry, target))
|
|
1530
|
+
.map(([observer]) => observer));
|
|
1531
|
+
if (entry.observerHolds.size)
|
|
1532
|
+
entry.phase = 'updating';
|
|
1533
|
+
// Dormant history is demand-only. The graph still exposes a loading/last-known projection for an
|
|
1534
|
+
// offline row, but does not fan out a summary build for every retained session. Selecting that row's
|
|
1535
|
+
// eval calls buildSessionEvals directly; live rows are eager only during a delta stream era.
|
|
1536
|
+
if (session.liveness === 'offline' && entry.running == null)
|
|
1537
|
+
entry.scheduled = null;
|
|
1538
|
+
this.authorize(entry);
|
|
1539
|
+
out.set(session.id, this.project(entry));
|
|
1540
|
+
}
|
|
1541
|
+
queueMicrotask(() => this.startScheduled());
|
|
1542
|
+
return out;
|
|
1543
|
+
}
|
|
1544
|
+
invalidate(target = 'all') {
|
|
1545
|
+
let changed = 0;
|
|
1546
|
+
for (const entry of this.entries.values()) {
|
|
1547
|
+
if (target !== 'all' && target.id !== entry.id && target.path !== entry.path)
|
|
1548
|
+
continue;
|
|
1549
|
+
entry.generation++;
|
|
1550
|
+
entry.phase = 'updating';
|
|
1551
|
+
entry.scheduled = null;
|
|
1552
|
+
entry.demandCancelledGeneration = null;
|
|
1553
|
+
changed++;
|
|
1554
|
+
}
|
|
1555
|
+
return changed;
|
|
1556
|
+
}
|
|
1557
|
+
holdObserver(observer, target = 'all') {
|
|
1558
|
+
if (this.observerHolds.has(observer))
|
|
1559
|
+
return false;
|
|
1560
|
+
this.observerHolds.set(observer, target);
|
|
1561
|
+
for (const entry of this.entries.values()) {
|
|
1562
|
+
if (!this.matches(entry, target))
|
|
1563
|
+
continue;
|
|
1564
|
+
entry.observerHolds.add(observer);
|
|
1565
|
+
entry.generation++;
|
|
1566
|
+
entry.phase = 'updating';
|
|
1567
|
+
entry.scheduled = null;
|
|
1568
|
+
entry.demandCancelledGeneration = null;
|
|
1569
|
+
}
|
|
1570
|
+
return true;
|
|
1571
|
+
}
|
|
1572
|
+
releaseObserver(observer) {
|
|
1573
|
+
if (!this.observerHolds.delete(observer))
|
|
1574
|
+
return false;
|
|
1575
|
+
for (const entry of this.entries.values()) {
|
|
1576
|
+
if (!entry.observerHolds.delete(observer))
|
|
1577
|
+
continue;
|
|
1578
|
+
entry.generation++;
|
|
1579
|
+
entry.phase = 'updating';
|
|
1580
|
+
entry.scheduled = null;
|
|
1581
|
+
entry.demandCancelledGeneration = null;
|
|
1582
|
+
}
|
|
1583
|
+
for (const check of [...this.observerWaiters])
|
|
1584
|
+
check();
|
|
1585
|
+
return true;
|
|
1586
|
+
}
|
|
1587
|
+
isObserverHeld(id, path) {
|
|
1588
|
+
const entry = this.entries.get(id);
|
|
1589
|
+
if (entry?.observerHolds.size)
|
|
1590
|
+
return true;
|
|
1591
|
+
return [...this.observerHolds.values()].some((target) => this.matches({ id, path }, target));
|
|
1592
|
+
}
|
|
1593
|
+
waitUntilObservable(id, path, timeoutMs) {
|
|
1594
|
+
if (!this.isObserverHeld(id, path))
|
|
1595
|
+
return Promise.resolve(true);
|
|
1596
|
+
return new Promise((resolve) => {
|
|
1597
|
+
let timer = null;
|
|
1598
|
+
const finish = (observable) => {
|
|
1599
|
+
if (timer)
|
|
1600
|
+
clearTimeout(timer);
|
|
1601
|
+
this.observerWaiters.delete(check);
|
|
1602
|
+
resolve(observable);
|
|
1603
|
+
};
|
|
1604
|
+
const check = () => {
|
|
1605
|
+
if (!this.isObserverHeld(id, path))
|
|
1606
|
+
finish(true);
|
|
1607
|
+
};
|
|
1608
|
+
this.observerWaiters.add(check);
|
|
1609
|
+
timer = setTimeout(() => finish(false), Math.max(0, timeoutMs));
|
|
1610
|
+
timer.unref?.();
|
|
1611
|
+
check();
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
get(id) {
|
|
1615
|
+
const entry = this.entries.get(id);
|
|
1616
|
+
return entry ? this.project(entry) : null;
|
|
1617
|
+
}
|
|
1618
|
+
async demand(id, path, run) {
|
|
1619
|
+
const existing = this.demands.get(id);
|
|
1620
|
+
if (existing)
|
|
1621
|
+
return existing;
|
|
1622
|
+
const entry = this.ensureEntry(id, path);
|
|
1623
|
+
// An unstarted summary is superseded by the full demand build. A running summary stays the one flight for
|
|
1624
|
+
// this entry; demand waits for it only, never for unrelated session work.
|
|
1625
|
+
if (entry.running == null && entry.scheduled === entry.generation) {
|
|
1626
|
+
entry.scheduled = null;
|
|
1627
|
+
entry.demandCancelledGeneration = entry.generation;
|
|
1628
|
+
}
|
|
1629
|
+
const priorFlight = entry.flight;
|
|
1630
|
+
const promise = (async () => {
|
|
1631
|
+
if (priorFlight)
|
|
1632
|
+
await priorFlight;
|
|
1633
|
+
return run();
|
|
1634
|
+
})();
|
|
1635
|
+
this.demands.set(id, promise);
|
|
1636
|
+
promise.finally(() => {
|
|
1637
|
+
if (this.demands.get(id) === promise)
|
|
1638
|
+
this.demands.delete(id);
|
|
1639
|
+
}).catch(() => { });
|
|
1640
|
+
return promise;
|
|
1641
|
+
}
|
|
1642
|
+
async idle() {
|
|
1643
|
+
for (;;) {
|
|
1644
|
+
await Promise.resolve();
|
|
1645
|
+
const pending = [...this.flights, ...this.demands.values()];
|
|
1646
|
+
if (!pending.length)
|
|
1647
|
+
return;
|
|
1648
|
+
await Promise.allSettled(pending);
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
accept(id, generation, revision, value) {
|
|
1652
|
+
const entry = this.entries.get(id);
|
|
1653
|
+
if (!entry || entry.generation !== generation || entry.observerHolds.size)
|
|
1654
|
+
return false;
|
|
1655
|
+
const changed = entry.phase !== 'ready' || entry.current?.revision !== revision;
|
|
1656
|
+
entry.current = { generation, revision, value };
|
|
1657
|
+
entry.phase = 'ready';
|
|
1658
|
+
entry.scheduled = null;
|
|
1659
|
+
if (changed)
|
|
1660
|
+
this.notify();
|
|
1661
|
+
return true;
|
|
1662
|
+
}
|
|
1663
|
+
project(entry) {
|
|
1664
|
+
const stable = entry.current;
|
|
1665
|
+
return {
|
|
1666
|
+
epoch: this.epoch,
|
|
1667
|
+
generation: entry.generation,
|
|
1668
|
+
phase: entry.phase,
|
|
1669
|
+
...(entry.phase === 'ready' && stable
|
|
1670
|
+
? { revision: stable.revision, value: stable.value }
|
|
1671
|
+
: stable ? { lastKnown: stable } : {}),
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
matches(entry, target) {
|
|
1675
|
+
return target === 'all' || target.id === entry.id || target.path === entry.path;
|
|
1676
|
+
}
|
|
1677
|
+
publish(staged) {
|
|
1678
|
+
let changed = false;
|
|
1679
|
+
const { entry, generation, result } = staged;
|
|
1680
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.observerHolds.size)
|
|
1681
|
+
return false;
|
|
1682
|
+
if (result.kind === 'unstable') {
|
|
1683
|
+
entry.generation++;
|
|
1684
|
+
entry.phase = 'updating';
|
|
1685
|
+
entry.scheduled = null;
|
|
1686
|
+
entry.demandCancelledGeneration = null;
|
|
1687
|
+
this.authorize(entry);
|
|
1688
|
+
return true;
|
|
1689
|
+
}
|
|
1690
|
+
if (result.kind === 'missing') {
|
|
1691
|
+
changed = entry.phase !== 'error';
|
|
1692
|
+
entry.phase = 'error';
|
|
1693
|
+
entry.scheduled = null;
|
|
1694
|
+
return changed;
|
|
1695
|
+
}
|
|
1696
|
+
changed = entry.phase !== 'ready' || entry.current?.revision !== result.revision;
|
|
1697
|
+
entry.current = { generation, revision: result.revision, value: result.summary };
|
|
1698
|
+
entry.phase = 'ready';
|
|
1699
|
+
entry.scheduled = null;
|
|
1700
|
+
return changed;
|
|
1701
|
+
}
|
|
1702
|
+
startScheduled() {
|
|
1703
|
+
for (const entry of this.entries.values()) {
|
|
1704
|
+
if (entry.scheduled == null || entry.running != null)
|
|
1705
|
+
continue;
|
|
1706
|
+
this.startEntry({ entry, generation: entry.scheduled });
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
startEntry(row) {
|
|
1710
|
+
const flight = this.runEntry(row).then((staged) => {
|
|
1711
|
+
if (staged && this.publish(staged))
|
|
1712
|
+
this.notify();
|
|
1713
|
+
});
|
|
1714
|
+
row.entry.flight = flight;
|
|
1715
|
+
this.flights.add(flight);
|
|
1716
|
+
void flight.then(() => {
|
|
1717
|
+
this.flights.delete(flight);
|
|
1718
|
+
if (row.entry.flight === flight)
|
|
1719
|
+
row.entry.flight = null;
|
|
1720
|
+
queueMicrotask(() => this.startScheduled());
|
|
1721
|
+
}, (error) => {
|
|
1722
|
+
this.flights.delete(flight);
|
|
1723
|
+
if (row.entry.flight === flight)
|
|
1724
|
+
row.entry.flight = null;
|
|
1725
|
+
console.warn(`spec-eval: session summary runner failed for ${row.entry.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1726
|
+
queueMicrotask(() => this.startScheduled());
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
async runEntry({ entry, generation }) {
|
|
1730
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation
|
|
1731
|
+
|| entry.scheduled !== generation || entry.running != null)
|
|
1732
|
+
return null;
|
|
1733
|
+
entry.scheduled = null;
|
|
1734
|
+
entry.running = generation;
|
|
1735
|
+
let result;
|
|
1736
|
+
try {
|
|
1737
|
+
result = await this.build(entry.id, entry.path);
|
|
1738
|
+
}
|
|
1739
|
+
catch (error) {
|
|
1740
|
+
console.warn(`spec-eval: session summary build failed for ${entry.id}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1741
|
+
result = { kind: 'missing' };
|
|
1742
|
+
}
|
|
1743
|
+
entry.running = null;
|
|
1744
|
+
if (this.entries.get(entry.id) !== entry || entry.generation !== generation || entry.scheduled !== null)
|
|
1745
|
+
return null;
|
|
1746
|
+
return { entry, generation, result };
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
async function buildSummaryAttempt(id, _path) {
|
|
1750
|
+
const identity = sessionEvalHost().reviewIdentity(id);
|
|
1751
|
+
if (!identity)
|
|
1752
|
+
return { kind: 'missing' };
|
|
1753
|
+
const wtPath = worktreePathForBranch(identity.branch);
|
|
1754
|
+
const ctxPath = wtPath ?? repoRoot();
|
|
1755
|
+
return withEventLedgerDemand(ctxPath, async () => {
|
|
1756
|
+
const before = await sessionEvalContentRevision(ctxPath);
|
|
1757
|
+
const cacheKey = `${id}\0${before}`;
|
|
1758
|
+
const cached = summaryByContent.get(cacheKey);
|
|
1759
|
+
if (cached) {
|
|
1760
|
+
const after = await sessionEvalContentRevision(ctxPath);
|
|
1761
|
+
return before === after
|
|
1762
|
+
? { kind: 'stable', revision: after, summary: cached.summary }
|
|
1763
|
+
: { kind: 'unstable' };
|
|
1764
|
+
}
|
|
1765
|
+
const model = await buildSessionEvalModel(id, identity, wtPath);
|
|
1766
|
+
const after = await sessionEvalContentRevision(ctxPath);
|
|
1767
|
+
if (before !== after)
|
|
1768
|
+
return { kind: 'unstable' };
|
|
1769
|
+
const summary = sessionEvalSummary(model.nodes);
|
|
1770
|
+
// Keep one content-addressed stable value per session. Revisions, not elapsed time, decide reuse.
|
|
1771
|
+
// This fold IS the demand's fold, so it deposits the model too and a later open replays it.
|
|
1772
|
+
depositStableCut(id, after, { summary, model });
|
|
1773
|
+
return { kind: 'stable', revision: after, summary };
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
const summaryByContent = new Map();
|
|
1777
|
+
// Content addressing IS the invalidation: a moved input yields a different key. Keeping only the newest key
|
|
1778
|
+
// per session bounds the map at one cut per session and drops summary and model together, so the two can
|
|
1779
|
+
// never disagree about which revision they describe.
|
|
1780
|
+
function depositStableCut(id, revision, cut) {
|
|
1781
|
+
const key = `${id}\0${revision}`;
|
|
1782
|
+
const existing = summaryByContent.get(key);
|
|
1783
|
+
for (const other of [...summaryByContent.keys()])
|
|
1784
|
+
if (other.startsWith(`${id}\0`))
|
|
1785
|
+
summaryByContent.delete(other);
|
|
1786
|
+
summaryByContent.set(key, { summary: cut.summary, model: cut.model ?? existing?.model });
|
|
1787
|
+
}
|
|
1788
|
+
const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt, () => { }, randomUUID(), false);
|
|
1789
|
+
const OBSERVER_RECOVERY_TIMEOUT_MS = 10_000;
|
|
1790
|
+
async function awaitObservableInputs(id, path) {
|
|
1791
|
+
if (await projectionCache.waitUntilObservable(id, path, OBSERVER_RECOVERY_TIMEOUT_MS))
|
|
1792
|
+
return;
|
|
1793
|
+
throw new SessionEvalUnavailableError('session eval inputs remain temporarily unobservable');
|
|
1794
|
+
}
|
|
1795
|
+
export function setSessionEvalProjectionNotify(notify) { projectionCache.setNotify(notify); }
|
|
1796
|
+
export function setSessionEvalProjectionWarmup(enabled) { projectionCache.setPrecompute(enabled); }
|
|
1797
|
+
export function sessionEvalProjections(sessions) {
|
|
1798
|
+
return projectionCache.snapshot(sessions);
|
|
1799
|
+
}
|
|
1800
|
+
// The single-session READ of the same projection: no entry is minted, no build is scheduled, no generation
|
|
1801
|
+
// moves. `null` means this cache has never held that session — a caller that must distinguish "not computed"
|
|
1802
|
+
// from "computed and empty" gets to; one that builds instead goes through buildSessionEvals.
|
|
1803
|
+
export function sessionEvalProjection(id) {
|
|
1804
|
+
return projectionCache.get(id);
|
|
1805
|
+
}
|
|
1806
|
+
export function invalidateSessionEvalProjections(target = 'all') {
|
|
1807
|
+
return projectionCache.invalidate(target);
|
|
1808
|
+
}
|
|
1809
|
+
export function holdSessionEvalProjectionObserver(observer, target = 'all') {
|
|
1810
|
+
return projectionCache.holdObserver(observer, target);
|
|
1811
|
+
}
|
|
1812
|
+
export function releaseSessionEvalProjectionObserver(observer) {
|
|
1813
|
+
return projectionCache.releaseObserver(observer);
|
|
1814
|
+
}
|
|
1815
|
+
export async function awaitSessionEvalProjectionIdle() { await projectionCache.idle(); }
|
|
1816
|
+
// `pick` makes this a FOCUSED build: the caller names, from the population's sequence, the few nodes whose
|
|
1817
|
+
// verdicts its response will publish, and only those pay the freshness pass. A focused build is PARTIAL, so
|
|
1818
|
+
// it never deposits — but it still prefers a cached FULL model when one exists, because a complete answer
|
|
1819
|
+
// already paid for beats a cheap incomplete one.
|
|
1820
|
+
export async function buildSessionEvals(id, pick) {
|
|
1821
|
+
// A full model is demand-only. It joins its session's current summary flight, but unrelated summaries never
|
|
1822
|
+
// gate this demand; a cached stable cut then replays without re-deriving Git state.
|
|
1823
|
+
const identity = sessionEvalHost().reviewIdentity(id);
|
|
1824
|
+
if (!identity)
|
|
1825
|
+
return null;
|
|
1826
|
+
const wtPath = worktreePathForBranch(identity.branch);
|
|
1827
|
+
const ctxPath = wtPath ?? repoRoot();
|
|
1828
|
+
for (;;) {
|
|
1829
|
+
const attempt = await projectionCache.demand(id, '', () => withEventLedgerDemand(ctxPath, async () => {
|
|
1830
|
+
const known = projectionCache.get(id);
|
|
1831
|
+
const generation = known?.generation ?? 0;
|
|
1832
|
+
await awaitObservableInputs(id, ctxPath);
|
|
1833
|
+
const before = await sessionEvalContentRevision(ctxPath);
|
|
1834
|
+
// A repeat open at an unmoved revision replays the cut this same owner already published, instead of
|
|
1835
|
+
// re-deriving it from Git. The stability, observer and generation fences below still decide whether the
|
|
1836
|
+
// answer may be published, so a replay is certified exactly like a fresh fold.
|
|
1837
|
+
// @@@a replay cannot hide a dead selector - the validation that raises 503 reads the declarations and
|
|
1838
|
+
// trees the content revision already covers, so an identical revision has an identical verdict; and a
|
|
1839
|
+
// failing fold deposits nothing, so an unavailable projection always re-derives and re-raises.
|
|
1840
|
+
const replay = summaryByContent.get(`${id}\0${before}`);
|
|
1841
|
+
if (replay?.model) {
|
|
1842
|
+
const settled = await sessionEvalContentRevision(ctxPath);
|
|
1843
|
+
const live = projectionCache.get(id);
|
|
1844
|
+
if (before === settled && !projectionCache.isObserverHeld(id, ctxPath)
|
|
1845
|
+
&& !(live && live.generation !== generation)) {
|
|
1846
|
+
return { kind: 'ready', model: replay.model, summary: replay.summary, generation, revision: settled };
|
|
1847
|
+
}
|
|
1848
|
+
if (before !== settled)
|
|
1849
|
+
projectionCache.invalidate({ id });
|
|
1850
|
+
return { kind: 'retry' };
|
|
1851
|
+
}
|
|
1852
|
+
const model = await buildSessionEvalModel(id, identity, wtPath, pick);
|
|
1853
|
+
const after = await sessionEvalContentRevision(ctxPath);
|
|
1854
|
+
const current = projectionCache.get(id);
|
|
1855
|
+
if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
|
|
1856
|
+
|| (current && current.generation !== generation)) {
|
|
1857
|
+
if (before !== after)
|
|
1858
|
+
projectionCache.invalidate({ id });
|
|
1859
|
+
return { kind: 'retry' };
|
|
1860
|
+
}
|
|
1861
|
+
// A focused model holds only the nodes it was asked to publish, so its counts are NOT the scope's and
|
|
1862
|
+
// its nodes are NOT the scope's. Neither may be deposited or accepted: doing so would tell the list
|
|
1863
|
+
// page and the graph that the session's whole evaluation is those few nodes.
|
|
1864
|
+
if (model.order)
|
|
1865
|
+
return { kind: 'ready', model, summary: known?.value, generation, revision: after };
|
|
1866
|
+
const summary = sessionEvalSummary(model.nodes);
|
|
1867
|
+
// Only a settled fold deposits, and only here does a model enter the cut — a thrown build reaches
|
|
1868
|
+
// neither line, so a failure can never poison the entry.
|
|
1869
|
+
depositStableCut(id, after, { summary, model });
|
|
1870
|
+
if (current)
|
|
1871
|
+
projectionCache.accept(id, generation, after, summary);
|
|
1872
|
+
return { kind: 'ready', model, summary, generation, revision: after };
|
|
1873
|
+
}));
|
|
1874
|
+
if (attempt.kind === 'retry')
|
|
1875
|
+
continue;
|
|
1876
|
+
return {
|
|
1877
|
+
...attempt.model,
|
|
1878
|
+
// absent on a focused build whose cut holds no summary — never a fold over the rendered window
|
|
1879
|
+
...(attempt.summary ? { summary: attempt.summary } : {}),
|
|
1880
|
+
evalRevision: { epoch: projectionCache.epoch, generation: attempt.generation, content: attempt.revision },
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
}
|