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.
Files changed (101) hide show
  1. package/README.md +56 -44
  2. package/package.json +3 -3
  3. package/spec-cli/bin/spex.mjs +2 -2
  4. package/spec-cli/hooks/dispatch.sh +1 -1
  5. package/spec-cli/hooks/harness.sh +26 -6
  6. package/spec-cli/src/anchors.ts +300 -0
  7. package/spec-cli/src/attach.ts +2 -2
  8. package/spec-cli/src/cli.ts +691 -536
  9. package/spec-cli/src/client.ts +31 -30
  10. package/spec-cli/src/contract-filter.ts +1 -1
  11. package/spec-cli/src/doctor.ts +40 -13
  12. package/spec-cli/src/gateway.ts +11 -7
  13. package/spec-cli/src/git.ts +23 -15
  14. package/spec-cli/src/{board.ts → graph.ts} +44 -14
  15. package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
  16. package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
  17. package/spec-cli/src/graphStream.ts +288 -0
  18. package/spec-cli/src/guide.ts +142 -98
  19. package/spec-cli/src/harness-select.ts +2 -2
  20. package/spec-cli/src/harness.ts +30 -14
  21. package/spec-cli/src/help.ts +300 -385
  22. package/spec-cli/src/hooks.ts +1 -1
  23. package/spec-cli/src/index.ts +144 -103
  24. package/spec-cli/src/init.ts +14 -13
  25. package/spec-cli/src/issues.ts +89 -33
  26. package/spec-cli/src/layout.ts +5 -5
  27. package/spec-cli/src/lint.ts +128 -30
  28. package/spec-cli/src/localIssues.ts +61 -60
  29. package/spec-cli/src/materialize.ts +1 -1
  30. package/spec-cli/src/mentions.ts +15 -15
  31. package/spec-cli/src/migrate-table.ts +398 -0
  32. package/spec-cli/src/migrate.ts +386 -0
  33. package/spec-cli/src/ranker.ts +30 -4
  34. package/spec-cli/src/reaper.ts +117 -0
  35. package/spec-cli/src/search.bench.mjs +12 -12
  36. package/spec-cli/src/search.ts +1 -1
  37. package/spec-cli/src/sessions.ts +244 -138
  38. package/spec-cli/src/specs.ts +40 -25
  39. package/spec-cli/src/supervise.ts +2 -2
  40. package/spec-cli/src/tree.ts +5 -5
  41. package/spec-cli/src/uninstall.ts +4 -4
  42. package/spec-cli/templates/hooks/post-checkout +1 -1
  43. package/spec-cli/templates/hooks/post-merge +3 -3
  44. package/spec-cli/templates/hooks/pre-commit +9 -9
  45. package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
  46. package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
  47. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
  48. package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
  49. package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
  50. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
  51. package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
  52. package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
  53. package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
  54. package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
  55. package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
  56. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
  58. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
  59. package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
  60. package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
  61. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
  62. package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
  63. package/spec-cli/templates/spec/project/spec.md +1 -1
  64. package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
  65. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
  66. package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
  67. package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
  68. package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
  69. package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
  70. package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
  71. package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
  74. package/spec-dashboard/dist/index.html +2 -2
  75. package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
  76. package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
  77. package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
  78. package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
  79. package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
  80. package/spec-eval/src/humanok.ts +43 -0
  81. package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
  82. package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
  83. package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
  84. package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
  85. package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
  86. package/spec-forge/src/__fixtures__/github-forge.json +9 -9
  87. package/spec-forge/src/cli.ts +14 -13
  88. package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
  89. package/spec-cli/src/boardStream.ts +0 -179
  90. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
  91. package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
  92. package/spec-cli/templates/spec/project/.config/spec.md +0 -15
  93. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
  94. package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
  95. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
  96. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
  97. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
  98. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
  99. /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
  100. /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
  101. /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
