spexcode 0.1.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 (55) hide show
  1. package/bin/spex.mjs +15 -0
  2. package/dashboard-dist/assets/index-B60MILFg.js +139 -0
  3. package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
  4. package/dashboard-dist/index.html +16 -0
  5. package/package.json +35 -0
  6. package/src/board.ts +119 -0
  7. package/src/cli.ts +487 -0
  8. package/src/client.ts +102 -0
  9. package/src/gateway.ts +241 -0
  10. package/src/git.ts +492 -0
  11. package/src/guide.ts +134 -0
  12. package/src/harness.ts +674 -0
  13. package/src/hooks.ts +41 -0
  14. package/src/index.ts +233 -0
  15. package/src/init.ts +120 -0
  16. package/src/layout.ts +246 -0
  17. package/src/lint.ts +206 -0
  18. package/src/login-page.ts +79 -0
  19. package/src/materialize.ts +85 -0
  20. package/src/pty-bridge.ts +235 -0
  21. package/src/ranker.ts +129 -0
  22. package/src/resilience.ts +41 -0
  23. package/src/search.bench.mjs +47 -0
  24. package/src/search.ts +24 -0
  25. package/src/self.ts +256 -0
  26. package/src/sessions.ts +1469 -0
  27. package/src/slash-commands.ts +242 -0
  28. package/src/specs.ts +331 -0
  29. package/src/supervise.ts +158 -0
  30. package/src/uploads.ts +31 -0
  31. package/templates/hooks/pre-commit +57 -0
  32. package/templates/hooks/prepare-commit-msg +14 -0
  33. package/templates/spec/project/.config/core/idle/idle.sh +15 -0
  34. package/templates/spec/project/.config/core/idle/spec.md +13 -0
  35. package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
  36. package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
  37. package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
  38. package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
  39. package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
  40. package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
  41. package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
  42. package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
  43. package/templates/spec/project/.config/core/spec.md +13 -0
  44. package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
  45. package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
  46. package/templates/spec/project/.config/extract/spec.md +60 -0
  47. package/templates/spec/project/.config/forge-link/spec.md +9 -0
  48. package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
  49. package/templates/spec/project/.config/regroup/spec.md +25 -0
  50. package/templates/spec/project/.config/scenario/spec.md +32 -0
  51. package/templates/spec/project/.config/spec.md +15 -0
  52. package/templates/spec/project/.config/supervisor/spec.md +8 -0
  53. package/templates/spec/project/.config/tidy/spec.md +25 -0
  54. package/templates/spec/project/spec.md +19 -0
  55. package/templates/spexcode.json +5 -0
