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.
- package/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +695 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +2 -2
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +123 -96
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +289 -384
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +130 -103
- package/spec-cli/src/init.ts +9 -9
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +73 -8
- package/spec-cli/src/localIssues.ts +42 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +397 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +10 -10
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +25 -15
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
- package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
package/spec-cli/src/hooks.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { loadHookConfig } from './specs.js'
|
|
2
2
|
|
|
3
3
|
// @@@ hook manifest - the harness-agnostic hook system has THREE parts: (1) the discovered handlers —
|
|
4
|
-
// `surface: hook` nodes under .
|
|
4
|
+
// `surface: hook` nodes under .plugins/core/* (spec-governed content, each shipping one co-located .sh);
|
|
5
5
|
// (2) this COMPILER, which flattens them into a flat per-session manifest; (3) a pure-shell dispatcher
|
|
6
6
|
// (spec-cli/hooks/dispatch.sh) the committed .claude/.codex shim binds to every harness event. The compiler
|
|
7
7
|
// is the ONLY step that parses spec frontmatter, so it runs ONCE at SessionStart and the hot-path dispatcher
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -1,25 +1,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 './
|
|
14
|
-
import { boardStream, notifyBoardChanged } from './
|
|
15
|
+
import { getBoardJson } from './graphCache.js'
|
|
16
|
+
import { boardStream, notifyBoardChanged } from './graphStream.js'
|
|
15
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
16
|
-
import { newSession, listSessions,
|
|
18
|
+
import { newSession, listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
|
|
17
19
|
import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
|
|
18
|
-
import { evalTimeline, readBlobByHash } from '../../spec-
|
|
19
|
-
import { putBlob } from '../../spec-
|
|
20
|
-
import {
|
|
21
|
-
import { fileHumanReading } from '../../spec-
|
|
22
|
-
import {
|
|
20
|
+
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
21
|
+
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
22
|
+
import { evalNodes } from '../../spec-eval/src/scenarios.js'
|
|
23
|
+
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
24
|
+
import { 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/
|
|
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
|
|
40
|
-
// as `spex
|
|
41
|
-
// dashboard reloads on a `/api/
|
|
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 [[
|
|
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 [[
|
|
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/
|
|
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: '
|
|
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
|
|
58
|
-
// reloads the instant status moves instead of waiting for its slow fallback poll ([[
|
|
59
|
-
app.get('/api/
|
|
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 ([[
|
|
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
|
|
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(
|
|
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 ([[
|
|
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
|
|
103
|
-
// flag, newest-first; `
|
|
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-
|
|
106
|
-
// reading (verdict + optional transcript) through the SAME append the CLI
|
|
107
|
-
// call this — [[event-detail]] reads readings and hosts remarks, never files.
|
|
108
|
-
app.post('/api/specs/:id/
|
|
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/
|
|
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
|
|
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/
|
|
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
|
|
140
|
-
if (buf.length > MAX_UPLOAD_BYTES) return c.json({ error: '
|
|
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
|
-
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
// the
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// falling through to the built-in `claude` launcher.
|
|
153
|
-
app.get('/api/launchers', (c) => c.json({
|
|
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[] —
|
|
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 `
|
|
315
|
-
app.get('/api/sessions/
|
|
316
|
-
app.post('/api/sessions/
|
|
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/
|
|
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 [[
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (!m) return c.text('no such session', 404)
|
|
353
|
-
if (c.req.query('format') === 'json') return c.json(m)
|
|
354
|
-
return c.html(renderProofHtml(m))
|
|
355
|
-
})
|
|
356
|
-
// the session EVAL model ([[review-proof]]'s interactive face): worktree-rooted rows only — no diff
|
|
357
|
-
// enrichment, no inlined bytes; evidence streams lazily from /api/yatsu/blob. Each reading carries
|
|
358
|
-
// `inSession` so the tab leads with what THIS session measured.
|
|
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
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
})
|
|
437
|
-
// the preserved tmux send-keys path (distinct from the ❯ prompt socket): the human drives the agent's
|
|
438
|
-
// interactive TUI menus in real time. Accepts an ORDERED BATCH (`keys`, the client coalesces fast typing) or a
|
|
439
|
-
// single `key`; rawKey delivers them in array order so tap order is preserved ([[nav-mode-key-ordering]]).
|
|
440
|
-
app.post('/api/sessions/:id/rawkey', async (c) => {
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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 (
|
|
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/
|
|
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
|
-
// ([[
|
|
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).
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
// change under-effective); a 10s sweep makes reaping land within timeout+10s, well under the multi-minute
|
|
482
|
-
// defaults (requestTimeout 300s) that let the conns accumulate. headersTimeout > keepAliveTimeout (Node's rule).
|
|
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:
|
|
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)
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -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 `.
|
|
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 `.
|
|
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 .
|
|
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 `.
|
|
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 .
|
|
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
|
-
// .
|
|
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
|
|
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.)`)
|