spexcode 0.6.2 → 0.6.6
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 +3 -1
- package/bin/spex.mjs +4 -0
- package/node_modules/@spexcode/session-core/dist/delivery-queue.d.ts +23 -0
- package/node_modules/@spexcode/session-core/dist/delivery-queue.js +195 -0
- package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
- package/node_modules/@spexcode/session-core/dist/index.js +5 -0
- package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
- package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
- package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
- package/node_modules/@spexcode/session-core/dist/message.js +52 -0
- package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
- package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
- package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +50 -0
- package/node_modules/@spexcode/session-core/dist/runtime-session.js +286 -0
- package/node_modules/@spexcode/session-core/dist/session-cursors.d.ts +14 -0
- package/node_modules/@spexcode/session-core/dist/session-cursors.js +82 -0
- package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +46 -0
- package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
- package/node_modules/@spexcode/session-core/package.json +33 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +77 -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 +36 -0
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +316 -0
- package/node_modules/@spexcode/spec-cli/dist/cli.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/cli.js +1908 -0
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +112 -0
- package/node_modules/@spexcode/spec-cli/dist/client.js +481 -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 +58 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +798 -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/doctor.d.ts +9 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +553 -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 +363 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +38 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +527 -0
- package/node_modules/@spexcode/spec-cli/dist/graphCache.d.ts +27 -0
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +717 -0
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.d.ts +14 -0
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +36 -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} +36 -32
- 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 +338 -0
- package/node_modules/@spexcode/spec-cli/dist/harness.js +3146 -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 +15 -0
- package/node_modules/@spexcode/spec-cli/dist/help.js +594 -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 +8 -0
- package/node_modules/@spexcode/spec-cli/dist/listen.js +24 -0
- 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 +5 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +215 -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/runtime-rotate.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -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-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 +11 -0
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +20 -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 +433 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +4667 -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 +1 -1
- package/node_modules/@spexcode/spec-cli/package.json +49 -0
- package/node_modules/@spexcode/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -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/{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-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 +170 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +2768 -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 +187 -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 -728
- package/spec-cli/src/attach.ts +0 -43
- package/spec-cli/src/claude-headless.ts +0 -321
- package/spec-cli/src/cli.ts +0 -1258
- package/spec-cli/src/client.ts +0 -414
- 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 -165
- package/spec-cli/src/doctor.ts +0 -516
- package/spec-cli/src/execution-trace.ts +0 -444
- 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 -358
- package/spec-cli/src/gateway.ts +0 -461
- package/spec-cli/src/git.ts +0 -2556
- package/spec-cli/src/graph.ts +0 -271
- package/spec-cli/src/graphCache.ts +0 -710
- 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 -3025
- package/spec-cli/src/headless-controller.ts +0 -63
- package/spec-cli/src/help.ts +0 -492
- package/spec-cli/src/host-resources.ts +0 -762
- 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 -138
- package/spec-cli/src/index.ts +0 -818
- 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 -640
- package/spec-cli/src/lint.ts +0 -488
- package/spec-cli/src/listen.ts +0 -28
- package/spec-cli/src/localIssues.ts +0 -538
- package/spec-cli/src/materialize.ts +0 -417
- 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 -434
- 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-execution.ts +0 -68
- package/spec-cli/src/session-files.ts +0 -121
- package/spec-cli/src/session-follow.ts +0 -182
- package/spec-cli/src/session-reparent.ts +0 -33
- package/spec-cli/src/session-timeline.ts +0 -247
- package/spec-cli/src/session-web.ts +0 -135
- package/spec-cli/src/sessions.ts +0 -3883
- 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-dashboard/dist/assets/App-B6IbFbGu.js +0 -2
- package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +0 -1
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-BG9rNOgV.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-D9yPMR3A.js +0 -2
- package/spec-dashboard/dist/assets/Modal-B3EcgeA3.js +0 -1
- package/spec-dashboard/dist/assets/PageScroll-CNZOugWV.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-C2CRHmvs.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +0 -32
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +0 -40
- package/spec-dashboard/dist/assets/SessionWindow-1nYgZ4CL.js +0 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +0 -1
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +0 -13
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +0 -1
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +0 -1
- package/spec-dashboard/dist/assets/index-DLVeOHL-.js +0 -41
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +0 -1
- package/spec-dashboard/dist/assets/launch-Cp579UoJ.css +0 -1
- package/spec-dashboard/dist/assets/launch-yxCYr64x.js +0 -276
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.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 -894
- package/spec-eval/src/evaltab.ts +0 -264
- package/spec-eval/src/filing.ts +0 -39
- package/spec-eval/src/freshness.ts +0 -836
- 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 -2220
- 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/comment-altitude/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/core/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/forge-link/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +0 -0
- /package/{spec-cli → node_modules/@spexcode/spec-cli}/templates/spec/project/.plugins/prompts/reproduce-before-fix/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
|
@@ -1,1019 +0,0 @@
|
|
|
1
|
-
import { streamSSE } from 'hono/streaming'
|
|
2
|
-
import type { Context } from 'hono'
|
|
3
|
-
import { watch, mkdirSync, readdirSync, readFileSync, type FSWatcher } from 'node:fs'
|
|
4
|
-
import { join, dirname, relative, resolve, basename } from 'node:path'
|
|
5
|
-
import { sessionsRoot, gitCommonDir, sessionBranchIndex, mainBranch } from './layout.js'
|
|
6
|
-
import { hotSignature, warmSignature, listSessions, pendingSessionCreateWorktreePaths } from './sessions.js'
|
|
7
|
-
import { getBoard, getBoardForSessionRefresh, invalidateBoard, patrolBoard } from './graphCache.js'
|
|
8
|
-
import { unitize, tagOf, diffUnits, type Units } from './graphDelta.js'
|
|
9
|
-
import {
|
|
10
|
-
holdSessionEvalProjectionObserver,
|
|
11
|
-
invalidateSessionEvalProjections,
|
|
12
|
-
releaseSessionEvalProjectionObserver,
|
|
13
|
-
setSessionEvalProjectionNotify,
|
|
14
|
-
setSessionEvalProjectionWarmup,
|
|
15
|
-
} from '../../spec-eval/src/sessioneval.js'
|
|
16
|
-
|
|
17
|
-
type Scope = 'sessions' | 'full'
|
|
18
|
-
type EvalTarget = 'all' | { id?: string; path?: string }
|
|
19
|
-
type Notify = () => void
|
|
20
|
-
type Frame = { event: string; data: string }
|
|
21
|
-
type DeltaSend = (frame: Frame) => void
|
|
22
|
-
type TreeWatchCallback = (event: 'rename' | 'change', filename: string | Buffer | null) => void
|
|
23
|
-
// the one filesystem primitive the registry is allowed to call — `fs.watch`'s overloads narrowed to the
|
|
24
|
-
// exact shape both transports use, so an injected fake is the same three arguments.
|
|
25
|
-
type WatchFactory = (path: string, options: { recursive?: boolean }, callback: TreeWatchCallback) => FSWatcher
|
|
26
|
-
type WatchTransport = 'consolidated-recursive' | 'exact-directory'
|
|
27
|
-
|
|
28
|
-
type TreeWatcherOptions = {
|
|
29
|
-
root: string
|
|
30
|
-
source: string
|
|
31
|
-
scope: Scope
|
|
32
|
-
recursive?: boolean
|
|
33
|
-
ignore?: (relativePath: string) => boolean
|
|
34
|
-
watchFactory?: WatchFactory
|
|
35
|
-
transport?: WatchTransport
|
|
36
|
-
onInput: (event: 'rename' | 'change', relativePath: string) => void
|
|
37
|
-
onFailure: (error: Error) => void
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// @@@ watch transport - the ONE place the platform appears. Observing a tree is a single product-level
|
|
41
|
-
// registration; how many registrations the OS actually holds for it is the transport's business.
|
|
42
|
-
// consolidated-recursive — the OS itself observes a whole subtree from one registration (Darwin
|
|
43
|
-
// FSEvents, Windows ReadDirectoryChangesW). It reports by PATH, so an atomic replacement inside the
|
|
44
|
-
// tree stays visible, and exclusions filter on delivery because nothing is consumed per directory.
|
|
45
|
-
// exact-directory — no such observer exists (Linux), and Node's `recursive` option is a USERSPACE
|
|
46
|
-
// fan-out registering one inotify watch per file AND directory (measured: 201 directories -> 801
|
|
47
|
-
// watches) which, watching files by inode, goes blind the moment a file is atomically replaced. So
|
|
48
|
-
// we enumerate directories once and install one NON-recursive watch each, all multiplexed onto the
|
|
49
|
-
// loop's single shared inotify descriptor, excluding .git/node_modules at traversal time.
|
|
50
|
-
export const consolidatedRecursiveWatch = (platform: NodeJS.Platform = process.platform): boolean =>
|
|
51
|
-
platform === 'darwin' || platform === 'win32'
|
|
52
|
-
|
|
53
|
-
// The live census across every registry: `sources` is what graph-stream asked the platform for (one per
|
|
54
|
-
// canonical root), `registrations` is what the platform is actually holding for them. Both are read by the
|
|
55
|
-
// watcher-budget tests and logged under SPEXCODE_BOARD_DEBUG, so a plateau is observable on every platform
|
|
56
|
-
// rather than only where /proc exposes inotify descriptors.
|
|
57
|
-
const liveRegistries = new Set<TreeWatcherRegistry>()
|
|
58
|
-
let liveRegistrations = 0
|
|
59
|
-
export function graphWatcherCensus(): { sources: number; registrations: number } {
|
|
60
|
-
return { sources: liveRegistries.size, registrations: liveRegistrations }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export class TreeWatcherRegistry {
|
|
64
|
-
readonly root: string
|
|
65
|
-
readonly source: string
|
|
66
|
-
readonly scope: Scope
|
|
67
|
-
readonly transport: WatchTransport
|
|
68
|
-
private readonly recursive: boolean
|
|
69
|
-
private readonly ignore: (relativePath: string) => boolean
|
|
70
|
-
private readonly watchFactory: WatchFactory
|
|
71
|
-
private readonly onInput: TreeWatcherOptions['onInput']
|
|
72
|
-
private readonly onFailure: TreeWatcherOptions['onFailure']
|
|
73
|
-
private readonly handles = new Map<string, FSWatcher>()
|
|
74
|
-
private refreshImmediate: ReturnType<typeof setImmediate> | null = null
|
|
75
|
-
private failed = false
|
|
76
|
-
|
|
77
|
-
constructor(options: TreeWatcherOptions) {
|
|
78
|
-
this.root = resolve(options.root)
|
|
79
|
-
this.source = options.source
|
|
80
|
-
this.scope = options.scope
|
|
81
|
-
this.recursive = options.recursive !== false
|
|
82
|
-
this.transport = options.transport
|
|
83
|
-
?? (this.recursive && consolidatedRecursiveWatch() ? 'consolidated-recursive' : 'exact-directory')
|
|
84
|
-
this.ignore = options.ignore ?? (() => false)
|
|
85
|
-
this.watchFactory = options.watchFactory ?? (watch as unknown as WatchFactory)
|
|
86
|
-
this.onInput = options.onInput
|
|
87
|
-
this.onFailure = options.onFailure
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
get size(): number { return this.handles.size }
|
|
91
|
-
paths(): string[] { return [...this.handles.keys()].sort() }
|
|
92
|
-
// one kernel-side observer covers the subtree: the root is the only path we register, and no rename can
|
|
93
|
-
// make the desired set drift, so there is nothing to re-walk.
|
|
94
|
-
private get consolidated(): boolean { return this.recursive && this.transport === 'consolidated-recursive' }
|
|
95
|
-
|
|
96
|
-
private desiredDirectories(): Set<string> {
|
|
97
|
-
const desired = new Set<string>()
|
|
98
|
-
const visit = (dir: string): void => {
|
|
99
|
-
desired.add(dir)
|
|
100
|
-
if (!this.recursive || this.consolidated) return
|
|
101
|
-
let entries: import('node:fs').Dirent[]
|
|
102
|
-
try { entries = readdirSync(dir, { withFileTypes: true }) }
|
|
103
|
-
catch (error) {
|
|
104
|
-
if (dir !== this.root && (error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
105
|
-
desired.delete(dir)
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
throw error
|
|
109
|
-
}
|
|
110
|
-
for (const entry of entries) {
|
|
111
|
-
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
|
112
|
-
const path = join(dir, entry.name)
|
|
113
|
-
const rel = relative(this.root, path)
|
|
114
|
-
if (!this.ignore(rel)) visit(path)
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
visit(this.root)
|
|
118
|
-
return desired
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
private closeHandles(): void {
|
|
122
|
-
if (this.refreshImmediate) { clearImmediate(this.refreshImmediate); this.refreshImmediate = null }
|
|
123
|
-
const handles = [...this.handles.values()]
|
|
124
|
-
this.handles.clear()
|
|
125
|
-
liveRegistrations -= handles.length
|
|
126
|
-
liveRegistries.delete(this)
|
|
127
|
-
for (const handle of handles) { try { handle.close() } catch { /* already gone */ } }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
private sourceError(path: string, error: unknown): Error {
|
|
131
|
-
const err = error as NodeJS.ErrnoException
|
|
132
|
-
const reason = error instanceof Error ? error.message : String(error)
|
|
133
|
-
// source + path + errno, always — an exhausted platform must name which syscall budget it refused.
|
|
134
|
-
const errno = err?.code && !reason.includes(err.code) ? ` (errno ${err.code})` : ''
|
|
135
|
-
return new Error(`spec-cli: graph watcher '${this.source}' failed at ${path}: ${reason}${errno}`)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
private fail(path: string, error: unknown): false {
|
|
139
|
-
if (this.failed) return false
|
|
140
|
-
this.failed = true
|
|
141
|
-
this.closeHandles()
|
|
142
|
-
this.onFailure(this.sourceError(path, error))
|
|
143
|
-
return false
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private scheduleRefresh(): void {
|
|
147
|
-
if (this.refreshImmediate || this.failed) return
|
|
148
|
-
this.refreshImmediate = setImmediate(() => {
|
|
149
|
-
this.refreshImmediate = null
|
|
150
|
-
this.refresh()
|
|
151
|
-
})
|
|
152
|
-
this.refreshImmediate.unref?.()
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
refresh(): boolean {
|
|
156
|
-
if (this.refreshImmediate) { clearImmediate(this.refreshImmediate); this.refreshImmediate = null }
|
|
157
|
-
this.failed = false
|
|
158
|
-
let desired: Set<string>
|
|
159
|
-
try { desired = this.desiredDirectories() }
|
|
160
|
-
catch (error) { return this.fail(this.root, error) }
|
|
161
|
-
|
|
162
|
-
const before = this.handles.size
|
|
163
|
-
for (const [path, handle] of [...this.handles]) {
|
|
164
|
-
if (desired.has(path)) continue
|
|
165
|
-
this.handles.delete(path)
|
|
166
|
-
liveRegistrations--
|
|
167
|
-
try { handle.close() } catch { /* already gone */ }
|
|
168
|
-
}
|
|
169
|
-
const options = this.consolidated ? { recursive: true } : {}
|
|
170
|
-
for (const path of desired) {
|
|
171
|
-
if (this.handles.has(path)) continue
|
|
172
|
-
try {
|
|
173
|
-
let handle: FSWatcher
|
|
174
|
-
const callback: TreeWatchCallback = (event, filename) => {
|
|
175
|
-
if (this.handles.get(path) !== handle) return
|
|
176
|
-
if (filename == null) { this.fail(path, new Error('pathless filesystem event')); return }
|
|
177
|
-
const inputPath = resolve(path, String(filename))
|
|
178
|
-
const rel = relative(this.root, inputPath)
|
|
179
|
-
if (rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || rel === '..') {
|
|
180
|
-
this.fail(path, new Error(`event escaped watcher root: ${String(filename)}`))
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
if (this.ignore(rel)) return
|
|
184
|
-
this.onInput(event, rel)
|
|
185
|
-
// only the userspace transport's desired set can drift: a new directory under a consolidated
|
|
186
|
-
// observer is already covered by the kernel.
|
|
187
|
-
if (this.recursive && !this.consolidated && event === 'rename') this.scheduleRefresh()
|
|
188
|
-
}
|
|
189
|
-
handle = this.watchFactory(path, options, callback)
|
|
190
|
-
this.handles.set(path, handle)
|
|
191
|
-
liveRegistrations++
|
|
192
|
-
handle.on('error', (error) => {
|
|
193
|
-
if (this.handles.get(path) === handle) this.fail(path, error)
|
|
194
|
-
})
|
|
195
|
-
} catch (error) { return this.fail(path, error) }
|
|
196
|
-
}
|
|
197
|
-
if (this.handles.size) liveRegistries.add(this)
|
|
198
|
-
if (DEBUG && this.handles.size !== before)
|
|
199
|
-
console.warn(`spec-cli: graph watchers — sources=${liveRegistries.size} registrations=${liveRegistrations} (${this.source}: ${this.transport} ${this.handles.size})`)
|
|
200
|
-
return true
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
close(): void {
|
|
204
|
-
this.failed = false
|
|
205
|
-
this.closeHandles()
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const plainSubs = new Set<Notify>()
|
|
210
|
-
const deltaSubs = new Set<DeltaSend>()
|
|
211
|
-
let debounce: ReturnType<typeof setTimeout> | null = null
|
|
212
|
-
let pendingFull = false
|
|
213
|
-
let pendingSessions = false
|
|
214
|
-
export type PendingGraphChanges = { full: boolean; sessions: boolean }
|
|
215
|
-
export const addPendingGraphChange = (pending: PendingGraphChanges, scope: Scope): PendingGraphChanges => ({
|
|
216
|
-
full: pending.full || scope === 'full',
|
|
217
|
-
sessions: pending.sessions || scope === 'sessions',
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
// Git creates the registry entry before the session transaction publishes its durable record. A registry
|
|
221
|
-
// event for that private candidate must not start a full board build while the `git worktree add` child is
|
|
222
|
-
// still running; the create route flushes the deferred event after publication (or cleanup).
|
|
223
|
-
export function isSessionCreateCandidateRegistryEvent(relativePath: string, candidatePaths: Iterable<string>): boolean {
|
|
224
|
-
const entry = relativePath.split(/[\\/]+/).filter(Boolean)[0] ?? ''
|
|
225
|
-
if (!entry) return false
|
|
226
|
-
for (const path of candidatePaths) if (basename(resolve(path)) === entry) return true
|
|
227
|
-
return false
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// under SPEXCODE_BOARD_DEBUG=1, every broadcast logs its changed unit keys + trigger tags + build ms.
|
|
231
|
-
const DEBUG = process.env.SPEXCODE_BOARD_DEBUG === '1'
|
|
232
|
-
function traceLatency(stage: 'sessions-signal' | 'session-projection-complete' | 'broadcast', detail: Record<string, unknown> = {}): void {
|
|
233
|
-
if (DEBUG) console.warn(`spec-cli: graph latency ${JSON.stringify({ at: Date.now(), stage, ...detail })}`)
|
|
234
|
-
}
|
|
235
|
-
// the set of trigger tags accrued SINCE THE LAST BROADCAST — each fireChanged adds its scope, the cold tick
|
|
236
|
-
// adds 'patrol'. Cleared on every broadcast. Its job: prove WHO caused a broadcast, so a change that only
|
|
237
|
-
// the patrol saw (tag set === {'patrol'}) is flagged as a repair — some leaf watcher was blind.
|
|
238
|
-
const triggerTags = new Set<string>()
|
|
239
|
-
|
|
240
|
-
// watchers a test can amputate to prove the patrol still heals the graph ([[graph-stream]]): a CSV of
|
|
241
|
-
// store,refs,worktrees makes the matching ensure* a no-op (with a one-time warning), so a change on that
|
|
242
|
-
// path reaches subscribers ONLY via the cold-tick patrol — the missing-watcher scenario, on demand.
|
|
243
|
-
const DISABLED = new Set((process.env.SPEXCODE_DISABLE_WATCHERS || '').split(',').map((s) => s.trim()).filter(Boolean))
|
|
244
|
-
const warnedDisabled = new Set<string>()
|
|
245
|
-
function isDisabled(name: string): boolean {
|
|
246
|
-
if (!DISABLED.has(name)) return false
|
|
247
|
-
if (!warnedDisabled.has(name)) { warnedDisabled.add(name); console.warn(`spec-cli: graph watcher '${name}' disabled via SPEXCODE_DISABLE_WATCHERS — the cold-tick patrol must cover it`) }
|
|
248
|
-
return true
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ---- the rebuild→diff→broadcast pipeline (runs only while delta subscribers exist) ----
|
|
252
|
-
// last successfully-broadcast snapshot: the delta chain's anchor. `lastFullFrame` is what a fresh
|
|
253
|
-
// subscriber gets instantly; `lastUnits`+`lastTag` are what the next diff chains from. A snapshot that
|
|
254
|
-
// failed the unitize precondition anchors nothing (lastUnits=null) so every following send is a full.
|
|
255
|
-
let lastUnits: Units | null = null
|
|
256
|
-
let lastTag = ''
|
|
257
|
-
let lastFullFrame: Frame | null = null
|
|
258
|
-
let building = false
|
|
259
|
-
let dirty = false
|
|
260
|
-
let patrolPending = false
|
|
261
|
-
let sessionRefreshRequested = false
|
|
262
|
-
let wakeSessionRefresh: (() => void) | null = null
|
|
263
|
-
|
|
264
|
-
async function rebuildAndBroadcast(patrol = false, sessions = false, full = false): Promise<void> {
|
|
265
|
-
if (patrol) patrolPending = true
|
|
266
|
-
if (sessions) sessionRefreshRequested = true
|
|
267
|
-
if (building) {
|
|
268
|
-
dirty = true
|
|
269
|
-
if (sessions) {
|
|
270
|
-
wakeSessionRefresh?.()
|
|
271
|
-
wakeSessionRefresh = null
|
|
272
|
-
}
|
|
273
|
-
return
|
|
274
|
-
}
|
|
275
|
-
building = true
|
|
276
|
-
try {
|
|
277
|
-
do {
|
|
278
|
-
dirty = false
|
|
279
|
-
const validate = patrolPending
|
|
280
|
-
patrolPending = false
|
|
281
|
-
const sessionsFirst = sessionRefreshRequested
|
|
282
|
-
sessionRefreshRequested = false
|
|
283
|
-
let servedSessionProjection = sessionsFirst
|
|
284
|
-
let board: unknown
|
|
285
|
-
// share the route's single-flight build ([[graph-cache]]); fireChanged() already invalidated the
|
|
286
|
-
// cache (at the accumulated scope), so this gets a fresh build/splice (or joins one a concurrent poll
|
|
287
|
-
// already started). The patrol instead asks that same cache flight to validate its input revision;
|
|
288
|
-
// equal inputs return the anchor without invoking a producer.
|
|
289
|
-
const t0 = Date.now()
|
|
290
|
-
try {
|
|
291
|
-
let wake!: () => void
|
|
292
|
-
const sessionWake = new Promise<void>((resolve) => { wake = resolve })
|
|
293
|
-
wakeSessionRefresh = wake
|
|
294
|
-
// A sessions-first turn can still fall back to an active route-owned full. Keep its wait wakeable
|
|
295
|
-
// too: a later persisted session change must re-enter the cheap projection rather than queue behind it.
|
|
296
|
-
const boardWait = sessionsFirst
|
|
297
|
-
? getBoardForSessionRefresh()
|
|
298
|
-
: validate ? patrolBoard() : getBoard()
|
|
299
|
-
const outcome = await Promise.race([
|
|
300
|
-
boardWait.then((value) => ({ value })),
|
|
301
|
-
sessionWake.then(() => ({ value: null as unknown })),
|
|
302
|
-
])
|
|
303
|
-
if (wakeSessionRefresh === wake) wakeSessionRefresh = null
|
|
304
|
-
if (outcome.value === null) {
|
|
305
|
-
boardWait.catch(() => {})
|
|
306
|
-
board = await getBoardForSessionRefresh()
|
|
307
|
-
servedSessionProjection = true
|
|
308
|
-
if (validate) patrolPending = true
|
|
309
|
-
dirty = true // the full wait was preempted only for delivery; it remains owed.
|
|
310
|
-
} else {
|
|
311
|
-
board = outcome.value
|
|
312
|
-
if (sessionsFirst && validate) { patrolPending = true; dirty = true }
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
// A failed refresh consumes no cause: graph-cache restores the producer scope, so its stream-side
|
|
317
|
-
// attribution must remain owed too. This also retains watcher causes that arrived while the failed
|
|
318
|
-
// flight was occupied. A later patrol may recover the work, but it is then one cause alongside those
|
|
319
|
-
// healthy leaf signals rather than a false patrol-only blind-watcher repair.
|
|
320
|
-
for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
|
|
321
|
-
continue
|
|
322
|
-
}
|
|
323
|
-
const buildMs = Date.now() - t0
|
|
324
|
-
if (servedSessionProjection) traceLatency('session-projection-complete', { patrol: validate })
|
|
325
|
-
const boardJson = JSON.stringify(board)
|
|
326
|
-
const { units, ok } = unitize(board as Record<string, unknown>)
|
|
327
|
-
const tag = tagOf(units)
|
|
328
|
-
// A session-first frame consumes its own cause, but a full/patrol cause remains owed until structural
|
|
329
|
-
// convergence. Otherwise the first cheap projection would erase patrol accountability before the full
|
|
330
|
-
// result could name it. A normal frame consumes its whole trigger set, including a no-op frame.
|
|
331
|
-
const tags = [...triggerTags]
|
|
332
|
-
triggerTags.clear()
|
|
333
|
-
if (servedSessionProjection)
|
|
334
|
-
for (const tag of tags) if (tag === 'full' || tag === 'patrol') triggerTags.add(tag)
|
|
335
|
-
if (sessionsFirst && full) dirty = true
|
|
336
|
-
if (tag === lastTag) continue
|
|
337
|
-
// the changed unit keys — computed against the prior anchor when we have one (a first paint has no
|
|
338
|
-
// anchor, so no repair claim can be made against it).
|
|
339
|
-
const changedKeys = lastUnits ? (() => { const { set, del } = diffUnits(lastUnits, units); return [...Object.keys(set), ...del] })() : []
|
|
340
|
-
const fullFrame: Frame = { event: 'graph-full', data: `{"to":"${tag}","graph":${boardJson}}` }
|
|
341
|
-
let frame = fullFrame
|
|
342
|
-
if (lastUnits && ok) {
|
|
343
|
-
const { set, del } = diffUnits(lastUnits, units)
|
|
344
|
-
const deltaData = JSON.stringify({ from: lastTag, to: tag, set, del })
|
|
345
|
-
// guaranteed win: ship the patch only when it actually beats the snapshot
|
|
346
|
-
if (deltaData.length < fullFrame.data.length) frame = { event: 'graph-delta', data: deltaData }
|
|
347
|
-
}
|
|
348
|
-
// the anchor is only meaningful while a delta subscriber holds the chain live: with none left,
|
|
349
|
-
// nothing rebuilds on change, so a cached anchor would silently age into a stale first frame for the
|
|
350
|
-
// NEXT era's subscriber (issue #70). A build that completes after the last unsub caches nothing
|
|
351
|
-
// (stopSourcesIfIdle cleared the anchor; leaving lastTag/lastUnits stale-cleared is consistent —
|
|
352
|
-
// rebuilds only run while delta subscribers exist, so nothing chains from them meanwhile).
|
|
353
|
-
if (deltaSubs.size) { lastUnits = ok ? units : null; lastTag = tag; lastFullFrame = fullFrame }
|
|
354
|
-
traceLatency('broadcast', { event: frame.event, sessionProjection: servedSessionProjection, tags, changedKeys })
|
|
355
|
-
for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
|
|
356
|
-
for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
|
|
357
|
-
// ---- repair accounting: a real (tag-moved) broadcast whose ONLY trigger was the cold-tick patrol
|
|
358
|
-
// means a leaf watcher was BLIND — the patrol self-healed it. That is a bug report, not routine, so
|
|
359
|
-
// it is ALWAYS loud (repairs are supposed to be zero — [[graph-stream]]). Under DEBUG, every
|
|
360
|
-
// broadcast logs its changed keys + triggers + build ms.
|
|
361
|
-
if (changedKeys.length && tags.length === 1 && tags[0] === 'patrol')
|
|
362
|
-
console.warn(`spec-cli: PATROL-REPAIR — the cold tick caught a change no leaf watcher pushed; changed units: [${changedKeys.join(', ')}] — a blind watcher, investigate`)
|
|
363
|
-
if (DEBUG)
|
|
364
|
-
console.warn(`spec-cli: graph broadcast — changed [${changedKeys.join(', ')}] triggers {${tags.join(', ')}} build ${buildMs}ms`)
|
|
365
|
-
} while (dirty)
|
|
366
|
-
} finally { building = false }
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// a merge/launch/close touches several record files at once; collapse the burst into ONE signal. Each call
|
|
370
|
-
// carries its own change SCOPE: full and sessions are independent obligations, not a max-scope replacement.
|
|
371
|
-
// With delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same
|
|
372
|
-
// tag-moved gate — no spurious refetches); without them it stays the zero-build legacy notify.
|
|
373
|
-
function fireChanged(scope: Scope = 'full', evalTarget?: EvalTarget): void {
|
|
374
|
-
// Advance eval input generations BEFORE invalidating/building the board, so the first frame caused by an
|
|
375
|
-
// input event is `updating(lastKnown)`. Summary completion calls this function without a target.
|
|
376
|
-
if (scope === 'sessions') traceLatency('sessions-signal')
|
|
377
|
-
if (evalTarget) invalidateSessionEvalProjections(evalTarget)
|
|
378
|
-
const pending = addPendingGraphChange({ full: pendingFull, sessions: pendingSessions }, scope)
|
|
379
|
-
pendingFull = pending.full
|
|
380
|
-
pendingSessions = pending.sessions
|
|
381
|
-
// invalidate the route's board cache ([[graph-cache]]) on EVERY change signal at its OWN scope,
|
|
382
|
-
// before the debounce guard — a plain-mode client that polls /api/graph (no delta rebuild here) must
|
|
383
|
-
// still see fresh data on its next poll, and a delta rebuild below re-reads the same now-stale cache.
|
|
384
|
-
invalidateBoard(scope)
|
|
385
|
-
triggerTags.add(scope)
|
|
386
|
-
// DEBOUNCE = 25ms. Real fs-event bursts (a merge touching many records) were MEASURED to span 0–5ms, so a
|
|
387
|
-
// 25ms window collapses them with room to spare while shaving ~125ms off the old 150ms lag; anything
|
|
388
|
-
// wider than the window is coalesced anyway by the in-flight build's dirty-rerun loop, which is the real
|
|
389
|
-
// burst absorber. So the debounce is a micro-collapse, not the coalescer.
|
|
390
|
-
if (debounce) return
|
|
391
|
-
debounce = setTimeout(() => {
|
|
392
|
-
debounce = null
|
|
393
|
-
const full = pendingFull, sessions = pendingSessions
|
|
394
|
-
pendingFull = false
|
|
395
|
-
pendingSessions = false
|
|
396
|
-
if (deltaSubs.size) void rebuildAndBroadcast(false, sessions, full)
|
|
397
|
-
else for (const notify of [...plainSubs]) { try { notify() } catch { /* swept on abort */ } }
|
|
398
|
-
}, 25)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ---- event source 0: an EXPLICIT server-side nudge ----
|
|
402
|
-
// for a server-side mutation that must show instantly regardless of watcher health: /rename writes the
|
|
403
|
-
// session's global record (`session.json` — [[session-rename]]), which lives INSIDE the watched store, so
|
|
404
|
-
// source 1 normally sees the write too. The explicit route call stays because that fs watch is best-effort
|
|
405
|
-
// (it can fail to attach), and the nudge makes the sub-second rename guarantee deterministic. Same
|
|
406
|
-
// debounced funnel as every other source; defaults to 'full' but the rename route passes 'sessions'.
|
|
407
|
-
export const notifyBoardChanged = (scope: Scope = 'full'): void =>
|
|
408
|
-
fireChanged(scope, scope === 'full' ? 'all' : undefined)
|
|
409
|
-
|
|
410
|
-
// Stable summary batches re-enter the SAME session-unit graph path; this is not a second transport.
|
|
411
|
-
setSessionEvalProjectionNotify(() => fireChanged('sessions'))
|
|
412
|
-
|
|
413
|
-
// ---- ONE repair scheduler for every filesystem source ----
|
|
414
|
-
// A source the platform refuses keeps its observer hold and is retried by THIS timer alone — never by a
|
|
415
|
-
// graph build, an HTTP read, a poller tick or a registry event. That is the whole anti-storm rule: the
|
|
416
|
-
// resource being exhausted (a registration budget, a descriptor table) is PROCESS-wide, so its backoff
|
|
417
|
-
// belongs to the process, not to each source racing its own retry and re-walking the corpus every time.
|
|
418
|
-
// `heldSources` is what an ordinary pass may not re-attempt; only the scheduled pass lifts that.
|
|
419
|
-
const heldSources = new Set<string>()
|
|
420
|
-
let repairTimer: ReturnType<typeof setTimeout> | null = null
|
|
421
|
-
let repairStep = 0
|
|
422
|
-
let repairing = false
|
|
423
|
-
|
|
424
|
-
const mayAttach = (source: string): boolean => repairing || !heldSources.has(source)
|
|
425
|
-
|
|
426
|
-
// The failure is loud ONCE per episode and names source + path + errno (the registry built that message);
|
|
427
|
-
// every later source felled by the same exhausted budget is counted, not re-printed, and the repair line
|
|
428
|
-
// reports the total. A half-attached registry is already closed by the registry itself before we get here.
|
|
429
|
-
function noteSourceFailure(source: string, error: unknown): void {
|
|
430
|
-
const known = heldSources.has(source)
|
|
431
|
-
heldSources.add(source)
|
|
432
|
-
if (!known && heldSources.size === 1) console.error(error instanceof Error ? error.message : String(error))
|
|
433
|
-
scheduleWatcherRepair()
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function noteSourceHealthy(source: string): void {
|
|
437
|
-
if (!heldSources.delete(source)) return
|
|
438
|
-
if (heldSources.size === 0) repairStep = 0
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function scheduleWatcherRepair(): void {
|
|
442
|
-
if (repairTimer || !heldSources.size) return
|
|
443
|
-
const delay = Math.min(30_000, 250 * 2 ** Math.min(repairStep, 7))
|
|
444
|
-
repairStep++
|
|
445
|
-
const era = watcherEra
|
|
446
|
-
console.error(`spec-cli: graph watcher repair — ${heldSources.size} source(s) held, retrying in ${delay}ms; the cold-tick patrol covers the gap until they reattach`)
|
|
447
|
-
repairTimer = setTimeout(() => {
|
|
448
|
-
repairTimer = null
|
|
449
|
-
if (era !== watcherEra) return
|
|
450
|
-
repairing = true
|
|
451
|
-
void ensureBoardFileWatchers()
|
|
452
|
-
.catch((error) => console.error(`spec-cli: graph watcher repair failed — ${error instanceof Error ? error.message : String(error)}`))
|
|
453
|
-
.finally(() => {
|
|
454
|
-
repairing = false
|
|
455
|
-
if (heldSources.size) scheduleWatcherRepair()
|
|
456
|
-
})
|
|
457
|
-
}, delay)
|
|
458
|
-
repairTimer.unref?.()
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// ---- event source 1: the session store (lifecycle status writes) → 'sessions' ----
|
|
462
|
-
let activeStoreRoot: string | null = null
|
|
463
|
-
let activeCommonRoot: string | null = null
|
|
464
|
-
let watcherEra = 0
|
|
465
|
-
let storeWatcher: TreeWatcherRegistry | null = null
|
|
466
|
-
|
|
467
|
-
function ensureWatcher(root: string): void {
|
|
468
|
-
if (storeWatcher?.root === root) return
|
|
469
|
-
if (storeWatcher) { storeWatcher.close(); storeWatcher = null }
|
|
470
|
-
if (isDisabled('store') || !mayAttach('store')) return
|
|
471
|
-
try { mkdirSync(root, { recursive: true }) }
|
|
472
|
-
catch (error) {
|
|
473
|
-
console.error(`spec-cli: graph watcher 'store' could not create ${root}: ${error instanceof Error ? error.message : String(error)}`)
|
|
474
|
-
}
|
|
475
|
-
const registry = new TreeWatcherRegistry({
|
|
476
|
-
root,
|
|
477
|
-
source: 'store',
|
|
478
|
-
scope: 'sessions',
|
|
479
|
-
onInput: () => { dropBranchIndex(); fireChanged('sessions') }, // a created/renamed session may own a new branch
|
|
480
|
-
onFailure: (error) => {
|
|
481
|
-
if (storeWatcher === registry) storeWatcher = null
|
|
482
|
-
noteSourceFailure('store', error)
|
|
483
|
-
fireChanged('sessions')
|
|
484
|
-
},
|
|
485
|
-
})
|
|
486
|
-
storeWatcher = registry
|
|
487
|
-
if (!registry.refresh()) {
|
|
488
|
-
if (storeWatcher === registry) storeWatcher = null
|
|
489
|
-
return
|
|
490
|
-
}
|
|
491
|
-
noteSourceHealthy('store')
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) → 'full' ----
|
|
495
|
-
// refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
|
|
496
|
-
// for packed-refs rewrites and HEAD flips. Ordinary graph units still have the patrol; eval projections are
|
|
497
|
-
// observer-held across a failure and only become current after a replacement watch authorizes a rescan.
|
|
498
|
-
type RegistryGroup = {
|
|
499
|
-
root: string
|
|
500
|
-
close(): void
|
|
501
|
-
}
|
|
502
|
-
let refsWatchers: RegistryGroup | null = null
|
|
503
|
-
const REFS_OBSERVER = 'graph:refs'
|
|
504
|
-
|
|
505
|
-
// @@@ the moved ref NAMES its scope - the watcher has always known which ref moved and threw it away, so
|
|
506
|
-
// every ref movement anywhere invalidated every session's evaluation. On a host carrying dozens of branches
|
|
507
|
-
// and a bot that commits continuously that means nothing is ever warm: observed on adopter-a as an input
|
|
508
|
-
// generation of 1208 against a last-known 254. A session's fingerprint reads exactly three refs — its own
|
|
509
|
-
// tip, the base tip, and their merge-base — so a ref that is neither cannot move it. `packed-refs` and
|
|
510
|
-
// `HEAD` stay broad on purpose: a packed update rewrites many refs behind ONE event, so it names nothing.
|
|
511
|
-
// the index is read from the session store, so it is only ever stale when that store moved — and the store
|
|
512
|
-
// has its own watcher, which drops it. A ref burst therefore costs one map lookup, not 76 record reads.
|
|
513
|
-
let branchIndexMemo: Map<string, string> | null = null
|
|
514
|
-
function branchIndex(): Map<string, string> {
|
|
515
|
-
return (branchIndexMemo ??= sessionBranchIndex())
|
|
516
|
-
}
|
|
517
|
-
function dropBranchIndex(): void { branchIndexMemo = null }
|
|
518
|
-
|
|
519
|
-
export function evalTargetForRef(ref: string | undefined, base: string, branches: Map<string, string>): EvalTarget | undefined {
|
|
520
|
-
if (ref === undefined) return 'all' // an unnamed movement must assume the worst
|
|
521
|
-
const rel = ref.replace(/\\/g, '/')
|
|
522
|
-
if (!rel.startsWith('heads/')) return undefined // tags and remotes feed no session fingerprint
|
|
523
|
-
const branch = rel.slice('heads/'.length)
|
|
524
|
-
if (!branch) return 'all'
|
|
525
|
-
if (branch === base) return 'all' // every merge-base may have moved
|
|
526
|
-
const id = branches.get(branch)
|
|
527
|
-
return id ? { id } : undefined // a branch no session owns moves no session's evaluation
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
export function watchSessionEvalRefs(
|
|
531
|
-
common: string,
|
|
532
|
-
onInput: (ref?: string) => void,
|
|
533
|
-
onFailure: (error: Error) => void,
|
|
534
|
-
): RegistryGroup {
|
|
535
|
-
let attached: TreeWatcherRegistry[] = []
|
|
536
|
-
let failed = false
|
|
537
|
-
let ready = false
|
|
538
|
-
let attachError: Error | null = null
|
|
539
|
-
const close = () => {
|
|
540
|
-
const registries = attached
|
|
541
|
-
attached = []
|
|
542
|
-
for (const registry of registries) registry.close()
|
|
543
|
-
}
|
|
544
|
-
const fail = (error: Error) => {
|
|
545
|
-
if (failed) return
|
|
546
|
-
failed = true
|
|
547
|
-
close()
|
|
548
|
-
if (ready) onFailure(error)
|
|
549
|
-
else attachError = error
|
|
550
|
-
}
|
|
551
|
-
const refs = new TreeWatcherRegistry({
|
|
552
|
-
root: join(common, 'refs'),
|
|
553
|
-
source: 'refs',
|
|
554
|
-
scope: 'full',
|
|
555
|
-
onInput: (_event, rel) => onInput(rel),
|
|
556
|
-
onFailure: fail,
|
|
557
|
-
})
|
|
558
|
-
attached.push(refs)
|
|
559
|
-
if (!refs.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'refs' failed at ${refs.root}`)
|
|
560
|
-
|
|
561
|
-
const commonFiles = new TreeWatcherRegistry({
|
|
562
|
-
root: common,
|
|
563
|
-
source: 'refs-common',
|
|
564
|
-
scope: 'full',
|
|
565
|
-
recursive: false,
|
|
566
|
-
onInput: (_event, file) => { if (file === 'packed-refs' || file === 'HEAD') onInput() }, // names nothing -> 'all'
|
|
567
|
-
onFailure: fail,
|
|
568
|
-
})
|
|
569
|
-
attached.push(commonFiles)
|
|
570
|
-
if (!commonFiles.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'refs-common' failed at ${common}`)
|
|
571
|
-
ready = true
|
|
572
|
-
return { root: resolve(common), close }
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
function refsWatcherFailed(error: Error): void {
|
|
576
|
-
refsWatchers = null
|
|
577
|
-
noteSourceFailure('refs', error)
|
|
578
|
-
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function ensureRefsWatcher(common = activeCommonRoot): void {
|
|
582
|
-
if (!common) return
|
|
583
|
-
if (refsWatchers?.root === common) return
|
|
584
|
-
if (refsWatchers) { refsWatchers.close(); refsWatchers = null }
|
|
585
|
-
if (isDisabled('refs')) {
|
|
586
|
-
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
587
|
-
return
|
|
588
|
-
}
|
|
589
|
-
if (!mayAttach('refs')) return
|
|
590
|
-
try {
|
|
591
|
-
refsWatchers = watchSessionEvalRefs(common, (ref) => fireChanged('full', evalTargetForRef(ref, mainBranch(), branchIndex())), refsWatcherFailed)
|
|
592
|
-
noteSourceHealthy('refs')
|
|
593
|
-
if (releaseSessionEvalProjectionObserver(REFS_OBSERVER)) fireChanged('full')
|
|
594
|
-
} catch (error) {
|
|
595
|
-
refsWatchers = null
|
|
596
|
-
noteSourceFailure('refs', error)
|
|
597
|
-
if (holdSessionEvalProjectionObserver(REFS_OBSERVER, 'all')) fireChanged('full')
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// ---- event source 3: worktree registry + working roots + per-worktree indexes → 'full' ----
|
|
602
|
-
// Summary inputs include ordinary dirty source and staged-only changes, not just `.spec`. Each live
|
|
603
|
-
// worktree is therefore TWO canonical roots — its working tree and git's metadata dir for it (`index`) —
|
|
604
|
-
// and that pair is the whole per-worktree registration cost, whatever the corpus inside it holds.
|
|
605
|
-
let registryWatcher: TreeWatcherRegistry | null = null
|
|
606
|
-
let registryReady = false
|
|
607
|
-
type WorktreeWatch = {
|
|
608
|
-
path: string
|
|
609
|
-
root: TreeWatcherRegistry
|
|
610
|
-
index: TreeWatcherRegistry
|
|
611
|
-
close(): void
|
|
612
|
-
}
|
|
613
|
-
const worktreeWatchers = new Map<string, WorktreeWatch>()
|
|
614
|
-
const worktreeObserver = (name: string): string => `graph:worktree:${name}`
|
|
615
|
-
const worktreeSource = (name: string): string => `worktree:${name}`
|
|
616
|
-
|
|
617
|
-
const ignoredWorktreePath = (file: string): boolean =>
|
|
618
|
-
file.split(/[\\/]/).some((segment) => segment === '.git' || segment === 'node_modules')
|
|
619
|
-
|
|
620
|
-
export function watchSessionEvalWorktree(
|
|
621
|
-
wtPath: string,
|
|
622
|
-
gitDir: string,
|
|
623
|
-
onInput: () => void,
|
|
624
|
-
onFailure: (error: Error) => void,
|
|
625
|
-
): WorktreeWatch {
|
|
626
|
-
let failed = false
|
|
627
|
-
let ready = false
|
|
628
|
-
let attachError: Error | null = null
|
|
629
|
-
let root: TreeWatcherRegistry | null = null
|
|
630
|
-
let index: TreeWatcherRegistry | null = null
|
|
631
|
-
const close = () => {
|
|
632
|
-
root?.close()
|
|
633
|
-
index?.close()
|
|
634
|
-
}
|
|
635
|
-
const fail = (error: Error) => {
|
|
636
|
-
if (failed) return
|
|
637
|
-
failed = true
|
|
638
|
-
close()
|
|
639
|
-
if (ready) onFailure(error)
|
|
640
|
-
else attachError = error
|
|
641
|
-
}
|
|
642
|
-
root = new TreeWatcherRegistry({
|
|
643
|
-
root: wtPath,
|
|
644
|
-
source: `worktree:${resolve(wtPath)}`,
|
|
645
|
-
scope: 'full',
|
|
646
|
-
ignore: ignoredWorktreePath,
|
|
647
|
-
onInput: () => onInput(),
|
|
648
|
-
onFailure: fail,
|
|
649
|
-
})
|
|
650
|
-
if (!root.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree' failed at ${wtPath}`)
|
|
651
|
-
index = new TreeWatcherRegistry({
|
|
652
|
-
root: gitDir,
|
|
653
|
-
source: `worktree-index:${resolve(wtPath)}`,
|
|
654
|
-
scope: 'full',
|
|
655
|
-
recursive: false,
|
|
656
|
-
onInput: (_event, file) => { if (file === 'index') onInput() },
|
|
657
|
-
onFailure: fail,
|
|
658
|
-
})
|
|
659
|
-
if (!index.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-index' failed at ${gitDir}`)
|
|
660
|
-
ready = true
|
|
661
|
-
return { path: resolve(wtPath), root, index, close }
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
function dropWorktreeWatcher(name: string): WorktreeWatch | null {
|
|
665
|
-
const row = worktreeWatchers.get(name)
|
|
666
|
-
if (!row) return null
|
|
667
|
-
worktreeWatchers.delete(name)
|
|
668
|
-
row.close()
|
|
669
|
-
return row
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function watcherFailed(name: string, path: string, error: Error): void {
|
|
673
|
-
// Failure/overflow has no trustworthy path. Keep last-known, mark the target updating, and hand the
|
|
674
|
-
// reattach to the one repair scheduler; the authorized summary build is the one authoritative rescan,
|
|
675
|
-
// never a periodic sweep and never a re-walk driven by whatever read noticed the failure.
|
|
676
|
-
dropWorktreeWatcher(name)
|
|
677
|
-
noteSourceFailure(worktreeSource(name), error)
|
|
678
|
-
if (holdSessionEvalProjectionObserver(worktreeObserver(name), { path })) fireChanged('full')
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
const forcedWorktreeSessions = new Set<string>()
|
|
682
|
-
let worktreeReconcileFlight: Promise<void> | null = null
|
|
683
|
-
|
|
684
|
-
export function sessionWorktreeWatchPaths(
|
|
685
|
-
sessions: { id: string; path: string; liveness?: string }[],
|
|
686
|
-
forcedSessions: Set<string> = new Set(),
|
|
687
|
-
): Set<string> {
|
|
688
|
-
return new Set(sessions
|
|
689
|
-
.filter((session) => session.liveness !== 'offline' || forcedSessions.has(session.id))
|
|
690
|
-
.map((session) => resolve(session.path)))
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
async function reconcileWorktreePass(forcedSessions: Set<string>, era: number, common: string): Promise<void> {
|
|
694
|
-
const dir = join(common, 'worktrees')
|
|
695
|
-
let sessions: Awaited<ReturnType<typeof listSessions>>
|
|
696
|
-
try { sessions = await listSessions() } catch { return }
|
|
697
|
-
if (era !== watcherEra) return
|
|
698
|
-
const wantedPaths = sessionWorktreeWatchPaths(sessions, forcedSessions)
|
|
699
|
-
let ents: import('node:fs').Dirent[] = []
|
|
700
|
-
try { ents = readdirSync(dir, { withFileTypes: true }) } catch { /* no worktrees registry yet */ }
|
|
701
|
-
const wantedNames = new Set<string>()
|
|
702
|
-
let released = false
|
|
703
|
-
for (const e of ents) {
|
|
704
|
-
if (!e.isDirectory()) continue
|
|
705
|
-
let wtPath: string
|
|
706
|
-
try { wtPath = dirname(readFileSync(join(dir, e.name, 'gitdir'), 'utf8').trim()) } catch { continue }
|
|
707
|
-
const normalizedPath = resolve(wtPath)
|
|
708
|
-
if (!wantedPaths.has(normalizedPath)) {
|
|
709
|
-
if (dropWorktreeWatcher(e.name)) released = releaseSessionEvalProjectionObserver(worktreeObserver(e.name)) || released
|
|
710
|
-
noteSourceHealthy(worktreeSource(e.name))
|
|
711
|
-
continue
|
|
712
|
-
}
|
|
713
|
-
wantedNames.add(e.name)
|
|
714
|
-
const existing = worktreeWatchers.get(e.name)
|
|
715
|
-
if (existing?.path === normalizedPath) continue
|
|
716
|
-
const rootChanged = existing != null
|
|
717
|
-
if (existing) dropWorktreeWatcher(e.name)
|
|
718
|
-
// a held worktree is the repair scheduler's to reattach — an ordinary reconcile pass must not re-walk
|
|
719
|
-
// it, which is what turned one refused registration into a per-read attach storm.
|
|
720
|
-
if (!mayAttach(worktreeSource(e.name))) continue
|
|
721
|
-
try {
|
|
722
|
-
// the entry's `gitdir` file points at the worktree's `<tree>/.git` (file or dir); its parent is the tree.
|
|
723
|
-
const row = watchSessionEvalWorktree(
|
|
724
|
-
wtPath,
|
|
725
|
-
join(dir, e.name),
|
|
726
|
-
() => fireChanged('full', { path: wtPath }),
|
|
727
|
-
(error) => watcherFailed(e.name, wtPath, error),
|
|
728
|
-
)
|
|
729
|
-
if (era !== watcherEra) { row.close(); return }
|
|
730
|
-
worktreeWatchers.set(e.name, row)
|
|
731
|
-
noteSourceHealthy(worktreeSource(e.name))
|
|
732
|
-
if (rootChanged) fireChanged('full', { path: wtPath })
|
|
733
|
-
// The replacement is live before its hold is removed. This delta authorizes one double-read rescan,
|
|
734
|
-
// so edits made anywhere in the unwatched interval are inside the new generation's fingerprint.
|
|
735
|
-
if (releaseSessionEvalProjectionObserver(worktreeObserver(e.name))) fireChanged('full')
|
|
736
|
-
} catch (error) {
|
|
737
|
-
// An attach failure is observable: mark unknown/full now. The repair scheduler owns the reattach; no
|
|
738
|
-
// patrol is allowed to call the eval projection current meanwhile.
|
|
739
|
-
noteSourceFailure(worktreeSource(e.name), error)
|
|
740
|
-
if (holdSessionEvalProjectionObserver(worktreeObserver(e.name), { path: wtPath })) fireChanged('full')
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
for (const name of worktreeWatchers.keys()) if (!wantedNames.has(name)) {
|
|
744
|
-
dropWorktreeWatcher(name)
|
|
745
|
-
released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
|
|
746
|
-
}
|
|
747
|
-
for (const source of [...heldSources]) {
|
|
748
|
-
const name = source.startsWith('worktree:') ? source.slice('worktree:'.length) : null
|
|
749
|
-
if (name && !wantedNames.has(name)) {
|
|
750
|
-
noteSourceHealthy(source)
|
|
751
|
-
released = releaseSessionEvalProjectionObserver(worktreeObserver(name)) || released
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
if (released) fireChanged('full')
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
function reconcileWorktrees(forceSessionId?: string): Promise<void> {
|
|
758
|
-
// Blinding a leaf must blind it from EVERY entry point. Reconciliation is reached from the liveness
|
|
759
|
-
// poller and from registry events as well as from the ensure pass, so gating only the ensure pass left
|
|
760
|
-
// the per-worktree observers attaching anyway — the injection looked applied while the leaf still saw
|
|
761
|
-
// everything, which makes the patrol's accountability untestable rather than merely untested.
|
|
762
|
-
if (isDisabled('worktrees')) return Promise.resolve()
|
|
763
|
-
if (forceSessionId) forcedWorktreeSessions.add(forceSessionId)
|
|
764
|
-
if (worktreeReconcileFlight) return worktreeReconcileFlight
|
|
765
|
-
const era = watcherEra
|
|
766
|
-
const common = activeCommonRoot
|
|
767
|
-
if (!common) return Promise.resolve()
|
|
768
|
-
const flight = (async () => {
|
|
769
|
-
do {
|
|
770
|
-
if (era !== watcherEra) return
|
|
771
|
-
const forced = new Set(forcedWorktreeSessions)
|
|
772
|
-
forcedWorktreeSessions.clear()
|
|
773
|
-
await reconcileWorktreePass(forced, era, common)
|
|
774
|
-
} while (forcedWorktreeSessions.size)
|
|
775
|
-
})().finally(() => { if (worktreeReconcileFlight === flight) worktreeReconcileFlight = null })
|
|
776
|
-
worktreeReconcileFlight = flight
|
|
777
|
-
void flight.catch((error) => {
|
|
778
|
-
console.warn(`spec-cli: worktree watcher reconciliation failed — ${error instanceof Error ? error.message : String(error)}`)
|
|
779
|
-
})
|
|
780
|
-
return flight
|
|
781
|
-
}
|
|
782
|
-
const WORKTREE_REGISTRY_OBSERVER = 'graph:worktree-registry'
|
|
783
|
-
let deferredRegistryChange = false
|
|
784
|
-
let deferredRegistryTimer: ReturnType<typeof setTimeout> | null = null
|
|
785
|
-
|
|
786
|
-
function flushDeferredRegistryChange(): void {
|
|
787
|
-
if (!deferredRegistryChange) return
|
|
788
|
-
deferredRegistryChange = false
|
|
789
|
-
if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
|
|
790
|
-
void reconcileWorktrees()
|
|
791
|
-
fireChanged('full', 'all')
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// A crashed or disconnected create request must not leave the registry event deferred forever. The normal
|
|
795
|
-
// route flush is immediate; this bounded fallback preserves the watcher contract if the process never reaches
|
|
796
|
-
// its response boundary.
|
|
797
|
-
function deferRegistryChange(): void {
|
|
798
|
-
deferredRegistryChange = true
|
|
799
|
-
if (deferredRegistryTimer) return
|
|
800
|
-
deferredRegistryTimer = setTimeout(() => {
|
|
801
|
-
deferredRegistryTimer = null
|
|
802
|
-
flushDeferredRegistryChange()
|
|
803
|
-
}, 10_000)
|
|
804
|
-
deferredRegistryTimer.unref?.()
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
export function flushDeferredWorktreeRegistryChange(): void {
|
|
808
|
-
flushDeferredRegistryChange()
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
export function watchSessionEvalRegistry(
|
|
812
|
-
dir: string,
|
|
813
|
-
onInput: (event: 'rename' | 'change', relativePath: string) => void,
|
|
814
|
-
onFailure: (error: Error) => void,
|
|
815
|
-
): TreeWatcherRegistry {
|
|
816
|
-
let ready = false
|
|
817
|
-
let attachError: Error | null = null
|
|
818
|
-
const registry = new TreeWatcherRegistry({
|
|
819
|
-
root: dir,
|
|
820
|
-
source: 'worktree-registry',
|
|
821
|
-
scope: 'full',
|
|
822
|
-
recursive: false,
|
|
823
|
-
onInput: (_event, relativePath) => onInput(_event, relativePath),
|
|
824
|
-
onFailure: (error) => {
|
|
825
|
-
if (ready) onFailure(error)
|
|
826
|
-
else attachError = error
|
|
827
|
-
},
|
|
828
|
-
})
|
|
829
|
-
if (!registry.refresh()) throw attachError ?? new Error(`spec-cli: graph watcher 'worktree-registry' failed at ${dir}`)
|
|
830
|
-
ready = true
|
|
831
|
-
return registry
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
function registryWatcherFailed(error: Error): void {
|
|
835
|
-
registryWatcher = null
|
|
836
|
-
registryReady = false
|
|
837
|
-
noteSourceFailure('worktree-registry', error)
|
|
838
|
-
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
async function ensureWorktreeRegistry(forceSessionId?: string): Promise<void> {
|
|
842
|
-
const common = activeCommonRoot
|
|
843
|
-
if (!common) return
|
|
844
|
-
const dir = resolve(join(common, 'worktrees'))
|
|
845
|
-
// The registry watcher already reconciles add/remove events. Re-scanning every ordinary graph/evals read
|
|
846
|
-
// turns a large worktree registry into an artificial request latency floor; only a scoped read may demand
|
|
847
|
-
// one target after startup, while the unscoped hot path reuses the attached live watchers.
|
|
848
|
-
// an attached registry short-circuits the scan for ordinary reads, but a REPAIR pass exists precisely to
|
|
849
|
-
// reattach held worktrees, so it must reach the reconcile even when the registry itself is healthy.
|
|
850
|
-
if (registryWatcher?.root === dir) {
|
|
851
|
-
if (forceSessionId || repairing) await reconcileWorktrees(forceSessionId)
|
|
852
|
-
return
|
|
853
|
-
}
|
|
854
|
-
if (registryWatcher) { registryWatcher.close(); registryWatcher = null; registryReady = false }
|
|
855
|
-
// A platform may reject registry watches. Once the initial reconciliation has run, ordinary
|
|
856
|
-
// reads must not repeat its full worktree scan while the repair pass is pending; scoped reads can
|
|
857
|
-
// still demand their target explicitly.
|
|
858
|
-
if (registryReady && !forceSessionId && !repairing) return
|
|
859
|
-
if (registryReady && (forceSessionId || repairing)) { await reconcileWorktrees(forceSessionId); return }
|
|
860
|
-
if (isDisabled('worktrees')) {
|
|
861
|
-
registryReady = true
|
|
862
|
-
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
863
|
-
return
|
|
864
|
-
}
|
|
865
|
-
if (!mayAttach('worktree-registry')) return
|
|
866
|
-
registryReady = true
|
|
867
|
-
try {
|
|
868
|
-
try { mkdirSync(dir, { recursive: true }) }
|
|
869
|
-
catch (error) { console.error(`spec-cli: graph watcher 'worktree-registry' could not create ${dir}: ${error instanceof Error ? error.message : String(error)}`) }
|
|
870
|
-
// a registry add/remove is itself a 'full' change (a new/gone worktree reshapes the overlay); also
|
|
871
|
-
// reconcile the per-worktree `.spec` watchers on every registry event.
|
|
872
|
-
registryWatcher = watchSessionEvalRegistry(dir, (_event, relativePath) => {
|
|
873
|
-
if (isSessionCreateCandidateRegistryEvent(relativePath, pendingSessionCreateWorktreePaths())) {
|
|
874
|
-
deferRegistryChange()
|
|
875
|
-
return
|
|
876
|
-
}
|
|
877
|
-
void reconcileWorktrees()
|
|
878
|
-
fireChanged('full', 'all')
|
|
879
|
-
}, registryWatcherFailed)
|
|
880
|
-
noteSourceHealthy('worktree-registry')
|
|
881
|
-
} catch (error) {
|
|
882
|
-
registryWatcher = null
|
|
883
|
-
registryReady = false
|
|
884
|
-
noteSourceFailure('worktree-registry', error)
|
|
885
|
-
if (holdSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER, 'all')) fireChanged('full')
|
|
886
|
-
}
|
|
887
|
-
await reconcileWorktrees(forceSessionId) // attach for the live/demanded worktrees that already exist
|
|
888
|
-
if (registryWatcher && releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)) fireChanged('full')
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
// Attach the canonical filesystem sources before an HTTP snapshot starts summary work. This closes the
|
|
892
|
-
// request→SSE handoff gap: an edit after the snapshot build has a watcher before it can occur. A source
|
|
893
|
-
// the platform refused is NOT retried here — it is held for the repair scheduler, so an HTTP read never
|
|
894
|
-
// becomes a reattach loop.
|
|
895
|
-
export async function ensureBoardFileWatchers(forceSessionId?: string): Promise<void> {
|
|
896
|
-
const storeRoot = resolve(sessionsRoot())
|
|
897
|
-
const commonRoot = resolve(gitCommonDir())
|
|
898
|
-
if ((activeStoreRoot && activeStoreRoot !== storeRoot) || (activeCommonRoot && activeCommonRoot !== commonRoot))
|
|
899
|
-
closeBoardFileWatchers()
|
|
900
|
-
activeStoreRoot = storeRoot
|
|
901
|
-
activeCommonRoot = commonRoot
|
|
902
|
-
ensureWatcher(storeRoot)
|
|
903
|
-
ensureRefsWatcher(commonRoot)
|
|
904
|
-
await ensureWorktreeRegistry(forceSessionId)
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
export function closeBoardFileWatchers(): void {
|
|
908
|
-
watcherEra++
|
|
909
|
-
if (repairTimer) { clearTimeout(repairTimer); repairTimer = null }
|
|
910
|
-
if (deferredRegistryTimer) { clearTimeout(deferredRegistryTimer); deferredRegistryTimer = null }
|
|
911
|
-
deferredRegistryChange = false
|
|
912
|
-
heldSources.clear()
|
|
913
|
-
repairStep = 0
|
|
914
|
-
repairing = false
|
|
915
|
-
forcedWorktreeSessions.clear()
|
|
916
|
-
worktreeReconcileFlight = null
|
|
917
|
-
|
|
918
|
-
storeWatcher?.close()
|
|
919
|
-
storeWatcher = null
|
|
920
|
-
refsWatchers?.close()
|
|
921
|
-
refsWatchers = null
|
|
922
|
-
registryWatcher?.close()
|
|
923
|
-
registryWatcher = null
|
|
924
|
-
registryReady = false
|
|
925
|
-
for (const [name, row] of worktreeWatchers) {
|
|
926
|
-
row.close()
|
|
927
|
-
releaseSessionEvalProjectionObserver(worktreeObserver(name))
|
|
928
|
-
}
|
|
929
|
-
worktreeWatchers.clear()
|
|
930
|
-
releaseSessionEvalProjectionObserver(REFS_OBSERVER)
|
|
931
|
-
releaseSessionEvalProjectionObserver(WORKTREE_REGISTRY_OBSERVER)
|
|
932
|
-
activeStoreRoot = null
|
|
933
|
-
activeCommonRoot = null
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// ---- event source 4: the two-tier tmux-derived pollers (liveness + activity — never a file write) → 'sessions' ----
|
|
937
|
-
// The signals a store watch can't see are tmux-derived, and they split by cost ([[sessions]]): a HOT 100ms
|
|
938
|
-
// poll of a cheap syscall-only fingerprint (a socket dying, a listener wedging) and a WARM 1s poll of the
|
|
939
|
-
// pane-title self-summaries (a headline change is a tmux round-trip, too dear at 100ms). Both fire 'sessions'.
|
|
940
|
-
let hotPoller: ReturnType<typeof setInterval> | null = null
|
|
941
|
-
let warmPoller: ReturnType<typeof setInterval> | null = null
|
|
942
|
-
let lastHot = ''
|
|
943
|
-
let lastWarm = ''
|
|
944
|
-
function ensurePollers(): void {
|
|
945
|
-
if (!hotPoller) hotPoller = setInterval(() => {
|
|
946
|
-
void hotSignature().then((sig) => {
|
|
947
|
-
if (sig !== lastHot) { lastHot = sig; void reconcileWorktrees(); fireChanged('sessions') }
|
|
948
|
-
}).catch(() => {})
|
|
949
|
-
}, 100)
|
|
950
|
-
if (!warmPoller) warmPoller = setInterval(() => {
|
|
951
|
-
void warmSignature().then((sig) => { if (sig !== lastWarm) { lastWarm = sig; fireChanged('sessions') } }).catch(() => {})
|
|
952
|
-
}, 1000)
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
// ---- event source 5: the cold-tick PATROL — the server-side replacement for every client's slow fallback
|
|
956
|
-
// poll, AND the self-heal authority. A relaxed tick asks graph-cache's ONE flight to compare its board-input
|
|
957
|
-
// revision: unchanged state returns the anchor without assembly, while an unobserved ref/worktree/store move
|
|
958
|
-
// escalates there to a full rebuild. The 'patrol' tag keeps that repair accountable. Delta-gated: plain-only
|
|
959
|
-
// clients keep their own client-side fallback, so without delta subscribers this does no validation work.
|
|
960
|
-
let coldTick: ReturnType<typeof setInterval> | null = null
|
|
961
|
-
function ensureColdTick(): void {
|
|
962
|
-
if (coldTick) return
|
|
963
|
-
coldTick = setInterval(() => {
|
|
964
|
-
if (!deltaSubs.size) return
|
|
965
|
-
triggerTags.add('patrol')
|
|
966
|
-
void rebuildAndBroadcast(true)
|
|
967
|
-
}, 15000)
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
function stopSourcesIfIdle(): void {
|
|
971
|
-
// the delta anchor dies with its era's last subscriber: past this point changes invalidate the cache but
|
|
972
|
-
// never rebuild, so lastFullFrame would drift arbitrarily far from the real board — and the next era's
|
|
973
|
-
// first subscriber would be anchored on it (its warm-terminal set then drops live sessions' panes, and the
|
|
974
|
-
// client's recovery lanes can latch each other out — issue #70). A new era opens on a fresh build instead.
|
|
975
|
-
if (deltaSubs.size === 0) { lastUnits = null; lastTag = ''; lastFullFrame = null; patrolPending = false }
|
|
976
|
-
if (deltaSubs.size === 0) setSessionEvalProjectionWarmup(false)
|
|
977
|
-
if (plainSubs.size + deltaSubs.size > 0) return
|
|
978
|
-
if (hotPoller) { clearInterval(hotPoller); hotPoller = null; lastHot = '' }
|
|
979
|
-
if (warmPoller) { clearInterval(warmPoller); warmPoller = null; lastWarm = '' }
|
|
980
|
-
if (coldTick) { clearInterval(coldTick); coldTick = null }
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// GET /api/graph/stream — one SSE per dashboard tab, server→client only, with a periodic `ping` so an
|
|
984
|
-
// idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
|
|
985
|
-
// auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `graph-full`, so the
|
|
986
|
-
// chain re-anchors with no client-side repair logic.
|
|
987
|
-
export async function boardStream(c: Context) {
|
|
988
|
-
const delta = c.req.query('mode') === 'delta'
|
|
989
|
-
await ensureBoardFileWatchers()
|
|
990
|
-
return streamSSE(c, async (stream) => {
|
|
991
|
-
let aborted = false
|
|
992
|
-
const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
|
|
993
|
-
const notify: Notify = () => { void stream.writeSSE({ event: 'graph-changed', data: 'x' }).catch(() => {}) }
|
|
994
|
-
if (delta) {
|
|
995
|
-
deltaSubs.add(send)
|
|
996
|
-
setSessionEvalProjectionWarmup(true)
|
|
997
|
-
ensureColdTick()
|
|
998
|
-
} else { plainSubs.add(notify) }
|
|
999
|
-
ensurePollers()
|
|
1000
|
-
const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
|
|
1001
|
-
stream.onAbort(() => { aborted = true; unsub() })
|
|
1002
|
-
try {
|
|
1003
|
-
await stream.writeSSE({ event: 'ready', data: 'x' })
|
|
1004
|
-
if (delta) {
|
|
1005
|
-
// seed the chain: the cached anchor snapshot immediately (same tag the next delta chains from),
|
|
1006
|
-
// then a fire so a connect during a quiet stretch converges to truly-current within one build.
|
|
1007
|
-
if (lastFullFrame) { await stream.writeSSE(lastFullFrame).catch(() => {}) ; fireChanged() }
|
|
1008
|
-
else void rebuildAndBroadcast()
|
|
1009
|
-
}
|
|
1010
|
-
while (!aborted) {
|
|
1011
|
-
// ping every 10s — the client's heartbeat contract is 2.5× this window ([[graph-stream]]), so a
|
|
1012
|
-
// silent-death gap is caught inside one client watchdog interval, and idle proxies never time out.
|
|
1013
|
-
await stream.sleep(10000)
|
|
1014
|
-
if (aborted) break
|
|
1015
|
-
await stream.writeSSE({ event: 'ping', data: 'x' })
|
|
1016
|
-
}
|
|
1017
|
-
} finally { unsub() }
|
|
1018
|
-
})
|
|
1019
|
-
}
|