spexcode 0.2.8 → 0.4.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 +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +23 -15
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
package/spec-cli/src/hooks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadHookConfig } from './specs.js'
|
|
2
2
|
|
|
3
3
|
// @@@ hook manifest - the harness-agnostic hook system has THREE parts: (1) the discovered handlers —
|
|
4
|
-
// `surface: hook` nodes under .
|
|
4
|
+
// `surface: hook` nodes under .plugins/core/* (spec-governed content, each shipping one co-located .sh);
|
|
5
5
|
// (2) this COMPILER, which flattens them into a flat per-session manifest; (3) a pure-shell dispatcher
|
|
6
6
|
// (spec-cli/hooks/dispatch.sh) the committed .claude/.codex shim binds to every harness event. The compiler
|
|
7
7
|
// is the ONLY step that parses spec frontmatter, so it runs ONCE at SessionStart and the hot-path dispatcher
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import { serve } from '@hono/node-server'
|
|
2
|
+
import type { Server as HttpServer } from 'node:http'
|
|
3
|
+
import { installConnectionReaper } from './reaper.js'
|
|
2
4
|
import { Hono } from 'hono'
|
|
3
5
|
import { cors } from 'hono/cors'
|
|
4
6
|
import { etag } from 'hono/etag'
|
|
5
7
|
import { createNodeWebSocket } from '@hono/node-ws'
|
|
6
8
|
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig } from './specs.js'
|
|
7
9
|
import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
|
|
8
|
-
import { closeIssue, createIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
|
|
10
|
+
import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
|
|
9
11
|
import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
|
|
10
12
|
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
11
13
|
import { summarize } from './mentions.js'
|
|
12
14
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
13
|
-
import { getBoardJson } from './
|
|
14
|
-
import { boardStream, notifyBoardChanged } from './
|
|
15
|
+
import { getBoardJson } from './graphCache.js'
|
|
16
|
+
import { boardStream, notifyBoardChanged } from './graphStream.js'
|
|
15
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
16
|
-
import { newSession, listSessions,
|
|
18
|
+
import { newSession, listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
|
|
17
19
|
import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
|
|
18
|
-
import { evalTimeline, readBlobByHash } from '../../spec-
|
|
19
|
-
import { putBlob } from '../../spec-
|
|
20
|
-
import {
|
|
21
|
-
import { fileHumanReading } from '../../spec-
|
|
22
|
-
import {
|
|
20
|
+
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
21
|
+
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
22
|
+
import { evalNodes } from '../../spec-eval/src/scenarios.js'
|
|
23
|
+
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
24
|
+
import { fileHumanOk } from '../../spec-eval/src/humanok.js'
|
|
25
|
+
import { buildExportModel, renderExportHtml, buildSessionEvals } from '../../spec-eval/src/sessioneval.js'
|
|
23
26
|
import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
|
|
24
27
|
import { attachViewer, detachViewer, resizeBridge, forwardWheel, superviseBridges, type Viewer } from './pty-bridge.js'
|
|
25
28
|
import { installProcessGuards } from './resilience.js'
|
|
@@ -32,40 +35,40 @@ const app = new Hono()
|
|
|
32
35
|
app.use('/api/*', cors())
|
|
33
36
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
|
34
37
|
|
|
35
|
-
app.get('/', (c) => c.text('spec-cli — GET /api/
|
|
38
|
+
app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/slash-commands'))
|
|
36
39
|
// the supervisor's readiness gate (supervise.ts): a bare git-free 200 so a booting child reports ready the
|
|
37
40
|
// instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
|
|
38
41
|
app.get('/health', (c) => c.text('ok'))
|
|
39
|
-
// the assembled
|
|
40
|
-
// as `spex
|
|
41
|
-
// dashboard reloads on a `/api/
|
|
42
|
+
// the assembled graph (merged tree + overlay + sessions) — the dashboard's single source. Same data
|
|
43
|
+
// as `spex graph --json`; the frontend only adds x/y pixels on top. Freshness is PUSH-first ([[graph-stream]]): the
|
|
44
|
+
// dashboard reloads on a `/api/graph/stream` event, not a tight poll, so the route is a conditional-request
|
|
42
45
|
// endpoint: `etag()` hashes the serialized body, and a reload whose `If-None-Match` matches gets a bodyless 304
|
|
43
46
|
// instead of the full transfer (~1 MB on the dogfood board — it scales with the node count). The 304 saves the
|
|
44
|
-
// WIRE only; the COMPUTE is saved by [[
|
|
47
|
+
// WIRE only; the COMPUTE is saved by [[graph-cache]]: getBoard() is single-flight + cached, so a poll storm
|
|
45
48
|
// shares ONE build instead of each running its own — the poll-frequency cut (push channel) and the
|
|
46
49
|
// build-coalescing cut compound. A hard timeout bounds a wedged build to a loud 503 rather than an
|
|
47
50
|
// unboundedly-held connection (the wall sits well above the legitimately-several-seconds cold first build);
|
|
48
51
|
// a merely-slow single-flight build keeps running and caches for the next poll, while a NEVER-settling one
|
|
49
|
-
// is bounded by [[
|
|
52
|
+
// is bounded by [[graph-cache]]'s own build watchdog, so the next poll retries a fresh build.
|
|
50
53
|
const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
|
|
51
|
-
app.get('/api/
|
|
54
|
+
app.get('/api/graph', etag(), async (c) => {
|
|
52
55
|
const timeout = Symbol('timeout')
|
|
53
56
|
const json = await Promise.race([getBoardJson(), new Promise<typeof timeout>((r) => setTimeout(() => r(timeout), BOARD_TIMEOUT_MS))])
|
|
54
|
-
if (json === timeout) return c.json({ error: '
|
|
57
|
+
if (json === timeout) return c.json({ error: 'graph build timed out' }, 503)
|
|
55
58
|
return c.body(json as string, 200, { 'content-type': 'application/json; charset=UTF-8' })
|
|
56
59
|
})
|
|
57
|
-
// the
|
|
58
|
-
// reloads the instant status moves instead of waiting for its slow fallback poll ([[
|
|
59
|
-
app.get('/api/
|
|
60
|
+
// the graph's push channel: an SSE that fires `board-changed` on any session-store write, so the dashboard
|
|
61
|
+
// reloads the instant status moves instead of waiting for its slow fallback poll ([[graph-stream]]).
|
|
62
|
+
app.get('/api/graph/stream', (c) => boardStream(c))
|
|
60
63
|
app.get('/api/specs', async (c) => c.json(await loadSpecs()))
|
|
61
|
-
// the search corpus ([[
|
|
64
|
+
// the search corpus ([[graph-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
|
|
62
65
|
// board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
|
|
63
66
|
// to rank nodes over their prose — off the board's hot poll. A literal segment, before the `:id` routes.
|
|
64
|
-
// Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags}), so a
|
|
67
|
+
// Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags}), so a measurable
|
|
65
68
|
// node's row here carries its declared scenarios' description/expected (+ per-scenario code) — one fetch
|
|
66
69
|
// serves both the palette's scenario plane and the focus-panel preview.
|
|
67
70
|
app.get('/api/specs/lite', (c) => {
|
|
68
|
-
const scByNode = new Map(
|
|
71
|
+
const scByNode = new Map(evalNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
|
|
69
72
|
return c.json(loadSpecsLite().map((row) => {
|
|
70
73
|
const sc = scByNode.get(row.id)
|
|
71
74
|
return sc?.length
|
|
@@ -73,7 +76,7 @@ app.get('/api/specs/lite', (c) => {
|
|
|
73
76
|
: row
|
|
74
77
|
}))
|
|
75
78
|
})
|
|
76
|
-
// one node's body + parsed parts ([[
|
|
79
|
+
// one node's body + parsed parts ([[graph-lean]]): the board no longer ships either, so the detail view
|
|
77
80
|
// fetches this when a node opens. 404 for an unknown id.
|
|
78
81
|
app.get('/api/specs/:id/content', (c) => {
|
|
79
82
|
const x = specContent(c.req.param('id'))
|
|
@@ -99,24 +102,37 @@ app.get('/api/edit', async (c) => {
|
|
|
99
102
|
}
|
|
100
103
|
return c.json({ patch })
|
|
101
104
|
})
|
|
102
|
-
// a node's eval timeline (read half of `spex
|
|
103
|
-
// flag, newest-first; `
|
|
105
|
+
// a node's eval timeline (read half of `spex eval`): eval-sidecar readings joined with a live freshness
|
|
106
|
+
// flag, newest-first; `hasEvalFile:false` when none declared. Contract belongs to [[spec-eval]].
|
|
104
107
|
app.get('/api/specs/:id/evals', async (c) => c.json(await evalTimeline(c.req.param('id'))))
|
|
105
|
-
// the eval seam's WRITE half over HTTP ([[spec-
|
|
106
|
-
// reading (verdict + optional transcript) through the SAME append the CLI
|
|
107
|
-
// call this — [[event-detail]] reads readings and hosts remarks, never files.
|
|
108
|
-
app.post('/api/specs/:id/
|
|
108
|
+
// the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts) — the REST pair of the GET above: a
|
|
109
|
+
// programmatic caller files a reading (verdict + optional transcript) through the SAME append the CLI
|
|
110
|
+
// uses. The dashboard does not call this — [[event-detail]] reads readings and hosts remarks, never files.
|
|
111
|
+
app.post('/api/specs/:id/evals', async (c) => {
|
|
109
112
|
const b = await c.req.json().catch(() => null)
|
|
110
113
|
if (!b || typeof b.scenario !== 'string') return c.json({ error: 'body needs { scenario, status, note?, transcript? }' }, 400)
|
|
111
114
|
const r = fileHumanReading(c.req.param('id'), b)
|
|
112
115
|
return r.ok ? c.json({ ok: true, reading: r.reading }) : c.json({ error: r.error }, 400)
|
|
113
116
|
})
|
|
117
|
+
// the HUMAN SIGN-OFF write ([[human-ok]]) — the dashboard's ok affordance and `spex eval ok` share this ONE
|
|
118
|
+
// write (LAW L: no dashboard-only path). Identity is SERVER-DERIVED 'human', never the request body (the
|
|
119
|
+
// same rule as /api/remarks). The write appends a monotonic human-ok event bound to the scenario's latest
|
|
120
|
+
// reading and — on the trunk checkout — commits it straight to trunk; the board cache is invalidated
|
|
121
|
+
// atomically with persistence so the writer's own refetch never races a stale cache.
|
|
122
|
+
app.post('/api/specs/:id/evals/ok', async (c) => {
|
|
123
|
+
const b = await c.req.json().catch(() => null)
|
|
124
|
+
if (!b || typeof b.scenario !== 'string') return c.json({ error: 'body needs { scenario }' }, 400)
|
|
125
|
+
const r = fileHumanOk(c.req.param('id'), b.scenario, 'human')
|
|
126
|
+
if (!r.ok) return c.json({ error: r.error }, 400)
|
|
127
|
+
notifyBoardChanged('full')
|
|
128
|
+
return c.json({ ok: true, already: r.already, humanOk: r.humanOk })
|
|
129
|
+
})
|
|
114
130
|
// serve a reading's evidence blob by content hash (bytes never enter git): bad hash → 400, missing → 404,
|
|
115
131
|
// else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
|
|
116
132
|
// HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
|
|
117
133
|
// currentTime to the seekable window, which stays [0,0] without them); one general mechanism at the
|
|
118
134
|
// transport, so every evidence kind streams the same way.
|
|
119
|
-
app.get('/api/
|
|
135
|
+
app.get('/api/evidence/:hash', (c) => {
|
|
120
136
|
const r = readBlobByHash(c.req.param('hash'))
|
|
121
137
|
if (!r.ok) return c.text(r.message, r.reason === 'invalid' ? 400 : 404)
|
|
122
138
|
const total = r.bytes.length
|
|
@@ -131,29 +147,31 @@ app.get('/api/yatsu/blob/:hash', (c) => {
|
|
|
131
147
|
return c.body(new Uint8Array(r.bytes), 200, base)
|
|
132
148
|
})
|
|
133
149
|
// the WRITE half of the blob store ([[annotator]]): the annotator captures a circled video frame to a PNG
|
|
134
|
-
// and stashes the bytes here, content-addressed (same putBlob the
|
|
150
|
+
// and stashes the bytes here, content-addressed (same putBlob the eval cache uses). The returned hash is
|
|
135
151
|
// what an anchored comment references (image link in the body, and the typed evidence[] on its thread) —
|
|
136
152
|
// bytes never enter git. Raw body, sniffed by the same content-addressed name. Empty → 400, over cap → 413.
|
|
137
|
-
app.post('/api/
|
|
153
|
+
app.post('/api/evidence', async (c) => {
|
|
138
154
|
const buf = Buffer.from(await c.req.arrayBuffer())
|
|
139
|
-
if (buf.length === 0) return c.json({ error: 'empty
|
|
140
|
-
if (buf.length > MAX_UPLOAD_BYTES) return c.json({ error: '
|
|
155
|
+
if (buf.length === 0) return c.json({ error: 'empty evidence' }, 400)
|
|
156
|
+
if (buf.length > MAX_UPLOAD_BYTES) return c.json({ error: 'evidence too large' }, 413)
|
|
141
157
|
return c.json({ hash: putBlob(buf) }, 201)
|
|
142
158
|
})
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
// the
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// falling through to the built-in `claude` launcher.
|
|
153
|
-
app.get('/api/launchers', (c) => c.json({
|
|
159
|
+
// the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
|
|
160
|
+
// `layout` (resolveLayout()'s main/worktrees/branch shape — the write-guard's project-identity probe reads
|
|
161
|
+
// `.layout.main`) and the named launcher profiles ([[launcher-select]]) the New-Session form's dropdown
|
|
162
|
+
// offers — `{ name, harness }` only (the `cmd` is a host secret, never shipped to the browser) — plus the
|
|
163
|
+
// configured `default` NAME so the dropdown pre-selects the SAME launcher a bare `spex session new` uses
|
|
164
|
+
// (the CLI/config default), instead of the alphabetically-first one. Missing defaultLauncher is returned
|
|
165
|
+
// as an actionable config error, not hidden by falling through to the built-in `claude` launcher.
|
|
166
|
+
app.get('/api/settings', async (c) => c.json({
|
|
167
|
+
layout: await resolveLayout(),
|
|
154
168
|
launchers: launcherList().map(({ name, harness }) => ({ name, harness })),
|
|
155
169
|
...launcherDefault(),
|
|
156
170
|
}))
|
|
171
|
+
// the `surface: command` plugin-root nodes (built/active only) for the new-session `/` dropdown — each with
|
|
172
|
+
// its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
|
|
173
|
+
// frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
|
|
174
|
+
app.get('/api/plugins', (c) => c.json(loadConfig()))
|
|
157
175
|
// the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
|
|
158
176
|
// (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
|
|
159
177
|
// (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
|
|
@@ -164,6 +182,13 @@ app.get('/api/issues', etag(), (c) =>
|
|
|
164
182
|
stores: issueStores(),
|
|
165
183
|
issues: mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)),
|
|
166
184
|
}))
|
|
185
|
+
// the single-thread read ([[issues]]) behind `spex issue show <id>` — the SAME findIssue lookup, from the
|
|
186
|
+
// resident forge slice (instant view, background reconcile — the list route's freshness contract). A local
|
|
187
|
+
// id, or a forge id (`<host>#<n>`); unknown → 404 (eval-remark threads are not issues, so they 404 here too).
|
|
188
|
+
app.get('/api/issues/:id', (c) => {
|
|
189
|
+
const t = findIssue(c.req.param('id'), { host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id))
|
|
190
|
+
return t ? c.json(t) : c.json({ error: `no issue '${c.req.param('id')}'` }, 404)
|
|
191
|
+
})
|
|
167
192
|
// the WRITE surface ([[local-issues]] / [[issues-view]]) — the human reply path, STORE-ROUTED through the one
|
|
168
193
|
// reply verb ([[issues]] replyIssue): a local id git-commits to the trunk store, a forge id ('github#N')
|
|
169
194
|
// posts a REAL comment through the driver; either way the text's @-mentions dispatch (a human summons an
|
|
@@ -187,6 +212,7 @@ app.post('/api/issues/:id/reply', async (c) => {
|
|
|
187
212
|
: null
|
|
188
213
|
const r = await replyIssue(id, text, { author: 'human', node, evidence })
|
|
189
214
|
if (r.store !== 'local') await refreshForgeNow()
|
|
215
|
+
notifyBoardChanged('full') // atomic with persistence — see the /api/remarks block below
|
|
190
216
|
return c.json({ ok: true, replies: r.replies, url: r.url, outcomes: summarize(r.outcomes, r.loopIn) })
|
|
191
217
|
} catch (e) {
|
|
192
218
|
const msg = String((e as Error).message || e)
|
|
@@ -201,6 +227,7 @@ app.post('/api/issues/:id/close', async (c) => {
|
|
|
201
227
|
try {
|
|
202
228
|
const r = await closeIssue(id)
|
|
203
229
|
if (r.store !== 'local') await refreshForgeNow()
|
|
230
|
+
notifyBoardChanged('full') // atomic with persistence — see the /api/remarks block below
|
|
204
231
|
return c.json({ ok: true, ...r })
|
|
205
232
|
} catch (e) {
|
|
206
233
|
const msg = String((e as Error).message || e)
|
|
@@ -215,11 +242,12 @@ app.post('/api/issues', async (c) => {
|
|
|
215
242
|
const nodes = Array.isArray(body?.nodes) ? (body.nodes as unknown[]).filter((n): n is string => typeof n === 'string') : []
|
|
216
243
|
const postBody = typeof body?.body === 'string' ? body.body : undefined
|
|
217
244
|
const store = typeof body?.store === 'string' && body.store.trim() ? body.store.trim() : 'local'
|
|
218
|
-
// typed evidence[] —
|
|
245
|
+
// typed evidence[] — content-addressed evidence hashes (the annotator's clip reference rides here, not prose)
|
|
219
246
|
const evidence = Array.isArray(body?.evidence) ? (body.evidence as unknown[]).filter((h): h is string => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) : []
|
|
220
247
|
try {
|
|
221
248
|
const r = await createIssue(concern, { store, nodes, body: postBody, evidence, author: 'human' })
|
|
222
249
|
if (r.store !== 'local') await refreshForgeNow()
|
|
250
|
+
notifyBoardChanged('full') // atomic with persistence — see the /api/remarks block below
|
|
223
251
|
return c.json({ ok: true, id: r.id, store: r.store, url: r.url, outcomes: summarize(r.outcomes) }, 201)
|
|
224
252
|
} catch (e) {
|
|
225
253
|
return c.json({ error: String((e as Error).message || e) }, store === 'local' ? 500 : 502)
|
|
@@ -237,6 +265,7 @@ app.post('/api/issues/:id/promote', async (c) => {
|
|
|
237
265
|
try {
|
|
238
266
|
const r = await promote(id, { author: 'human' })
|
|
239
267
|
await refreshForgeNow()
|
|
268
|
+
notifyBoardChanged('full') // atomic with persistence — see the /api/remarks block below
|
|
240
269
|
return c.json({ ok: true, ...r })
|
|
241
270
|
} catch (e) {
|
|
242
271
|
const msg = String((e as Error).message || e)
|
|
@@ -254,6 +283,13 @@ app.post('/api/issues/:id/promote', async (c) => {
|
|
|
254
283
|
// resolves through the CLI, and self-resolve stays rejected by the same identity comparison ('human' can
|
|
255
284
|
// never resolve a human-authored remark) — and retract binds to the author (only the human's own remarks).
|
|
256
285
|
// Who-may-resolve/retract cannot depend on transport.
|
|
286
|
+
//
|
|
287
|
+
// Every issue/remark write route below ends its success path with notifyBoardChanged('full') — the board
|
|
288
|
+
// cache is invalidated ATOMICALLY with persistence ([[remark-substrate]] write-visibility), before the
|
|
289
|
+
// response, so the writer's own post-write refetch can never race an async fs event into the stale cache.
|
|
290
|
+
// This explicit nudge is the ONE in-process mechanism (the store dir is deliberately NOT in the watch set);
|
|
291
|
+
// a cross-process write (a CLI `spex remark add`) reaches the board through its trunk commit via the
|
|
292
|
+
// existing refs watcher instead.
|
|
257
293
|
app.post('/api/remarks', async (c) => {
|
|
258
294
|
if (!issuesEnabled()) return c.json({ error: 'issues workflow is off' }, 403)
|
|
259
295
|
const body = await c.req.json().catch(() => ({}))
|
|
@@ -266,6 +302,7 @@ app.post('/api/remarks', async (c) => {
|
|
|
266
302
|
const codeSha = typeof body?.codeSha === 'string' ? body.codeSha : undefined
|
|
267
303
|
try {
|
|
268
304
|
const r = await remarkOnHost(host, text, { codeSha, author: 'human', evidence })
|
|
305
|
+
notifyBoardChanged('full')
|
|
269
306
|
return c.json({ ok: true, ref: r.ref, rid: r.rid, codeSha: r.codeSha, outcomes: summarize(r.outcomes, r.loopIn) }, 201)
|
|
270
307
|
} catch (e) {
|
|
271
308
|
return c.json({ error: String((e as Error).message || e) }, 400)
|
|
@@ -280,6 +317,7 @@ app.post('/api/remarks/:action{resolve|retract}', async (c) => {
|
|
|
280
317
|
try {
|
|
281
318
|
if (c.req.param('action') === 'resolve') resolveRemark(ref, by)
|
|
282
319
|
else retractRemark(ref, by)
|
|
320
|
+
notifyBoardChanged('full')
|
|
283
321
|
return c.json({ ok: true, ref })
|
|
284
322
|
} catch (e) {
|
|
285
323
|
return c.json({ error: String((e as Error).message || e) }, 400)
|
|
@@ -310,16 +348,16 @@ app.post('/api/uploads', async (c) => {
|
|
|
310
348
|
// sessions: real tmux-backed Claude Code sessions. List + spawn, stream the live pane (WebSocket),
|
|
311
349
|
// forward keystrokes, and close.
|
|
312
350
|
app.get('/api/sessions', async (c) => c.json(await listSessions()))
|
|
313
|
-
// edges derived live from `spex watch` monitors (A→B = agent A is watching B), not a stored subscription;
|
|
314
|
-
// watch/unwatch register + heartbeat. A literal `
|
|
315
|
-
app.get('/api/sessions/
|
|
316
|
-
app.post('/api/sessions/
|
|
351
|
+
// edges derived live from `spex session watch` monitors (A→B = agent A is watching B), not a stored subscription;
|
|
352
|
+
// watch/unwatch register + heartbeat. A literal `edges` segment so it never collides with the `:id` routes.
|
|
353
|
+
app.get('/api/sessions/edges', async (c) => c.json(await sessionGraph()))
|
|
354
|
+
app.post('/api/sessions/edges/watch', async (c) => {
|
|
317
355
|
const b = await c.req.json().catch(() => ({}))
|
|
318
356
|
const selectors = Array.isArray(b?.selectors) ? b.selectors.map(String) : []
|
|
319
357
|
const ok = registerWatch(String(b?.token || ''), String(b?.watcher || ''), selectors, Number(b?.ttlMs) || undefined)
|
|
320
358
|
return c.json({ ok }, ok ? 200 : 400)
|
|
321
359
|
})
|
|
322
|
-
app.post('/api/sessions/
|
|
360
|
+
app.post('/api/sessions/edges/unwatch', async (c) => {
|
|
323
361
|
const b = await c.req.json().catch(() => ({}))
|
|
324
362
|
const ok = deregisterWatch(String(b?.token || ''))
|
|
325
363
|
return c.json({ ok }, ok ? 200 : 404)
|
|
@@ -339,28 +377,25 @@ app.post('/api/sessions', async (c) => {
|
|
|
339
377
|
} catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown launcher id → 400, not a 500
|
|
340
378
|
})
|
|
341
379
|
// one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
|
|
342
|
-
// dashboard and `spex review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
|
|
380
|
+
// dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
|
|
343
381
|
app.get('/api/sessions/:id/review', async (c) => {
|
|
344
382
|
const r = await reviewPayload(c.req.param('id'))
|
|
345
383
|
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
346
384
|
})
|
|
347
|
-
// the [[
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (!m) return c.text('no such session', 404)
|
|
353
|
-
if (c.req.query('format') === 'json') return c.json(m)
|
|
354
|
-
return c.html(renderProofHtml(m))
|
|
355
|
-
})
|
|
356
|
-
// the session EVAL model ([[review-proof]]'s interactive face): worktree-rooted rows only — no diff
|
|
357
|
-
// enrichment, no inlined bytes; evidence streams lazily from /api/yatsu/blob. Each reading carries
|
|
358
|
-
// `inSession` so the tab leads with what THIS session measured.
|
|
385
|
+
// the ONE session eval read ([[session-eval]]): default = the lean interactive model — worktree-rooted
|
|
386
|
+
// rows only, no diff enrichment, no inlined bytes (evidence streams lazily from /api/evidence), each
|
|
387
|
+
// reading carrying `inSession` so the tab leads with what THIS session measured. `?format=html` = the
|
|
388
|
+
// EXPORT artifact: one self-contained HTML (diff + gates + evidence inlined as data-URIs) for
|
|
389
|
+
// CI/share/bare-browser — a heavier REPRESENTATION of the same read, not a second route. 404 unknown id.
|
|
359
390
|
app.get('/api/sessions/:id/evals', async (c) => {
|
|
391
|
+
if (c.req.query('format') === 'html') {
|
|
392
|
+
const m = await buildExportModel(c.req.param('id'))
|
|
393
|
+
return m ? c.html(renderExportHtml(m)) : c.text('no such session', 404)
|
|
394
|
+
}
|
|
360
395
|
const m = await buildSessionEvals(c.req.param('id'))
|
|
361
396
|
return m ? c.json(m) : c.json({ error: 'no such session' }, 404)
|
|
362
397
|
})
|
|
363
|
-
// the session's live pane as text (one-shot snapshot) for a backend client (`spex capture`). Empty and fail
|
|
398
|
+
// the session's live pane as text (one-shot snapshot) for a backend client (`spex session show --capture`). Empty and fail
|
|
364
399
|
// stay distinct: an empty pane is 200 with empty body; unknown id → 404, offline (no live pane) → 409, error → 502.
|
|
365
400
|
app.get('/api/sessions/:id/capture', async (c) => {
|
|
366
401
|
const r = await captureSessionResult(c.req.param('id'))
|
|
@@ -369,10 +404,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
|
|
|
369
404
|
if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
|
|
370
405
|
return c.text('capture failed', 502)
|
|
371
406
|
})
|
|
372
|
-
// the session
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
407
|
+
// the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
|
|
408
|
+
// plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
|
|
409
|
+
// the CLI's show; 404 for an unknown id.
|
|
410
|
+
app.get('/api/sessions/:id', async (c) => {
|
|
411
|
+
const id = c.req.param('id')
|
|
412
|
+
const row = (await listSessions()).find((s) => s.id === id)
|
|
413
|
+
if (!row) return c.json({ error: 'no such session' }, 404)
|
|
414
|
+
return c.json({ ...row, prompt: await sessionPrompt(id) })
|
|
376
415
|
})
|
|
377
416
|
// lifecycle transitions (thin callers of the session state machine)
|
|
378
417
|
// relaunch ONLY if confirmed offline; demotes working→idle, keeps any declaration. The RESUME GUARD refuses
|
|
@@ -381,7 +420,7 @@ app.get('/api/sessions/:id/prompt', async (c) => {
|
|
|
381
420
|
app.post('/api/sessions/:id/resume', async (c) => {
|
|
382
421
|
const body = await c.req.json().catch(() => ({} as { force?: boolean }))
|
|
383
422
|
const force = body?.force === true || c.req.query('force') === '1'
|
|
384
|
-
const r = await
|
|
423
|
+
const r = await resumeSession(c.req.param('id'), { force })
|
|
385
424
|
return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
|
|
386
425
|
})
|
|
387
426
|
// a dispatch to the session's own agent (it runs the merge), never a server merge — the server never touches
|
|
@@ -424,38 +463,41 @@ app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
|
|
|
424
463
|
onClose() { if (viewer) detachViewer(id, viewer) },
|
|
425
464
|
}
|
|
426
465
|
}))
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
})
|
|
437
|
-
// the preserved tmux send-keys path (distinct from the ❯ prompt socket): the human drives the agent's
|
|
438
|
-
// interactive TUI menus in real time. Accepts an ORDERED BATCH (`keys`, the client coalesces fast typing) or a
|
|
439
|
-
// single `key`; rawKey delivers them in array order so tap order is preserved ([[nav-mode-key-ordering]]).
|
|
440
|
-
app.post('/api/sessions/:id/rawkey', async (c) => {
|
|
466
|
+
// ONE input route, `kind` the discriminator — the transport split is an implementation fact, not API surface.
|
|
467
|
+
// kind:"text" (the docked ❯ line, `spex session send`, the server-side merge dispatch) injects a whole prompt
|
|
468
|
+
// through the rendezvous control socket — socket-only + fail-loud: a prompt the agent doesn't confirm
|
|
469
|
+
// accepting returns 502 with the reason (never a silent 200), so a dead dispatch is seen, not a false success.
|
|
470
|
+
// kind:"keys" is the LAST-RESORT raw face (`send --keys`, the dashboard's type mode): an ORDERED BATCH of
|
|
471
|
+
// nav-mode key tokens over tmux send-keys, delivered in array order so tap order survives
|
|
472
|
+
// ([[nav-mode-key-ordering]]); unstable by nature — callers try a plain text send first. An unknown kind is a
|
|
473
|
+
// loud 400, never a guessed channel.
|
|
474
|
+
app.post('/api/sessions/:id/input', async (c) => {
|
|
441
475
|
const body = await c.req.json().catch(() => ({}))
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
476
|
+
if (body?.kind === 'text') {
|
|
477
|
+
// `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
|
|
478
|
+
// edge ([[comms-edge]]); a raw human dispatch omits it and is not logged.
|
|
479
|
+
const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined)
|
|
480
|
+
return c.json(r, r.ok ? 200 : 502)
|
|
481
|
+
}
|
|
482
|
+
if (body?.kind === 'keys') {
|
|
483
|
+
const keys = Array.isArray(body?.keys) ? body.keys.filter((k: unknown) => typeof k === 'string') : []
|
|
484
|
+
const ok = await rawKey(c.req.param('id'), keys)
|
|
485
|
+
return c.json({ ok }, ok ? 200 : 404)
|
|
486
|
+
}
|
|
487
|
+
return c.json({ error: 'input needs kind: "text" | "keys"' }, 400)
|
|
446
488
|
})
|
|
447
|
-
// soft stop: kill the agent's tmux + socket but KEEP the worktree (
|
|
489
|
+
// soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
|
|
448
490
|
// removes the worktree. {ok:false} = no such session.
|
|
449
|
-
app.post('/api/sessions/:id/
|
|
491
|
+
app.post('/api/sessions/:id/stop', async (c) => c.json({ ok: await stopSession(c.req.param('id')) }))
|
|
450
492
|
app.post('/api/sessions/:id/close', async (c) => c.json({ ok: await closeSession(c.req.param('id')) }))
|
|
451
493
|
// set (or clear, with a blank) a session's display-name override; persists to the session's global record
|
|
452
494
|
// (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
|
|
453
495
|
// the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
|
|
454
|
-
// ([[
|
|
496
|
+
// ([[graph-stream]]) — the rename shows in ~150ms deterministically, never waiting out a cold tick.
|
|
455
497
|
app.post('/api/sessions/:id/rename', async (c) => {
|
|
456
498
|
const body = await c.req.json().catch(() => ({}))
|
|
457
499
|
const ok = await renameSession(c.req.param('id'), typeof body?.name === 'string' ? body.name : '')
|
|
458
|
-
if (ok) notifyBoardChanged()
|
|
500
|
+
if (ok) notifyBoardChanged('sessions')
|
|
459
501
|
return c.json({ ok }, ok ? 200 : 404)
|
|
460
502
|
})
|
|
461
503
|
|
|
@@ -471,22 +513,21 @@ app.post('/api/sessions/:id/sort', async (c) => {
|
|
|
471
513
|
const port = Number(process.env.PORT || 8787)
|
|
472
514
|
// @@@ server-side connection reaping ([[spec-cli]]) - abandoned connections must die SERVER-SIDE, or they
|
|
473
515
|
// pile up and wedge the backend (135 leaked conns once starved :8787 into looking dead — the cascade that
|
|
474
|
-
// triggered the mass-restore incident, since every client-side timeout-kill leaks one).
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
// change under-effective); a 10s sweep makes reaping land within timeout+10s, well under the multi-minute
|
|
482
|
-
// defaults (requestTimeout 300s) that let the conns accumulate. headersTimeout > keepAliveTimeout (Node's rule).
|
|
516
|
+
// triggered the mass-restore incident, since every client-side timeout-kill leaks one). The `serverOptions`
|
|
517
|
+
// timeouts below are kept (harmless), but they are NOT the mechanism: MEASURED (eval
|
|
518
|
+
// server-reaps-abandoned-connections), Node's `headersTimeout`/`requestTimeout` do NOT reap an INCOMPLETE
|
|
519
|
+
// request via the connectionsCheckingInterval sweep — a slow-loris survives indefinitely; only
|
|
520
|
+
// keepAliveTimeout (idle-between-requests) ever fires. So the real reaper is the explicit socket-level
|
|
521
|
+
// `installConnectionReaper` below (see reaper.ts): a per-socket deadline that reaps a slow-loris / idle
|
|
522
|
+
// keep-alive but exempts an ACTIVE WS/SSE stream (board-stream, terminal socket) for as long as it streams.
|
|
483
523
|
// @@@ loopback bind ([[public-mode]]) - this child is NEVER the internet face: the supervisor (and in public
|
|
484
524
|
// mode the gateway) fronts it, and dials it only via 127.0.0.1. Binding loopback is what makes "loopback is
|
|
485
525
|
// the trust boundary" true — without a hostname Node binds all interfaces and the child is reachable from
|
|
486
|
-
// the LAN with no password, bypassing the gate entirely (measured:
|
|
526
|
+
// the LAN with no password, bypassing the gate entirely (measured: eval auth-boundary).
|
|
487
527
|
const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1', serverOptions: {
|
|
488
528
|
keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000,
|
|
489
529
|
} })
|
|
530
|
+
installConnectionReaper(server as unknown as HttpServer)
|
|
490
531
|
injectWebSocket(server)
|
|
491
532
|
superviseBridges() // keep a warm tmux client per live session, so opening a tab is instant
|
|
492
533
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -10,12 +10,13 @@ import { resolveHarnessTargets } from './harness-select.js'
|
|
|
10
10
|
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
11
11
|
const TEMPLATES = join(pkgRoot, 'templates')
|
|
12
12
|
|
|
13
|
-
// the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.
|
|
14
|
-
// instance set (planted from templates/spec);
|
|
15
|
-
// templates/presets/<tier>/ that seeding stacks ON TOP — a superset
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
13
|
+
// the cumulative preset chain, lean → cautious (see [[init-preset]]). `default` is the live `.plugins`
|
|
14
|
+
// instance set (planted from templates/spec); a higher tier would be a SEPARATE package under
|
|
15
|
+
// templates/presets/<tier>/ that seeding stacks ON TOP — a superset. No non-default tier ships today
|
|
16
|
+
// (the `careful` package was retired); the chain mechanism stays for when one earns its keep.
|
|
17
|
+
// Selection matters ONLY here at seed time; the running repo just
|
|
18
|
+
// walks whatever `.plugins` ended up planted, so there is no launcher-side preset gate.
|
|
19
|
+
const PRESET_TIERS = ['default'] as const
|
|
19
20
|
const presetRank = (name: string): number => (PRESET_TIERS as readonly string[]).indexOf(name)
|
|
20
21
|
|
|
21
22
|
// recursively copy srcDir -> destDir, NEVER overwriting an existing file. Returns the repo-relative
|
|
@@ -86,16 +87,16 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
86
87
|
console.warn(`• .spec already exists at ${specDest} — skipping spec scaffold (won't overwrite an existing tree).`)
|
|
87
88
|
} else {
|
|
88
89
|
const planted = copyTreeNoClobber(join(TEMPLATES, 'spec'), specDest, targetDir)
|
|
89
|
-
console.log(`✓ seeded ${planted.length} spec file(s) under .spec/ (root 'project' node + default .
|
|
90
|
+
console.log(`✓ seeded ${planted.length} spec file(s) under .spec/ (root 'project' node + default .plugins)`)
|
|
90
91
|
// 1a. stack the selected preset's package(s) ON TOP of the default set — cumulative, so every tier from
|
|
91
92
|
// just above `default` up to the selection is planted. Each lives under templates/presets/<tier>/ mirroring
|
|
92
|
-
// the default layout (its `.
|
|
93
|
+
// the default layout (its `.plugins/<plugin>` lands in the seeded project node's `.plugins`). See [[init-preset]].
|
|
93
94
|
for (let r = 1; r <= presetRank(selected); r++) {
|
|
94
95
|
const tier = PRESET_TIERS[r]
|
|
95
96
|
const pkg = join(TEMPLATES, 'presets', tier)
|
|
96
97
|
if (!existsSync(pkg)) { console.warn(`• preset '${tier}' has no package at ${pkg} — skipped.`); continue }
|
|
97
98
|
const added = copyTreeNoClobber(pkg, join(specDest, 'project'), targetDir)
|
|
98
|
-
console.log(`✓ seeded preset '${tier}' (${added.length} file(s)) into .
|
|
99
|
+
console.log(`✓ seeded preset '${tier}' (${added.length} file(s)) into .plugins`)
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -148,7 +149,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
148
149
|
// steps: the hook manifest (in the GLOBAL per-project store, not the worktree), the AGENTS.md/CLAUDE.md
|
|
149
150
|
// <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
|
|
150
151
|
// scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
|
|
151
|
-
// .
|
|
152
|
+
// .plugins. Idempotent — the planted git hooks (pre-commit/post-checkout/post-merge) keep it fresh
|
|
152
153
|
// thereafter on the git-native anchors ([[commit-surgery]]); no harness event ever triggers a materialize.
|
|
153
154
|
const prevCwd = process.cwd()
|
|
154
155
|
try {
|
|
@@ -168,12 +169,12 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
168
169
|
console.log(`
|
|
169
170
|
Next steps:
|
|
170
171
|
1. Edit .spec/project/spec.md to describe YOUR project, then grow child nodes beneath it.
|
|
171
|
-
2. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex lint\` governs —
|
|
172
|
+
2. lint.governedRoots in spexcode.json (currently ${rootsNow}) names what \`spex spec lint\` governs —
|
|
172
173
|
["."] governs the whole git-tracked tree (tests excluded); narrow it to explicit source roots
|
|
173
174
|
when you want a curated graph.
|
|
174
|
-
3. Start the backend and open the
|
|
175
|
+
3. Start the backend and open the dashboard:
|
|
175
176
|
spex serve # http://localhost:8787
|
|
176
|
-
4. \`spex lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
|
|
177
|
+
4. \`spex spec lint\` should report 0 errors. Coverage warnings are your adoption TODO (source files no
|
|
177
178
|
spec node claims yet). You're adopting SpexCode — the spec tree is now ground truth.
|
|
178
179
|
(On a fresh CLONE, re-run \`spex init\` — git never clones .git/hooks/, and the harness shims are
|
|
179
180
|
gitignored machine-local files that regenerate per-machine.)`)
|