spexcode 0.1.6 → 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.
- package/README.md +86 -25
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +172 -119
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +49 -13
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +175 -12
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +360 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +272 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +250 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +683 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
- package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/index.ts
CHANGED
|
@@ -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 {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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]]
|
|
123
|
-
//
|
|
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
|
-
|
|
146
|
-
|
|
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
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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):
|
|
191
|
-
//
|
|
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
|
|
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
|
-
|
|
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)
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -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) {
|