spexcode 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/cli.ts +88 -37
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +105 -55
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +181 -33
- package/spec-cli/src/help.ts +64 -27
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +145 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +188 -66
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// One browser-safe identity registry shared by backend validation and every dashboard projection.
|
|
2
|
+
// Geometry is data so the React renderer and favicon serializer cannot drift into separate drawings.
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_PROJECT_ICON = 'spexcode'
|
|
5
|
+
export const DEFAULT_GATEWAY_ICON = 'gateway'
|
|
6
|
+
|
|
7
|
+
export const IDENTITY_PRESETS = Object.freeze([
|
|
8
|
+
{
|
|
9
|
+
id: 'spexcode', label: 'SpexCode', bg: '#166534', fg: '#f0fdf4',
|
|
10
|
+
shapes: [
|
|
11
|
+
{ tag: 'circle', cx: 12, cy: 6.2, r: 2.2 },
|
|
12
|
+
{ tag: 'circle', cx: 6.2, cy: 17.5, r: 2 },
|
|
13
|
+
{ tag: 'circle', cx: 17.8, cy: 17.5, r: 2 },
|
|
14
|
+
{ tag: 'path', d: 'M12 8.4v2.1c0 2.2-5.8 1.8-5.8 5' },
|
|
15
|
+
{ tag: 'path', d: 'M12 10.5c0 2.2 5.8 1.8 5.8 5' },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'gateway', label: 'Gateway', bg: '#155e75', fg: '#ecfeff',
|
|
20
|
+
shapes: [
|
|
21
|
+
{ tag: 'path', d: 'm12 3 8 4.2-8 4.2-8-4.2Z' },
|
|
22
|
+
{ tag: 'path', d: 'm4 11.2 8 4.2 8-4.2' },
|
|
23
|
+
{ tag: 'path', d: 'm4 15.2 8 4.2 8-4.2' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'mdi:rocket-launch', label: 'Rocket', bg: '#9f1239', fg: '#fff1f2',
|
|
28
|
+
shapes: [
|
|
29
|
+
{ tag: 'path', d: 'M14.5 5.2c2.2-2.2 4.8-2 5.3-1.8.2.5.4 3.1-1.8 5.3l-5.5 5.5-4.2-4.2Z' },
|
|
30
|
+
{ tag: 'path', d: 'm11.2 6.8-4.1.6-2.5 2.5 4.1.7' },
|
|
31
|
+
{ tag: 'path', d: 'm16.4 12-1 4.7-2.5 2.5-.7-4.1' },
|
|
32
|
+
{ tag: 'circle', cx: 16.1, cy: 7.1, r: 1.2 },
|
|
33
|
+
{ tag: 'path', d: 'M7.6 14.6c-2.2.4-3.4 1.6-3.6 3.8 2.2-.2 3.4-1.4 3.8-3.6' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'compass', label: 'Compass', bg: '#1d4ed8', fg: '#eff6ff',
|
|
38
|
+
shapes: [
|
|
39
|
+
{ tag: 'circle', cx: 12, cy: 12, r: 8.5 },
|
|
40
|
+
{ tag: 'path', d: 'm15.2 8.8-1.8 4.6-4.6 1.8 1.8-4.6Z' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'terminal', label: 'Terminal', bg: '#3f3f46', fg: '#fafafa',
|
|
45
|
+
shapes: [
|
|
46
|
+
{ tag: 'rect', x: 3.5, y: 4.5, width: 17, height: 15, rx: 2 },
|
|
47
|
+
{ tag: 'path', d: 'm7 9 3 3-3 3' },
|
|
48
|
+
{ tag: 'path', d: 'M12.5 15H17' },
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
id: 'package', label: 'Package', bg: '#6d28d9', fg: '#f5f3ff',
|
|
53
|
+
shapes: [
|
|
54
|
+
{ tag: 'path', d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9Z' },
|
|
55
|
+
{ tag: 'path', d: 'm4.3 7.7 7.7 4.4 7.7-4.4' },
|
|
56
|
+
{ tag: 'path', d: 'M12 12.1V21' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'database', label: 'Database', bg: '#a16207', fg: '#fefce8',
|
|
61
|
+
shapes: [
|
|
62
|
+
{ tag: 'ellipse', cx: 12, cy: 6, rx: 7.5, ry: 3 },
|
|
63
|
+
{ tag: 'path', d: 'M4.5 6v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3V6' },
|
|
64
|
+
{ tag: 'path', d: 'M4.5 12v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'spark', label: 'Spark', bg: '#c2410c', fg: '#fff7ed',
|
|
69
|
+
shapes: [
|
|
70
|
+
{ tag: 'path', d: 'm12 3 1.5 5.2L19 10l-5.5 1.8L12 17l-1.5-5.2L5 10l5.5-1.8Z' },
|
|
71
|
+
{ tag: 'path', d: 'm18.5 15 .7 2.2 2.3.8-2.3.8-.7 2.2-.7-2.2-2.3-.8 2.3-.8Z' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const BY_ID = new Map(IDENTITY_PRESETS.map((preset) => [preset.id, preset]))
|
|
77
|
+
const ICONIFY_ID = /^[a-z0-9-]+[:/][a-z0-9-]+$/i
|
|
78
|
+
const ALIASES = new Map([
|
|
79
|
+
['rocket', 'mdi:rocket-launch'],
|
|
80
|
+
['mdi/rocket-launch', 'mdi:rocket-launch'],
|
|
81
|
+
['layers', 'gateway'],
|
|
82
|
+
['default', 'spexcode'],
|
|
83
|
+
])
|
|
84
|
+
|
|
85
|
+
export const IDENTITY_PRESET_IDS = Object.freeze(IDENTITY_PRESETS.map((preset) => preset.id))
|
|
86
|
+
|
|
87
|
+
export function resolvedIdentityIcon(value, fallback = DEFAULT_PROJECT_ICON) {
|
|
88
|
+
const raw = typeof value === 'string' ? value.trim() : ''
|
|
89
|
+
const id = ALIASES.get(raw) || raw
|
|
90
|
+
return id || fallback
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function identityPreset(value) {
|
|
94
|
+
const raw = typeof value === 'string' ? value.trim() : ''
|
|
95
|
+
return BY_ID.get(ALIASES.get(raw) || raw) || null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isIconifyIcon(value) {
|
|
99
|
+
return ICONIFY_ID.test(typeof value === 'string' ? value.trim() : '')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function requireIdentityChoice(value) {
|
|
103
|
+
const raw = typeof value === 'string' ? value.trim() : ''
|
|
104
|
+
const id = ALIASES.get(raw) || raw
|
|
105
|
+
if (BY_ID.has(id)) return id
|
|
106
|
+
if (isIconifyIcon(id)) return id.replace('/', ':')
|
|
107
|
+
throw new Error(`unknown identity icon '${raw}' (choose a preset or Iconify prefix:name)`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function attrs(shape) {
|
|
111
|
+
return Object.entries(shape).filter(([key]) => key !== 'tag')
|
|
112
|
+
.map(([key, value]) => `${key === 'className' ? 'class' : key}="${String(value).replaceAll('&', '&').replaceAll('"', '"')}"`).join(' ')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function identitySvg(value, fallback = DEFAULT_PROJECT_ICON) {
|
|
116
|
+
const preset = identityPreset(value) || identityPreset(fallback)
|
|
117
|
+
const geometry = preset.shapes.map((shape) => `<${shape.tag} ${attrs(shape)}/>`).join('')
|
|
118
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="5" fill="${preset.bg}"/><g fill="none" stroke="${preset.fg}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${geometry}</g></svg>`
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function identityFaviconHref(value, fallback = DEFAULT_PROJECT_ICON) {
|
|
122
|
+
const resolved = resolvedIdentityIcon(value, fallback)
|
|
123
|
+
if (identityPreset(resolved)) return `data:image/svg+xml,${encodeURIComponent(identitySvg(resolved, fallback))}`
|
|
124
|
+
if (/^https?:\/\//.test(resolved)) return resolved
|
|
125
|
+
if (isIconifyIcon(resolved)) return `https://api.iconify.design/${resolved.replace(':', '/')}.svg`
|
|
126
|
+
const glyph = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y=".86em" font-size="82" text-anchor="middle">${resolved.replaceAll('&', '&').replaceAll('<', '<')}</text></svg>`
|
|
127
|
+
return `data:image/svg+xml,${encodeURIComponent(glyph)}`
|
|
128
|
+
}
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -13,20 +13,21 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
|
13
13
|
import { summarize } from './mentions.js'
|
|
14
14
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
15
15
|
import { getBoardJson } from './graphCache.js'
|
|
16
|
-
import { boardStream, notifyBoardChanged } from './graphStream.js'
|
|
16
|
+
import { boardStream, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
17
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
|
-
import {
|
|
18
|
+
import { listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, TMUX_SOCK } from './sessions.js'
|
|
19
19
|
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
20
20
|
import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
|
|
21
21
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
22
22
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
23
|
-
import { evalNodes } from '../../spec-eval/src/scenarios.js'
|
|
24
23
|
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
25
24
|
import { fileHumanOk } from '../../spec-eval/src/humanok.js'
|
|
26
|
-
import { buildExportModel, renderExportHtml,
|
|
25
|
+
import { buildExportModel, renderExportHtml, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
|
|
27
26
|
import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
|
|
28
|
-
import { attachViewer, detachViewer, resizeBridge,
|
|
27
|
+
import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges, type Viewer } from './pty-bridge.js'
|
|
29
28
|
import { installProcessGuards } from './resilience.js'
|
|
29
|
+
import { resolveProjectIdentity } from './project-identity.js'
|
|
30
|
+
import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
|
|
30
31
|
|
|
31
32
|
// last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
|
|
32
33
|
// self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
|
|
@@ -34,12 +35,34 @@ installProcessGuards()
|
|
|
34
35
|
|
|
35
36
|
const app = new Hono()
|
|
36
37
|
app.use('/api/*', cors())
|
|
38
|
+
app.onError((error, c) => {
|
|
39
|
+
if (error instanceof SessionEvalUnavailableError) return c.json({ error: error.message }, 503)
|
|
40
|
+
console.error(error)
|
|
41
|
+
return c.text('Internal Server Error', 500)
|
|
42
|
+
})
|
|
37
43
|
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
|
|
38
44
|
|
|
39
45
|
app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/slash-commands'))
|
|
40
46
|
// the supervisor's readiness gate (supervise.ts): a bare git-free 200 so a booting child reports ready the
|
|
41
47
|
// instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
|
|
42
48
|
app.get('/health', (c) => c.text('ok'))
|
|
49
|
+
// @@@ instance identity - who THIS backend is: the serve generation's instanceId (minted by the supervisor,
|
|
50
|
+
// constant across zero-downtime reloads, handed down via env) and the project root it serves. This is the
|
|
51
|
+
// answer the host gateway ([[host-gateway]]) compares an endpoint record against before proxying to it — a
|
|
52
|
+
// recycled port serving another project or a stale record fails the match instead of being routed to. Git-free
|
|
53
|
+
// after the first memoized resolution; a self-run child (no supervisor) answers instanceId:null, which no
|
|
54
|
+
// record claims, so it is simply not hosted.
|
|
55
|
+
const instanceStartedAt = new Date().toISOString()
|
|
56
|
+
app.get('/api/instance', (c) => {
|
|
57
|
+
const root = repoRoot()
|
|
58
|
+
return c.json({
|
|
59
|
+
instanceId: process.env.SPEXCODE_INSTANCE_ID ?? null,
|
|
60
|
+
root,
|
|
61
|
+
identity: resolveProjectIdentity(root, root),
|
|
62
|
+
pid: process.pid,
|
|
63
|
+
startedAt: instanceStartedAt,
|
|
64
|
+
})
|
|
65
|
+
})
|
|
43
66
|
// the assembled graph (merged tree + overlay + sessions) — the dashboard's single source. Same data
|
|
44
67
|
// as `spex graph --json`; the frontend only adds x/y pixels on top. Freshness is PUSH-first ([[graph-stream]]): the
|
|
45
68
|
// dashboard reloads on a `/api/graph/stream` event, not a tight poll, so the route is a conditional-request
|
|
@@ -53,6 +76,7 @@ app.get('/health', (c) => c.text('ok'))
|
|
|
53
76
|
// is bounded by [[graph-cache]]'s own build watchdog, so the next poll retries a fresh build.
|
|
54
77
|
const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
|
|
55
78
|
app.get('/api/graph', etag(), async (c) => {
|
|
79
|
+
ensureBoardFileWatchers()
|
|
56
80
|
const timeout = Symbol('timeout')
|
|
57
81
|
const json = await Promise.race([getBoardJson(), new Promise<typeof timeout>((r) => setTimeout(() => r(timeout), BOARD_TIMEOUT_MS))])
|
|
58
82
|
if (json === timeout) return c.json({ error: 'graph build timed out' }, 503)
|
|
@@ -64,23 +88,9 @@ app.get('/api/graph/stream', (c) => boardStream(c))
|
|
|
64
88
|
app.get('/api/specs', async (c) => c.json(await loadSpecs()))
|
|
65
89
|
// the search corpus ([[graph-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
|
|
66
90
|
// board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
|
|
67
|
-
// to rank nodes over their prose — off the board's hot poll.
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
// serves both the palette's scenario plane and the focus-panel preview.
|
|
71
|
-
app.get('/api/specs/lite', (c) => {
|
|
72
|
-
const scByNode = new Map(evalNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
|
|
73
|
-
return c.json(loadSpecsLite().map((row) => {
|
|
74
|
-
const sc = scByNode.get(row.id)
|
|
75
|
-
return sc?.length
|
|
76
|
-
? { ...row, scenarios: sc.map((s) => ({
|
|
77
|
-
name: s.name, description: s.description, expected: s.expected,
|
|
78
|
-
...(s.test ? { test: s.test } : {}),
|
|
79
|
-
...(s.code?.length ? { code: s.code } : {}),
|
|
80
|
-
})) }
|
|
81
|
-
: row
|
|
82
|
-
}))
|
|
83
|
-
})
|
|
91
|
+
// to rank nodes over their prose — off the board's hot poll. Review rows, including scenarios, come only
|
|
92
|
+
// from their paged endpoints and cannot be reconstructed from this corpus.
|
|
93
|
+
app.get('/api/specs/lite', (c) => c.json(loadSpecsLite()))
|
|
84
94
|
// one node's body + parsed parts ([[graph-lean]]): the board no longer ships either, so the detail view
|
|
85
95
|
// fetches this when a node opens. 404 for an unknown id.
|
|
86
96
|
app.get('/api/specs/:id/content', (c) => {
|
|
@@ -136,9 +146,11 @@ app.post('/api/specs/:id/evals/ok', async (c) => {
|
|
|
136
146
|
// else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
|
|
137
147
|
// HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
|
|
138
148
|
// currentTime to the seekable window, which stays [0,0] without them); one general mechanism at the
|
|
139
|
-
// transport, so every evidence kind streams the same way.
|
|
149
|
+
// transport, so every evidence kind streams the same way. A trailing `.<ext>` on the hash is IGNORED
|
|
150
|
+
// decoration for third-party markdown renderers (GitLab/GitHub only emit a <video> player when the URL
|
|
151
|
+
// ends in a video extension); the served bytes and MIME stay the stored ones — a wrong suffix never lies.
|
|
140
152
|
app.get('/api/evidence/:hash', (c) => {
|
|
141
|
-
const r = readBlobByHash(c.req.param('hash'))
|
|
153
|
+
const r = readBlobByHash(c.req.param('hash').replace(/\.[a-z0-9]+$/i, ''))
|
|
142
154
|
if (!r.ok) return c.text(r.message, r.reason === 'invalid' ? 400 : 404)
|
|
143
155
|
const total = r.bytes.length
|
|
144
156
|
const base = { 'Content-Type': r.mime, 'Cache-Control': 'public, max-age=31536000, immutable', 'Accept-Ranges': 'bytes' }
|
|
@@ -163,17 +175,23 @@ app.post('/api/evidence', async (c) => {
|
|
|
163
175
|
})
|
|
164
176
|
// the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
|
|
165
177
|
// `layout` (resolveLayout()'s main/worktrees/branch shape — the write-guard's project-identity probe reads
|
|
166
|
-
// `.layout.main`) and the named launcher profiles ([[launcher-select]]) the New-Session
|
|
167
|
-
// offers — `{ name, harness }
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
178
|
+
// `.layout.main`) and the named launcher profiles ([[launcher-select]]) the New-Session picker
|
|
179
|
+
// offers — `{ name, harness, cmd }`: the cmd is read-only display data for the picker (the dashboard sits
|
|
180
|
+
// behind the gateway auth; the browser can read but never edit config) — plus the configured `default` NAME
|
|
181
|
+
// so the picker pre-selects the SAME
|
|
182
|
+
// launcher a bare `spex session new` uses (the CLI/config default), instead of the alphabetically-first one,
|
|
183
|
+
// Missing defaultLauncher is returned as an actionable config error, not hidden by falling through to the
|
|
184
|
+
// built-in `claude` launcher.
|
|
185
|
+
// `tmuxSocket` is the `-L <name>` label our private tmux server runs under (a backend fact, env-overridable),
|
|
186
|
+
// so the row's attach modal ([[attach-menu]]) can offer the RAW `tmux -L <socket> attach -t <id>` fallback
|
|
187
|
+
// beside the blessed `spex session attach` command — the frontend never hardcodes the socket.
|
|
171
188
|
app.get('/api/settings', async (c) => c.json({
|
|
172
189
|
layout: await resolveLayout(),
|
|
173
|
-
launchers: launcherList()
|
|
190
|
+
launchers: launcherList(),
|
|
191
|
+
tmuxSocket: TMUX_SOCK,
|
|
174
192
|
...launcherDefault(),
|
|
175
193
|
}))
|
|
176
|
-
// the `surface: command` plugin-root nodes (built/active only) for
|
|
194
|
+
// the `surface: command` plugin-root nodes (built/active only) for new-session and live-inbox `/` dropdowns — each with
|
|
177
195
|
// its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
|
|
178
196
|
// frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
|
|
179
197
|
// `?surface=review` lists the review-track presets instead ([[review-commands]] — the eval detail's
|
|
@@ -183,12 +201,24 @@ app.get('/api/plugins', (c) => c.json(c.req.query('surface') === 'review' ? load
|
|
|
183
201
|
// (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
|
|
184
202
|
// (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
|
|
185
203
|
// the issues-workflow on/off switch so the frontend hides the view when the feature is OFF.
|
|
186
|
-
app.get('/api/issues', etag(), (c) =>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
})
|
|
204
|
+
app.get('/api/issues', etag(), async (c) => c.json(await issuesReview(c.req.query('q'), c.req.query('page'))))
|
|
205
|
+
// Evals uses the identical paged-review response. `scope:` inside q selects the worktree source; without
|
|
206
|
+
// it the source is the current cached board. Filtering/counts always precede the one 25-row slice.
|
|
207
|
+
app.get('/api/evals', etag(), async (c) => {
|
|
208
|
+
ensureBoardFileWatchers()
|
|
209
|
+
const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') })
|
|
210
|
+
return page ? c.json(page) : c.json({ error: 'no such review source' }, 404)
|
|
211
|
+
})
|
|
212
|
+
// ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
|
|
213
|
+
// most five lightweight neighbors. It never serializes another scenario's history or the scoped model.
|
|
214
|
+
app.get('/api/evals/detail', etag(), async (c) => {
|
|
215
|
+
ensureBoardFileWatchers()
|
|
216
|
+
const node = c.req.query('node')?.trim()
|
|
217
|
+
const scenario = c.req.query('scenario')?.trim()
|
|
218
|
+
if (!node || !scenario) return c.json({ error: 'node and scenario are required' }, 400)
|
|
219
|
+
const detail = await evalDetailReview(node, scenario, c.req.query('scope')?.trim() || null)
|
|
220
|
+
return detail ? c.json(detail) : c.json({ error: 'no such review source' }, 404)
|
|
221
|
+
})
|
|
192
222
|
// the single-thread read ([[issues]]) behind `spex issue show <id>` — the SAME findIssue lookup, from the
|
|
193
223
|
// resident forge slice (instant view, background reconcile — the list route's freshness contract). A local
|
|
194
224
|
// id, or a forge id (`<host>#<n>`); unknown → 404 (eval-remark threads are not issues, so they 404 here too).
|
|
@@ -330,7 +360,7 @@ app.post('/api/remarks/:action{resolve|retract}', async (c) => {
|
|
|
330
360
|
return c.json({ error: String((e as Error).message || e) }, 400)
|
|
331
361
|
}
|
|
332
362
|
})
|
|
333
|
-
// the dashboard input's `/` dropdown — computed by the launcher's HARNESS adapter the same way that harness
|
|
363
|
+
// the harness slice of the dashboard input's `/` dropdown — computed by the launcher's HARNESS adapter the same way that harness
|
|
334
364
|
// computes its own `/` menu ([[harness-adapter]]). The client passes `?harness=<id>` for the ACTIVE session,
|
|
335
365
|
// so a codex tab gets CODEX's menu, not the default's; unknown/absent → default. Insert-only on the client.
|
|
336
366
|
app.get('/api/slash-commands', (c) => {
|
|
@@ -370,18 +400,9 @@ app.post('/api/sessions/edges/unwatch', async (c) => {
|
|
|
370
400
|
return c.json({ ok }, ok ? 200 : 404)
|
|
371
401
|
})
|
|
372
402
|
app.post('/api/sessions', async (c) => {
|
|
373
|
-
const body = await c.req.json().catch(() =>
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
if (typeof body?.harness === 'string') return c.json({ error: 'harness is not a create-session input; use launcher' }, 400)
|
|
377
|
-
// the named launcher ([[launcher-select]]) — fixes the session's harness AND its persisted launch command.
|
|
378
|
-
const launcher = typeof body?.launcher === 'string' && body.launcher.trim() ? body.launcher.trim() : undefined
|
|
379
|
-
// parent = the spawning session's id, resolved by the CALLER (createSession) in its own process and passed
|
|
380
|
-
// through here ([[session-nesting]]); the browser's New Session omits it → a top-level session.
|
|
381
|
-
const parent = typeof body?.parent === 'string' && body.parent.trim() ? body.parent.trim() : null
|
|
382
|
-
try {
|
|
383
|
-
return c.json(await newSession(typeof body?.node === 'string' ? body.node : null, prompt, parent, launcher), 201)
|
|
384
|
-
} catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown launcher id → 400, not a 500
|
|
403
|
+
const body = await c.req.json().catch(() => null)
|
|
404
|
+
const result = await sessionCreateRequest(body)
|
|
405
|
+
return result.status === 201 ? c.json(result.session, 201) : c.json({ error: result.error }, 400)
|
|
385
406
|
})
|
|
386
407
|
// one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
|
|
387
408
|
// dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
|
|
@@ -389,18 +410,14 @@ app.get('/api/sessions/:id/review', async (c) => {
|
|
|
389
410
|
const r = await reviewPayload(c.req.param('id'))
|
|
390
411
|
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
391
412
|
})
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// reading carrying `inSession` so the tab leads with what THIS session measured. `?format=html` = the
|
|
395
|
-
// EXPORT artifact: one self-contained HTML (diff + gates + evidence inlined as data-URIs) for
|
|
396
|
-
// CI/share/bare-browser — a heavier REPRESENTATION of the same read, not a second route. 404 unknown id.
|
|
413
|
+
// The self-contained HTML is the sole full-model transport exception. Interactive rows, including the CLI,
|
|
414
|
+
// use /api/evals pages; a bare request fails loudly rather than reopening a hidden full JSON path.
|
|
397
415
|
app.get('/api/sessions/:id/evals', async (c) => {
|
|
398
416
|
if (c.req.query('format') === 'html') {
|
|
399
417
|
const m = await buildExportModel(c.req.param('id'))
|
|
400
418
|
return m ? c.html(renderExportHtml(m)) : c.text('no such session', 404)
|
|
401
419
|
}
|
|
402
|
-
|
|
403
|
-
return m ? c.json(m) : c.json({ error: 'no such session' }, 404)
|
|
420
|
+
return c.json({ error: 'interactive eval rows use /api/evals pagination; use ?format=html only for export' }, 400)
|
|
404
421
|
})
|
|
405
422
|
// the session's live pane as text (one-shot snapshot) for a backend client (`spex session show --capture`). Empty and fail
|
|
406
423
|
// stay distinct: an empty pane is 200 with empty body; unknown id → 404, offline (no live pane) → 409, error → 502.
|
|
@@ -445,44 +462,88 @@ app.post('/api/sessions/:id/merge', async (c) => {
|
|
|
445
462
|
return c.json(r, r.dispatched ? 200 : 409)
|
|
446
463
|
})
|
|
447
464
|
|
|
448
|
-
// one WS
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
465
|
+
// one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
|
|
466
|
+
// client→server text controls resize, visibility, and xterm-native input (which carries the mouse/wheel
|
|
467
|
+
// SGR reports xterm produces natively in mouse-report mode). Server→client text commits a completed resize immediately before its binary tmux transaction;
|
|
468
|
+
// hiding starts that viewer's bounded helper release without closing the warm socket. tmux itself resolves wheel input between
|
|
469
|
+
// copy-mode and a mouse-owning TUI. The bridge never splices capture-pane state into this stream.
|
|
470
|
+
// keep-alive ping cadence for the terminal socket — the server half of [[reconnect]]'s heartbeat contract,
|
|
471
|
+
// and the contract's ONE primitive number: the client mirrors it (SERVER_PING_MS in the dashboard's
|
|
472
|
+
// resilientSocket.js, pinned by its test) and DERIVES its silence deadline (2.5×) from it.
|
|
473
|
+
// A healthy link is guaranteed inbound traffic every PING window, so the client may presume an OPEN socket
|
|
474
|
+
// silent past its derived window dead. The same tick also sends a WebSocket protocol ping; browsers answer its
|
|
475
|
+
// pong below JavaScript, so a backend reload stays compatible with a tab running the previous frontend bundle.
|
|
476
|
+
// The server owns that mirror deadline and detaches the viewer itself when a half-open link never reports close.
|
|
477
|
+
// Terminal pixels remain binary frames, so heartbeat controls never enter xterm.
|
|
478
|
+
const TERM_PING_MS = 10000
|
|
479
|
+
const TERM_DEAD_MS = 2.5 * TERM_PING_MS
|
|
453
480
|
app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
|
|
454
481
|
const id = c.req.param('id') as string
|
|
455
|
-
// the size-first handshake: a client that already knows its pane size carries it as ?cols=&rows= so the
|
|
456
|
-
// first frame is drawn at the true size. Absent/garbage → undefined, and the bridge falls back to prewarm.
|
|
457
|
-
const qc = Number(c.req.query('cols')), qr = Number(c.req.query('rows'))
|
|
458
|
-
const initialSize = qc > 0 && qr > 0 ? { cols: qc, rows: qr } : undefined
|
|
459
482
|
let viewer: Viewer | null = null
|
|
483
|
+
let ping: ReturnType<typeof setInterval> | undefined
|
|
484
|
+
let pongDeadline: ReturnType<typeof setTimeout> | undefined
|
|
485
|
+
let cleaned = false
|
|
486
|
+
const disarmPongDeadline = () => { if (pongDeadline) clearTimeout(pongDeadline); pongDeadline = undefined }
|
|
487
|
+
let armPongDeadline = () => {}
|
|
488
|
+
const cleanup = () => {
|
|
489
|
+
if (cleaned) return
|
|
490
|
+
cleaned = true
|
|
491
|
+
if (ping) clearInterval(ping)
|
|
492
|
+
disarmPongDeadline()
|
|
493
|
+
if (viewer) detachViewer(id, viewer)
|
|
494
|
+
viewer = null
|
|
495
|
+
}
|
|
460
496
|
return {
|
|
461
497
|
onOpen(_evt, ws) {
|
|
462
|
-
viewer = {
|
|
463
|
-
|
|
498
|
+
viewer = {
|
|
499
|
+
send: (buf) => { try { ws.send(Uint8Array.from(buf)) } catch { /* viewer gone */ } },
|
|
500
|
+
commitSize: (cols, rows) => { try { ws.send(JSON.stringify({ t: 'resize-commit', cols, rows })) } catch { /* viewer gone */ } },
|
|
501
|
+
}
|
|
502
|
+
attachViewer(id, viewer)
|
|
503
|
+
armPongDeadline = () => {
|
|
504
|
+
if (cleaned) return
|
|
505
|
+
disarmPongDeadline()
|
|
506
|
+
pongDeadline = setTimeout(() => {
|
|
507
|
+
cleanup()
|
|
508
|
+
try { ws.close() } catch { /* cleanup already detached the dead viewer */ }
|
|
509
|
+
}, TERM_DEAD_MS)
|
|
510
|
+
pongDeadline.unref()
|
|
511
|
+
}
|
|
512
|
+
// `raw` is @hono/node-ws's real ws.WebSocket. Protocol pong is intentionally the server-side liveness
|
|
513
|
+
// signal: unlike an application text reply, every browser generation answers it automatically.
|
|
514
|
+
ws.raw.on('pong', armPongDeadline)
|
|
515
|
+
armPongDeadline()
|
|
516
|
+
ping = setInterval(() => {
|
|
517
|
+
try { ws.raw.ping() } catch { /* viewer gone; onClose reaps */ }
|
|
518
|
+
try { ws.send('ping') } catch { /* client dead-man still needs observable inbound traffic */ }
|
|
519
|
+
}, TERM_PING_MS)
|
|
464
520
|
},
|
|
465
521
|
onMessage(evt) {
|
|
466
522
|
if (!viewer) return
|
|
467
523
|
const data = evt.data
|
|
468
|
-
//
|
|
469
|
-
//
|
|
524
|
+
// Binary input is ignored; JSON keeps terminal input distinct from binary pane output while preserving
|
|
525
|
+
// xterm's ordered string exactly. The bridge accepts input only from this viewer's visible claim.
|
|
470
526
|
if (typeof data === 'string') {
|
|
527
|
+
if (data === 'pong') {
|
|
528
|
+
armPongDeadline()
|
|
529
|
+
return
|
|
530
|
+
}
|
|
471
531
|
try {
|
|
472
532
|
const m = JSON.parse(data)
|
|
473
|
-
if (m?.t === 'resize') resizeBridge(id, Number(m.cols), Number(m.rows)
|
|
474
|
-
else if (m?.t === '
|
|
533
|
+
if (m?.t === 'resize') resizeBridge(id, viewer, Number(m.cols), Number(m.rows))
|
|
534
|
+
else if (m?.t === 'visible' && m.visible === false) hideViewer(id, viewer)
|
|
535
|
+
else if (m?.t === 'input' && typeof m.data === 'string') forwardInput(id, viewer, m.data)
|
|
475
536
|
} catch { /* ignore */ }
|
|
476
537
|
}
|
|
477
538
|
},
|
|
478
|
-
onClose() {
|
|
539
|
+
onClose() { cleanup() },
|
|
479
540
|
}
|
|
480
541
|
}))
|
|
481
542
|
// ONE input route, `kind` the discriminator — the transport split is an implementation fact, not API surface.
|
|
482
|
-
// kind:"text" (
|
|
543
|
+
// kind:"text" (Command Box, `spex session send`, the server-side merge dispatch) injects a whole prompt
|
|
483
544
|
// through the rendezvous control socket — socket-only + fail-loud: a prompt the agent doesn't confirm
|
|
484
545
|
// accepting returns 502 with the reason (never a silent 200), so a dead dispatch is seen, not a false success.
|
|
485
|
-
// kind:"keys" is the LAST-RESORT raw face (`send --keys
|
|
546
|
+
// kind:"keys" is the LAST-RESORT raw face (`spex session send --keys`): an ORDERED BATCH of
|
|
486
547
|
// nav-mode key tokens over tmux send-keys, delivered in array order so tap order survives
|
|
487
548
|
// ([[nav-mode-key-ordering]]); unstable by nature — callers try a plain text send first. An unknown kind is a
|
|
488
549
|
// loud 400, never a guessed channel.
|
|
@@ -530,23 +591,21 @@ app.post('/api/sessions/:id/sort', async (c) => {
|
|
|
530
591
|
const port = Number(process.env.PORT || 8787)
|
|
531
592
|
// @@@ server-side connection reaping ([[spec-cli]]) - abandoned connections must die SERVER-SIDE, or they
|
|
532
593
|
// pile up and wedge the backend (135 leaked conns once starved :8787 into looking dead — the cascade that
|
|
533
|
-
// triggered the mass-restore incident, since every client-side timeout-kill leaks one). The
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
594
|
+
// triggered the mass-restore incident, since every client-side timeout-kill leaks one). The ONE mechanism is
|
|
595
|
+
// the socket-level `installConnectionReaper` below (reaper.ts): a per-socket deadline that reaps a
|
|
596
|
+
// slow-loris / idle keep-alive but exempts an ACTIVE WS/SSE stream (board-stream, terminal socket) for as
|
|
597
|
+
// long as it streams. Deliberately NO `serverOptions` timeouts here: they were measured to be not harmless
|
|
598
|
+
// but a second mechanism racing the reaper (issue #65 — a 20s headersTimeout won at default config and
|
|
599
|
+
// silently capped SPEXCODE_REAP_HEADER_MS); the install disables Node's overlapping timeouts so the
|
|
600
|
+
// deadlines have a single owner.
|
|
540
601
|
// @@@ loopback bind ([[public-mode]]) - this child is NEVER the internet face: the supervisor (and in public
|
|
541
602
|
// mode the gateway) fronts it, and dials it only via 127.0.0.1. Binding loopback is what makes "loopback is
|
|
542
603
|
// the trust boundary" true — without a hostname Node binds all interfaces and the child is reachable from
|
|
543
604
|
// the LAN with no password, bypassing the gate entirely (measured: eval auth-boundary).
|
|
544
|
-
const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1'
|
|
545
|
-
keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000,
|
|
546
|
-
} })
|
|
605
|
+
const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' })
|
|
547
606
|
installConnectionReaper(server as unknown as HttpServer)
|
|
548
607
|
injectWebSocket(server)
|
|
549
|
-
superviseBridges() //
|
|
608
|
+
superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
|
|
550
609
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
551
610
|
superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
|
|
552
611
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join, resolve, relative } from 'node:path'
|
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { execFileSync } from 'node:child_process'
|
|
5
5
|
import { readConfig, readJsonConfig } from './layout.js'
|
|
6
|
-
import { resolveHarnessTargets } from './harness-select.js'
|
|
6
|
+
import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './harness-select.js'
|
|
7
7
|
|
|
8
8
|
// this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
|
|
9
9
|
// launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
|
|
@@ -54,7 +54,7 @@ function resolveHooksDir(dir: string): string | null {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export async function specInit(targetArg: string | undefined, presetArg?: string): Promise<void> {
|
|
57
|
+
export async function specInit(targetArg: string | undefined, presetArg?: string, harnessArg?: string): Promise<void> {
|
|
58
58
|
const targetDir = resolve(targetArg ?? process.cwd())
|
|
59
59
|
|
|
60
60
|
// the preset the NEW adopter gets — `--preset <name>` wins, else an existing target spexcode.json's
|
|
@@ -78,6 +78,24 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
78
78
|
process.exit(1)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// the harness DELIVERY TARGET set ([[harness-select]]) is a REQUIRED, explicit choice — `--harness <ids>`
|
|
82
|
+
// stamps it into spexcode.json; absent the flag, a pre-existing explicit `harnesses` field IS the choice.
|
|
83
|
+
// Neither → abort BEFORE writing anything: with many harnesses, a silent "deliver to all" would litter the
|
|
84
|
+
// adopter's tree (and global tool configs) with artifacts for harnesses they never installed. Legality
|
|
85
|
+
// (unknown ids, plugin exclusivity, empty set) fails loud here too, not as a soft materialize warning.
|
|
86
|
+
const flagRaw = (harnessArg ?? '').trim() ? parseHarnessFlag(harnessArg!.trim()) : null
|
|
87
|
+
const chosenHarnesses = flagRaw ?? readConfig(targetDir).harnesses ?? null
|
|
88
|
+
if (chosenHarnesses === null) {
|
|
89
|
+
console.error(`spex init: --harness is required — name the harness(es) this repo delivers into, e.g. \`spex init --harness claude\`. Known native ids: ${NATIVE_HARNESS_IDS.join(', ')} (comma-separate several; a plugin bundle: --harness plugin:<folder>). A pre-existing spexcode.json "harnesses" field also satisfies this.`)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
resolveHarnessTargets(chosenHarnesses)
|
|
94
|
+
} catch (e) {
|
|
95
|
+
console.error(`spex init: ${(e as Error).message}`)
|
|
96
|
+
process.exit(1)
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true })
|
|
82
100
|
console.log(`spex init → ${targetDir}`)
|
|
83
101
|
|
|
@@ -100,27 +118,35 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
100
118
|
}
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
// 1b. plant a starter spexcode.json (the lint/layout knob)
|
|
104
|
-
// template
|
|
105
|
-
//
|
|
121
|
+
// 1b. plant a starter spexcode.json (the lint/layout knob) carrying the CHOSEN `harnesses` set. The
|
|
122
|
+
// template ships launchers for every native harness; seeding keeps only the SELECTED ones (a launcher for
|
|
123
|
+
// a tool the adopter didn't pick is exactly the litter --harness exists to prevent) — a plugin-only
|
|
124
|
+
// selection keeps them all, since the bundle serves the HOST agent while dispatched sessions still need a
|
|
125
|
+
// launcher. The success message reports values read back from the planted file, never restated literals.
|
|
106
126
|
const cfgDest = join(targetDir, 'spexcode.json')
|
|
127
|
+
const nativeChosen = (chosenHarnesses as unknown[]).filter((m): m is string => typeof m === 'string')
|
|
107
128
|
if (existsSync(cfgDest)) {
|
|
108
|
-
|
|
129
|
+
if (flagRaw) {
|
|
130
|
+
// an explicit --harness on a re-init is a deliberate command: restamp THAT field, touch nothing else.
|
|
131
|
+
const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
|
|
132
|
+
cfg.harnesses = flagRaw
|
|
133
|
+
writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
|
|
134
|
+
console.log(`✓ stamped "harnesses": ${JSON.stringify(flagRaw)} into the existing spexcode.json (other fields untouched)`)
|
|
135
|
+
} else {
|
|
136
|
+
console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched (harnesses: ${JSON.stringify(chosenHarnesses)}).`)
|
|
137
|
+
}
|
|
109
138
|
} else {
|
|
110
|
-
|
|
139
|
+
const cfg = (readJsonConfig(join(TEMPLATES, 'spexcode.json')) ?? {}) as Record<string, any>
|
|
140
|
+
cfg.harnesses = chosenHarnesses
|
|
141
|
+
if (nativeChosen.length && cfg.sessions?.launchers) {
|
|
142
|
+
cfg.sessions.launchers = Object.fromEntries(
|
|
143
|
+
Object.entries(cfg.sessions.launchers as Record<string, { harness?: string }>).filter(([, l]) => nativeChosen.includes(l.harness ?? 'claude')))
|
|
144
|
+
const names = Object.keys(cfg.sessions.launchers)
|
|
145
|
+
if (names.length) cfg.sessions.defaultLauncher = names[0]
|
|
146
|
+
}
|
|
147
|
+
writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
|
|
111
148
|
const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
112
|
-
console.log(`✓ planted spexcode.json — lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// validate the harness DELIVERY TARGET set ([[harness-select]]) up front: a bad `harnesses` set (plugin +
|
|
116
|
-
// native, or a plugin with no folder) must fail LOUD here, not be silently swallowed by the materialize
|
|
117
|
-
// try/catch below. A fresh starter spexcode.json omits the field (defaults to all natives), so this only
|
|
118
|
-
// bites a hand-edited or re-init'd config — exactly where a clear error belongs.
|
|
119
|
-
try {
|
|
120
|
-
resolveHarnessTargets(readConfig(targetDir).harnesses)
|
|
121
|
-
} catch (e) {
|
|
122
|
-
console.error(`spex init: ${(e as Error).message}`)
|
|
123
|
-
process.exit(1)
|
|
149
|
+
console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
// 2. install the git hooks: templates/hooks/* -> <repo>/<common-git-dir>/hooks/* (skip any that exist).
|
|
@@ -145,18 +171,20 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
145
171
|
if (installed.length) console.log(`✓ installed git hooks (${installed.join(', ')}) → ${hooksDir}`)
|
|
146
172
|
}
|
|
147
173
|
|
|
148
|
-
// 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched
|
|
149
|
-
// steps
|
|
150
|
-
// <spexcode> contract block (user content preserved), the .claude/.codex shims, and the Codex trust (global,
|
|
151
|
-
// scoped) so codex self-launch is prompt-free. Runs with cwd = the target so the loaders read the just-seeded
|
|
174
|
+
// 2c. MATERIALIZE the harness-discovered artifacts so a USER-self-launched harness works with zero further
|
|
175
|
+
// steps. Runs with cwd = the target so the loaders read the just-seeded
|
|
152
176
|
// .plugins. Idempotent — the planted git hooks (pre-commit/post-checkout/post-merge) keep it fresh
|
|
153
177
|
// thereafter on the git-native anchors ([[commit-surgery]]); no harness event ever triggers a materialize.
|
|
154
178
|
const prevCwd = process.cwd()
|
|
155
179
|
try {
|
|
156
180
|
process.chdir(targetDir)
|
|
157
181
|
const { materialize } = await import('./materialize.js')
|
|
158
|
-
materialize(targetDir)
|
|
159
|
-
|
|
182
|
+
const result = materialize(targetDir)
|
|
183
|
+
const display = (path: string) => {
|
|
184
|
+
const local = relative(targetDir, path)
|
|
185
|
+
return local && local !== '..' && !local.startsWith('../') && !local.startsWith('..\\') ? local : path
|
|
186
|
+
}
|
|
187
|
+
console.log(`✓ materialized harness artifacts (${result.planted.map((a) => `${a.kind}: ${display(a.path)}`).join(', ')})`)
|
|
160
188
|
} catch (e) {
|
|
161
189
|
console.warn(`• materialize skipped (${(e as Error).message}) — run \`spex materialize\` once the packages are installed.`)
|
|
162
190
|
} finally {
|