spexcode 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +149 -102
  2. package/README.zh-CN.md +170 -0
  3. package/package.json +1 -1
  4. package/spec-cli/bin/spex.mjs +24 -1
  5. package/spec-cli/src/attach.ts +50 -0
  6. package/spec-cli/src/cli.ts +227 -66
  7. package/spec-cli/src/client.ts +47 -9
  8. package/spec-cli/src/{self.ts → doctor.ts} +26 -25
  9. package/spec-cli/src/gateway.ts +15 -11
  10. package/spec-cli/src/guide.ts +73 -17
  11. package/spec-cli/src/harness.ts +48 -19
  12. package/spec-cli/src/help.ts +141 -51
  13. package/spec-cli/src/index.ts +41 -14
  14. package/spec-cli/src/issues.ts +109 -31
  15. package/spec-cli/src/layout.ts +4 -4
  16. package/spec-cli/src/localIssues.ts +59 -58
  17. package/spec-cli/src/materialize.ts +4 -2
  18. package/spec-cli/src/mentions.ts +22 -1
  19. package/spec-cli/src/pty-bridge.ts +39 -4
  20. package/spec-cli/src/ranker.ts +31 -12
  21. package/spec-cli/src/search.bench.mjs +30 -7
  22. package/spec-cli/src/search.ts +39 -0
  23. package/spec-cli/src/sessions.ts +149 -62
  24. package/spec-cli/src/specs.ts +16 -4
  25. package/spec-cli/src/supervise.ts +30 -6
  26. package/spec-cli/src/tree.ts +118 -0
  27. package/spec-cli/templates/hooks/post-merge +2 -2
  28. package/spec-cli/templates/hooks/pre-commit +34 -15
  29. package/spec-cli/templates/hooks/prepare-commit-msg +8 -1
  30. package/spec-dashboard/dist/assets/Dashboard-BwZ2KzxB.js +27 -0
  31. package/spec-dashboard/dist/assets/Dashboard-C5ap-Sga.css +1 -0
  32. package/spec-dashboard/dist/assets/EvalsPage-DV75EdP4.js +3 -0
  33. package/spec-dashboard/dist/assets/FoldToggle-GwE0-k1d.js +1 -0
  34. package/spec-dashboard/dist/assets/IssuesPage-B17pnl9I.js +1 -0
  35. package/spec-dashboard/dist/assets/MobileApp-WEZbR8M1.js +1 -0
  36. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +32 -0
  37. package/spec-dashboard/dist/assets/SessionInterface-Sh8kHpnj.js +71 -0
  38. package/spec-dashboard/dist/assets/SessionWindow-EzFq-hLG.js +9 -0
  39. package/spec-dashboard/dist/assets/Settings-Dgtg-Xb9.js +1 -0
  40. package/spec-dashboard/dist/assets/index-CCmnCbKS.css +1 -0
  41. package/spec-dashboard/dist/assets/index-Dd0_U5rk.js +41 -0
  42. package/spec-dashboard/dist/index.html +2 -2
  43. package/spec-forge/src/cli.ts +4 -10
  44. package/spec-forge/src/drivers.ts +13 -0
  45. package/spec-yatsu/src/cli.ts +89 -15
  46. package/spec-yatsu/src/scenariofresh.ts +100 -30
  47. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +0 -145
  48. package/spec-dashboard/dist/assets/index-BTU-44Os.css +0 -32
@@ -1,11 +1,12 @@
1
- // @@@ spex self - the SELF-DIAGNOSIS surface (spec-cli/self). When a user launches their OWN claude/codex
2
- // with no SpexCode process in the launch, the workflow reaches that agent only through the files
3
- // materialize() renders (the manifest in the global store; the in-tree contract blocks + hook shims + codex
4
- // trust). `self` answers "is this agent actually governed, or silently running free?" diagnosing that
5
- // materialized contract per LAYER, looping the same HARNESSES adapter materialize renders through (so claude
6
- // AND codex are covered with no hardcoded paths). It catches the SILENT failure: a shim whose handler is
7
- // missing, a PATH that can't resolve `spex`, a contract that never landed. Read-only today: `doctor`,
8
- // `contract` (print the surface:system text any agent reads), `env`. install/uninstall are STAGED (noteStaged).
1
+ // @@@ spex doctor - the DIAGNOSIS surface ([[doctor]]; command renamed from `self`, which misread as the
2
+ // tool itself / the global install). When a user launches their OWN claude/codex with no SpexCode process
3
+ // in the launch, the workflow reaches that agent only through the files materialize() renders (the manifest
4
+ // in the global store; the in-tree contract blocks + hook shims + codex trust). Bare `spex doctor` answers
5
+ // "is this agent actually governed, or silently running free?" diagnosing that materialized contract per
6
+ // LAYER, looping the same HARNESSES adapter materialize renders through (so claude AND codex are covered
7
+ // with no hardcoded paths). It catches the SILENT failure: a shim whose handler is missing, a PATH that
8
+ // can't resolve `spex`, a contract that never landed. Read-only today: the bare report, `contract` (print
9
+ // the surface:system text any agent reads), `conflicts`. install/uninstall are STAGED (noteStaged).
9
10
  import { existsSync, readFileSync, readdirSync, accessSync, constants } from 'node:fs'