@@ -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 .config/core/* (spec-governed content, each shipping one co-located .sh);
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
@@ -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 './boardCache.js'
14
- import { boardStream, notifyBoardChanged } from './boardStream.js'
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, sendKeys, rawKey, exitSession, closeSession, reopen, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
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-yatsu/src/evaltab.js'
19
- import { putBlob } from '../../spec-yatsu/src/cache.js'
20
- import { yatsuNodes } from '../../spec-yatsu/src/yatsu.js'
21
- import { fileHumanReading } from '../../spec-yatsu/src/filing.js'
22
- import { buildProofModel, renderProofHtml, buildSessionEvals } from '../../spec-yatsu/src/proof.js'
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/board · /api/specs · /api/specs/:id/history · /api/layout · /api/sessions · /api/slash-commands'))
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 board (merged tree + overlay + sessions) — the dashboard's single source. Same data
40
- // as `spex board`; the frontend only adds x/y pixels on top. Freshness is PUSH-first ([[board-stream]]): the
41
- // dashboard reloads on a `/api/board/stream` event, not a tight poll, so the route is a conditional-request
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 [[board-cache]]: getBoard() is single-flight + cached, so a poll storm
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 [[board-cache]]'s own build watchdog, so the next poll retries a fresh build.
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/board', etag(), async (c) => {
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: 'board build timed out' }, 503)
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 board's push channel: an SSE that fires `board-changed` on any session-store write, so the dashboard
58
- // reloads the instant status moves instead of waiting for its slow fallback poll ([[board-stream]]).
59
- app.get('/api/board/stream', (c) => boardStream(c))
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 ([[board-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
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 yatsu
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(yatsuNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
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 ([[board-lean]]): the board no longer ships either, so the detail view
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 yatsu`): yatsu-sidecar readings joined with a live freshness
103
- // flag, newest-first; `hasYatsu:false` when none declared. Contract belongs to [[spec-yatsu]].
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-yatsu]] filing.ts): a programmatic caller files a manual@1
106
- // reading (verdict + optional transcript) through the SAME append the CLI uses. The dashboard does not
107
- // call this — [[event-detail]] reads readings and hosts remarks, never files.
108
- app.post('/api/specs/:id/yatsu/eval', async (c) => {
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/yatsu/blob/:hash', (c) => {
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 yatsu cache uses). The returned hash is
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/yatsu/blob', async (c) => {
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 blob' }, 400)
140
- if (buf.length > MAX_UPLOAD_BYTES) return c.json({ error: 'blob too large' }, 413)
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
- app.get('/api/layout', async (c) => c.json(await resolveLayout()))
144
- // the `surface: command` config-root plugins (built/active only) for the new-session `/` dropdown — each with
145
- // its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
146
- // frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
147
- app.get('/api/config', (c) => c.json(loadConfig()))
148
- // the named launcher profiles ([[launcher-select]]) the New-Session form's dropdown offers `{ name, harness }`
149
- // only (the `cmd` is a host secret, never shipped to the browser) plus the configured `default` NAME so the
150
- // dropdown pre-selects the SAME launcher a bare `spex new` uses (the CLI/config default), instead of the
151
- // alphabetically-first one. Missing defaultLauncher is returned as an actionable config error, not hidden by
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[] — yatsu content-addressed hashes (the annotator's clip reference rides here, not prose)
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 `graph` segment so it never collides with the `:id` routes.
315
- app.get('/api/sessions/graph', async (c) => c.json(await sessionGraph()))
316
- app.post('/api/sessions/graph/watch', async (c) => {
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/graph/unwatch', async (c) => {
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 [[review-proof]] EXPORT artifact: one self-contained HTML (diff + gates + evidence inlined as
348
- // data-URIs) for CI/share/bare-browser. `?format=json` returns the model; default = rendered HTML. The
349
- // dashboard's interactive face is the lean route below, never this. 404 unknown id.
350
- app.get('/api/sessions/:id/proof', async (c) => {
351
- const m = await buildProofModel(c.req.param('id'))
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's originating prompt (what it was asked to do), for a manager client; 404 if none recorded.
373
- app.get('/api/sessions/:id/prompt', async (c) => {
374
- const p = await sessionPrompt(c.req.param('id'))
375
- return p == null ? c.text('no prompt recorded', 404) : c.text(p)
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 reopen(c.req.param('id'), { force })
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
- // the docked line input (and server-side merge dispatch) dispatch a whole prompt through the rendezvous
428
- // control socket. Socket-only + fail-loud: a prompt the agent doesn't confirm accepting returns 502 with the
429
- // reason (never a silent 200), so the dashboard/manager sees a dead dispatch instead of a false success.
430
- app.post('/api/sessions/:id/keys', async (c) => {
431
- const body = await c.req.json().catch(() => ({}))
432
- // `from` (the sender's session id) rides only an agent-to-agent send the backend records the comms
433
- // edge ([[comms-edge]]); a raw human dispatch omits it and is not logged.
434
- const r = await sendKeys(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined)
435
- return c.json(r, r.ok ? 200 : 502)
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
- const keys = Array.isArray(body?.keys) ? body.keys.filter((k: unknown) => typeof k === 'string')
443
- : typeof body?.key === 'string' ? [body.key] : []
444
- const ok = await rawKey(c.req.param('id'), keys)
445
- return c.json({ ok }, ok ? 200 : 404)
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 (relaunchable). Distinct from close, which
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/exit', async (c) => c.json({ ok: await exitSession(c.req.param('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
- // ([[board-stream]]) — the rename shows in ~150ms deterministically, never waiting out a cold tick.
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). keepAliveTimeout
475
- // reaps an idle keep-alive socket; headersTimeout a slow-header/slow-loris; requestTimeout a request whose
476
- // body never completes. These reap IDLE/STALLED sockets only — an ACTIVE WS/SSE response is not "keep-alive
477
- // idle", so the board-stream SSE and the terminal socket are untouched. requestTimeout bounds RECEIVING a
478
- // request only (not the response), so a slow board build or a streaming SSE/WS response is never cut. Node
479
- // enforces these on a periodic sweep whose cadence (connectionsCheckingInterval) is armed AT CONSTRUCTION —
480
- // so they MUST ride `serverOptions` (setting them post-`serve()` leaves the sweep on its 30s default and the
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: yatsu auth-boundary).
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)
@@ -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 `.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
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 .config)`)
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 `.config/<plugin>` lands in the seeded project node's `.config`). See [[init-preset]].
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 .config`)
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
- // .config. Idempotent — the planted git hooks (pre-commit/post-checkout/post-merge) keep it fresh
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 board:
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.)`)