spexcode 0.5.2 → 0.5.3
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +946 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1249 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
package/spec-cli/src/index.ts
CHANGED
|
@@ -13,11 +13,10 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
|
13
13
|
import { summarize } from './mentions.js'
|
|
14
14
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
15
15
|
import { getBoardJson } from './graphCache.js'
|
|
16
|
-
import { boardStream, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
16
|
+
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
17
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
|
-
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, TMUX_SOCK } from './sessions.js'
|
|
18
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
19
19
|
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
20
|
-
import { readSessionMessages, sessionMessageStream } from './message-stream.js'
|
|
21
20
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
22
21
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
23
22
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
@@ -29,6 +28,9 @@ import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, sup
|
|
|
29
28
|
import { installProcessGuards } from './resilience.js'
|
|
30
29
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
31
30
|
import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
|
|
31
|
+
import { collectResourceReport, ResourceConflict } from './host-resources.js'
|
|
32
|
+
import { exactProcessIdentity, maintenanceErrorPayload, sessionMaintenance, SessionMaintenanceError, type Authorization } from './session-maintenance.js'
|
|
33
|
+
import { readBackendInstanceRecords } from './runtime-ownership.js'
|
|
32
34
|
|
|
33
35
|
// last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
|
|
34
36
|
// self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
|
|
@@ -38,12 +40,18 @@ const app = new Hono()
|
|
|
38
40
|
app.use('/api/*', cors())
|
|
39
41
|
app.onError((error, c) => {
|
|
40
42
|
if (error instanceof SessionEvalUnavailableError) return c.json({ error: error.message }, 503)
|
|
43
|
+
// a record that cannot carry state is a CONFLICT with the caller's request, not a server fault: the refusal
|
|
44
|
+
// is deliberate and already carries its own diagnosis + repair ([[sessions-core]]). Answering 500 with a
|
|
45
|
+
// stack would hide exactly the sentence the human needs.
|
|
46
|
+
if (error instanceof SessionRecordUnusable) return c.json({ error: error.message, code: error.code }, 409)
|
|
47
|
+
if (error instanceof ResourceConflict) return c.json({ error: error.message, code: error.code }, 409)
|
|
48
|
+
if (error instanceof SessionMaintenanceError) return c.json(maintenanceErrorPayload(error)!, 409)
|
|
41
49
|
console.error(error)
|
|
42
50
|
return c.text('Internal Server Error', 500)
|
|
43
51
|
})
|
|
44
52
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
|
45
53
|
|
|
46
|
-
app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/slash-commands'))
|
|
54
|
+
app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/resources · /api/slash-commands'))
|
|
47
55
|
// the supervisor's readiness gate (supervise.ts): a bare git-free 200 so a booting child reports ready the
|
|
48
56
|
// instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
|
|
49
57
|
app.get('/health', (c) => c.text('ok'))
|
|
@@ -77,11 +85,21 @@ app.get('/api/instance', (c) => {
|
|
|
77
85
|
// is bounded by [[graph-cache]]'s own build watchdog, so the next poll retries a fresh build.
|
|
78
86
|
const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
|
|
79
87
|
app.get('/api/graph', etag(), async (c) => {
|
|
80
|
-
ensureBoardFileWatchers()
|
|
88
|
+
await ensureBoardFileWatchers()
|
|
81
89
|
const timeout = Symbol('timeout')
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
91
|
+
const result = await Promise.race([
|
|
92
|
+
getBoardJson('stale-ok'),
|
|
93
|
+
new Promise<typeof timeout>((resolve) => {
|
|
94
|
+
timer = setTimeout(() => resolve(timeout), BOARD_TIMEOUT_MS)
|
|
95
|
+
timer.unref?.()
|
|
96
|
+
}),
|
|
97
|
+
])
|
|
98
|
+
clearTimeout(timer)
|
|
99
|
+
if (result === timeout) return c.json({ error: 'graph build timed out' }, 503)
|
|
100
|
+
const freshness = result.refreshing ? `${result.freshness}, refreshing` : result.freshness
|
|
101
|
+
c.header('x-spexcode-graph', freshness)
|
|
102
|
+
return c.body(result.json, 200, { 'content-type': 'application/json; charset=UTF-8' })
|
|
85
103
|
})
|
|
86
104
|
// the graph's push channel: an SSE that fires `board-changed` on any session-store write, so the dashboard
|
|
87
105
|
// reloads the instant status moves instead of waiting for its slow fallback poll ([[graph-stream]]).
|
|
@@ -206,14 +224,15 @@ app.get('/api/issues', etag(), async (c) => c.json(await issuesReview(c.req.quer
|
|
|
206
224
|
// Evals uses the identical paged-review response. `scope:` inside q selects the worktree source; without
|
|
207
225
|
// it the source is the current cached board. Filtering/counts always precede the one 25-row slice.
|
|
208
226
|
app.get('/api/evals', etag(), async (c) => {
|
|
209
|
-
|
|
227
|
+
const scope = c.req.query('q')?.match(/(?:^|\s)scope:([^\s]+)/)?.[1]
|
|
228
|
+
await ensureBoardFileWatchers(scope)
|
|
210
229
|
const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') })
|
|
211
230
|
return page ? c.json(page) : c.json({ error: 'no such review source' }, 404)
|
|
212
231
|
})
|
|
213
232
|
// ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
|
|
214
233
|
// most five lightweight neighbors. It never serializes another scenario's history or the scoped model.
|
|
215
234
|
app.get('/api/evals/detail', etag(), async (c) => {
|
|
216
|
-
ensureBoardFileWatchers()
|
|
235
|
+
await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined)
|
|
217
236
|
const node = c.req.query('node')?.trim()
|
|
218
237
|
const scenario = c.req.query('scenario')?.trim()
|
|
219
238
|
if (!node || !scenario) return c.json({ error: 'node and scenario are required' }, 400)
|
|
@@ -385,7 +404,48 @@ app.post('/api/uploads', async (c) => {
|
|
|
385
404
|
|
|
386
405
|
// sessions: real tmux-backed Claude Code sessions. List + spawn, stream the live pane (WebSocket),
|
|
387
406
|
// forward keystrokes, and close.
|
|
388
|
-
app.get('/api/sessions', async (c) => c.json(await listSessions()))
|
|
407
|
+
app.get('/api/sessions', async (c) => c.json(await listSessions(c.req.query('all') === '1' || c.req.query('all') === 'true')))
|
|
408
|
+
app.get('/api/resources', async (c) => c.json(await collectResourceReport()))
|
|
409
|
+
const maintenance = sessionMaintenance()
|
|
410
|
+
const maintenanceToken = (header: (name: string) => string | undefined) => header('x-spexcode-session-maintenance')?.trim() || ''
|
|
411
|
+
const maintenanceLeaseOwner = () => {
|
|
412
|
+
const instanceId = process.env.SPEXCODE_INSTANCE_ID?.trim()
|
|
413
|
+
const record = instanceId ? readBackendInstanceRecords(repoRoot()).find((candidate) => candidate.instanceId === instanceId) : null
|
|
414
|
+
const exact = record ? exactProcessIdentity(record.pid) : null
|
|
415
|
+
if (!instanceId || !record || !exact || exact.startToken !== record.startToken)
|
|
416
|
+
throw new SessionMaintenanceError('maintenance_identity_unknown', 'cannot prove the exact live backend supervisor generation')
|
|
417
|
+
return { instanceId, pid: record.pid, startToken: record.startToken }
|
|
418
|
+
}
|
|
419
|
+
const operationAuthorization = async (
|
|
420
|
+
header: (name: string) => string | undefined,
|
|
421
|
+
operation: { op: 'stop'; sessionId: string } | { op: 'resume'; sessionId: string; force: boolean },
|
|
422
|
+
): Promise<Authorization | undefined> => maintenance.authorizeHttpOperation({
|
|
423
|
+
authenticated: true,
|
|
424
|
+
projectMatches: true,
|
|
425
|
+
headers: { [maintenance.headerName]: maintenanceToken(header) },
|
|
426
|
+
operation,
|
|
427
|
+
})
|
|
428
|
+
app.get('/api/session-maintenance', (c) => c.json(maintenance.readState()))
|
|
429
|
+
app.post('/api/session-maintenance/acquire', async (c) => {
|
|
430
|
+
const body = await c.req.json().catch(() => ({}))
|
|
431
|
+
const lease = await maintenance.acquireLease({
|
|
432
|
+
capabilities: Array.isArray(body?.capabilities) ? body.capabilities : body?.capabilities,
|
|
433
|
+
owner: maintenanceLeaseOwner(),
|
|
434
|
+
ttlMs: Number(body?.ttlMs),
|
|
435
|
+
waitMs: Number(body?.waitMs),
|
|
436
|
+
})
|
|
437
|
+
return c.json(lease, lease.state === 'active' ? 201 : 202)
|
|
438
|
+
})
|
|
439
|
+
app.post('/api/session-maintenance/heartbeat', async (c) => {
|
|
440
|
+
const body = await c.req.json().catch(() => ({}))
|
|
441
|
+
await maintenance.heartbeatLease({ token: maintenanceToken(c.req.header.bind(c.req)), epoch: Number(body?.epoch), ttlMs: Number(body?.ttlMs) })
|
|
442
|
+
return c.json({ ok: true, ...maintenance.readState() })
|
|
443
|
+
})
|
|
444
|
+
app.post('/api/session-maintenance/release', async (c) => {
|
|
445
|
+
const body = await c.req.json().catch(() => ({}))
|
|
446
|
+
await maintenance.releaseLease({ token: maintenanceToken(c.req.header.bind(c.req)), epoch: Number(body?.epoch) })
|
|
447
|
+
return c.json({ ok: true })
|
|
448
|
+
})
|
|
389
449
|
// edges derived live from `spex session watch` monitors (A→B = agent A is watching B), not a stored subscription;
|
|
390
450
|
// watch/unwatch register + heartbeat. A literal `edges` segment so it never collides with the `:id` routes.
|
|
391
451
|
app.get('/api/sessions/edges', async (c) => c.json(await sessionGraph()))
|
|
@@ -437,19 +497,12 @@ app.get('/api/sessions/:id/timeline', (c) => {
|
|
|
437
497
|
const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined)
|
|
438
498
|
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
439
499
|
})
|
|
440
|
-
// An adapter may expose its native event log as the console's optional full-process drill-down. REST establishes
|
|
441
|
-
// the complete ordered snapshot + byte cursor; SSE follows appends from that cursor (or Last-Event-ID on reconnect).
|
|
442
|
-
app.get('/api/sessions/:id/messages', (c) => {
|
|
443
|
-
const r = readSessionMessages(c.req.param('id'))
|
|
444
|
-
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
445
|
-
})
|
|
446
|
-
app.get('/api/sessions/:id/messages/stream', (c) => sessionMessageStream(c))
|
|
447
500
|
// the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
|
|
448
501
|
// plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
|
|
449
502
|
// the CLI's show; 404 for an unknown id.
|
|
450
503
|
app.get('/api/sessions/:id', async (c) => {
|
|
451
504
|
const id = c.req.param('id')
|
|
452
|
-
const row = (await listSessions()).find((s) => s.id === id)
|
|
505
|
+
const row = (await listSessions(true)).find((s) => s.id === id)
|
|
453
506
|
if (!row) return c.json({ error: 'no such session' }, 404)
|
|
454
507
|
return c.json({ ...row, prompt: await sessionPrompt(id) })
|
|
455
508
|
})
|
|
@@ -460,7 +513,9 @@ app.get('/api/sessions/:id', async (c) => {
|
|
|
460
513
|
app.post('/api/sessions/:id/resume', async (c) => {
|
|
461
514
|
const body = await c.req.json().catch(() => ({} as { force?: boolean }))
|
|
462
515
|
const force = body?.force === true || c.req.query('force') === '1'
|
|
463
|
-
const
|
|
516
|
+
const sessionId = c.req.param('id')
|
|
517
|
+
const authorization = await operationAuthorization(c.req.header.bind(c.req), { op: 'resume', sessionId, force })
|
|
518
|
+
const r = await resumeSession(sessionId, { force, authorization })
|
|
464
519
|
return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
|
|
465
520
|
})
|
|
466
521
|
// a dispatch to the session's own agent (it runs the merge), never a server merge — the server never touches
|
|
@@ -574,12 +629,22 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
574
629
|
})
|
|
575
630
|
// soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
|
|
576
631
|
// removes the worktree. {ok:false} = no such session.
|
|
577
|
-
app.post('/api/sessions/:id/stop', async (c) =>
|
|
632
|
+
app.post('/api/sessions/:id/stop', async (c) => {
|
|
633
|
+
const sessionId = c.req.param('id')
|
|
634
|
+
const authorization = await operationAuthorization(c.req.header.bind(c.req), { op: 'stop', sessionId })
|
|
635
|
+
return c.json({ ok: await stopSession(sessionId, { authorization }) })
|
|
636
|
+
})
|
|
578
637
|
app.post('/api/sessions/:id/interrupt', async (c) => {
|
|
579
638
|
const result = await interruptSession(c.req.param('id'))
|
|
580
639
|
return c.json(result, result.ok ? 200 : 502)
|
|
581
640
|
})
|
|
582
641
|
app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
|
|
642
|
+
// archive / legacy unarchive signpost ([[archive]]) — archive proves exact cold/offline ownership before filing;
|
|
643
|
+
// `{on:false}` enters the same resume transition and recreates the preserved conversation. {ok:false}=no such session.
|
|
644
|
+
app.post('/api/sessions/:id/archive', async (c) => {
|
|
645
|
+
const body = await c.req.json().catch(() => ({}))
|
|
646
|
+
return c.json({ ok: await archiveSession(c.req.param('id'), body?.on !== false) })
|
|
647
|
+
})
|
|
583
648
|
// set (or clear, with a blank) a session's display-name override; persists to the session's global record
|
|
584
649
|
// (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
|
|
585
650
|
// the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
|
|
@@ -622,10 +687,19 @@ superviseQueue() // launch queued sessions as slots free (catches agent-auth
|
|
|
622
687
|
superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
|
|
623
688
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
624
689
|
|
|
690
|
+
let graphWatchersClosed = false
|
|
691
|
+
const closeGraphWatchers = (): void => {
|
|
692
|
+
if (graphWatchersClosed) return
|
|
693
|
+
graphWatchersClosed = true
|
|
694
|
+
closeBoardFileWatchers()
|
|
695
|
+
}
|
|
696
|
+
process.once('exit', closeGraphWatchers)
|
|
697
|
+
|
|
625
698
|
// graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new
|
|
626
699
|
// connections, let in-flight requests finish, and sweep now-idle keep-alive sockets so close() fires the
|
|
627
700
|
// instant the last request drains. A hard cap still forces exit if a connection won't close.
|
|
628
701
|
process.on('SIGTERM', () => {
|
|
702
|
+
closeGraphWatchers()
|
|
629
703
|
const srv = server as unknown as { close(cb?: () => void): void; closeIdleConnections?(): void }
|
|
630
704
|
const sweep = setInterval(() => srv.closeIdleConnections?.(), 200)
|
|
631
705
|
srv.close(() => { clearInterval(sweep); process.exit(0) })
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, copyFileSync, readdirSync, statSync, chmodSync, writeFileSync } from 'node:fs'
|
|
1
|
+
import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
2
3
|
import { join, resolve, relative } from 'node:path'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { execFileSync } from 'node:child_process'
|
|
@@ -9,6 +10,32 @@ import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './h
|
|
|
9
10
|
// launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
|
|
10
11
|
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
11
12
|
const TEMPLATES = join(pkgRoot, 'templates')
|
|
13
|
+
const MANAGED_HOOK_HEADER = '# spexcode-managed-hook-v1'
|
|
14
|
+
const LEGACY_MANAGED_HOOKS: Record<string, Set<string>> = {
|
|
15
|
+
'pre-commit': new Set(['fc33a6ff2b444fa47210a2594b6a714613d9c62fd8ec77a9a13aec81143de8a7']),
|
|
16
|
+
'prepare-commit-msg': new Set(['08b26ad8ffb305a64b7cddc868231854c84d539b8e5e9324702bd2c293044183']),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hookDigest(path: string): string {
|
|
20
|
+
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isManagedHook(path: string, name: string): boolean {
|
|
24
|
+
const source = readFileSync(path, 'utf8')
|
|
25
|
+
return source.split('\n').slice(0, 4).includes(MANAGED_HOOK_HEADER)
|
|
26
|
+
|| LEGACY_MANAGED_HOOKS[name]?.has(createHash('sha256').update(source).digest('hex')) === true
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function replaceHook(source: string, dest: string): void {
|
|
30
|
+
const temp = `${dest}.spexcode-${process.pid}`
|
|
31
|
+
try {
|
|
32
|
+
copyFileSync(source, temp)
|
|
33
|
+
chmodSync(temp, 0o755)
|
|
34
|
+
renameSync(temp, dest)
|
|
35
|
+
} finally {
|
|
36
|
+
rmSync(temp, { force: true })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
12
39
|
|
|
13
40
|
// the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.plugins`
|
|
14
41
|
// instance set (planted from templates/spec); a higher tier would be a SEPARATE package under
|
|
@@ -149,7 +176,8 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
149
176
|
console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
|
|
150
177
|
}
|
|
151
178
|
|
|
152
|
-
// 2. install the git hooks
|
|
179
|
+
// 2. install the git hooks. Unknown existing hooks are user-owned and stay byte-identical. An exact
|
|
180
|
+
// SpexCode snapshot is refreshed because a multi-hook protocol cannot mix old and new generations.
|
|
153
181
|
const hooksDir = resolveHooksDir(targetDir)
|
|
154
182
|
if (!hooksDir) {
|
|
155
183
|
console.warn(`• ${targetDir} is not a git repository — skipped hook install. Run \`git init\` there, then \`spex init\` again (or \`npm run hooks\`).`)
|
|
@@ -157,18 +185,27 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
157
185
|
mkdirSync(hooksDir, { recursive: true })
|
|
158
186
|
const hooksSrc = join(TEMPLATES, 'hooks')
|
|
159
187
|
const installed: string[] = []
|
|
188
|
+
const refreshed: string[] = []
|
|
160
189
|
for (const e of readdirSync(hooksSrc, { withFileTypes: true })) {
|
|
161
190
|
if (!e.isFile()) continue
|
|
191
|
+
const source = join(hooksSrc, e.name)
|
|
162
192
|
const dest = join(hooksDir, e.name)
|
|
163
193
|
if (existsSync(dest)) {
|
|
164
|
-
|
|
194
|
+
if (hookDigest(dest) === hookDigest(source)) continue
|
|
195
|
+
if (isManagedHook(dest, e.name)) {
|
|
196
|
+
replaceHook(source, dest)
|
|
197
|
+
refreshed.push(e.name)
|
|
198
|
+
} else {
|
|
199
|
+
console.warn(`• user hook ${e.name} already exists in ${hooksDir} — left untouched.`)
|
|
200
|
+
}
|
|
165
201
|
continue
|
|
166
202
|
}
|
|
167
|
-
copyFileSync(
|
|
203
|
+
copyFileSync(source, dest)
|
|
168
204
|
chmodSync(dest, 0o755)
|
|
169
205
|
installed.push(e.name)
|
|
170
206
|
}
|
|
171
207
|
if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
|
|
208
|
+
if (refreshed.length) console.log(`✓ refreshed SpexCode hook snapshots (${refreshed.join(', ')}) → ${hooksDir}`)
|
|
172
209
|
}
|
|
173
210
|
|
|
174
211
|
// 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched harness works with zero further
|
|
@@ -196,13 +233,15 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
196
233
|
const rootsNow = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
197
234
|
console.log(`
|
|
198
235
|
Next steps:
|
|
199
|
-
1.
|
|
200
|
-
|
|
236
|
+
1. The seeded .spec/ tree and spexcode.json are project source of truth: add and commit them. Generated
|
|
237
|
+
harness artifacts are machine-local and can stay untracked.
|
|
238
|
+
2. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
|
|
239
|
+
3. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex spec lint\` governs —
|
|
201
240
|
["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
|
|
202
241
|
when you want a curated graph.
|
|
203
|
-
|
|
242
|
+
4. Start the backend and open the dashboard:
|
|
204
243
|
spex serve # http://localhost:8787
|
|
205
|
-
|
|
244
|
+
5. \`spex spec lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
|
|
206
245
|
spec node claims yet). You're adopting SpexCode — the spec tree is now ground truth.
|
|
207
246
|
(On a fresh CLONE, re-run \`spex init\` — git never clones .git/hooks/, and the harness shims are
|
|
208
247
|
gitignored machine-local files that regenerate per-machine.)`)
|
package/spec-cli/src/issues.ts
CHANGED
|
@@ -140,10 +140,43 @@ export function fromForge(slice: ForgeSlice, nodeIds: string[]): Issue[] {
|
|
|
140
140
|
// board issue badge, the `spex issue ls` drain — is free of them by construction (they reach the EVAL side
|
|
141
141
|
// through loadEvalRemarkTracks / the reading overlay instead).
|
|
142
142
|
export function mergedIssues(forge: ForgeSlice | null, nodeIds: string[]): Issue[] {
|
|
143
|
+
return allThreads(forge, nodeIds).filter((i) => !isEvalConcern(i.concern))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// the same one merged read BEFORE the read-time split: every thread in the store, both halves, one walk.
|
|
147
|
+
// Deliberately NOT exported — the split above is what every SURFACE read owes ([[eval-issue-split]]), and
|
|
148
|
+
// an unsplit set escaping to a surface would put eval remarks back in the issue drain. The one consumer
|
|
149
|
+
// whose question is about the STORE ITSELF reaches it through boardThreads below.
|
|
150
|
+
function allThreads(forge: ForgeSlice | null, nodeIds: string[]): Issue[] {
|
|
143
151
|
const remote = forge ? fromForge(forge, nodeIds) : []
|
|
144
|
-
return [...loadLocalIssues(), ...remote]
|
|
145
|
-
|
|
146
|
-
|
|
152
|
+
return [...loadLocalIssues(), ...remote].sort((a, b) => b.created.localeCompare(a.created))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// @@@ boardThreads - the board's ONE store read ([[graph-lean]]'s fold and [[remark-substrate]]'s carrier
|
|
156
|
+
// from one walk). It hands back the SPLIT issue population every issue surface consumes and the freshness
|
|
157
|
+
// stamp folded over the WHOLE store, together, because the two answer different questions and must not be
|
|
158
|
+
// derived from each other: the split is a SURFACE question (which page renders this thread), the stamp is a
|
|
159
|
+
// STORE question (was anything written at all). Folding the stamp from whatever the surface read returned
|
|
160
|
+
// is exactly the confusion that left an open reading blind to every remark on it — a scenario-hosted remark
|
|
161
|
+
// moved no board byte, so no push ever fired. Pairing them here means no caller can pick the wrong set.
|
|
162
|
+
export function boardThreads(forge: ForgeSlice | null, nodeIds: string[]): { issues: Issue[]; stamp: string } {
|
|
163
|
+
const threads = allThreads(forge, nodeIds)
|
|
164
|
+
return { issues: threads.filter((i) => !isEvalConcern(i.concern)), stamp: threadStamp(threads) }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// @@@ threadStamp - the board's freshness carrier over a thread set ([[remark-substrate]] write-visibility):
|
|
168
|
+
// open-count : thread-count : reply-count : latest-activity. Every thread write — open, reply, remark,
|
|
169
|
+
// resolve, retract, close — moves at least one component, so a store write ALWAYS moves board bytes and
|
|
170
|
+
// [[graph-delta]]'s no-change suppression can never swallow the push. Pure over the set it is handed, which
|
|
171
|
+
// is what makes "does THIS write move it" a test rather than a claim; the caller owes it the WHOLE store
|
|
172
|
+
// (allThreads), because a set missing a half is a carrier blind to that half.
|
|
173
|
+
export function threadStamp(threads: Issue[]): string {
|
|
174
|
+
return [
|
|
175
|
+
threads.filter((i) => i.status === 'open').length,
|
|
176
|
+
threads.length,
|
|
177
|
+
threads.reduce((n, i) => n + i.replies.length, 0),
|
|
178
|
+
threads.flatMap((i) => [i.created, ...i.replies.flatMap((r) => [r.at, r.resolvedAt ?? ''])]).reduce((a, b) => (b > a ? b : a), ''),
|
|
179
|
+
].join(':')
|
|
147
180
|
}
|
|
148
181
|
|
|
149
182
|
// @@@ createIssue - the ONE creation port, store-routed ([[issues]]): the dashboard's New form
|