10
11
  import { join, dirname, basename } from 'node:path'
11
12
  import { fileURLToPath } from 'node:url'
@@ -171,17 +172,17 @@ async function doubleDeliveryReport(base: string): Promise<{ lines: string[]; co
171
172
  L.push(' • remove the independently-installed plugin bundle (delete its dir, or `claude plugin uninstall spexcode`); or')
172
173
  L.push(' • if you WANT the plugin, stop the native delivery: set spexcode.json "harnesses" to a plugin target')
173
174
  L.push(' (e.g. ["plugin",{"plugin":".claude"}] → {"plugin":".claude"}) so `spex materialize` prunes the loose shim/contract/skills; or')
174
- L.push(' • remove the loose copy directly (`spex self uninstall` [staged] / `spex uninstall`).')
175
+ L.push(' • remove the loose copy directly (`spex doctor uninstall` [staged] / `spex uninstall`).')
175
176
  } else {
176
177
  L.push('No double-delivery: each harness is reached by at most one spexcode-stamped channel.')
177
178
  }
178
179
  return { lines: L, conflict }
179
180
  }
180
181
 
181
- // ping the backend (apiBase) with a short timeout so doctor never hangs on a dead/wrong SPEXCODE_API_URL.
182
+ // ping the backend (the resolved apiBase) with a short timeout so doctor never hangs on a dead/wrong backend.
182
183
  async function backendReachable(): Promise<{ base: string; up: boolean }> {
183
184
  let base = 'http://127.0.0.1:8787'
184
- try { base = (await import('./sessions.js')).apiBase() } catch { /* keep default */ }
185
+ try { base = await (await import('./sessions.js')).apiBase() } catch { /* keep default */ }
185
186
  const ctrl = new AbortController()
186
187
  const t = setTimeout(() => ctrl.abort(), 800)
187
188
  try { return { base, up: (await fetch(`${base}/api/sessions`, { signal: ctrl.signal })).ok } }
@@ -210,7 +211,7 @@ async function doctor(): Promise<number> {
210
211
 
211
212
  const L: string[] = []
212
213
  const line = (k: string, v: string) => L.push(` ${k.padEnd(16)}: ${v}`)
213
- L.push('spex self doctor — how the SpexCode workflow reaches this agent\n')
214
+ L.push('spex doctor — how the SpexCode workflow reaches this agent\n')
214
215
 
215
216
  L.push('Agent')
216
217
  line('detected', runningHarness ? `${runningHarness.id} (${runningHarness.sessionEnvVar}=${process.env[runningHarness.sessionEnvVar]})` : 'none detected (no harness session env var set)')
@@ -244,9 +245,9 @@ async function doctor(): Promise<number> {
244
245
  line('nodes', names.length ? names.join(', ') : 'none — contract is empty')
245
246
  for (const h of HARNESSES) {
246
247
  const present = h.contractFiles(base).every((f) => /<!--\s*spexcode:start\s*-->/.test(read(f)))
247
- line(`in ${h.id}`, present ? `block present (${h.contractFiles(base).map((f) => f.replace(base + '/', '')).join(', ')})` : 'NOT landed — run `spex self contract` / materialize')
248
+ line(`in ${h.id}`, present ? `block present (${h.contractFiles(base).map((f) => f.replace(base + '/', '')).join(', ')})` : 'NOT landed — run `spex doctor contract` / materialize')
248
249
  }
249
- line('view', 'spex self contract')
250
+ line('view', 'spex doctor contract')
250
251
 
251
252
  // --- hooks: the shim → dispatch, the manifest, and EVERY handler readable in the worktree ---
252
253
  L.push('\nLayer 3 — hooks (shim → dispatch · manifest · handler-existence)')
@@ -286,7 +287,7 @@ async function doctor(): Promise<number> {
286
287
  line('layer 2', body.length === 0 ? 'ABSENT (no contract)' : 'see per-harness above')
287
288
  line('layer 3', manifestText ? 'see handler-existence above' : 'ABSENT (no manifest — agent ungoverned)')
288
289
  line('layer 4', up ? 'present' : managed ? 'EXPECTED but backend down' : 'absent (normal for bring-your-own-agent)')
289
- line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex self conflicts`)' : 'clean (single channel)')
290
+ line('layer 5', dd.conflict ? 'CONFLICT (double-delivery — see Layer 5; `spex doctor conflicts`)' : 'clean (single channel)')
290
291
 
291
292
  // --- footprint: every artifact Spex wrote here, + any slot held by something not ours ---
292
293
  L.push('\nFootprint (what Spex wrote into this environment)')
@@ -310,7 +311,7 @@ async function doctor(): Promise<number> {
310
311
  // print the layer-2 contract so any agent/harness can be handed exactly what materialize folds in.
311
312
  function contract(): number {
312
313
  const { body } = contractText()
313
- if (!body) { console.error('spex self: no surface:system nodes in this .spec tree — the contract is empty.'); return 0 }
314
+ if (!body) { console.error('spex doctor: no surface:system nodes in this .spec tree — the contract is empty.'); return 0 }
314
315
  console.log(body)
315
316
  return 0
316
317
  }
@@ -321,7 +322,7 @@ async function conflicts(): Promise<number> {
321
322
  const cwd = process.cwd()
322
323
  const base = repoRoot(cwd) ?? cwd
323
324
  const { lines, conflict } = await doubleDeliveryReport(base)
324
- console.log(['spex self conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
325
+ console.log(['spex doctor conflicts — does SpexCode reach this agent through more than one discovery channel?\n', ...lines].join('\n'))
325
326
  return conflict ? 1 : 0
326
327
  }
327
328
 
@@ -329,16 +330,16 @@ async function conflicts(): Promise<number> {
329
330
  // install/uninstall are STAGED: wiring layer-3 hooks into a standalone repo is only SAFE once the hooks
330
331
  // detect a missing managed session and degrade. So the diagnosis ships first; the installer lands behind it.
331
332
  function noteStaged(verb: string): number {
332
- console.error(`spex self ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
333
+ console.error(`spex doctor ${verb} is not available yet — it is staged behind the hook-degradation prerequisite
333
334
  (the live hooks must detect a missing managed session and degrade before they can be safely wired into your
334
- own agent's config). Meanwhile: \`spex self doctor\` reports your coverage, and \`spex self contract\` prints
335
+ own agent's config). Meanwhile: \`spex doctor\` reports your coverage, and \`spex doctor contract\` prints
335
336
  the workflow text you can hand any agent.`)
336
337
  return 2
337
338
  }
338
339
 
339
340
  function usage(): number {
340
- console.error(`spex self — diagnose how the SpexCode workflow reaches your agent
341
- doctor per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint (default)
341
+ console.error(`spex doctor — diagnose how the SpexCode workflow reaches your agent
342
+ (bare) per-layer report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
342
343
  contract print the surface:system contract text (hand it to any agent)
343
344
  conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
344
345
  install [staged] wire the materialized contract + hooks into your agent (--agent claude, --minimal)
@@ -346,14 +347,14 @@ function usage(): number {
346
347
  return 0
347
348
  }
348
349
 
349
- export async function runSelf(args: string[]): Promise<number> {
350
- switch (args[0] ?? 'doctor') {
351
- case 'doctor': return await doctor()
350
+ export async function runDoctor(args: string[]): Promise<number> {
351
+ switch (args[0]) {
352
+ case undefined: return await doctor()
352
353
  case 'contract': return contract()
353
354
  case 'conflicts': return await conflicts()
354
355
  case 'install': return noteStaged('install')
355
356
  case 'uninstall': return noteStaged('uninstall')
356
357
  case 'help': case '--help': case '-h': return usage()
357
- default: console.error(`spex self: unknown subcommand "${args[0]}"`); usage(); return 2
358
+ default: console.error(`spex doctor: unknown subcommand "${args[0]}"`); usage(); return 2
358
359
  }
359
360
  }
@@ -173,15 +173,17 @@ export function startGateway(opts: GatewayOpts): void {
173
173
  socket.on('error', bail); up.on('error', bail)
174
174
  })
175
175
 
176
- // `spex dashboard` passes an explicit loopback host; `--public` passes none → bind ALL interfaces (the
177
- // original behaviour, IPv4+IPv6), so adding the local path never narrows the public gateway's reach.
178
- const isLocal = !!opts.host
176
+ // `spex dashboard` passes an explicit host (loopback by default, --host to widen); `--public` passes
177
+ // none → bind ALL interfaces (the original behaviour, IPv4+IPv6), so adding the local path never
178
+ // narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
179
+ // an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
180
+ const isLoopback = opts.host === '127.0.0.1' || opts.host === 'localhost' || opts.host === '::1'
179
181
  const onListen = () => {
180
182
  const scheme = secure ? 'https' : 'http'
181
183
  const label = opts.label ?? 'public mode'
182
- const gate = isLocal ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}` // ungated loopback is normal, not a warning
183
- console.log(`[gateway] ${label} on ${scheme}://${isLocal ? 'localhost' : '0.0.0.0'}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`)
184
- if (!secure && !isLocal) console.log('[gateway] (TLS off — --http)')
184
+ const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`
185
+ console.log(`[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`)
186
+ if (!secure && !isLoopback && !opts.host) console.log('[gateway] (TLS off — --http)')
185
187
  }
186
188
  // a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
187
189
  // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex dashboard` fail a port clash identically.
@@ -270,13 +272,15 @@ export function ensureDashboardBuilt(repoRoot: string, distDir: string): void {
270
272
  }
271
273
 
272
274
  // @@@ serveDashboardLocal - the engine behind `spex dashboard`: the SAME gateway as public mode, bound to
273
- // loopback with no TLS and no password. It serves the bundled dist and proxies /api + the terminal socket
274
- // to a separately-run `spex serve`. This is the post-install replacement for the dogfood-only `npm run web`
275
- // (a vite dev server an installed user has no source tree for). See [[packaging]].
276
- export function serveDashboardLocal(opts: { port: number; apiPort: number }): void {
275
+ // loopback by default with no TLS and no password `--host` widens the bind to a chosen interface
276
+ // (LAN/tailnet viewing) while staying plain HTTP; the internet face remains `spex serve --public`.
277
+ // It serves the bundled dist and proxies /api + the terminal socket to a separately-run `spex serve`.
278
+ // This is the post-install replacement for the dogfood-only `npm run web` (a vite dev server an
279
+ // installed user has no source tree for). See [[packaging]].
280
+ export function serveDashboardLocal(opts: { port: number; apiPort: number; host?: string }): void {
277
281
  const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
278
282
  const distDir = resolveDistDir()
279
283
  ensureDashboardBuilt(join(pkgRoot, '..'), distDir) // bundled dist already has index.html → returns at once
280
284
  console.log(`[dashboard] serving ${distDir.endsWith('dashboard-dist') ? 'bundled' : 'monorepo'} build, /api → backend :${opts.apiPort}`)
281
- startGateway({ host: '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard' })
285
+ startGateway({ host: opts.host ?? '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard' })
282
286
  }
@@ -3,9 +3,14 @@ const SETUP = `spex guide — run SpexCode on your own repo
3
3
  The product model: install SpexCode ONCE, then use it across all your projects — an agent drives
4
4
  the rest, you don't hand-author the spec tree or wire the dashboard yourself.
5
5
 
6
- 1. Install the CLI (one-time, global — this ONE checkout serves every project)
7
- cd spec-cli && npm install && npm link # \`spex\` now runs from ANY directory
6
+ 1. Install the CLI (one-time, global — ONE install serves every project)
7
+ npm i -g spexcode # ONE command lands on PATH: \`spex\` (Node 22)
8
8
  It always operates on the repo of your current directory — that cwd is the only "which repo" knob.
9
+ (Dogfooding an unpublished HEAD from a source checkout? \`npm link\` at the repo ROOT — that links
10
+ the \`spexcode\` package itself, never the internal @spexcode/spec-cli. Both paths own the same
11
+ \`spex\` bin, so uninstall one before switching (\`npm rm -g spexcode\`; a legacy link of the
12
+ inner package uninstalls as \`@spexcode/spec-cli\`). A source link ships no
13
+ prebuilt dashboard dist — \`spex dashboard\` needs a manual dashboard build, or use the dev server.)
9
14
 
10
15
  2. Adopt a repo
11
16
  cd <your-repo> && spex init # seeds .spec/ + git hooks (additive, never overwrites)
@@ -17,11 +22,14 @@ the rest, you don't hand-author the spec tree or wire the dashboard yourself.
17
22
  Serve a different repo by running it from there; two repos at once = two \`spex serve\` on two PORTs.
18
23
 
19
24
  4. Open the dashboard — the SAME board for every project, pointed per project
20
- cd spec-dashboard && npm install # once
21
- API_URL=http://localhost:<port> npm run dev # point this board at step 3's backend
22
- The board is a viewer: API_URL is how the shared install points at each project (one dev-server
23
- per project). "dashboard": { "apiUrl": "..." } in spexcode.json is the default ONLY when the board
24
- lives inside the project (the dogfood layout) for a shared install, use API_URL.
25
+ spex dashboard # serves the bundled board on :5173, proxying /api
26
+ Point it at another backend with --api-port (pairs with \`spex serve --port\`); one dashboard per
27
+ project. The board is a viewer which backend it proxies is the only "which project" knob.
28
+ Loopback-only by default: viewing from another machine needs \`--host 0.0.0.0\` (or a specific
29
+ interface) still plain HTTP with no gate, so bind wide only on a LAN/tailnet you trust (for
30
+ the internet, use \`spex serve --public\` instead).
31
+ (Dogfood/source alternative: API_URL=http://localhost:<port> npm run dev in spec-dashboard —
32
+ the dev server; "dashboard": { "apiUrl": "..." } in spexcode.json applies only to that layout.)
25
33
 
26
34
  5. Govern your layout (optional)
27
35
  spexcode.json sets lint's governedRoots/sourceExtensions and any non-default worktree layout.
@@ -118,13 +126,39 @@ pre-commit \`yatsu check-staged\` BLOCKS the commit.
118
126
  BODY (after the frontmatter): prose naming the measurement method — YATU ("You As The User"): the agent
119
127
  looks at / calls the real product surface, not an internal helper chosen to make the proof easy.
120
128
 
121
- MEASURING AND FILING: the agent runs the scenario however it likes (a browser screenshot, an API
129
+ MEASURING AND FILING: the agent runs the scenario however it likes (a browser run, an API
122
130
  transcript, a by-hand pass), compares the result to \`expected\`, and files it:
123
- spex yatsu eval <node> --scenario <name> (--pass | --fail) [--note <text>] [--image <png> | --result <txt>|-]
131
+ spex yatsu eval <node> --scenario <name> (--pass | --fail) [--note <text>]
132
+ [--image <png> …repeatable] [--result <txt>|-] [--video <webm|mp4> [--timeline <json>]]
124
133
  The verdict is \`--pass\` or \`--fail\` (a measurement must commit to one — an unmeasured scenario is \`missing\`,
125
134
  not a hedged fail). \`--note <text>\` is an OPTIONAL one-line annotation on either (why it failed, how far a
126
- pass sits from ideal); it does NOT replace evidence — the image/transcript is the captured actual behaviour.
127
- Frontend \`--image <png>\` (visual evidence); backend \`--result <txt>\` (a transcript; \`-\` reads stdin).
135
+ pass sits from ideal); it does NOT replace evidence — the image/video/transcript is the captured actual behaviour.
136
+ PICK THE EVIDENCE KIND BY WHAT THE BEHAVIOUR DOES OVER TIME:
137
+ MOVES / is timed → \`--video <webm|mp4>\`. Terminal scroll or redraw, an animation or transition, media
138
+ playback, a multi-step interaction flow, keyboard timing — a still of a moving thing
139
+ proves the wrong thing; RECORD the run (e.g. playwright \`recordVideo\` on the context).
140
+ STEP EVIDENCE gets a STEP-MAP: when the evidence unfolds in steps, carry named steps
141
+ anchored to the evidence's OWN axis, EXPORTED BY THE RUN that produced it — never a
142
+ value the agent eyeballs off the finished artefact afterwards (that's misaligned and
143
+ dishonest, worse than none). The axis is the evidence's: a video's is TIME. Today's
144
+ \`--timeline <json>\` implements exactly that time axis, so a multi-step VIDEO carries one
145
+ (skip it for a short single-action clip); copy this shape:
146
+ { "v": 1, "events": [ { "tMs": 0, "step": "open board" },
147
+ { "tMs": 1200, "step": "type query" } ] }
148
+ \`tMs\` = ms from video start, \`step\` = a short name for that moment. The run exports it:
149
+ in whatever drives the recording — Playwright is ONE example, equally a computer-use hand
150
+ or any UI-automation/screenshot script — take \`start\` when recording opens and at EACH
151
+ real action push \`{ tMs: now - start, step: "…" }\`; dump that array as \`--timeline\`.
152
+ (Non-time-axis step evidence — a screenshot SEQUENCE by frame index, a CLI transcript by
153
+ line — is the same idea on a different axis, but the schema is tMs-only for now; see the
154
+ open issue to generalize \`tMs\` → \`position\`. Don't force a non-time axis into \`tMs\`.)
155
+ STATIC end state → \`--image <png>\` (repeatable — N stills). Layout, an icon, copy, one rendered frame.
156
+ backend / CLI → \`--result <txt>\` (a transcript; \`-\` reads stdin).
157
+ The flags combine in ONE filing — several stills can ride beside the clip of the same run.
158
+ ANCHOR DISCIPLINE: a reading's \`codeSha\` is HEAD at filing time, and a git sha names only a COMMIT — an
159
+ uncommitted change has none. So measure the tree you are about to commit, COMMIT it, then file; confidence
160
+ is earned on the working tree, but the anchor can only land after the commit. Filing from a dirty tree
161
+ mis-anchors the reading (its sha lacks the change it measured) and it goes stale the moment you commit.
128
162
 
129
163
  A botched filing (a junk e2e/smoke run, a wrong verdict) is undone through the SAME surface:
130
164
  spex yatsu retract <node> [--scenario <name>] [--last | --ts <iso>] [--note <why>]
@@ -158,7 +192,8 @@ PORTABILITY, and picking the right one is the whole discipline:
158
192
  Rule of thumb — is the value TRUE FOR THE PROJECT or TRUE FOR THIS MACHINE? A branch name, a dashboard
159
193
  icon, a lint budget, a launcher's name+harness are project facts → committed spexcode.json. The ABSOLUTE
160
194
  PATH of a launcher wrapper, a TLS cert path, private mode are machine facts → gitignored spexcode.local.json.
161
- Both files are optional; omit any field to take its default.
195
+ Both files are optional; omit any field to take its default, except \`sessions.defaultLauncher\` when using
196
+ \`spex new\` or the dashboard without an explicit launcher choice.
162
197
 
163
198
  MERGE: spexcode.local.json is layered over spexcode.json ONE LEVEL DEEP — per top-level section (dashboard,
164
199
  sessions, …), the two objects are shallow-merged with LOCAL WINNING per key; sections only one file names
@@ -187,19 +222,23 @@ Example:
187
222
  Counts compute slots, not total sessions: idle/asking/review/done do not
188
223
  occupy one. A policy number → committed spexcode.json; omit it to use the
189
224
  default, or tune higher/lower for the project's usual host.
190
- sessions.claudeCmd the UNNAMED default worker launcher for Claude (default
225
+ sessions.claudeCmd command used by the built-in "claude" launcher (default
191
226
  'claude --dangerously-skip-permissions'); env SPEXCODE_CLAUDE_CMD overrides.
192
- sessions.codexCmd the UNNAMED default worker launcher for Codex (default 'codex --yolo'); env
227
+ sessions.codexCmd command used by the built-in "codex" launcher (default 'codex --yolo'); env
193
228
  SPEXCODE_CODEX_CMD overrides.
194
- sessions.launchers NAMED launcher profiles (see LAUNCHERS).
229
+ sessions.launchers additional NAMED launcher profiles (see LAUNCHERS).
195
230
  sessions.defaultLauncher the launcher name a create with no explicit --launcher/dropdown pick uses
196
- (else the unnamed claudeCmd/codexCmd path). A portable NAME → committed.
231
+ (required for no-choice creates). A portable NAME → committed.
197
232
  A claudeCmd/codexCmd or a launcher \`cmd\` that is a HOST-SPECIFIC ABSOLUTE PATH belongs in
198
233
  spexcode.local.json — the committed file must stay free of machine paths.
199
234
 
200
235
  ── LAUNCHERS (the profile block, split across the two files) ──
201
236
  A named launcher profile fixes BOTH a session's harness AND its exact launch command; a create picks one
202
- by name, and the chosen name is persisted on the record so a resume reuses the same auth. Shape:
237
+ by name with --launcher/the dashboard dropdown, and the chosen name is persisted on the record so a resume
238
+ reuses the same auth. Built-in profiles always exist:
239
+ "claude" → { "harness": "claude", "cmd": sessions.claudeCmd or SPEXCODE_CLAUDE_CMD }
240
+ "codex" → { "harness": "codex", "cmd": sessions.codexCmd or SPEXCODE_CODEX_CMD }
241
+ Add more profiles when a project needs named auth/config-dir variants. Shape:
203
242
  "launchers": { "<name>": { "harness": "claude" | "codex", "cmd": "<launch command>" } }
204
243
  \`harness\` defaults to "claude"; \`cmd\` is required. Because \`cmd\` is a machine fact (an abs wrapper path),
205
244
  the DEFINITION lives in the gitignored spexcode.local.json, while the portable defaultLauncher NAME sits
@@ -226,6 +265,23 @@ in the committed spexcode.json — the merge keeps both:
226
265
  spexcode.local.json.
227
266
  The gateway password is NEVER read from these files (flag/env only), so serve.public stays committable.
228
267
 
268
+ ── BACKEND ROUTING (not a config field — how a \`spex\` command picks its backend) ──
269
+ One host runs many projects' backends, and a shell inherits the launching backend's SPEXCODE_API_URL —
270
+ an env var cannot prove intent (exported-on-this-command vs inherited look identical), so the client
271
+ resolves its backend per this ladder, flag first:
272
+ 1. --api <url> explicit flag on any session verb — ALWAYS wins (--port <n> is localhost
273
+ sugar for --api http://127.0.0.1:<n>).
274
+ 2a. worker (SPEXCODE_SESSION_ID set): env SPEXCODE_API_URL — the backend-injected lifeline; cwd
275
+ discovery never steals it.
276
+ 2b. human (no session id): the cwd project's RECORDED live backend — \`spex serve\` records {url,pid}
277
+ in ~/.spexcode/projects/<enc>/backend.json at bind time; the reader
278
+ health-probes before trusting (a dead record is ignored).
279
+ 3. the other side as fallback (human with no live record → env; worker with no env → record).
280
+ 4. default http://127.0.0.1:$PORT||8787.
281
+ WRITES are project-bound: every mutating verb (new/merge/send/close/rename/rawkey/reopen/exit) refuses
282
+ loudly when the resolved backend serves a DIFFERENT same-host project — an explicit --api/--port skips
283
+ the guard (the flag is the proof of intent). Reads point anywhere.
284
+
229
285
  ── ISSUES (spexcode.json — portable policy) ──
230
286
  issues.enabled the issues-workflow on/off switch (default ON). OFF silences the post-merge nudge and
231
287
  hides the dashboard view; the CLI toggle is \`spex issues on|off\`.
@@ -8,7 +8,6 @@ import { promisify } from 'node:util'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { claudeSlashCommands, codexSlashCommands, type SlashCommand } from './slash-commands.js'
10
10
  import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
11
- import { tsxBin } from './tsx-bin.js'
12
11
 
13
12
  // @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
14
13
  // Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
@@ -19,8 +18,8 @@ import { tsxBin } from './tsx-bin.js'
19
18
  // DETECTION. There is no payload-sniffing: each adapter OWNS its shim, and the shim bakes the harness id as
20
19
  // dispatch.sh's first argument (`bash <dispatch> <id> <Event>`). dispatch.sh exports SPEXCODE_HARNESS, so a
21
20
  // hook subprocess learns its harness deterministically from the shim that wired it — never from guessing the
22
- // payload shape. On the TS side the harness is the launcher's choice (the dashboard launches `defaultHarness`)
23
- // or ALL adapters at once (materialize renders every harness's artifacts).
21
+ // payload shape. On the TS side the harness is derived from the selected launcher or ALL adapters at once
22
+ // (materialize renders every harness's artifacts).
24
23
 
25
24
  export type HarnessId = 'claude' | 'codex'
26
25
  export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
@@ -53,7 +52,7 @@ export interface Harness {
53
52
  // visible TUI with `--remote` pointed at it. `cmd`, when given, is the SESSION's persisted launcher command
54
53
  // ([[launcher-select]]) and OVERRIDES the ambient env→config→default resolution — so a session created under a
55
54
  // named launcher keeps that exact command (and auth) on resume, never silently reverting to the global
56
- // default. Omitted the unnamed default resolution (env-overridable, for tests + old records).
55
+ // default. Omitted is only for tests and old records before launch_cmd was pinned.
57
56
  launchCmd(id: string, runtimeDir?: string, cmd?: string): string
58
57
  // the RESOLVED base launcher command alone — the wrapper/binary that carries the agent's config-dir env
59
58
  // (claude `CLAUDE_CONFIG_DIR`, codex `CODEX_HOME`), WITHOUT the per-launch script built around it. `cmd`,
@@ -226,11 +225,12 @@ function shQuote(s: string): string {
226
225
  return `'${s.replace(/'/g, `'\\''`)}'`
227
226
  }
228
227
 
229
- // the tsx + cli.ts invocation, baked into the codex launch script (mirrors materialize.ts's SPEX) so the
230
- // launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
231
- // exec's the visible TUI.
228
+ // the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
229
+ // the launch shell can call back into `spex codex-launch` to own the thread + fire the first turn before it
230
+ // exec's the visible TUI. The launcher, never a raw `tsx cli.ts` pair: it owns tsx resolution and the
231
+ // mid-merge guard (conflicted source → one line + exit 75, not a stacktrace).
232
232
  const PKG = fileURLToPath(new URL('..', import.meta.url))
233
- const SPEX = `${tsxBin(PKG)} ${join(PKG, 'src', 'cli.ts')}`
233
+ const SPEX = join(PKG, 'bin', 'spex.mjs')
234
234
 
235
235
  // @@@ replyViaSocket - OPTIMISTIC-after-liveness delivery: connect to the LIVE rendezvous socket and WRITE the
236
236
  // `{type:reply}\n` line; once that line FLUSHES to the socket with no immediate transport error, the reply is on
@@ -1031,8 +1031,7 @@ export const codexHarness: Harness = {
1031
1031
  // every adapter — materialize iterates this to render each harness's artifacts in one pass.
1032
1032
  export const HARNESSES: readonly Harness[] = [claudeHarness, codexHarness]
1033
1033
 
1034
- // the harness the dashboard/CLI launcher drives today (Claude). The single place a future codex launcher
1035
- // would flip; product code reads this rather than naming Claude.
1034
+ // the legacy/default adapter for old records and config defaults. New launches derive harness from a launcher.
1036
1035
  export const defaultHarness: Harness = claudeHarness
1037
1036
 
1038
1037
  // resolve an adapter by id (the detector). Throws on an unknown id — fail loud, never silently default.
@@ -1047,24 +1046,54 @@ export function harnessById(id: string): Harness {
1047
1046
  // harness defaults to claude. resolveLauncher throws fail-loud on an unknown name (a session must never
1048
1047
  // silently launch under the wrong auth) and validates the harness id.
1049
1048
  export type Launcher = { name: string; harness: string; cmd: string }
1049
+ export type LauncherDefault = { default: string | null; error: string | null }
1050
1050
 
1051
- // the configured launchers, as a stable name-sorted list (for the dashboard dropdown + the CLI). Empty when a
1052
- // project configured none — the caller then falls back to the unnamed harness pick.
1051
+ function builtinLauncher(name: string, root = mainCheckout()): Launcher | null {
1052
+ const cfg = readConfig(root).sessions
1053
+ if (name === 'claude') return { name: 'claude', harness: 'claude', cmd: process.env.SPEXCODE_CLAUDE_CMD || cfg?.claudeCmd || 'claude --dangerously-skip-permissions' }
1054
+ if (name === 'codex') return { name: 'codex', harness: 'codex', cmd: process.env.SPEXCODE_CODEX_CMD || cfg?.codexCmd || 'codex --yolo' }
1055
+ return null
1056
+ }
1057
+
1058
+ // the configured launchers plus the built-in `claude`/`codex` profiles, as a stable name-sorted list (for the
1059
+ // dashboard dropdown + the CLI). There is always a launcher choice; the old separate harness pick is gone.
1053
1060
  export function launcherList(root = mainCheckout()): Launcher[] {
1054
1061
  const m = readConfig(root).sessions?.launchers || {}
1055
- return Object.keys(m).sort().map((name) => ({ name, harness: m[name].harness || defaultHarness.id, cmd: m[name].cmd }))
1062
+ const out = new Map<string, Launcher>([
1063
+ ['claude', builtinLauncher('claude', root)!],
1064
+ ['codex', builtinLauncher('codex', root)!],
1065
+ ])
1066
+ for (const name of Object.keys(m)) out.set(name, { name, harness: m[name].harness || defaultHarness.id, cmd: m[name].cmd })
1067
+ return [...out.values()].sort((a, b) => a.name.localeCompare(b.name))
1056
1068
  }
1057
1069
 
1070
+ export const MISSING_DEFAULT_LAUNCHER_ERROR =
1071
+ 'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})'
1072
+
1058
1073
  // the configured default launcher NAME ([[launcher-select]]) — the profile `spex new`/a dropdown pick with no
1059
- // explicit choice resolves. '' when none configured. The dashboard reads this to PRE-SELECT its New-Session
1060
- // dropdown to match the CLI/config default, so the two surfaces agree on which launcher a bare create uses.
1074
+ // explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to the
1075
+ // built-in `claude` launcher.
1061
1076
  export function defaultLauncher(root = mainCheckout()): string {
1062
- return readConfig(root).sessions?.defaultLauncher || ''
1077
+ const name = readConfig(root).sessions?.defaultLauncher?.trim()
1078
+ if (!name) throw new Error(MISSING_DEFAULT_LAUNCHER_ERROR)
1079
+ return name
1080
+ }
1081
+
1082
+ export function launcherDefault(root = mainCheckout()): LauncherDefault {
1083
+ try {
1084
+ const name = defaultLauncher(root)
1085
+ resolveLauncher(name, root)
1086
+ return { default: name, error: null }
1087
+ } catch (e) {
1088
+ return { default: null, error: String((e as Error).message || e) }
1089
+ }
1063
1090
  }
1064
1091
 
1065
1092
  export function resolveLauncher(name: string, root = mainCheckout()): Launcher {
1066
1093
  const l = readConfig(root).sessions?.launchers?.[name]
1067
- if (!l || !l.cmd) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
1068
- harnessById(l.harness || defaultHarness.id) // validate the harness id fail-loud
1069
- return { name, harness: l.harness || defaultHarness.id, cmd: l.cmd }
1094
+ if (l && !l.cmd) throw new Error(`launcher '${name}' is missing cmd`)
1095
+ const resolved = l ? { name, harness: l.harness || defaultHarness.id, cmd: l.cmd } : builtinLauncher(name, root)
1096
+ if (!resolved) throw new Error(`unknown launcher '${name}' (configured: ${launcherList(root).map((x) => x.name).join(', ') || 'none'})`)
1097
+ harnessById(resolved.harness) // validate the harness id fail-loud
1098
+ return resolved
1070
1099
  }