package/src/hooks.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { writeFileSync } from 'node:fs'
2
+ import { loadHookConfig } from './specs.js'
3
+
4
+ // @@@ hook manifest - the harness-agnostic hook system has THREE parts: (1) the discovered handlers —
5
+ // `surface: hook` nodes under .config/core/* (spec-governed content, each shipping one co-located .sh);
6
+ // (2) this COMPILER, which flattens them into a flat per-session manifest; (3) a pure-shell dispatcher
7
+ // (spec-cli/hooks/dispatch.sh) the committed .claude/.codex shim binds to every harness event. The compiler
8
+ // is the ONLY step that parses spec frontmatter, so it runs ONCE at SessionStart and the hot-path dispatcher
9
+ // (PreToolUse fires on every tool) just greps a flat file — never walks the spec tree.
10
+ //
11
+ // Manifest line = `event<TAB>order<TAB>block<TAB>script` (script = repo-relative path to the node's .sh),
12
+ // sorted by event, then order, then script — the DETERMINISTIC run order the native multi-hook model (which
13
+ // runs matching hooks in parallel on BOTH Claude Code and Codex) cannot guarantee. One node binds many
14
+ // events (mark-active → UserPromptSubmit+PreToolUse) → one manifest line per (node × event).
15
+ export function compileManifest(cfgs = loadHookConfig()): string {
16
+ const lines: string[] = []
17
+ for (const h of cfgs) {
18
+ const script = h.files.find((f) => f.endsWith('.sh'))
19
+ if (!script) throw new Error(`surface:hook node '${h.name}' ships no .sh script (one co-located .sh required)`)
20
+ for (const ev of h.events) lines.push(`${ev}\t${h.order}\t${h.block}\t${script}`)
21
+ }
22
+ lines.sort((a, b) => {
23
+ const A = a.split('\t'), B = b.split('\t')
24
+ return A[0].localeCompare(B[0]) || Number(A[1]) - Number(B[1]) || (a < b ? -1 : a > b ? 1 : 0)
25
+ })
26
+ return lines.length ? lines.join('\n') + '\n' : ''
27
+ }
28
+
29
+ // `spex hooks compile [--out <file>]` — write the manifest (SessionStart) or print it (debug/test).
30
+ export async function runHooks(argv: string[]): Promise<number> {
31
+ const sub = argv[0]
32
+ if (sub === 'compile') {
33
+ const i = argv.indexOf('--out')
34
+ const manifest = compileManifest()
35
+ if (i >= 0 && argv[i + 1]) writeFileSync(argv[i + 1], manifest)
36
+ else process.stdout.write(manifest)
37
+ return 0
38
+ }
39
+ console.error('spex hooks: compile [--out <file>]')
40
+ return 2
41
+ }
package/src/index.ts ADDED
@@ -0,0 +1,233 @@
1
+ import { serve } from '@hono/node-server'
2
+ import { Hono } from 'hono'
3
+ import { cors } from 'hono/cors'
4
+ import { createNodeWebSocket } from '@hono/node-ws'
5
+ import { loadSpecs, specHistory, specDiffAt, loadConfig } from './specs.js'
6
+ import { resolveLayout, mainBranch } from './layout.js'
7
+ import { buildBoard } from './board.js'
8
+ import { gitA, gitTry } from './git.js'
9
+ import { newSession, listSessions, sendKeys, rawKey, exitSession, closeSession, reopen, propose, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
10
+ import { defaultHarness, HARNESSES } from './harness.js'
11
+ import { evalTimeline, readBlobByHash } from '../../spec-yatsu/src/evaltab.js'
12
+ import { buildProofModel, renderProofHtml } from '../../spec-yatsu/src/proof.js'
13
+ import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
14
+ import { attachViewer, detachViewer, writeViewer, resizeBridge, superviseBridges, type Viewer } from './pty-bridge.js'
15
+ import { installProcessGuards } from './resilience.js'
16
+
17
+ // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
18
+ // self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
19
+ installProcessGuards()
20
+
21
+ const app = new Hono()
22
+ app.use('/api/*', cors())
23
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
24
+
25
+ app.get('/', (c) => c.text('spec-cli — GET /api/board · /api/specs · /api/specs/:id/history · /api/layout · /api/sessions · /api/slash-commands'))
26
+ // the supervisor's readiness gate (supervise.ts): a bare git-free 200 so a booting child reports ready the
27
+ // instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
28
+ app.get('/health', (c) => c.text('ok'))
29
+ // the assembled board (merged tree + overlay + sessions) — the dashboard's single source. Same data
30
+ // as `spex board`; the frontend only adds x/y pixels on top.
31
+ app.get('/api/board', async (c) => c.json(await buildBoard()))
32
+ app.get('/api/specs', async (c) => c.json(await loadSpecs()))
33
+ app.get('/api/specs/:id/history', async (c) => c.json(await specHistory(c.req.param('id'))))
34
+ // the spec.md line diff one version introduced — the history tab's per-version proof-of-change, fetched
35
+ // lazily when an older version's item expands (the latest version's diff ships with the board as node.lastDiff).
36
+ app.get('/api/specs/:id/diff/:hash', async (c) => c.json(await specDiffAt(c.req.param('id'), c.req.param('hash'))))
37
+ // a unified diff of a node's spec.md from its fork point (the worktree's merge-base with main) to that
38
+ // worktree's working tree. An untracked brand-new node is invisible to `git diff <base>`, so when the base
39
+ // diff is empty AND status is `??` synthesize an all-additions view via `diff --no-index` (gitTry — --no-index
40
+ // exits 1, which gitA would swallow). Gated on `??` so a tracked file with no pending change stays empty.
41
+ app.get('/api/edit', async (c) => {
42
+ const source = c.req.query('source') || '', path = c.req.query('path') || ''
43
+ if (!source || !path) return c.json({ patch: '' })
44
+ const mb = mainBranch()
45
+ const base = (await gitA(['-C', source, 'merge-base', mb, 'HEAD'])).trim() || mb
46
+ let patch = await gitA(['-C', source, 'diff', base, '--', path])
47
+ if (!patch) {
48
+ const status = await gitA(['-C', source, 'status', '--porcelain', '--untracked-files=all', '--', path])
49
+ if (status.startsWith('??')) patch = (await gitTry(['-C', source, 'diff', '--no-index', '--', '/dev/null', path])).stdout
50
+ }
51
+ return c.json({ patch })
52
+ })
53
+ // a node's eval timeline (read half of `spex yatsu`): yatsu-sidecar readings joined with a live freshness
54
+ // flag, newest-first; `hasYatsu:false` when none declared. Contract belongs to [[spec-yatsu]].
55
+ app.get('/api/specs/:id/evals', async (c) => c.json(await evalTimeline(c.req.param('id'))))
56
+ // serve a reading's evidence blob by content hash (bytes never enter git): bad hash → 400, missing → 404,
57
+ // else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
58
+ app.get('/api/yatsu/blob/:hash', (c) => {
59
+ const r = readBlobByHash(c.req.param('hash'))
60
+ if (!r.ok) return c.text(r.message, r.reason === 'invalid' ? 400 : 404)
61
+ return c.body(new Uint8Array(r.bytes), 200, { 'Content-Type': r.mime, 'Cache-Control': 'public, max-age=31536000, immutable' })
62
+ })
63
+ app.get('/api/layout', async (c) => c.json(await resolveLayout()))
64
+ // the `surface: slash` config-root plugins (built/active only) for the new-session `/` dropdown — each with
65
+ // its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
66
+ // frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
67
+ app.get('/api/config', (c) => c.json(loadConfig()))
68
+ // the dashboard input's `/` dropdown — computed by the launcher's HARNESS adapter the same way that harness
69
+ // computes its own `/` menu ([[harness-adapter]]). The client passes `?harness=<id>` for the ACTIVE session,
70
+ // so a codex tab gets CODEX's menu, not the default's; unknown/absent → default. Insert-only on the client.
71
+ app.get('/api/slash-commands', (c) => {
72
+ const h = HARNESSES.find((x) => x.id === c.req.query('harness')) || defaultHarness
73
+ return c.json(h.slashCommands())
74
+ })
75
+
76
+ // write a pasted/dropped/picked file to this (worker) machine's /tmp and return its absolute path for the
77
+ // client to splice into the prompt. Fail-loud: no/empty file → 400, over the size cap → 413, write error → 500.
78
+ app.post('/api/uploads', async (c) => {
79
+ const body = await c.req.parseBody().catch(() => ({} as Record<string, string | File>))
80
+ const file = body['file']
81
+ if (!(file instanceof File) || file.size === 0) return c.json({ error: 'no file' }, 400)
82
+ if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'file too large' }, 413)
83
+ try {
84
+ return c.json({ path: await saveUpload(file) }, 201)
85
+ } catch (e) {
86
+ return c.json({ error: String((e as Error)?.message || e) }, 500)
87
+ }
88
+ })
89
+
90
+ // sessions: real tmux-backed Claude Code sessions. List + spawn, stream the live pane (WebSocket),
91
+ // forward keystrokes, and close.
92
+ app.get('/api/sessions', async (c) => c.json(await listSessions()))
93
+ // edges derived live from `spex watch` monitors (A→B = agent A is watching B), not a stored subscription;
94
+ // watch/unwatch register + heartbeat. A literal `graph` segment so it never collides with the `:id` routes.
95
+ app.get('/api/sessions/graph', async (c) => c.json(await sessionGraph()))
96
+ app.post('/api/sessions/graph/watch', async (c) => {
97
+ const b = await c.req.json().catch(() => ({}))
98
+ const selectors = Array.isArray(b?.selectors) ? b.selectors.map(String) : []
99
+ const ok = registerWatch(String(b?.token || ''), String(b?.watcher || ''), selectors, Number(b?.ttlMs) || undefined)
100
+ return c.json({ ok }, ok ? 200 : 400)
101
+ })
102
+ app.post('/api/sessions/graph/unwatch', async (c) => {
103
+ const b = await c.req.json().catch(() => ({}))
104
+ const ok = deregisterWatch(String(b?.token || ''))
105
+ return c.json({ ok }, ok ? 200 : 404)
106
+ })
107
+ app.post('/api/sessions', async (c) => {
108
+ const body = await c.req.json().catch(() => ({}))
109
+ const prompt = typeof body?.prompt === 'string' ? body.prompt : ''
110
+ if (!prompt.trim()) return c.json({ error: 'empty prompt' }, 400)
111
+ const harness = typeof body?.harness === 'string' ? body.harness : undefined
112
+ try {
113
+ return c.json(await newSession(typeof body?.node === 'string' ? body.node : null, prompt, harness), 201)
114
+ } catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown harness id → 400, not a 500
115
+ })
116
+ // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
117
+ // dashboard and `spex review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
118
+ app.get('/api/sessions/:id/review', async (c) => {
119
+ const r = await reviewPayload(c.req.param('id'))
120
+ return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
121
+ })
122
+ // the [[review-proof]] HTML: the diff grouped by node, each node's measured yatsu loss with evidence inlined
123
+ // as data-URIs, and the merge gates. `?format=json` returns the model; default = rendered HTML. 404 unknown id.
124
+ app.get('/api/sessions/:id/proof', async (c) => {
125
+ const m = await buildProofModel(c.req.param('id'))
126
+ if (!m) return c.text('no such session', 404)
127
+ if (c.req.query('format') === 'json') return c.json(m)
128
+ return c.html(renderProofHtml(m))
129
+ })
130
+ // the session's live pane as text (one-shot snapshot) for a backend client (`spex capture`). Empty and fail
131
+ // stay distinct: an empty pane is 200 with empty body; unknown id → 404, offline (no live pane) → 409, error → 502.
132
+ app.get('/api/sessions/:id/capture', async (c) => {
133
+ const r = await captureSessionResult(c.req.param('id'))
134
+ if (r.ok) return c.text(r.pane)
135
+ if (r.reason === 'unknown') return c.text('no such session', 404)
136
+ if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
137
+ return c.text('capture failed', 502)
138
+ })
139
+ // the session's originating prompt (what it was asked to do), for a manager client; 404 if none recorded.
140
+ app.get('/api/sessions/:id/prompt', async (c) => {
141
+ const p = await sessionPrompt(c.req.param('id'))
142
+ return p == null ? c.text('no prompt recorded', 404) : c.text(p)
143
+ })
144
+ // lifecycle transitions (thin callers of the session state machine)
145
+ app.post('/api/sessions/:id/resume', async (c) => c.json({ ok: await reopen(c.req.param('id')) })) // back-to-working / relaunch
146
+ app.post('/api/sessions/:id/review', async (c) => c.json({ ok: await propose(c.req.param('id'), 'merge') }))
147
+ // a dispatch to the session's own agent (it runs the merge), never a server merge — the server never touches
148
+ // main's tree. 200 {dispatched:true} once the prompt is accepted, 409 {dispatched:false} if the agent is unreachable.
149
+ app.post('/api/sessions/:id/merge', async (c) => {
150
+ const r = await mergeSession(c.req.param('id'))
151
+ return c.json(r, r.dispatched ? 200 : 409)
152
+ })
153
+
154
+ // one bidirectional WS over a shared tmux client (pty-bridge): server→client = raw pane bytes (binary);
155
+ // client→server = raw terminal input (binary) + a text control frame {t:'resize',cols,rows}. A real tmux
156
+ // client, so scrollback is tmux's own and the first paint is one coherent repaint.
157
+ app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
158
+ const id = c.req.param('id') as string
159
+ let viewer: Viewer | null = null
160
+ return {
161
+ onOpen(_evt, ws) {
162
+ viewer = { send: (buf) => { try { ws.send(Uint8Array.from(buf)) } catch { /* viewer gone */ } } }
163
+ if (!attachViewer(id, viewer)) { try { ws.close() } catch { /* already closed */ } }
164
+ },
165
+ onMessage(evt) {
166
+ if (!viewer) return
167
+ const data = evt.data
168
+ if (typeof data === 'string') {
169
+ // text frame = control. Only resize today.
170
+ try { const m = JSON.parse(data); if (m?.t === 'resize') resizeBridge(id, Number(m.cols), Number(m.rows)) } catch { /* ignore */ }
171
+ } else if (data instanceof ArrayBuffer) {
172
+ writeViewer(id, Buffer.from(data)) // binary: raw terminal input
173
+ } else if (ArrayBuffer.isView(data)) {
174
+ writeViewer(id, Buffer.from(data.buffer, data.byteOffset, data.byteLength)) // (keystrokes / mouse)
175
+ }
176
+ },
177
+ onClose() { if (viewer) detachViewer(id, viewer) },
178
+ }
179
+ }))
180
+ // the docked ❯ line input (and server-side merge dispatch) dispatch a whole prompt through the rendezvous
181
+ // control socket. Socket-only + fail-loud: a prompt the agent doesn't confirm accepting returns 502 with the
182
+ // reason (never a silent 200), so the dashboard/manager sees a dead dispatch instead of a false success.
183
+ app.post('/api/sessions/:id/keys', async (c) => {
184
+ const body = await c.req.json().catch(() => ({}))
185
+ // `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
186
+ // edge ([[comms-edge]]); a raw human dispatch omits it and is not logged.
187
+ const r = await sendKeys(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined)
188
+ return c.json(r, r.ok ? 200 : 502)
189
+ })
190
+ // the preserved tmux send-keys path (distinct from the ❯ prompt socket): one key per keydown so a human can
191
+ // drive the agent's interactive TUI menus in real time. Forwards keystrokes only.
192
+ app.post('/api/sessions/:id/rawkey', async (c) => {
193
+ const body = await c.req.json().catch(() => ({}))
194
+ const ok = await rawKey(c.req.param('id'), typeof body?.key === 'string' ? body.key : '')
195
+ return c.json({ ok }, ok ? 200 : 404)
196
+ })
197
+ // soft stop: kill the agent's tmux + socket but KEEP the worktree (relaunchable). Distinct from close, which
198
+ // removes the worktree. {ok:false} = no such session.
199
+ app.post('/api/sessions/:id/exit', async (c) => c.json({ ok: await exitSession(c.req.param('id')) }))
200
+ app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
201
+ // set (or clear, with a blank) a session's display-name override; persists to the worktree's `.session` so
202
+ // it survives a restart. Unknown id → 404.
203
+ app.post('/api/sessions/:id/rename', async (c) => {
204
+ const body = await c.req.json().catch(() => ({}))
205
+ const ok = await renameSession(c.req.param('id'), typeof body?.name === 'string' ? body.name : '')
206
+ return c.json({ ok }, ok ? 200 : 404)
207
+ })
208
+
209
+ // set/clear a session's sort-key ([[session-reorder]]): a finite number pins the row's slot, null (or
210
+ // non-numeric) restores birth order. Mirrors /rename.
211
+ app.post('/api/sessions/:id/sort', async (c) => {
212
+ const body = await c.req.json().catch(() => ({}))
213
+ const key = typeof body?.key === 'number' && Number.isFinite(body.key) ? body.key : null
214
+ const ok = await setSessionSort(c.req.param('id'), key)
215
+ return c.json({ ok }, ok ? 200 : 404)
216
+ })
217
+
218
+ const port = Number(process.env.PORT || 8787)
219
+ const server = serve({ fetch: app.fetch, port })
220
+ injectWebSocket(server)
221
+ superviseBridges() // keep a warm tmux client per live session, so opening a tab is instant
222
+ superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
223
+ console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
224
+
225
+ // graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new
226
+ // connections, let in-flight requests finish, and sweep now-idle keep-alive sockets so close() fires the
227
+ // instant the last request drains. A hard cap still forces exit if a connection won't close.
228
+ process.on('SIGTERM', () => {
229
+ const srv = server as unknown as { close(cb?: () => void): void; closeIdleConnections?(): void }
230
+ const sweep = setInterval(() => srv.closeIdleConnections?.(), 200)
231
+ srv.close(() => { clearInterval(sweep); process.exit(0) })
232
+ setTimeout(() => process.exit(0), 10000).unref()
233
+ })
package/src/init.ts ADDED
@@ -0,0 +1,120 @@
1
+ import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync } from 'node:fs'
2
+ import { join, resolve, relative } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { execFileSync } from 'node:child_process'
5
+
6
+ // this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
7
+ // launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
8
+ const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
9
+ const TEMPLATES = join(pkgRoot, 'templates')
10
+
11
+ // recursively copy srcDir -> destDir, NEVER overwriting an existing file. Returns the repo-relative
12
+ // paths of files actually written (so the caller can report exactly what was planted vs already there).
13
+ function copyTreeNoClobber(srcDir: string, destDir: string, base: string): string[] {
14
+ const written: string[] = []
15
+ mkdirSync(destDir, { recursive: true })
16
+ for (const e of readdirSync(srcDir, { withFileTypes: true })) {
17
+ const src = join(srcDir, e.name)
18
+ const dest = join(destDir, e.name)
19
+ if (e.isDirectory()) {
20
+ written.push(...copyTreeNoClobber(src, dest, base))
21
+ } else if (existsSync(dest)) {
22
+ // additive only — a pre-existing file is the user's, leave it untouched.
23
+ continue
24
+ } else {
25
+ copyFileSync(src, dest)
26
+ written.push(relative(base, dest))
27
+ }
28
+ }
29
+ return written
30
+ }
31
+
32
+ // resolve the target repo's git hooks dir the same way scripts/install-hooks.sh does — the COMMON dir's
33
+ // hooks/ (shared across all worktrees). Returns null (with a loud reason) when <dir> isn't a git repo.
34
+ function resolveHooksDir(dir: string): string | null {
35
+ try {
36
+ const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
37
+ encoding: 'utf8',
38
+ stdio: ['ignore', 'pipe', 'ignore'], // swallow git's own "fatal: not a git repository" — our warning is the signal
39
+ }).trim()
40
+ return join(common, 'hooks')
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ export async function specInit(targetArg: string | undefined): Promise<void> {
47
+ const targetDir = resolve(targetArg ?? process.cwd())
48
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true })
49
+ console.log(`spex init → ${targetDir}`)
50
+
51
+ // 1. seed the spec tree: templates/spec/* -> <dir>/.spec/* (an existing .spec aborts this phase loudly).
52
+ const specDest = join(targetDir, '.spec')
53
+ if (existsSync(specDest)) {
54
+ console.warn(`• .spec already exists at ${specDest} — skipping spec scaffold (won't overwrite an existing tree).`)
55
+ } else {
56
+ const planted = copyTreeNoClobber(join(TEMPLATES, 'spec'), specDest, targetDir)
57
+ console.log(`✓ seeded ${planted.length} spec file(s) under .spec/ (root 'project' node + default .config)`)
58
+ }
59
+
60
+ // 1b. plant a starter spexcode.json (the lint/layout knob), pointing governedRoots at `src/`.
61
+ const cfgDest = join(targetDir, 'spexcode.json')
62
+ if (existsSync(cfgDest)) {
63
+ console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched.`)
64
+ } else {
65
+ copyFileSync(join(TEMPLATES, 'spexcode.json'), cfgDest)
66
+ console.log(`✓ planted spexcode.json — set lint.governedRoots to YOUR source dirs (starter: ["src"])`)
67
+ }
68
+
69
+ // 2. install the git hooks: templates/hooks/* -> <repo>/<common-git-dir>/hooks/* (skip any that exist).
70
+ const hooksDir = resolveHooksDir(targetDir)
71
+ if (!hooksDir) {
72
+ console.warn(`• ${targetDir} is not a git repository — skipped hook install. Run \`git init\` there, then \`spex init\` again (or \`npm run hooks\`).`)
73
+ } else {
74
+ mkdirSync(hooksDir, { recursive: true })
75
+ const hooksSrc = join(TEMPLATES, 'hooks')
76
+ const installed: string[] = []
77
+ for (const e of readdirSync(hooksSrc, { withFileTypes: true })) {
78
+ if (!e.isFile()) continue
79
+ const dest = join(hooksDir, e.name)
80
+ if (existsSync(dest)) {
81
+ console.warn(`• hook ${e.name} already exists in ${hooksDir} — left untouched.`)
82
+ continue
83
+ }
84
+ copyFileSync(join(hooksSrc, e.name), dest)
85
+ chmodSync(dest, 0o755)
86
+ installed.push(e.name)
87
+ }
88
+ if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
89
+ }
90
+
91
+ // 2c. RENDER the harness-discovered artifacts so a USER-self-launched claude/codex works with zero further
92
+ // steps: the hook manifest (in the GLOBAL per-project store, not the worktree), the AGENTS.md/CLAUDE.md
93
+ // <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
94
+ // scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
95
+ // .config. Idempotent — the dispatch.sh gate keeps it fresh thereafter on every .config edit.
96
+ const prevCwd = process.cwd()
97
+ try {
98
+ process.chdir(targetDir)
99
+ const { materialize } = await import('./materialize.js')
100
+ materialize(targetDir)
101
+ console.log('✓ materialized harness artifacts (global hook manifest, AGENTS.md/CLAUDE.md block, .claude/.codex shims, Codex trust)')
102
+ } catch (e) {
103
+ console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
104
+ } finally {
105
+ process.chdir(prevCwd)
106
+ }
107
+
108
+ // 3. next steps — what the human must do to bring the instance to life.
109
+ console.log(`
110
+ Next steps:
111
+ 1. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
112
+ 2. Set lint.governedRoots in spexcode.json to your source dir(s) — until you do, \`spex lint\`
113
+ warns it is governing nothing (it ships pointing at "src").
114
+ 3. Start the backend and open the board:
115
+ spex serve # http://localhost:8787
116
+ 4. \`spex lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
117
+ spec node claims yet). You're adopting SpexCode — the spec tree is now ground truth.
118
+ (On a fresh CLONE, re-run \`spex init\` — git never clones .git/hooks/, and the harness shims are
119
+ gitignored machine-local files that regenerate per-machine.)`)
120
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { readFileSync, existsSync, readdirSync } from 'node:fs'
2
+ import { join, dirname } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+ import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
5
+ import { guardWorktree } from './resilience.js'
6
+ import { HARNESSES } from './harness.js'
7
+
8
+ type Config = {
9
+ main?: string // path to the source-of-truth checkout (default: the `main` worktree)
10
+ mainBranch?: string // source-of-truth BRANCH worktrees fork from (default: auto-detected — see mainBranch())
11
+ branchPrefix?: string // how a branch names its node (default: "node/")
12
+ dashboard?: {
13
+ apiUrl?: string // the per-project backend the board proxies to (read frontend-side; see api-endpoint)
14
+ title?: string // override for the browser-tab name (default: the repo-root basename; see tab-title)
15
+ icon?: string // the browser-tab favicon: an emoji ("🔭") or an Iconify name ("mdi:rocket-launch"); see tab-icon
16
+ }
17
+ sessions?: {
18
+ maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 6; see sessions.ts maxActive)
19
+ }
20
+ serve?: {
21
+ // public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
22
+ // The password is NEVER read from here — flag/env only — so this file stays committable.
23
+ public?: {
24
+ enabled?: boolean // turn public mode on without the --public flag
25
+ http?: boolean // drop TLS (the --http escape hatch) — password then travels in cleartext
26
+ tls?: { cert?: string; key?: string } // PATHS to your own cert/key; omit for a cached self-signed default
27
+ }
28
+ }
29
+ }
30
+ // the resolved LAYOUT convention — main/mainBranch/branchPrefix filled to defaults. `dashboard`, `sessions`,
31
+ // and `serve` are frontend/runtime concerns (read separately via readConfig; see api-endpoint / sessions.ts
32
+ // maxActive / gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
33
+ type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve'>>
34
+
35
+ export type Worktree = {
36
+ path: string; branch: string | null; node: string | null
37
+ session: string | null; status: string | null; isMain: boolean
38
+ ops: NodeOp[] // pending spec-node changes this worktree makes vs main (the board's overlay)
39
+ }
40
+ export type Layout = { main: string; convention: Convention; worktrees: Worktree[] }
41
+
42
+ export function readConfig(root: string): Config {
43
+ const p = join(root, 'spexcode.json')
44
+ if (!existsSync(p)) return {}
45
+ try { return JSON.parse(readFileSync(p, 'utf8')) } catch { return {} }
46
+ }
47
+
48
+ // the shared git common dir (env-stripped git() so a hook's exported GIT_DIR can't misdirect it). Memoized:
49
+ // it's a process constant, but mainBranch()/mainRoot() resolve it per call (~60 git rev-parse forks per board build without the cache).
50
+ let commonDirCache: string | null = null
51
+ export function gitCommonDir(): string {
52
+ if (commonDirCache === null) commonDirCache = git(['rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
53
+ return commonDirCache
54
+ }
55
+
56
+ export function mainBranch(): string {
57
+ try {
58
+ const override = readConfig(mainCheckout()).mainBranch?.trim()
59
+ if (override) return override
60
+ const cur = git(['-C', mainCheckout(), 'symbolic-ref', '--short', 'HEAD']).trim()
61
+ if (cur) return cur
62
+ } catch { /* fall through to the conventional default */ }
63
+ return 'main'
64
+ }
65
+
66
+ // the MAIN checkout (the root working tree) for a project — the SAME answer from main OR any linked worktree
67
+ // (dirname of the shared git common dir). Codex reads a LINKED worktree's PROJECT hooks from the root checkout's
68
+ // `.codex` (codex-rs hooks_config_folder override), NOT the worktree's, so the codex hooks shim + trust
69
+ // materialize here while AGENTS.md/skills stay per-worktree — see [[harness-adapter]] (harness.ts).
70
+ export function mainCheckout(proj?: string): string {
71
+ const gcd = proj
72
+ ? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
73
+ : gitCommonDir()
74
+ return dirname(gcd)
75
+ }
76
+
77
+ // @@@ global per-session store - Fork A: NO SpexCode files live in the worktree any more, so the worktree's
78
+ // spec/code tree is pristine (zero per-session pollution). Every per-session runtime artifact — the
79
+ // structured record (session.json) AND the launcher products (prompt, launch, launch.sh) AND the recorded comms AND
80
+ // the spec-discipline sentinels — lives in a per-USER GLOBAL store, keyed by the harness `session_id` so two
81
+ // agents in one folder never clobber, and grouped PER PROJECT (mirroring Claude's ~/.claude/projects/<enc>/)
82
+ // so the board enumerates ONE directory. This is the single seam that knows where the store sits; sessions.ts
83
+ // and the shell hooks resolve through the SAME scheme (the hooks reimplement it in bash, so any change here
84
+ // must be mirrored in .config/core/*/). SPEXCODE_HOME overrides the root for test isolation.
85
+ export function spexcodeHome(): string {
86
+ return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
87
+ }
88
+ // encode a project-root path into ONE safe directory segment (Claude's scheme: path separators → '-'). The
89
+ // SAME transform runs in TS and in the shell hooks, so a board read and a hook write land on the SAME dir.
90
+ export function encodeProject(root: string): string {
91
+ return root.replace(/[/.]/g, '-')
92
+ }
93
+ // the project a store groups by = the MAIN checkout root (dirname of the shared git common dir). It resolves
94
+ // IDENTICALLY from the main checkout OR any linked worktree, so the board (running at main) and a hook
95
+ // (running in a worktree) agree on the key — unlike `git rev-parse --show-toplevel`, which in a worktree is
96
+ // the worktree path and would scatter a session under a per-worktree key the board never reads.
97
+ export function projectKey(): string {
98
+ return encodeProject(dirname(gitCommonDir()))
99
+ }
100
+ // this project's per-PROJECT runtime tier — the materialized hook manifest + content-hash marker (and the
101
+ // gate's lock) — living alongside sessions/ under the SAME global per-project dir, so NOTHING SpexCode renders
102
+ // stays in the worktree (not even the manifest; the worktree holds only the harness-discovered CLAUDE.md/
103
+ // AGENTS.md + shims, which must sit in-tree). proj-aware for `spex init <dir>` / materialize(proj); cwd-based
104
+ // default for the hooks/board. The shell hooks mirror this as hp_runtime_dir.
105
+ export function runtimeRoot(proj?: string): string {
106
+ const gcd = proj
107
+ ? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
108
+ : gitCommonDir()
109
+ return join(spexcodeHome(), 'projects', encodeProject(dirname(gcd)))
110
+ }
111
+ // this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
112
+ // all keyed by session_id under <home>/projects/<enc>/sessions/.
113
+ export function sessionsRoot(): string { return join(runtimeRoot(), 'sessions') }
114
+ export function sessionStoreDir(id: string): string { return join(sessionsRoot(), id) }
115
+ export function sessionRecordPath(id: string): string { return join(sessionStoreDir(id), 'session.json') }
116
+ export function sessionArtifactPath(id: string, name: string): string { return join(sessionStoreDir(id), name) }
117
+
118
+ // the structured per-session record, as it sits on disk. Written one-field-per-line with EVERY key present
119
+ // (see sessions.ts writeRecord) so the hot-path mark-active shell hook can value-replace status/proposal/note
120
+ // with sed and never needs jq. Read here for the overlay; sessions.ts owns the full typed read/write.
121
+ export type RawRecord = {
122
+ session_id: string; governed: boolean; worktree_path: string; branch: string | null
123
+ node: string | null; title: string | null; name: string | null
124
+ status: string; proposal: string | null; merges: number; note: string | null
125
+ sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
126
+ }
127
+ // the agent's OWN session id from the environment — the only locator now that the record left the worktree.
128
+ // Three tiers, in order:
129
+ // (1) a harness's per-thread env var (`sessionEnvVar`) RESOLVED VIA THE ALIAS — when it lands on a governed
130
+ // record (directly, or through that record's `harness_session_id`), that record's SpexCode id is the
131
+ // answer. This MUST win: codex's design-C runs ONE shared per-project app-server whose env carries the
132
+ // FIRST launched session's `SPEXCODE_SESSION_ID`, and the agent's shell tool (its `spex session
133
+ // done/park/ask`) runs INSIDE that app-server process, so `SPEXCODE_SESSION_ID` is contaminated with the
134
+ // wrong session. But codex injects the ACTING thread's id into every spawned command's env as
135
+ // CODEX_THREAD_ID (== codex's `sessionEnvVar`), so the per-thread var aliases to the RIGHT record while
136
+ // the shared `SPEXCODE_SESSION_ID` does not.
137
+ // (2) else `SPEXCODE_SESSION_ID` (the GOVERNED record id the launcher bakes in) — the claude path and the
138
+ // non-shared baseline, mirroring the shell hooks' `hp_session_id`.
139
+ // (3) else a harness's env var RAW — a self-launched, non-governed agent's own minted id, which has no
140
+ // governed record to alias to (codex CODEX_THREAD_ID / claude CLAUDE_CODE_SESSION_ID). The RAW form must
141
+ // stay BELOW (2): an un-aliased codex thread id is not a record key, so it must never beat a real
142
+ // `SPEXCODE_SESSION_ID`.
143
+ // Claude is UNCHANGED: its `sessionEnvVar` (CLAUDE_CODE_SESSION_ID) already EQUALS its record id, so tier (1)
144
+ // resolves to that very id — the same value `SPEXCODE_SESSION_ID` would have returned; there is no shared
145
+ // app-server to contaminate it. No worktree fallback. (sessions.ts's `ownSessionId` delegates here; spec-yatsu
146
+ // reads it to resolve the current node.)
147
+ export function envSessionId(): string | null {
148
+ for (const h of HARNESSES) {
149
+ const v = process.env[h.sessionEnvVar]
150
+ if (v && v.trim()) { const r = readAliasedRawRecord(v.trim()); if (r) return r.session_id }
151
+ }
152
+ const o = process.env.SPEXCODE_SESSION_ID
153
+ if (o && o.trim()) return o.trim()
154
+ for (const h of HARNESSES) { const v = process.env[h.sessionEnvVar]; if (v && v.trim()) return v.trim() }
155
+ return null
156
+ }
157
+ export function readRawRecord(id: string): RawRecord | null {
158
+ try {
159
+ const raw = JSON.parse(readFileSync(sessionRecordPath(id), 'utf8'))
160
+ return raw && typeof raw === 'object' && raw.session_id ? raw as RawRecord : null
161
+ } catch { return null }
162
+ }
163
+ // resolve a possibly-ALIASED session id to its raw record. A codex hook fires from the shared per-project
164
+ // app-server (no SPEXCODE_SESSION_ID in its env), so the id it carries is the codex THREAD id — the payload
165
+ // session_id — not the SpexCode record id the store is keyed by. Direct id wins; else the one record that
166
+ // captured this id as `harness_session_id` (the backend stored it at thread/start, before any tool turn).
167
+ // Null when neither resolves. Mirrors the shell `hp_store_dir` alias grep — one resolution rule, both layers.
168
+ export function readAliasedRawRecord(id: string): RawRecord | null {
169
+ const direct = readRawRecord(id)
170
+ if (direct) return direct
171
+ for (const sid of listSessionIds()) {
172
+ const r = readRawRecord(sid)
173
+ if (r && r.harness_session_id && r.harness_session_id === id) return r
174
+ }
175
+ return null
176
+ }
177
+ // every session_id this project has a record for (the board's enumeration source — replaces `git worktree
178
+ // list`). A MISSING store dir means no session ever launched → []. But any OTHER readdir failure THROWS
179
+ // (preserving the fail-loud-enumeration invariant `git worktree list` had): a transient FS error must never
180
+ // read as "every session vanished" — the watch poll skips the tick on a throw, never emitting a false mass-close.
181
+ export function listSessionIds(): string[] {
182
+ let ents
183
+ try { ents = readdirSync(sessionsRoot(), { withFileTypes: true }) }
184
+ catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; throw e }
185
+ return ents.filter((d) => d.isDirectory()).map((d) => d.name)
186
+ }
187
+
188
+ // memo the overlay (3 git diffs/worktree) keyed on fork-point merge-base + HEAD + spec sig — keying on the
189
+ // merge-base NOT main's HEAD means unrelated merges that don't move the fork point stay cache hits.
190
+ const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
191
+ const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
192
+ const safeMergeBase = async (wtPath: string, mainRef: string): Promise<string> => {
193
+ try { return (await gitA(['-C', wtPath, 'merge-base', mainRef, 'HEAD'])).trim() } catch { return '' }
194
+ }
195
+ let layoutHeadWarned = false
196
+ async function cachedDelta(wtPath: string, mainRef: string): Promise<NodeOp[]> {
197
+ const wtHead = safeHead(wtPath)
198
+ const base = await safeMergeBase(wtPath, mainRef)
199
+ // fail loud, never stale: if the merge-base or HEAD can't be read the key is untrustworthy — bypass the
200
+ // cache and recompute (warn once) rather than risk serving a delta keyed on an empty sha across a real change.
201
+ if (!base || !wtHead) {
202
+ if (!layoutHeadWarned) { layoutHeadWarned = true; console.warn('spec-cli: layout overlay cache bypassed (unreadable merge-base/HEAD), recomputing every read') }
203
+ return worktreeSpecDelta(wtPath, mainRef)
204
+ }
205
+ const key = `${base}\0${wtHead}\0${worktreeSpecSig(wtPath)}`
206
+ const hit = deltaCache.get(wtPath)
207
+ if (hit && hit.key === key) return hit.ops
208
+ const ops = await worktreeSpecDelta(wtPath, mainRef, base)
209
+ deltaCache.set(wtPath, { key, ops })
210
+ return ops
211
+ }
212
+
213
+ export async function resolveLayout(): Promise<Layout> {
214
+ const root = repoRoot()
215
+ const main = dirname(gitCommonDir()) // the main checkout — same answer from main OR any linked worktree
216
+ const cfg = readConfig(main)
217
+ const base = mainBranch()
218
+ const convention: Convention = {
219
+ main: cfg.main || '',
220
+ mainBranch: base,
221
+ branchPrefix: cfg.branchPrefix ?? 'node/',
222
+ }
223
+ const mainRef = base
224
+ // the board enumerates the GLOBAL per-session store (NOT `git worktree list`): every GOVERNED record this
225
+ // project owns, each carrying the worktree_path its spec-delta is computed from. Non-governed (user-self-
226
+ // launched) records are excluded — board state is a managed-session concern ([[state]]). Each delta is
227
+ // independent → compute (or cache-hit) in parallel, keyed by worktree path as before. guardWorktree wraps
228
+ // each: a worktree whose dir was genuinely removed mid-read (a worker self-merged + retired it) is OMITTED;
229
+ // one that still exists but hit a transient detail failure is kept as a DEGRADED row from the last cached delta.
230
+ const records = listSessionIds().map(readRawRecord).filter((r): r is RawRecord => !!r && r.governed)
231
+ const rows = await Promise.all(records.map((r) => {
232
+ const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
233
+ const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ops: [] }
234
+ return guardWorktree<Worktree>(r.worktree_path,
235
+ async (): Promise<Worktree> => ({ ...base, ops: await cachedDelta(r.worktree_path, mainRef) }),
236
+ (): Worktree => ({ ...base, ops: deltaCache.get(r.worktree_path)?.ops ?? [] }))
237
+ }))
238
+ const sessionWorktrees = rows.filter((w): w is Worktree => w !== null)
239
+ // the main checkout row (isMain) — always present, carries no overlay; it anchors the merged tree the board draws.
240
+ const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
241
+ const worktrees = [mainRow, ...sessionWorktrees]
242
+ // drop cache entries for worktrees no longer in the store (closed sessions), so the map stays bounded.
243
+ const live = new Set(sessionWorktrees.map((w) => w.path))
244
+ for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
245
+ return { main: convention.main || main || root, convention, worktrees }
246
+ }