spexcode 0.1.5 → 0.2.0

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 (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +60 -19
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -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
+ }