spexcode 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +60 -19
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -1,17 +1,26 @@
1
1
  import { serve } from '@hono/node-server'
2
2
  import { Hono } from 'hono'
3
3
  import { cors } from 'hono/cors'
4
+ import { etag } from 'hono/etag'
4
5
  import { createNodeWebSocket } from '@hono/node-ws'
5
- import { loadSpecs, specHistory, specDiffAt, loadConfig } from './specs.js'
6
+ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig } from './specs.js'
7
+ import { issuesEnabled, postLocalIssue, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
8
+ import { closeIssue, mergedIssues, replyIssue } from './issues.js'
9
+ import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
10
+ import { summarize } from './mentions.js'
6
11
  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'
12
+ import { getBoardJson } from './boardCache.js'
13
+ import { boardStream, notifyBoardChanged } from './boardStream.js'
14
+ import { gitA, gitTry, repoRoot } from './git.js'
15
+ import { newSession, listSessions, sendKeys, rawKey, exitSession, closeSession, reopen, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
16
+ import { defaultHarness, HARNESSES, launcherList, defaultLauncher } from './harness.js'
11
17
  import { evalTimeline, readBlobByHash } from '../../spec-yatsu/src/evaltab.js'
12
- import { buildProofModel, renderProofHtml } from '../../spec-yatsu/src/proof.js'
18
+ import { putBlob } from '../../spec-yatsu/src/cache.js'
19
+ import { yatsuNodes } from '../../spec-yatsu/src/yatsu.js'
20
+ import { fileHumanReading } from '../../spec-yatsu/src/filing.js'
21
+ import { buildProofModel, renderProofHtml, buildSessionEvals } from '../../spec-yatsu/src/proof.js'
13
22
  import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
14
- import { attachViewer, detachViewer, writeViewer, resizeBridge, superviseBridges, type Viewer } from './pty-bridge.js'
23
+ import { attachViewer, detachViewer, resizeBridge, forwardWheel, superviseBridges, type Viewer } from './pty-bridge.js'
15
24
  import { installProcessGuards } from './resilience.js'
16
25
 
17
26
  // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
@@ -27,9 +36,47 @@ app.get('/', (c) => c.text('spec-cli — GET /api/board · /api/specs · /api/sp
27
36
  // instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
28
37
  app.get('/health', (c) => c.text('ok'))
29
38
  // 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()))
39
+ // as `spex board`; the frontend only adds x/y pixels on top. Freshness is PUSH-first ([[board-stream]]): the
40
+ // dashboard reloads on a `/api/board/stream` event, not a tight poll, so the route is a conditional-request
41
+ // endpoint: `etag()` hashes the serialized body, and a reload whose `If-None-Match` matches gets a bodyless 304
42
+ // instead of the full transfer (~1 MB on the dogfood board — it scales with the node count). The 304 saves the
43
+ // WIRE only; the COMPUTE is saved by [[board-cache]]: getBoard() is single-flight + cached, so a poll storm
44
+ // shares ONE build instead of each running its own — the poll-frequency cut (push channel) and the
45
+ // build-coalescing cut compound. A hard timeout bounds a wedged build to a loud 503 rather than an
46
+ // unboundedly-held connection (the wall sits well above the legitimately-several-seconds cold first build);
47
+ // the underlying single-flight build keeps running and caches for the next poll.
48
+ const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
49
+ app.get('/api/board', etag(), async (c) => {
50
+ const timeout = Symbol('timeout')
51
+ const json = await Promise.race([getBoardJson(), new Promise<typeof timeout>((r) => setTimeout(() => r(timeout), BOARD_TIMEOUT_MS))])
52
+ if (json === timeout) return c.json({ error: 'board build timed out' }, 503)
53
+ return c.body(json as string, 200, { 'content-type': 'application/json; charset=UTF-8' })
54
+ })
55
+ // the board's push channel: an SSE that fires `board-changed` on any session-store write, so the dashboard
56
+ // reloads the instant status moves instead of waiting for its slow fallback poll ([[board-stream]]).
57
+ app.get('/api/board/stream', (c) => boardStream(c))
32
58
  app.get('/api/specs', async (c) => c.json(await loadSpecs()))
59
+ // the search corpus ([[board-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
60
+ // board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
61
+ // to rank nodes over their prose — off the board's hot poll. A literal segment, before the `:id` routes.
62
+ // Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags}), so a yatsu
63
+ // node's row here carries its declared scenarios' description/expected (+ per-scenario code) — one fetch
64
+ // serves both the palette's scenario plane and the focus-panel preview.
65
+ app.get('/api/specs/lite', (c) => {
66
+ const scByNode = new Map(yatsuNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
67
+ return c.json(loadSpecsLite().map((row) => {
68
+ const sc = scByNode.get(row.id)
69
+ return sc?.length
70
+ ? { ...row, scenarios: sc.map((s) => ({ name: s.name, description: s.description, expected: s.expected, ...(s.code?.length ? { code: s.code } : {}) })) }
71
+ : row
72
+ }))
73
+ })
74
+ // one node's body + parsed parts ([[board-lean]]): the board no longer ships either, so the detail view
75
+ // fetches this when a node opens. 404 for an unknown id.
76
+ app.get('/api/specs/:id/content', (c) => {
77
+ const x = specContent(c.req.param('id'))
78
+ return x ? c.json(x) : c.json({ body: '', parts: null }, 404)
79
+ })
33
80
  app.get('/api/specs/:id/history', async (c) => c.json(await specHistory(c.req.param('id'))))
34
81
  // the spec.md line diff one version introduced — the history tab's per-version proof-of-change, fetched
35
82
  // lazily when an older version's item expands (the latest version's diff ships with the board as node.lastDiff).
@@ -53,18 +100,162 @@ app.get('/api/edit', async (c) => {
53
100
  // a node's eval timeline (read half of `spex yatsu`): yatsu-sidecar readings joined with a live freshness
54
101
  // flag, newest-first; `hasYatsu:false` when none declared. Contract belongs to [[spec-yatsu]].
55
102
  app.get('/api/specs/:id/evals', async (c) => c.json(await evalTimeline(c.req.param('id'))))
103
+ // the eval seam's WRITE half over HTTP ([[spec-yatsu]] filing.ts): a programmatic caller files a manual@1
104
+ // reading (verdict + optional transcript) through the SAME append the CLI uses. The dashboard does not
105
+ // call this — [[event-detail]] reads readings and hosts remarks, never files.
106
+ app.post('/api/specs/:id/yatsu/eval', async (c) => {
107
+ const b = await c.req.json().catch(() => null)
108
+ if (!b || typeof b.scenario !== 'string') return c.json({ error: 'body needs { scenario, status, note?, transcript? }' }, 400)
109
+ const r = fileHumanReading(c.req.param('id'), b)
110
+ return r.ok ? c.json({ ok: true, reading: r.reading }) : c.json({ error: r.error }, 400)
111
+ })
56
112
  // serve a reading's evidence blob by content hash (bytes never enter git): bad hash → 400, missing → 404,
57
113
  // else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
114
+ // HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
115
+ // currentTime to the seekable window, which stays [0,0] without them); one general mechanism at the
116
+ // transport, so every evidence kind streams the same way.
58
117
  app.get('/api/yatsu/blob/:hash', (c) => {
59
118
  const r = readBlobByHash(c.req.param('hash'))
60
119
  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' })
120
+ const total = r.bytes.length
121
+ const base = { 'Content-Type': r.mime, 'Cache-Control': 'public, max-age=31536000, immutable', 'Accept-Ranges': 'bytes' }
122
+ const m = /^bytes=(\d*)-(\d*)$/.exec(c.req.header('range') ?? '')
123
+ if (m && (m[1] || m[2])) {
124
+ const start = m[1] ? parseInt(m[1], 10) : total - parseInt(m[2], 10)
125
+ const end = m[1] && m[2] ? Math.min(parseInt(m[2], 10), total - 1) : total - 1
126
+ if (!(start >= 0 && start <= end && end < total)) return c.body(null, 416, { 'Content-Range': `bytes */${total}` })
127
+ return c.body(new Uint8Array(r.bytes.subarray(start, end + 1)), 206, { ...base, 'Content-Range': `bytes ${start}-${end}/${total}` })
128
+ }
129
+ return c.body(new Uint8Array(r.bytes), 200, base)
130
+ })
131
+ // the WRITE half of the blob store ([[annotator]]): the annotator captures a circled video frame to a PNG
132
+ // and stashes the bytes here, content-addressed (same putBlob the yatsu cache uses). The returned hash is
133
+ // what an anchored comment references (image link in the body, and the typed evidence[] on its thread) —
134
+ // bytes never enter git. Raw body, sniffed by the same content-addressed name. Empty → 400, over cap → 413.
135
+ app.post('/api/yatsu/blob', async (c) => {
136
+ const buf = Buffer.from(await c.req.arrayBuffer())
137
+ if (buf.length === 0) return c.json({ error: 'empty blob' }, 400)
138
+ if (buf.length > MAX_UPLOAD_BYTES) return c.json({ error: 'blob too large' }, 413)
139
+ return c.json({ hash: putBlob(buf) }, 201)
62
140
  })
63
141
  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
142
+ // the `surface: command` config-root plugins (built/active only) for the new-session `/` dropdown — each with
65
143
  // its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
66
144
  // frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
67
145
  app.get('/api/config', (c) => c.json(loadConfig()))
146
+ // the named launcher profiles ([[launcher-select]]) the New-Session form's dropdown offers — `{ name, harness }`
147
+ // only (the `cmd` is a host secret, never shipped to the browser) — plus the configured `default` NAME so the
148
+ // dropdown pre-selects the SAME launcher a bare `spex new` uses (the CLI/config default), instead of the
149
+ // alphabetically-first one. `launchers` is empty (and `default` '') when a project configured none, so the form
150
+ // falls back to the plain harness picker.
151
+ app.get('/api/launchers', (c) => c.json({
152
+ launchers: launcherList().map(({ name, harness }) => ({ name, harness })),
153
+ default: defaultLauncher(),
154
+ }))
155
+ // the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
156
+ // (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
157
+ // (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
158
+ // the issues-workflow on/off switch so the frontend hides the view when the feature is OFF.
159
+ app.get('/api/issues', etag(), (c) =>
160
+ c.json({
161
+ enabled: issuesEnabled(),
162
+ issues: mergedIssues({ host: 'github', state: residentForgeState() }, loadSpecsLite().map((s) => s.id)),
163
+ }))
164
+ // the WRITE surface ([[local-issues]] / [[issues-view]]) — the human reply path, STORE-ROUTED through the one
165
+ // reply verb ([[issues]] replyIssue): a local id git-commits to the trunk store, a forge id ('github#N')
166
+ // posts a REAL comment through the driver; either way the text's @-mentions dispatch (a human summons an
167
+ // agent from the issues page). `outcomes` is the one-line @-dispatch summary the dashboard echoes. The
168
+ // server owns its freshness: a forge write forces the resident slice's read-back before answering, so the
169
+ // reload that follows shows the comment. Honor the on/off switch: 403 when the feature is OFF; an unknown
170
+ // local thread → 404; a failed forge write → 502 with the driver's own message (fail loud, never queued).
171
+ app.post('/api/issues/:id/reply', async (c) => {
172
+ if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
173
+ const body = await c.req.json().catch(() => ({}))
174
+ const text = typeof body?.body === 'string' ? body.body : ''
175
+ if (!text.trim()) return c.json({ error: 'empty reply' }, 400)
176
+ // typed evidence[] — an anchored annotation's frame-blob hashes accrue onto the local thread (same shape
177
+ // as the create route); a forge reply ignores them (its frame rides the comment body's image link).
178
+ const evidence = Array.isArray(body?.evidence) ? (body.evidence as unknown[]).filter((h): h is string => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) : []
179
+ const id = c.req.param('id')
180
+ try {
181
+ // the mention prompt's node context, from the same resident merge the GET serves
182
+ const node = id.includes('#')
183
+ ? mergedIssues({ host: 'github', state: residentForgeState() }, loadSpecsLite().map((s) => s.id)).find((i) => i.id === id)?.nodes[0] ?? null
184
+ : null
185
+ const r = await replyIssue(id, text, { author: 'human', node, evidence })
186
+ if (r.store !== 'local') await refreshForgeNow()
187
+ return c.json({ ok: true, replies: r.replies, url: r.url, outcomes: summarize(r.outcomes, r.loopIn) })
188
+ } catch (e) {
189
+ const msg = String((e as Error).message || e)
190
+ return c.json({ error: msg }, id.includes('#') ? 502 : 404)
191
+ }
192
+ })
193
+ // store-routed lifecycle close ([[issues]]): local resolves the local thread, forge closes the remote
194
+ // issue through the driver. A forge close forces read-back before the dashboard reloads the resident list.
195
+ app.post('/api/issues/:id/close', async (c) => {
196
+ if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
197
+ const id = c.req.param('id')
198
+ try {
199
+ const r = await closeIssue(id)
200
+ if (r.store !== 'local') await refreshForgeNow()
201
+ return c.json({ ok: true, ...r })
202
+ } catch (e) {
203
+ const msg = String((e as Error).message || e)
204
+ return c.json({ error: msg }, id.includes('#') ? 502 : 404)
205
+ }
206
+ })
207
+ app.post('/api/issues', async (c) => {
208
+ if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
209
+ const body = await c.req.json().catch(() => ({}))
210
+ const concern = typeof body?.concern === 'string' ? body.concern.trim() : ''
211
+ if (!concern) return c.json({ error: 'empty concern' }, 400)
212
+ const nodes = Array.isArray(body?.nodes) ? (body.nodes as unknown[]).filter((n): n is string => typeof n === 'string') : []
213
+ const postBody = typeof body?.body === 'string' ? body.body : undefined
214
+ // typed evidence[] — yatsu content-addressed hashes (the annotator's clip reference rides here, not prose)
215
+ const evidence = Array.isArray(body?.evidence) ? (body.evidence as unknown[]).filter((h): h is string => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) : []
216
+ const { thread, outcomes } = await postLocalIssue(concern, { nodes, body: postBody, evidence, author: 'human' })
217
+ return c.json({ ok: true, id: thread.id, outcomes: summarize(outcomes) }, 201)
218
+ })
219
+
220
+ // the REMARK write surface ([[remark-substrate]]) — server PARITY with the CLI: the dashboard can author /
221
+ // resolve / retract a remark through the SAME functions `spex remark|resolve|retract` call, adding no
222
+ // capability. A ref (`<thread-id>#<rid>`) rides the request BODY, not the path (a '#' in a URL is a
223
+ // fragment). Identity is derived SERVER-SIDE — this is the dashboard's human surface, so the actor is
224
+ // `'human'`, the SAME sentinel /api/issues stamps; it is NEVER read from the request body. That keeps R3's
225
+ // teeth structural (identity is not spoofable over the wire) and honest on both surfaces: resolve rejects
226
+ // `'human'` (agent-only — an agent resolves through the CLI, never the dashboard), and retract binds to the
227
+ // human who authored (only their own `'human'` remarks). Who-may-resolve/retract cannot depend on transport.
228
+ app.post('/api/remarks', async (c) => {
229
+ if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
230
+ const body = await c.req.json().catch(() => ({}))
231
+ const text = typeof body?.body === 'string' ? body.body : ''
232
+ if (!text.trim()) return c.json({ error: 'empty remark' }, 400)
233
+ const evidence = Array.isArray(body?.evidence) ? (body.evidence as unknown[]).filter((h): h is string => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) : []
234
+ const host = typeof body?.scenario === 'string' && body.scenario
235
+ ? { node: typeof body?.node === 'string' ? body.node : undefined, scenario: body.scenario as string }
236
+ : { issue: typeof body?.issue === 'string' ? body.issue : undefined }
237
+ const codeSha = typeof body?.codeSha === 'string' ? body.codeSha : undefined
238
+ try {
239
+ const r = await remarkOnHost(host, text, { codeSha, author: 'human', evidence })
240
+ return c.json({ ok: true, ref: r.ref, rid: r.rid, codeSha: r.codeSha, outcomes: summarize(r.outcomes, r.loopIn) }, 201)
241
+ } catch (e) {
242
+ return c.json({ error: String((e as Error).message || e) }, 400)
243
+ }
244
+ })
245
+ app.post('/api/remarks/:action{resolve|retract}', async (c) => {
246
+ if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
247
+ const body = await c.req.json().catch(() => ({}))
248
+ const ref = typeof body?.ref === 'string' ? body.ref : ''
249
+ if (!ref) return c.json({ error: 'missing remark ref' }, 400)
250
+ const by = 'human' // server-derived identity — never the request body (see /api/remarks above)
251
+ try {
252
+ if (c.req.param('action') === 'resolve') resolveRemark(ref, by)
253
+ else retractRemark(ref, by)
254
+ return c.json({ ok: true, ref })
255
+ } catch (e) {
256
+ return c.json({ error: String((e as Error).message || e) }, 400)
257
+ }
258
+ })
68
259
  // the dashboard input's `/` dropdown — computed by the launcher's HARNESS adapter the same way that harness
69
260
  // computes its own `/` menu ([[harness-adapter]]). The client passes `?harness=<id>` for the ACTIVE session,
70
261
  // so a codex tab gets CODEX's menu, not the default's; unknown/absent → default. Insert-only on the client.
@@ -109,9 +300,14 @@ app.post('/api/sessions', async (c) => {
109
300
  const prompt = typeof body?.prompt === 'string' ? body.prompt : ''
110
301
  if (!prompt.trim()) return c.json({ error: 'empty prompt' }, 400)
111
302
  const harness = typeof body?.harness === 'string' ? body.harness : undefined
303
+ // the named launcher ([[launcher-select]]) — fixes the session's harness AND its persisted launch command.
304
+ const launcher = typeof body?.launcher === 'string' && body.launcher.trim() ? body.launcher.trim() : undefined
305
+ // parent = the spawning session's id, resolved by the CALLER (createSession) in its own process and passed
306
+ // through here ([[session-nesting]]); the browser's New Session omits it → a top-level session.
307
+ const parent = typeof body?.parent === 'string' && body.parent.trim() ? body.parent.trim() : null
112
308
  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
309
+ return c.json(await newSession(typeof body?.node === 'string' ? body.node : null, prompt, harness, parent, launcher), 201)
310
+ } catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown harness/launcher id → 400, not a 500
115
311
  })
116
312
  // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
117
313
  // dashboard and `spex review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
@@ -119,14 +315,22 @@ app.get('/api/sessions/:id/review', async (c) => {
119
315
  const r = await reviewPayload(c.req.param('id'))
120
316
  return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
121
317
  })
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.
318
+ // the [[review-proof]] EXPORT artifact: one self-contained HTML (diff + gates + evidence inlined as
319
+ // data-URIs) for CI/share/bare-browser. `?format=json` returns the model; default = rendered HTML. The
320
+ // dashboard's interactive face is the lean route below, never this. 404 unknown id.
124
321
  app.get('/api/sessions/:id/proof', async (c) => {
125
322
  const m = await buildProofModel(c.req.param('id'))
126
323
  if (!m) return c.text('no such session', 404)
127
324
  if (c.req.query('format') === 'json') return c.json(m)
128
325
  return c.html(renderProofHtml(m))
129
326
  })
327
+ // the session EVAL model ([[review-proof]]'s interactive face): worktree-rooted rows only — no diff
328
+ // enrichment, no inlined bytes; evidence streams lazily from /api/yatsu/blob. Each reading carries
329
+ // `inSession` so the tab leads with what THIS session measured.
330
+ app.get('/api/sessions/:id/evals', async (c) => {
331
+ const m = await buildSessionEvals(c.req.param('id'))
332
+ return m ? c.json(m) : c.json({ error: 'no such session' }, 404)
333
+ })
130
334
  // the session's live pane as text (one-shot snapshot) for a backend client (`spex capture`). Empty and fail
131
335
  // stay distinct: an empty pane is 200 with empty body; unknown id → 404, offline (no live pane) → 409, error → 502.
132
336
  app.get('/api/sessions/:id/capture', async (c) => {
@@ -142,8 +346,15 @@ app.get('/api/sessions/:id/prompt', async (c) => {
142
346
  return p == null ? c.text('no prompt recorded', 404) : c.text(p)
143
347
  })
144
348
  // 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') }))
349
+ // relaunch ONLY if confirmed offline; demotes working→idle, keeps any declaration. The RESUME GUARD refuses
350
+ // (409) when the agent is alive or its liveness is unproven — restore-on-alive was the incident's kill-shot.
351
+ // `force` (query ?force=1 or JSON {force:true}) overrides for a wedged-but-alive process.
352
+ app.post('/api/sessions/:id/resume', async (c) => {
353
+ const body = await c.req.json().catch(() => ({} as { force?: boolean }))
354
+ const force = body?.force === true || c.req.query('force') === '1'
355
+ const r = await reopen(c.req.param('id'), { force })
356
+ return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
357
+ })
147
358
  // a dispatch to the session's own agent (it runs the merge), never a server merge — the server never touches
148
359
  // main's tree. 200 {dispatched:true} once the prompt is accepted, 409 {dispatched:false} if the agent is unreachable.
149
360
  app.post('/api/sessions/:id/merge', async (c) => {
@@ -151,27 +362,34 @@ app.post('/api/sessions/:id/merge', async (c) => {
151
362
  return c.json(r, r.dispatched ? 200 : 409)
152
363
  })
153
364
 
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.
365
+ // one WS over a shared tmux control-mode client (pty-bridge): server→client = raw pane bytes (binary); the
366
+ // view takes no keyboard input, so client→server is only a text control frame {t:'resize',cols,rows} or
367
+ // {t:'wheel',…}. The bridge resolves the wheel against tmux pane state: copy-mode repaint for normal panes,
368
+ // SGR mouse report injection for mouse-owning TUIs. A real tmux client, so the first paint is one coherent
369
+ // frame and live bytes arrive as events.
157
370
  app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
158
371
  const id = c.req.param('id') as string
372
+ // the size-first handshake: a client that already knows its pane size carries it as ?cols=&rows= so the
373
+ // first frame is drawn at the true size. Absent/garbage → undefined, and the bridge falls back to prewarm.
374
+ const qc = Number(c.req.query('cols')), qr = Number(c.req.query('rows'))
375
+ const initialSize = qc > 0 && qr > 0 ? { cols: qc, rows: qr } : undefined
159
376
  let viewer: Viewer | null = null
160
377
  return {
161
378
  onOpen(_evt, ws) {
162
379
  viewer = { send: (buf) => { try { ws.send(Uint8Array.from(buf)) } catch { /* viewer gone */ } } }
163
- if (!attachViewer(id, viewer)) { try { ws.close() } catch { /* already closed */ } }
380
+ if (!attachViewer(id, viewer, initialSize)) { try { ws.close() } catch { /* already closed */ } }
164
381
  },
165
382
  onMessage(evt) {
166
383
  if (!viewer) return
167
384
  const data = evt.data
385
+ // no keyboard input: the only client→server messages are the resize frame and the wheel frame. Binary
386
+ // is ignored; pane navigation stays inside the tmux bridge instead of becoming browser scroll state.
168
387
  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)
388
+ try {
389
+ const m = JSON.parse(data)
390
+ if (m?.t === 'resize') resizeBridge(id, Number(m.cols), Number(m.rows), !!m.full)
391
+ else if (m?.t === 'wheel') forwardWheel(id, !!m.up, Number(m.col), Number(m.row), Number(m.ticks))
392
+ } catch { /* ignore */ }
175
393
  }
176
394
  },
177
395
  onClose() { if (viewer) detachViewer(id, viewer) },
@@ -187,11 +405,14 @@ app.post('/api/sessions/:id/keys', async (c) => {
187
405
  const r = await sendKeys(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined)
188
406
  return c.json(r, r.ok ? 200 : 502)
189
407
  })
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.
408
+ // the preserved tmux send-keys path (distinct from the ❯ prompt socket): the human drives the agent's
409
+ // interactive TUI menus in real time. Accepts an ORDERED BATCH (`keys`, the client coalesces fast typing) or a
410
+ // single `key`; rawKey delivers them in array order so tap order is preserved ([[nav-mode-key-ordering]]).
192
411
  app.post('/api/sessions/:id/rawkey', async (c) => {
193
412
  const body = await c.req.json().catch(() => ({}))
194
- const ok = await rawKey(c.req.param('id'), typeof body?.key === 'string' ? body.key : '')
413
+ const keys = Array.isArray(body?.keys) ? body.keys.filter((k: unknown) => typeof k === 'string')
414
+ : typeof body?.key === 'string' ? [body.key] : []
415
+ const ok = await rawKey(c.req.param('id'), keys)
195
416
  return c.json({ ok }, ok ? 200 : 404)
196
417
  })
197
418
  // soft stop: kill the agent's tmux + socket but KEEP the worktree (relaunchable). Distinct from close, which
@@ -199,10 +420,12 @@ app.post('/api/sessions/:id/rawkey', async (c) => {
199
420
  app.post('/api/sessions/:id/exit', async (c) => c.json({ ok: await exitSession(c.req.param('id')) }))
200
421
  app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
201
422
  // 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.
423
+ // it survives a restart. Unknown id → 404. The worktree file is OUTSIDE the store the board watchers see,
424
+ // so the route nudges the stream explicitly ([[board-stream]]) — the rename shows in ~150ms, not a cold tick.
203
425
  app.post('/api/sessions/:id/rename', async (c) => {
204
426
  const body = await c.req.json().catch(() => ({}))
205
427
  const ok = await renameSession(c.req.param('id'), typeof body?.name === 'string' ? body.name : '')
428
+ if (ok) notifyBoardChanged()
206
429
  return c.json({ ok }, ok ? 200 : 404)
207
430
  })
208
431
 
@@ -216,7 +439,24 @@ app.post('/api/sessions/:id/sort', async (c) => {
216
439
  })
217
440
 
218
441
  const port = Number(process.env.PORT || 8787)
219
- const server = serve({ fetch: app.fetch, port })
442
+ // @@@ server-side connection reaping ([[spec-cli]]) - abandoned connections must die SERVER-SIDE, or they
443
+ // pile up and wedge the backend (135 leaked conns once starved :8787 into looking dead — the cascade that
444
+ // triggered the mass-restore incident, since every client-side timeout-kill leaks one). keepAliveTimeout
445
+ // reaps an idle keep-alive socket; headersTimeout a slow-header/slow-loris; requestTimeout a request whose
446
+ // body never completes. These reap IDLE/STALLED sockets only — an ACTIVE WS/SSE response is not "keep-alive
447
+ // idle", so the board-stream SSE and the terminal socket are untouched. requestTimeout bounds RECEIVING a
448
+ // request only (not the response), so a slow board build or a streaming SSE/WS response is never cut. Node
449
+ // enforces these on a periodic sweep whose cadence (connectionsCheckingInterval) is armed AT CONSTRUCTION —
450
+ // so they MUST ride `serverOptions` (setting them post-`serve()` leaves the sweep on its 30s default and the
451
+ // change under-effective); a 10s sweep makes reaping land within timeout+10s, well under the multi-minute
452
+ // defaults (requestTimeout 300s) that let the conns accumulate. headersTimeout > keepAliveTimeout (Node's rule).
453
+ // @@@ loopback bind ([[public-mode]]) - this child is NEVER the internet face: the supervisor (and in public
454
+ // mode the gateway) fronts it, and dials it only via 127.0.0.1. Binding loopback is what makes "loopback is
455
+ // the trust boundary" true — without a hostname Node binds all interfaces and the child is reachable from
456
+ // the LAN with no password, bypassing the gate entirely (measured: yatsu auth-boundary).
457
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1', serverOptions: {
458
+ keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000,
459
+ } })
220
460
  injectWebSocket(server)
221
461
  superviseBridges() // keep a warm tmux client per live session, so opening a tab is instant
222
462
  superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
@@ -2,12 +2,22 @@ import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync }
2
2
  import { join, resolve, relative } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { execFileSync } from 'node:child_process'
5
+ import { readConfig } from './layout.js'
6
+ import { resolveHarnessTargets } from './harness-select.js'
5
7
 
6
8
  // this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
7
9
  // launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
8
10
  const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
9
11
  const TEMPLATES = join(pkgRoot, 'templates')
10
12
 
13
+ // the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.config`
14
+ // instance set (planted from templates/spec); every higher tier is a SEPARATE package under
15
+ // templates/presets/<tier>/ that seeding stacks ON TOP — a superset, so selecting `careful` seeds the
16
+ // default set PLUS the careful package. Selection matters ONLY here at seed time; the running repo just
17
+ // walks whatever `.config` ended up planted, so there is no launcher-side preset gate.
18
+ const PRESET_TIERS = ['default', 'careful'] as const
19
+ const presetRank = (name: string): number => (PRESET_TIERS as readonly string[]).indexOf(name)
20
+
11
21
  // recursively copy srcDir -> destDir, NEVER overwriting an existing file. Returns the repo-relative
12
22
  // paths of files actually written (so the caller can report exactly what was planted vs already there).
13
23
  function copyTreeNoClobber(srcDir: string, destDir: string, base: string): string[] {
@@ -43,9 +53,18 @@ function resolveHooksDir(dir: string): string | null {
43
53
  }
44
54
  }
45
55
 
46
- export async function specInit(targetArg: string | undefined): Promise<void> {
56
+ export async function specInit(targetArg: string | undefined, presetArg?: string): Promise<void> {
47
57
  const targetDir = resolve(targetArg ?? process.cwd())
48
58
 
59
+ // the preset the NEW adopter gets — `--preset <name>` wins, else an existing target spexcode.json's
60
+ // `preset` field, else the lean `default`. Validated loudly against the chain (an unknown name would
61
+ // otherwise seed silently). A non-default tier stacks its template package on top of the default set below.
62
+ const selected = (presetArg ?? '').trim() || (readConfig(targetDir).preset ?? '').trim() || 'default'
63
+ if (!(PRESET_TIERS as readonly string[]).includes(selected)) {
64
+ console.error(`spex init: unknown preset '${selected}'. Valid presets (cumulative, lean→cautious): ${PRESET_TIERS.join(', ')}.`)
65
+ process.exit(1)
66
+ }
67
+
49
68
  // SpexCode is git-backed: git IS the version database and the hooks live in `.git`. A non-git target
50
69
  // would leave a HALF-STATE — specs on disk but no version history, no hooks, no harness shims, no
51
70
  // sessions. So fail LOUD before writing anything, rather than seeding debris and warning past it. We do
@@ -68,6 +87,16 @@ export async function specInit(targetArg: string | undefined): Promise<void> {
68
87
  } else {
69
88
  const planted = copyTreeNoClobber(join(TEMPLATES, 'spec'), specDest, targetDir)
70
89
  console.log(`✓ seeded ${planted.length} spec file(s) under .spec/ (root 'project' node + default .config)`)
90
+ // 1a. stack the selected preset's package(s) ON TOP of the default set — cumulative, so every tier from
91
+ // just above `default` up to the selection is planted. Each lives under templates/presets/<tier>/ mirroring
92
+ // the default layout (its `.config/<plugin>` lands in the seeded project node's `.config`). See [[init-preset]].
93
+ for (let r = 1; r <= presetRank(selected); r++) {
94
+ const tier = PRESET_TIERS[r]
95
+ const pkg = join(TEMPLATES, 'presets', tier)
96
+ if (!existsSync(pkg)) { console.warn(`• preset '${tier}' has no package at ${pkg} — skipped.`); continue }
97
+ const added = copyTreeNoClobber(pkg, join(specDest, 'project'), targetDir)
98
+ console.log(`✓ seeded preset '${tier}' (${added.length} file(s)) into .config`)
99
+ }
71
100
  }
72
101
 
73
102
  // 1b. plant a starter spexcode.json (the lint/layout knob), pointing governedRoots at `src/`.
@@ -79,6 +108,17 @@ export async function specInit(targetArg: string | undefined): Promise<void> {
79
108
  console.log(`✓ planted spexcode.json — set lint.governedRoots to YOUR source dirs (starter: ["src"])`)
80
109
  }
81
110
 
111
+ // validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
112
+ // native, or a plugin with no folder) must fail LOUD here, not be silently swallowed by the materialize
113
+ // try/catch below. A fresh starter spexcode.json omits the field (defaults to all natives), so this only
114
+ // bites a hand-edited or re-init'd config — exactly where a clear error belongs.
115
+ try {
116
+ resolveHarnessTargets(readConfig(targetDir).harnesses)
117
+ } catch (e) {
118
+ console.error(`spex init: ${(e as Error).message}`)
119
+ process.exit(1)
120
+ }
121
+
82
122
  // 2. install the git hooks: templates/hooks/* -> <repo>/<common-git-dir>/hooks/* (skip any that exist).
83
123
  const hooksDir = resolveHooksDir(targetDir)
84
124
  if (!hooksDir) {