spexcode 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +99 -35
  2. package/README.zh-CN.md +135 -0
  3. package/package.json +5 -6
  4. package/spec-cli/README.md +86 -0
  5. package/spec-cli/bin/spex.mjs +15 -3
  6. package/spec-cli/hooks/dispatch.sh +20 -8
  7. package/spec-cli/hooks/harness.sh +18 -11
  8. package/spec-cli/src/board.ts +47 -18
  9. package/spec-cli/src/boardCache.ts +70 -0
  10. package/spec-cli/src/boardDelta.ts +90 -0
  11. package/spec-cli/src/boardStream.ts +178 -0
  12. package/spec-cli/src/cli.ts +184 -122
  13. package/spec-cli/src/client.ts +6 -4
  14. package/spec-cli/src/gateway.ts +64 -24
  15. package/spec-cli/src/git.ts +105 -92
  16. package/spec-cli/src/guide.ts +186 -19
  17. package/spec-cli/src/harness-select.ts +63 -0
  18. package/spec-cli/src/harness.ts +506 -100
  19. package/spec-cli/src/help.ts +362 -0
  20. package/spec-cli/src/hooks.ts +0 -14
  21. package/spec-cli/src/index.ts +279 -32
  22. package/spec-cli/src/init.ts +41 -1
  23. package/spec-cli/src/issues.ts +301 -0
  24. package/spec-cli/src/layout.ts +70 -28
  25. package/spec-cli/src/lint.ts +12 -10
  26. package/spec-cli/src/listen.ts +28 -0
  27. package/spec-cli/src/localIssues.ts +700 -0
  28. package/spec-cli/src/materialize.ts +182 -27
  29. package/spec-cli/src/mentions.ts +192 -0
  30. package/spec-cli/src/plugin-harness.ts +145 -0
  31. package/spec-cli/src/pty-bridge.ts +378 -81
  32. package/spec-cli/src/self.ts +123 -20
  33. package/spec-cli/src/sessions.ts +461 -298
  34. package/spec-cli/src/specs.ts +55 -14
  35. package/spec-cli/src/supervise.ts +23 -3
  36. package/spec-cli/src/tsx-bin.ts +14 -5
  37. package/spec-cli/src/uninstall.ts +146 -0
  38. package/spec-cli/templates/hooks/post-merge +27 -0
  39. package/spec-cli/templates/hooks/pre-commit +51 -31
  40. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  41. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  42. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  43. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  44. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  45. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  47. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  48. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  49. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  50. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  51. package/spec-cli/templates/spec/project/spec.md +2 -2
  52. package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
  53. package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
  54. package/spec-dashboard/dist/index.html +17 -5
  55. package/spec-forge/src/cache.ts +16 -0
  56. package/spec-forge/src/cli.ts +4 -10
  57. package/spec-forge/src/drivers/github.ts +74 -4
  58. package/spec-forge/src/drivers.ts +13 -0
  59. package/spec-forge/src/port.ts +25 -0
  60. package/spec-forge/src/resident.ts +40 -6
  61. package/spec-yatsu/src/cli.ts +227 -38
  62. package/spec-yatsu/src/evaltab.ts +169 -19
  63. package/spec-yatsu/src/filing.ts +48 -0
  64. package/spec-yatsu/src/freshness.ts +55 -20
  65. package/spec-yatsu/src/proof.ts +89 -3
  66. package/spec-yatsu/src/scenariofresh.ts +92 -0
  67. package/spec-yatsu/src/sidecar.ts +75 -11
  68. package/spec-yatsu/src/timeline.ts +47 -0
  69. package/spec-yatsu/src/yatsu.ts +47 -3
  70. package/spec-cli/src/relay.ts +0 -28
  71. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  72. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  73. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -3,8 +3,10 @@ import { loadSpecs, deriveStatus } from './specs.js'
3
3
  import { resolveLayout, readConfig } from './layout.js'
4
4
  import { listSessions } from './sessions.js'
5
5
  import { repoRoot, driftIndex, historyIndex } from './git.js'
