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.
- package/bin/spex.mjs +15 -0
- package/dashboard-dist/assets/index-B60MILFg.js +139 -0
- package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
- package/dashboard-dist/index.html +16 -0
- package/package.json +35 -0
- package/src/board.ts +119 -0
- package/src/cli.ts +487 -0
- package/src/client.ts +102 -0
- package/src/gateway.ts +241 -0
- package/src/git.ts +492 -0
- package/src/guide.ts +134 -0
- package/src/harness.ts +674 -0
- package/src/hooks.ts +41 -0
- package/src/index.ts +233 -0
- package/src/init.ts +120 -0
- package/src/layout.ts +246 -0
- package/src/lint.ts +206 -0
- package/src/login-page.ts +79 -0
- package/src/materialize.ts +85 -0
- package/src/pty-bridge.ts +235 -0
- package/src/ranker.ts +129 -0
- package/src/resilience.ts +41 -0
- package/src/search.bench.mjs +47 -0
- package/src/search.ts +24 -0
- package/src/self.ts +256 -0
- package/src/sessions.ts +1469 -0
- package/src/slash-commands.ts +242 -0
- package/src/specs.ts +331 -0
- package/src/supervise.ts +158 -0
- package/src/uploads.ts +31 -0
- package/templates/hooks/pre-commit +57 -0
- package/templates/hooks/prepare-commit-msg +14 -0
- package/templates/spec/project/.config/core/idle/idle.sh +15 -0
- package/templates/spec/project/.config/core/idle/spec.md +13 -0
- package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
- package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
- package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
- package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
- package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
- package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
- package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.config/core/spec.md +13 -0
- package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
- package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
- package/templates/spec/project/.config/extract/spec.md +60 -0
- package/templates/spec/project/.config/forge-link/spec.md +9 -0
- package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
- package/templates/spec/project/.config/regroup/spec.md +25 -0
- package/templates/spec/project/.config/scenario/spec.md +32 -0
- package/templates/spec/project/.config/spec.md +15 -0
- package/templates/spec/project/.config/supervisor/spec.md +8 -0
- package/templates/spec/project/.config/tidy/spec.md +25 -0
- package/templates/spec/project/spec.md +19 -0
- 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
|
+
}
|