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