6
- import { residentForgeView } from '../../spec-forge/src/resident.js'
6
+ import { residentForgeState } from '../../spec-forge/src/resident.js'
7
+ import { mergedIssues } from './issues.js'
7
8
  import { evalContext, evalTimeline } from '../../spec-yatsu/src/evaltab.js'
9
+ import { yatsuNodesAsync } from '../../spec-yatsu/src/yatsu.js'
8
10
 
9
11
  // a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
10
12
  function resolveParent(path: string, byDir: Record<string, string>): string | null {
@@ -17,6 +19,22 @@ function resolveParent(path: string, byDir: Record<string, string>): string | nu
17
19
  return null
18
20
  }
19
21
 
22
+ // the board's eval summary ([[board-lean]]): the LATEST reading per scenario, each kept as the VERBATIM
23
+ // reading object — a filter, never a projection. Consumers hang optional fields off a reading (the
24
+ // annotator's timelineBlob rides only video readings), so dropping a field here is a SILENT downstream
25
+ // degradation no error would surface; the field-preservation unit test pins this contract.
26
+ export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
27
+ const seen = new Set<string>()
28
+ return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
29
+ }
30
+
31
+ // the board's scenario fold ([[board-lean]]): the declared set rides SLIM — {name, tags} is everything an
32
+ // overview surface joins state onto (badge, stats, focus rows, search rows); the prose (description/
33
+ // expected) and per-scenario code stay off the hot poll, carried by `/api/specs/lite` and `/api/specs/:id/evals`.
34
+ export function slimScenarios(scenarios: { name: string; tags?: string[] }[]): { name: string; tags?: string[] }[] {
35
+ return scenarios.map((s) => ({ name: s.name, ...(s.tags?.length ? { tags: s.tags } : {}) }))
36
+ }
37
+
20
38
  export async function buildBoard() {
21
39
  // all three sources are warm-cheap and independent, so the board inherits their speed for free: loadSpecs
22
40
  // REUSES the HEAD-keyed spec-history cache (the git-derived node data — see specs.ts/git.ts), resolveLayout
@@ -77,35 +95,46 @@ export async function buildBoard() {
77
95
  const nodes = [
78
96
  ...specs.map((n: any) => {
79
97
  const overlays = overlaysByNode[n.id] || []
80
- return { ...n, overlays, status: deriveStatus({ version: n.version, drift: n.drift, hasOverlay: overlays.length > 0, hasCode: (n.code?.length ?? 0) > 0, fmStatus: n.fmStatus ?? undefined }) }
98
+ // `body` and its derivation `parts` are DROPPED from the board payload ([[board-lean]]): together ~56% of
99
+ // the bytes, and detail the graph overview never renders. The detail view fetches them per node from
100
+ // `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
101
+ // once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
102
+ return { ...n, body: undefined, parts: undefined, overlays, status: deriveStatus({ version: n.version, drift: n.drift, hasOverlay: overlays.length > 0, hasCode: (n.code?.length ?? 0) > 0, fmStatus: n.fmStatus ?? undefined }) }
81
103
  }),
82
104
  ...Object.values(ghostById),
83
105
  ]
84
- // fold spec-forge issues onto each node (full set issues, open subset openIssues, attached only when
85
- // non-empty), non-blocking: residentForgeView never waits on `gh` and returns [] absent a forge, so a
86
- // forge-less board is unchanged. Sorted open-first, newest number first.
87
- const isOpen = (i: any) => (i.state || '').toLowerCase() === 'open'
88
- const issuesByNode: Record<string, any[]> = {}
89
- for (const link of residentForgeView(nodes.map((n) => n.id))) {
90
- issuesByNode[link.node] = link.issues
91
- .map((i) => ({ number: i.number, state: i.state, title: i.title, url: i.url }))
92
- .sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || b.number - a.number)
93
- }
106
+ // fold each node's issues onto it through the unified Issue port ([[issues]]): the resident forge slice
107
+ // AND the local store's threads, one merged store-tagged list (full set issues, open subset →
108
+ // openIssues, attached only when non-empty). Non-blocking: residentForgeState never waits on `gh` and is
109
+ // empty absent a forge, so the fold then carries the local slice alone. Sorted open-first, newest first.
110
+ const isOpen = (i: { status: string }) => i.status === 'open'
111
+ const issuesByNode: Record<string, ReturnType<typeof mergedIssues>> = {}
112
+ for (const issue of mergedIssues({ host: 'github', state: residentForgeState() }, nodes.map((n) => n.id)))
113
+ for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
94
114
  for (const n of nodes) {
95
115
  const issues = issuesByNode[n.id]
96
116
  if (!issues || !issues.length) continue
97
117
  n.issues = issues
98
- const open = issues.filter(isOpen)
118
+ .sort((a, b) => Number(isOpen(b)) - Number(isOpen(a)) || b.created.localeCompare(a.created))
119
+ .map((i) => ({ id: i.id, store: i.store, status: i.status, concern: i.concern, url: i.url }))
120
+ const open = n.issues.filter(isOpen)
99
121
  if (open.length) n.openIssues = open
100
122
  }
101
123
 
102
- // fold each yatsu node's eval timeline onto it, riding this one board poll: `evals` (readings, newest-first)
103
- // and `scenarios` (the declared set) attached only when the node declares scenarios. evalContext reuses the
104
- // specs + driftIndex above; evalTimeline short-circuits non-yatsu nodes so the poll stays fast.
105
- const ectx = evalContext(root, specs, idx, hidx)
124
+ // fold each yatsu node's eval state onto it as the LEAN summary ([[board-lean]]): `evals` carries only
125
+ // the LATEST reading per scenario (newest-first), which is all any overview surface consumes (the score
126
+ // badge, stats, search all reduce to latest-per-scenario anyway); the full timeline stays off the board
127
+ // and is lazy-loaded by the eval tab from `/api/specs/:id/evals`. `scenarios` (the declared set) rides
128
+ // SLIM — {name, tags} only, the fields every overview surface joins state onto — with its prose
129
+ // (description/expected) and per-scenario code off the hot poll: they ride the `/api/specs/lite` corpus
130
+ // (search palette, focus-panel preview) and the `/api/specs/:id/evals` timeline (eval tab).
131
+ // evalContext reuses the specs + driftIndex above; evalTimeline short-circuits non-yatsu nodes. The
132
+ // yatsu walk rides fs/promises ([[board-cache]]) so it yields the event loop instead of stalling /health.
133
+ const ynodes = await yatsuNodesAsync(root)
134
+ const ectx = await evalContext(root, specs, idx, hidx, undefined, ynodes)
106
135
  await Promise.all(nodes.map(async (n) => {
107
136
  const tl = await evalTimeline(n.id, ectx)
108
- if (tl.hasYatsu) { n.evals = tl.readings; n.scenarios = tl.scenarios }
137
+ if (tl.hasYatsu) { n.evals = latestPerScenario(tl.readings); n.scenarios = slimScenarios(tl.scenarios) }
109
138
  }))
110
139
 
111
140
  const opsByPath: Record<string, any[]> = {}
@@ -0,0 +1,70 @@
1
+ import { buildBoard } from './board.js'
2
+
3
+ // @@@ board-cache — single-flight + cache for the hot /api/board build ([[board-lean]]). Assembling the
4
+ // board is expensive (two full-history git-log walks cold, a full `.spec` fs walk every build), so the
5
+ // route MUST NOT rebuild per request: index.ts once ran `buildBoard()` inline on EVERY poll, so a normal
6
+ // dashboard's overlapping polls (+ SSE-triggered refetches) multiplied into N simultaneous builds and
7
+ // starved the event loop — one real user could wedge the backend. Here ONE build is shared by all
8
+ // concurrent callers (a promise memo — this IS the max-concurrent-builds cap: at most one runs) and its
9
+ // result is cached until a REAL change invalidates it. The cache is invalidated by the SAME freshness
10
+ // signals [[board-stream]] already watches (session-store writes, git-ref moves, the cold tick), via
11
+ // invalidateBoard(). So a poll storm costs ONE build, a quiet stretch costs ZERO, and the SSE rebuild and
12
+ // the route share the very same in-flight build.
13
+
14
+ export type Board = Awaited<ReturnType<typeof buildBoard>>
15
+
16
+ // a build slower than this is LOGGED, never silently tolerated — the fail-loud regression alarm. Sized
17
+ // above a warm build (~sub-second once the fs walks yield) but below the cold two-walk first build, so a
18
+ // genuinely-degraded hot path shouts while an ordinary cold start stays quiet-ish.
19
+ const BUDGET_MS = Number(process.env.SPEXCODE_BOARD_BUDGET_MS || 1500)
20
+
21
+ let cached: Board | null = null // last completed build; served while `valid`
22
+ let cachedJson: string | null = null // JSON.stringify(cached), serialized ONCE per build (see getBoardJson)
23
+ let valid = false
24
+ let inflight: Promise<Board> | null = null
25
+ let gen = 0 // bumped on every invalidation — detects a change that landed MID-build
26
+
27
+ // mark the cache stale. Called by every board-stream freshness source (see boardStream.fireChanged), so a
28
+ // real change forces the next getBoard() to rebuild while a quiet poll storm keeps hitting the cache.
29
+ export function invalidateBoard(): void {
30
+ gen++
31
+ valid = false
32
+ }
33
+
34
+ // the coalesced board read the route and the SSE rebuild both go through. A concurrent caller during a
35
+ // build shares the in-flight promise; a caller after a completed build gets the cached value until the
36
+ // next invalidation. A change that lands WHILE a build runs (gen moved) leaves the cache invalid so the
37
+ // NEXT read rebuilds — the just-finished build still returns to its waiters (freshest available when they
38
+ // asked), never cached as current. Mirrors [[board-stream]]'s building/dirty loop.
39
+ export function getBoard(): Promise<Board> {
40
+ if (inflight) return inflight
41
+ if (valid && cached) return Promise.resolve(cached)
42
+ const startGen = gen
43
+ const p = (async () => {
44
+ const t0 = Date.now()
45
+ try {
46
+ const board = await buildBoard()
47
+ cached = board
48
+ cachedJson = null // invalidate the memoized serialization; re-serialized lazily on first read
49
+ valid = gen === startGen
50
+ return board
51
+ } finally {
52
+ const ms = Date.now() - t0
53
+ if (ms > BUDGET_MS) console.warn(`spec-cli: /api/board build took ${ms}ms (budget ${BUDGET_MS}ms) — hot path is slow`)
54
+ inflight = null
55
+ }
56
+ })()
57
+ inflight = p
58
+ return p
59
+ }
60
+
61
+ // the SERIALIZED board for the /api/board route — JSON.stringify runs ONCE per build, not once per poll,
62
+ // so a poll storm of cache hits costs zero serialization CPU (only the etag hash for the 304 path). The SSE
63
+ // path still takes the object (getBoard) because it decomposes it into delta units ([[board-delta]]).
64
+ export async function getBoardJson(): Promise<string> {
65
+ const board = await getBoard()
66
+ if (board === cached && cachedJson !== null) return cachedJson
67
+ const json = JSON.stringify(board)
68
+ if (board === cached) cachedJson = json // memoize only the CURRENT build's serialization
69
+ return json
70
+ }
@@ -0,0 +1,90 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // @@@ board-delta — the pure core of the board's incremental push: decompose a board snapshot into a keyed
4
+ // UNIT MAP, tag it, and diff two unit maps into a minimal {set, del} patch. The transport ([[board-stream]])
5
+ // chains these patches over SSE (`from`/`to` tags) so a subscribed dashboard applies a few KB per change
6
+ // instead of refetching the full ~600KB snapshot; the client-side mirror of apply/reconstruct lives in the
7
+ // dashboard's data layer. Everything here is pure and synchronous — no fs, no git, no stream — so the
8
+ // equivalence argument (see the spec node's equivalence.md) is checkable by the property tests alone.
9
+ //
10
+ // Unit keys: `node:<id>` (one spec node), `sess:<id>` (one session row), `nodes#order` / `sess#order`
11
+ // (id sequences, preserving array order), `meta` (every other top-level field as one small object).
12
+ // Precondition P: node ids and session ids are collision-free. unitize REPORTS P (`ok`) rather than
13
+ // assuming it — on a violation the transport falls back to full-snapshot sends, so a delta is only ever
14
+ // chained between snapshots where the decomposition is a real bijection.
15
+
16
+ export type Units = Map<string, { j: string; v: unknown }>
17
+ export type Delta = { from: string; to: string; set: Record<string, unknown>; del: string[] }
18
+
19
+ type Boardish = { nodes?: unknown; sessions?: unknown; [k: string]: unknown }
20
+
21
+ // decompose a board into units. `ok` = the bijection precondition held (arrays are arrays, ids unique &
22
+ // non-empty); when false the map is still returned (usable for tagging) but must not seed a delta chain.
23
+ export function unitize(board: Boardish): { units: Units; ok: boolean } {
24
+ const units: Units = new Map()
25
+ let ok = true
26
+ const keyed = (arr: unknown, prefix: string, orderKey: string): void => {
27
+ const list = Array.isArray(arr) ? arr : (ok = false, [])
28
+ const order: string[] = []
29
+ for (const item of list) {
30
+ const id = (item as { id?: unknown })?.id
31
+ if (typeof id !== 'string' || !id || units.has(`${prefix}${id}`)) { ok = false; continue }
32
+ units.set(`${prefix}${id}`, { j: JSON.stringify(item), v: item })
33
+ order.push(id)
34
+ }
35
+ units.set(orderKey, { j: JSON.stringify(order), v: order })
36
+ }
37
+ const { nodes, sessions, ...meta } = board
38
+ keyed(nodes, 'node:', 'nodes#order')
39
+ keyed(sessions, 'sess:', 'sess#order')
40
+ units.set('meta', { j: JSON.stringify(meta), v: meta })
41
+ return { units, ok }
42
+ }
43
+
44
+ // the snapshot tag: a digest over every unit's key + content hash, order-independent (keys sorted). Two
45
+ // builds serializing equal content get equal tags; JSON.stringify equality is conservative (equal strings ⇒
46
+ // equal values; a key-order difference at worst re-sends an unchanged unit, never misses a changed one).
47
+ export function tagOf(units: Units): string {
48
+ const h = createHash('sha1')
49
+ for (const key of [...units.keys()].sort()) {
50
+ const u = units.get(key)!
51
+ h.update(key).update('\0').update(u.j).update('\0')
52
+ }
53
+ return h.digest('hex')
54
+ }
55
+
56
+ // diff two unit maps into the minimal patch: units whose serialized content moved land in `set` (with the
57
+ // NEW value), units that vanished land in `del`. apply(prev, diff(prev, next)) = next — the round-trip
58
+ // lemma the property tests pin down.
59
+ export function diffUnits(prev: Units, next: Units): { set: Record<string, unknown>; del: string[] } {
60
+ const set: Record<string, unknown> = {}
61
+ const del: string[] = []
62
+ for (const [key, u] of next) {
63
+ const p = prev.get(key)
64
+ if (!p || p.j !== u.j) set[key] = u.v
65
+ }
66
+ for (const key of prev.keys()) if (!next.has(key)) del.push(key)
67
+ return { set, del }
68
+ }
69
+
70
+ // apply a patch to a unit-value map — the exact algorithm the dashboard mirrors in data.js, kept here so
71
+ // the round-trip property is provable against the real shape, not a paraphrase of it.
72
+ export function applyDelta(values: Map<string, unknown>, d: Pick<Delta, 'set' | 'del'>): Map<string, unknown> {
73
+ const out = new Map(values)
74
+ for (const key of d.del) out.delete(key)
75
+ for (const [key, v] of Object.entries(d.set)) out.set(key, v)
76
+ return out
77
+ }
78
+
79
+ // reconstruct the board from unit values — R(U(B)) = B on the P-satisfying subspace (the client's render
80
+ // input after every applied patch). Order rides the #order units, so array order survives the round trip.
81
+ export function boardFromUnits(values: Map<string, unknown>): Boardish {
82
+ const pick = (prefix: string, orderKey: string): unknown[] => {
83
+ const order = (values.get(orderKey) as string[] | undefined) || []
84
+ return order.map((id) => values.get(`${prefix}${id}`))
85
+ }
86
+ const meta = (values.get('meta') as Record<string, unknown> | undefined) || {}
87
+ return { ...meta, nodes: pick('node:', 'nodes#order'), sessions: pick('sess:', 'sess#order') }
88
+ }
89
+
90
+ export const unitValues = (units: Units): Map<string, unknown> => new Map([...units].map(([k, u]) => [k, u.v]))
@@ -0,0 +1,178 @@
1
+ import { streamSSE } from 'hono/streaming'
2
+ import type { Context } from 'hono'
3
+ import { watch, mkdirSync, type FSWatcher } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { sessionsRoot, gitCommonDir } from './layout.js'
6
+ import { sessionSignature } from './sessions.js'
7
+ import { getBoard, invalidateBoard } from './boardCache.js'
8
+ import { unitize, tagOf, diffUnits, type Units } from './boardDelta.js'
9
+
10
+ // @@@ board-stream — the board's freshness is PUSHED, not polled. A dashboard subscribes here ONCE; in
11
+ // plain mode it gets a bare `board-changed` and refetches /api/board (the legacy protocol, kept verbatim
12
+ // for old clients); in DELTA mode (`?mode=delta`) the server itself rebuilds on change and streams the
13
+ // hash-chained patch ([[board-delta]]): a `board-full {to, board}` on connect, then `board-delta
14
+ // {from, to, set, del}` per change — a few KB against the ~600KB snapshot, with a full-snapshot send
15
+ // whenever the patch wouldn't win (bigger than the board, or the unit decomposition's id-uniqueness
16
+ // precondition failed), so a delta subscriber is NEVER worse off than a full refetch.
17
+ //
18
+ // Event sources, ALL funneled into one debounced pipeline: (1) fs.watch on the per-user session store —
19
+ // every lifecycle transition lands as a sessions/<id>/session.json write; (2) fs.watch on the shared git
20
+ // dir's refs (+ packed-refs/HEAD) — a commit or merge moves a ref, so tree reshapes push instead of
21
+ // waiting out a poll; (3) a subscriber-gated ~2s poll of the CHEAP tmux session signature ([[sessions]])
22
+ // for liveness/activity, which never touch a file; (4) a delta-gated ~15s cold tick that rebuilds and
23
+ // diffs server-side, catching what no watcher sees (uncommitted worktree spec edits, forge issues) — ONE
24
+ // rebuild per tick total, replacing every open dashboard's own 15s full refetch. Plain mode without delta
25
+ // subscribers keeps its zero-build behavior: sources just fan out `board-changed`.
26
+
27
+ type Notify = () => void
28
+ type Frame = { event: string; data: string }
29
+ type DeltaSend = (frame: Frame) => void
30
+ const plainSubs = new Set<Notify>()
31
+ const deltaSubs = new Set<DeltaSend>()
32
+ let debounce: ReturnType<typeof setTimeout> | null = null
33
+
34
+ // ---- the rebuild→diff→broadcast pipeline (runs only while delta subscribers exist) ----
35
+ // last successfully-broadcast snapshot: the delta chain's anchor. `lastFullFrame` is what a fresh
36
+ // subscriber gets instantly; `lastUnits`+`lastTag` are what the next diff chains from. A snapshot that
37
+ // failed the unitize precondition anchors nothing (lastUnits=null) so every following send is a full.
38
+ let lastUnits: Units | null = null
39
+ let lastTag = ''
40
+ let lastFullFrame: Frame | null = null
41
+ let building = false
42
+ let dirty = false
43
+
44
+ async function rebuildAndBroadcast(): Promise<void> {
45
+ if (building) { dirty = true; return }
46
+ building = true
47
+ try {
48
+ do {
49
+ dirty = false
50
+ let board: unknown
51
+ // share the route's single-flight build ([[board-cache]]); fireChanged() already invalidated the
52
+ // cache, so this gets a fresh build (or joins one a concurrent poll already started).
53
+ try { board = await getBoard() } catch { for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }; continue }
54
+ const boardJson = JSON.stringify(board)
55
+ const { units, ok } = unitize(board as Record<string, unknown>)
56
+ const tag = tagOf(units)
57
+ if (tag === lastTag) continue
58
+ const fullFrame: Frame = { event: 'board-full', data: `{"to":"${tag}","board":${boardJson}}` }
59
+ let frame = fullFrame
60
+ if (lastUnits && ok) {
61
+ const { set, del } = diffUnits(lastUnits, units)
62
+ const deltaData = JSON.stringify({ from: lastTag, to: tag, set, del })
63
+ // guaranteed win: ship the patch only when it actually beats the snapshot
64
+ if (deltaData.length < fullFrame.data.length) frame = { event: 'board-delta', data: deltaData }
65
+ }
66
+ lastUnits = ok ? units : null
67
+ lastTag = tag
68
+ lastFullFrame = fullFrame
69
+ for (const send of [...deltaSubs]) { try { send(frame) } catch { /* swept on abort */ } }
70
+ for (const n of [...plainSubs]) { try { n() } catch { /* swept on abort */ } }
71
+ } while (dirty)
72
+ } finally { building = false }
73
+ }
74
+
75
+ // a merge/launch/close touches several record files at once; collapse the burst into ONE signal. With
76
+ // delta subscribers the debounced fire rebuilds and broadcasts (plain subs then ride the same tag-moved
77
+ // gate — no spurious refetches); without them it stays the zero-build legacy notify.
78
+ function fireChanged(): void {
79
+ // invalidate the route's board cache ([[board-cache]]) on EVERY change signal, before the debounce guard
80
+ // — a plain-mode client that polls /api/board (no delta rebuild here) must still see fresh data on its
81
+ // next poll, and a delta rebuild below re-reads the same now-stale cache.
82
+ invalidateBoard()
83
+ if (debounce) return
84
+ debounce = setTimeout(() => {
85
+ debounce = null
86
+ if (deltaSubs.size) void rebuildAndBroadcast()
87
+ else for (const notify of [...plainSubs]) { try { notify() } catch { /* swept on abort */ } }
88
+ }, 150)
89
+ }
90
+
91
+ // ---- event source 0: an EXPLICIT server-side nudge ----
92
+ // for the one mutation the watchers structurally cannot see: /rename writes the worktree's `.session`
93
+ // (the name's ONE home, outside the watched store — [[session-rename]]). The route calls this instead of
94
+ // double-writing the name into the store: a notification problem gets a notification solution, never a
95
+ // second data home. Same debounced funnel as every other source.
96
+ export const notifyBoardChanged = (): void => fireChanged()
97
+
98
+ // ---- event source 1: the session store (lifecycle status writes) ----
99
+ let watcher: FSWatcher | null = null
100
+ function ensureWatcher(): void {
101
+ if (watcher) return
102
+ const root = sessionsRoot()
103
+ try { mkdirSync(root, { recursive: true }) } catch { /* best-effort; the watch below still tries */ }
104
+ try { watcher = watch(root, { recursive: true }, () => fireChanged()) } catch { watcher = null }
105
+ }
106
+
107
+ // ---- event source 2: git refs (a commit/merge reshapes the tree the moment the ref moves) ----
108
+ // refs/ recursively for loose refs (heads, worktree branches), plus the common dir itself non-recursively
109
+ // for packed-refs rewrites and HEAD flips. Best-effort like every source: no watch → the cold tick covers.
110
+ let refsWatchers: FSWatcher[] | null = null
111
+ function ensureRefsWatcher(): void {
112
+ if (refsWatchers) return
113
+ refsWatchers = []
114
+ try {
115
+ const common = gitCommonDir()
116
+ try { refsWatchers.push(watch(join(common, 'refs'), { recursive: true }, () => fireChanged())) } catch { /* loose refs unwatched */ }
117
+ try { refsWatchers.push(watch(common, (_e, f) => { if (f === 'packed-refs' || f === 'HEAD') fireChanged() })) } catch { /* packed refs unwatched */ }
118
+ } catch { /* not a repo? the cold tick still covers */ }
119
+ }
120
+
121
+ // ---- event source 3: the tmux-derived signature (liveness + activity — never a file write) ----
122
+ let poller: ReturnType<typeof setInterval> | null = null
123
+ let lastSig = ''
124
+ function ensureLivePoll(): void {
125
+ if (poller) return
126
+ poller = setInterval(() => {
127
+ void sessionSignature().then((sig) => { if (sig !== lastSig) { lastSig = sig; fireChanged() } }).catch(() => {})
128
+ }, 2000)
129
+ }
130
+
131
+ // ---- event source 4: the cold tick — the server-side replacement for every client's slow fallback poll.
132
+ // Rebuild+diff on a relaxed timer so what NO watcher sees (an uncommitted worktree spec edit, a forge
133
+ // issue refresh) still lands; an unchanged tag broadcasts nothing. Delta-gated: plain-only clients keep
134
+ // their own client-side fallback, so without delta subscribers this must not burn builds.
135
+ let coldTick: ReturnType<typeof setInterval> | null = null
136
+ function ensureColdTick(): void {
137
+ if (coldTick) return
138
+ coldTick = setInterval(() => { if (deltaSubs.size) void rebuildAndBroadcast() }, 15000)
139
+ }
140
+
141
+ function stopSourcesIfIdle(): void {
142
+ if (plainSubs.size + deltaSubs.size > 0) return
143
+ if (poller) { clearInterval(poller); poller = null; lastSig = '' }
144
+ if (coldTick) { clearInterval(coldTick); coldTick = null }
145
+ }
146
+
147
+ // GET /api/board/stream — one SSE per dashboard tab, server→client only, with a periodic `ping` so an
148
+ // idle proxy never times the connection out. On a backend hot-reload the stream drops and EventSource
149
+ // auto-reconnects to the fresh child; a delta subscriber's reconnect lands a fresh `board-full`, so the
150
+ // chain re-anchors with no client-side repair logic.
151
+ export function boardStream(c: Context) {
152
+ const delta = c.req.query('mode') === 'delta'
153
+ ensureWatcher()
154
+ ensureRefsWatcher()
155
+ return streamSSE(c, async (stream) => {
156
+ let aborted = false
157
+ const send: DeltaSend = (frame) => { void stream.writeSSE(frame).catch(() => {}) }
158
+ const notify: Notify = () => { void stream.writeSSE({ event: 'board-changed', data: 'x' }).catch(() => {}) }
159
+ if (delta) { deltaSubs.add(send); ensureColdTick() } else { plainSubs.add(notify) }
160
+ ensureLivePoll()
161
+ const unsub = (): void => { deltaSubs.delete(send); plainSubs.delete(notify); stopSourcesIfIdle() }
162
+ stream.onAbort(() => { aborted = true; unsub() })
163
+ try {
164
+ await stream.writeSSE({ event: 'ready', data: 'x' })
165
+ if (delta) {
166
+ // seed the chain: the cached anchor snapshot immediately (same tag the next delta chains from),
167
+ // then a fire so a connect during a quiet stretch converges to truly-current within one build.
168
+ if (lastFullFrame) { await stream.writeSSE(lastFullFrame).catch(() => {}) ; fireChanged() }
169
+ else void rebuildAndBroadcast()
170
+ }
171
+ while (!aborted) {
172
+ await stream.sleep(25000)
173
+ if (aborted) break
174
+ await stream.writeSSE({ event: 'ping', data: 'x' })
175
+ }
176
+ } finally { unsub() }
177
+ })
178
+ }